mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-15 05:00:18 +08:00
feat: coordinate concurrent CBM sessions
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
+49
-11
@@ -78,12 +78,13 @@ CXXFLAGS_TSAN = $(CXXFLAGS_COMMON) -g -O1 \
|
||||
|
||||
# Windows needs ws2_32 (Winsock), psapi (GetProcessMemoryInfo), shell32
|
||||
# (CommandLineToArgvW — the UTF-8 argv path in main.c, #423/#20), and
|
||||
# advapi32 (owner-only activation-candidate ACLs), plus
|
||||
# --allow-multiple-definition (MinGW CRT symbol clashes).
|
||||
# Auto-detected via compiler; no manual override needed.
|
||||
IS_MINGW := $(shell echo | $(CC) -dM -E - 2>/dev/null | grep -q 'define _WIN32 ' && echo yes || echo no)
|
||||
WIN32_LIBS :=
|
||||
ifeq ($(IS_MINGW),yes)
|
||||
WIN32_LIBS := -lws2_32 -lpsapi -lshell32 -Wl,--allow-multiple-definition -Wl,--stack,8388608 -static
|
||||
WIN32_LIBS := -lws2_32 -lpsapi -lshell32 -ladvapi32 -Wl,--allow-multiple-definition -Wl,--stack,8388608 -static
|
||||
endif
|
||||
|
||||
# STATIC=1 produces a fully static binary (for Alpine/musl portable builds)
|
||||
@@ -117,7 +118,10 @@ FOUNDATION_SRCS = \
|
||||
src/foundation/dump_verify.c \
|
||||
src/foundation/limits.c \
|
||||
src/foundation/subprocess.c \
|
||||
src/foundation/sha256.c
|
||||
src/foundation/sha256.c \
|
||||
src/foundation/macos_acl.c \
|
||||
src/foundation/private_file_lock.c \
|
||||
src/foundation/lock_registry.c
|
||||
|
||||
# Existing extraction C code (compiled from current location)
|
||||
EXTRACTION_SRCS = \
|
||||
@@ -181,6 +185,19 @@ CYPHER_SRCS = src/cypher/cypher.c
|
||||
# MCP server module (new)
|
||||
MCP_SRCS = src/mcp/mcp.c src/mcp/index_supervisor.c src/mcp/compact_out.c
|
||||
|
||||
# Shared MCP daemon coordination and local transport
|
||||
DAEMON_SRCS = \
|
||||
src/daemon/daemon.c \
|
||||
src/daemon/project_lock.c \
|
||||
src/daemon/version_cohort.c \
|
||||
src/daemon/service.c \
|
||||
src/daemon/runtime.c \
|
||||
src/daemon/application.c \
|
||||
src/daemon/frontend.c \
|
||||
src/daemon/host.c \
|
||||
src/daemon/bootstrap.c \
|
||||
src/daemon/ipc.c
|
||||
|
||||
# Discover module (new)
|
||||
DISCOVER_SRCS = \
|
||||
src/discover/language.c \
|
||||
@@ -245,7 +262,7 @@ GIT_SRCS = src/git/git_context.c
|
||||
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c \
|
||||
src/cli/agent_clients.c src/cli/agent_profiles.c \
|
||||
src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \
|
||||
src/cli/config_text_edit.c
|
||||
src/cli/config_text_edit.c src/cli/activation_transaction.c
|
||||
|
||||
# UI module (graph visualization)
|
||||
UI_SRCS = \
|
||||
@@ -304,7 +321,7 @@ TRE_CFLAGS = -std=c11 -g -O1 -w -Ivendored/tre
|
||||
YYJSON_SRC = vendored/yyjson/yyjson.c
|
||||
|
||||
# All production sources
|
||||
PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC)
|
||||
PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC)
|
||||
EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \
|
||||
$(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC)
|
||||
|
||||
@@ -320,7 +337,9 @@ TEST_FOUNDATION_SRCS = \
|
||||
tests/test_str_util.c \
|
||||
tests/test_platform.c \
|
||||
tests/test_dump_verify.c \
|
||||
tests/test_subprocess.c
|
||||
tests/test_subprocess.c \
|
||||
tests/test_private_file_lock.c \
|
||||
tests/test_lock_registry.c
|
||||
|
||||
TEST_EXTRACTION_SRCS = \
|
||||
tests/test_extraction.c \
|
||||
@@ -346,7 +365,19 @@ TEST_CYPHER_SRCS = \
|
||||
tests/test_cypher.c
|
||||
|
||||
TEST_MCP_SRCS = \
|
||||
tests/test_mcp.c
|
||||
tests/test_mcp.c \
|
||||
tests/test_index_supervisor.c
|
||||
|
||||
TEST_DAEMON_SRCS = \
|
||||
tests/test_daemon.c \
|
||||
tests/test_project_lock.c \
|
||||
tests/test_version_cohort.c \
|
||||
tests/test_daemon_version.c \
|
||||
tests/test_daemon_runtime.c \
|
||||
tests/test_daemon_application.c \
|
||||
tests/test_daemon_frontend.c \
|
||||
tests/test_daemon_bootstrap.c \
|
||||
tests/test_daemon_ipc.c
|
||||
|
||||
TEST_DISCOVER_SRCS = \
|
||||
tests/test_language.c \
|
||||
@@ -357,7 +388,7 @@ TEST_DISCOVER_SRCS = \
|
||||
|
||||
TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c
|
||||
|
||||
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c
|
||||
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c
|
||||
|
||||
TEST_WATCHER_SRCS = tests/test_watcher.c
|
||||
|
||||
@@ -403,7 +434,8 @@ TEST_TRACES_SRCS = tests/test_traces.c
|
||||
|
||||
TEST_CLI_SRCS = tests/test_cli.c tests/test_agent_clients.c tests/test_agent_profiles.c \
|
||||
tests/test_config_json_like.c \
|
||||
tests/test_config_toml_edit.c tests/test_config_yaml_edit.c tests/test_config_text_edit.c
|
||||
tests/test_config_toml_edit.c tests/test_config_yaml_edit.c tests/test_config_text_edit.c \
|
||||
tests/test_activation_transaction.c
|
||||
|
||||
TEST_MEM_SRCS = tests/test_mem.c
|
||||
|
||||
@@ -485,7 +517,7 @@ TEST_REPRO_SRCS = \
|
||||
tests/repro/repro_lsp_java_cs.c \
|
||||
tests/repro/repro_lsp_kt_php_rust.c
|
||||
|
||||
ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS)
|
||||
ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS)
|
||||
|
||||
|
||||
# ── Build directories ────────────────────────────────────────────
|
||||
@@ -513,7 +545,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o
|
||||
|
||||
# ── Targets ──────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: test test-repro test-foundation test-tsan cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security
|
||||
.PHONY: test test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security
|
||||
|
||||
$(BUILD_DIR):
|
||||
mkdir -p $(BUILD_DIR)
|
||||
@@ -676,6 +708,12 @@ $(BUILD_DIR)/test-runner-tsan: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS)
|
||||
test-tsan: $(BUILD_DIR)/test-runner-tsan
|
||||
cd $(CURDIR) && TSAN_OPTIONS="$(TSAN_OPTIONS)" $(BUILD_DIR)/test-runner-tsan $(TEST_TSAN_SUITES)
|
||||
|
||||
# Real-binary POSIX lifecycle smoke. The endpoint is deliberately account-wide;
|
||||
# an explicit Make invocation requires a clean rendezvous and fails rather than
|
||||
# silently skipping an occupied one. Deterministic C tests cover Windows IPC.
|
||||
test-daemon-smoke: $(BUILD_DIR)/codebase-memory-mcp
|
||||
cd $(CURDIR) && CBM_DAEMON_SMOKE_REQUIRE_RUN=1 python3 tests/test_daemon_smoke.py $(BUILD_DIR)/codebase-memory-mcp
|
||||
|
||||
# ── Production binary ────────────────────────────────────────────
|
||||
|
||||
# Grammar/TS/LSP objects for production (compiled with relaxed warnings, -O2)
|
||||
@@ -763,7 +801,7 @@ SYSROOT = $(shell xcrun --show-sdk-path 2>/dev/null)
|
||||
SYSROOT_FLAG = $(if $(SYSROOT),-isysroot $(SYSROOT),)
|
||||
|
||||
# Our source files (excluding vendored, grammars, tree-sitter runtime)
|
||||
LINT_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) \
|
||||
LINT_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) \
|
||||
$(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) \
|
||||
$(TRACES_SRCS) $(WATCHER_SRCS) $(CLI_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) \
|
||||
$(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(MAIN_SRC)
|
||||
|
||||
@@ -102,6 +102,18 @@ The `install` command automatically strips macOS quarantine attributes and ad-ho
|
||||
|
||||
The `install` command auto-detects installed coding agents and configures their documented MCP entries plus durable instructions, skills, and lifecycle hooks where supported.
|
||||
|
||||
### Session Coordination Daemon
|
||||
|
||||
CBM automatically shares one per-account coordination daemon across Claude Code, Codex, OpenCode, and every other configured client. There is no opt-in setting for MCP servers or hook clients: the first daemon-backed CBM session starts it, each session registers its own work, and the final session shuts it down. The daemon owns long-lived background services such as watchers, shared indexing jobs, and the optional UI. Closing one session cancels work owned only by that session, while work still needed by another session continues.
|
||||
|
||||
All active CBM processes must run the exact same version, executable build, and coordination ABI. MCP servers, hooks, one-shot CLI commands, temporary index workers, and the daemon share a crash-safe OS admission barrier; starting an ordinary newer or older CBM process while another build is active fails before doing work and records an explicit conflict in `daemon-conflicts.ndjson` in the private log directory.
|
||||
|
||||
The native `install`, `update`, and `uninstall` commands are the deliberate exception to that conflict rule. Download, verification, and private same-filesystem staging happen first so a bad candidate never disrupts active work. Activation then publishes account-wide maintenance intent, asks the daemon and every temporary local operation to cancel, and waits to a finite deadline for all coordinated CBM processes to exit. It holds the admission and lifetime barriers exclusively while changing the active binary, configuration, PATH, or indexes. New CBM work cannot enter during this window. Activation progress and results are recorded in the private `activation-events.ndjson` log, and a successful command tells you to restart open coding-agent sessions so they launch the activated build.
|
||||
|
||||
Package-manager setup (npm, PyPI, or Go) only verifies and atomically publishes that package's private cached binary; it does not replace the active native installation and therefore does not stop running CBM sessions. When that cached binary is executed, it still enters the same exact-build admission barrier. The shell and PowerShell installers invoke the verified candidate's native `install` command, so they do receive the full account-wide activation guarantee.
|
||||
|
||||
The ordinary `cli` mode is intentionally separate: it runs one command locally and never starts or connects to the coordination daemon, registers a daemon session, or starts watchers/UI. Its only shared state is the OS admission barrier plus per-project locks for graph mutations. While the command is running, a temporary monitor lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details.
|
||||
|
||||
### Graph Visualization UI
|
||||
|
||||
The UI ships as a separate `ui` build (it embeds the frontend). The default install on every channel is the lean, headless server; opt into the UI build with:
|
||||
@@ -117,7 +129,7 @@ Then run it:
|
||||
codebase-memory-mcp --ui=true --port=9749
|
||||
```
|
||||
|
||||
Open `http://localhost:9749` in your browser. The UI runs as a background thread alongside the MCP server — it's available whenever your agent is connected.
|
||||
Open `http://localhost:9749` in your browser. The UI is owned by the shared coordination daemon, so concurrent agent sessions do not start duplicate HTTP servers.
|
||||
|
||||
### Auto-Index
|
||||
|
||||
@@ -521,19 +533,30 @@ no longer a suitable automatic global target.
|
||||
|
||||
## CLI Mode
|
||||
|
||||
Every MCP tool can be invoked from the command line:
|
||||
Every MCP tool can be invoked as a local, one-shot command. CLI tools neither start nor connect to the coordination daemon and leave no standing process behind. They hold a crash-safe exact-build admission lease only for the command lifetime. `index_repository` is the only exception internally: it starts a temporary, exact-build supervised worker for the index, then stops that worker before the CLI command exits; the worker holds its own lease until exit.
|
||||
|
||||
Commands that mutate graph data use shared OS-backed, per-project locks. This serializes conflicting work from CLI and MCP sessions on the same project while allowing unrelated projects to proceed independently.
|
||||
|
||||
When stderr is an interactive terminal, the CLI automatically shows lifecycle and indexing progress. Pass `--progress` to force the same feedback when stderr is redirected or the command is run non-interactively. Progress is written only to stderr; stdout remains reserved for the command result, so pipes and scripts stay machine-safe. Pass `--json` when the full MCP result envelope is needed.
|
||||
|
||||
Use `cli <tool> --help` to see the flags generated from that tool's input schema:
|
||||
|
||||
```bash
|
||||
codebase-memory-mcp cli index_repository '{"repo_path": "/path/to/repo"}'
|
||||
codebase-memory-mcp cli index_repository --repo-path /path/to/repo
|
||||
codebase-memory-mcp cli list_projects
|
||||
|
||||
# Use the "name" returned by list_projects as the project value.
|
||||
codebase-memory-mcp cli search_graph '{"project": "my-project", "name_pattern": ".*Handler.*", "label": "Function"}'
|
||||
codebase-memory-mcp cli trace_path '{"project": "my-project", "function_name": "Search", "direction": "both"}'
|
||||
codebase-memory-mcp cli query_graph '{"project": "my-project", "query": "MATCH (f:Function) RETURN f.name LIMIT 5"}'
|
||||
codebase-memory-mcp cli --raw search_graph '{"project": "my-project", "label": "Function"}' | jq '.results[].name'
|
||||
codebase-memory-mcp cli search_graph --project my-project --name-pattern '.*Handler.*' --label Function
|
||||
codebase-memory-mcp cli trace_path --project my-project --function-name Search --direction both
|
||||
codebase-memory-mcp cli query_graph --project my-project --query 'MATCH (f:Function) RETURN f.name LIMIT 5'
|
||||
|
||||
# Force human-readable progress without contaminating stdout.
|
||||
codebase-memory-mcp cli --progress index_repository --repo-path /path/to/repo
|
||||
codebase-memory-mcp cli search_graph --project my-project --label Function | jq '.results[].name'
|
||||
```
|
||||
|
||||
JSON arguments can also be piped on stdin. Inline JSON remains accepted for backward compatibility but is deprecated in favor of flags, `--args-file`, or stdin.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### Indexing
|
||||
@@ -706,6 +729,7 @@ Also supported (not yet benchmarked): Ada, Agda, Apex, Assembly (NASM), Astro, A
|
||||
```
|
||||
src/
|
||||
main.c Entry point (MCP stdio server + CLI + install/update/config)
|
||||
daemon/ Per-account session coordination, IPC, lifecycle, shared jobs/watchers
|
||||
mcp/ MCP server (15 tools, JSON-RPC 2.0, session detection, auto-index)
|
||||
cli/ Install/uninstall/update/config (43 client surfaces, hooks, instructions)
|
||||
store/ SQLite graph storage (nodes, edges, traversal, search, Louvain)
|
||||
|
||||
@@ -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 }: {
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-medium">{t.control.thisProcess}</span>
|
||||
)}
|
||||
</div>
|
||||
{!proc.is_self && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onKill(); }}
|
||||
className="px-2 py-1 rounded-lg text-[10px] text-foreground/20 hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
>
|
||||
{t.control.kill}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-2">
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+118
-51
@@ -9,18 +9,78 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
# Enforce TLS 1.2+ (older PowerShell defaults to TLS 1.0 which GitHub rejects)
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13
|
||||
Add-Type -AssemblyName System.Net.Http
|
||||
|
||||
$Repo = "DeusData/codebase-memory-mcp"
|
||||
$InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp"
|
||||
$BinName = "codebase-memory-mcp.exe"
|
||||
$BaseUrl = if ($env:CBM_DOWNLOAD_URL) { $env:CBM_DOWNLOAD_URL } else { "https://github.com/$Repo/releases/latest/download" }
|
||||
|
||||
# Security: reject non-HTTPS download URLs (defense-in-depth)
|
||||
if (-not $BaseUrl.StartsWith("https://") -and -not $BaseUrl.StartsWith("http://localhost") -and -not $BaseUrl.StartsWith("http://127.0.0.1")) {
|
||||
try { $BaseUri = [Uri]$BaseUrl } catch { $BaseUri = $null }
|
||||
$AllowLoopbackHttp = (
|
||||
$BaseUri -and $BaseUri.IsAbsoluteUri -and
|
||||
$BaseUri.Scheme -eq "http" -and $BaseUri.IsLoopback -and
|
||||
[string]::IsNullOrEmpty($BaseUri.UserInfo)
|
||||
)
|
||||
if (-not $BaseUri -or -not $BaseUri.IsAbsoluteUri -or
|
||||
($BaseUri.Scheme -ne "https" -and -not $AllowLoopbackHttp) -or
|
||||
-not [string]::IsNullOrEmpty($BaseUri.UserInfo)) {
|
||||
Write-Host "error: refusing non-HTTPS download URL: $BaseUrl" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Invoke-CbmDownload {
|
||||
param([Parameter(Mandatory=$true)][string]$Url,
|
||||
[Parameter(Mandatory=$true)][string]$OutFile)
|
||||
|
||||
$current = [Uri]$Url
|
||||
$handler = New-Object System.Net.Http.HttpClientHandler
|
||||
$handler.AllowAutoRedirect = $false
|
||||
$client = New-Object -TypeName System.Net.Http.HttpClient -ArgumentList $handler
|
||||
$client.Timeout = [TimeSpan]::FromMinutes(10)
|
||||
try {
|
||||
for ($redirects = 0; $redirects -le 5; $redirects++) {
|
||||
$allowed = $current.IsAbsoluteUri -and
|
||||
[string]::IsNullOrEmpty($current.UserInfo) -and
|
||||
($current.Scheme -eq "https" -or
|
||||
($AllowLoopbackHttp -and $current.Scheme -eq "http" -and
|
||||
$current.IsLoopback))
|
||||
if (-not $allowed) {
|
||||
throw "download redirect escaped the allowed transport: $current"
|
||||
}
|
||||
$response = $client.GetAsync(
|
||||
$current, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead
|
||||
).GetAwaiter().GetResult()
|
||||
try {
|
||||
$status = [int]$response.StatusCode
|
||||
if ($status -in @(301, 302, 303, 307, 308)) {
|
||||
if ($redirects -eq 5 -or -not $response.Headers.Location) {
|
||||
throw "invalid or excessive download redirect from $current"
|
||||
}
|
||||
$current = [Uri]::new($current, $response.Headers.Location)
|
||||
continue
|
||||
}
|
||||
if (-not $response.IsSuccessStatusCode) {
|
||||
throw "HTTP $status for $current"
|
||||
}
|
||||
$input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
|
||||
try {
|
||||
$output = [System.IO.File]::Open(
|
||||
$OutFile, [System.IO.FileMode]::Create,
|
||||
[System.IO.FileAccess]::Write,
|
||||
[System.IO.FileShare]::None)
|
||||
try { $input.CopyTo($output) } finally { $output.Dispose() }
|
||||
} finally { $input.Dispose() }
|
||||
return
|
||||
} finally { $response.Dispose() }
|
||||
}
|
||||
throw "too many download redirects"
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
$handler.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Detect variant from args (--ui or --standard)
|
||||
$Variant = "standard"
|
||||
$SkipConfig = $false
|
||||
@@ -73,7 +133,7 @@ New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null
|
||||
|
||||
Write-Host "Downloading $Archive..."
|
||||
try {
|
||||
Invoke-WebRequest -Uri $Url -OutFile "$TmpDir\$Archive" -UseBasicParsing
|
||||
Invoke-CbmDownload -Url $Url -OutFile "$TmpDir\$Archive"
|
||||
} catch {
|
||||
Write-Host "error: download failed: $_" -ForegroundColor Red
|
||||
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
|
||||
@@ -81,25 +141,42 @@ try {
|
||||
}
|
||||
|
||||
|
||||
# Checksum verification
|
||||
# Checksum verification is mandatory. Do not request coordinated shutdown for
|
||||
# a candidate that was not positively matched to the published release digest.
|
||||
$ChecksumUrl = "$BaseUrl/checksums.txt"
|
||||
try {
|
||||
Invoke-WebRequest -Uri $ChecksumUrl -OutFile "$TmpDir\checksums.txt" -UseBasicParsing
|
||||
$checksumLine = Get-Content "$TmpDir\checksums.txt" | Where-Object { $_ -like "*$Archive*" }
|
||||
if ($checksumLine) {
|
||||
$expected = ($checksumLine -split '\s+')[0]
|
||||
$actual = (Get-FileHash -Path "$TmpDir\$Archive" -Algorithm SHA256).Hash.ToLower()
|
||||
if ($expected -ne $actual) {
|
||||
Write-Host "error: CHECKSUM MISMATCH!" -ForegroundColor Red
|
||||
Write-Host " expected: $expected"
|
||||
Write-Host " actual: $actual"
|
||||
Remove-Item -Recurse -Force $TmpDir
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Checksum verified."
|
||||
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"
|
||||
}
|
||||
$expected = $digest
|
||||
}
|
||||
$actual = (Get-FileHash -Path "$TmpDir\$Archive" -Algorithm SHA256).Hash.ToLower()
|
||||
if ($expected -ne $actual) {
|
||||
throw "CHECKSUM MISMATCH (expected $expected, actual $actual)"
|
||||
}
|
||||
Write-Host "Checksum verified."
|
||||
} catch {
|
||||
Write-Host "warning: could not verify checksum (non-fatal)"
|
||||
Write-Host "error: checksum verification failed: $_" -ForegroundColor Red
|
||||
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Extract
|
||||
@@ -120,26 +197,31 @@ if (-not (Test-Path $DlBin)) {
|
||||
}
|
||||
}
|
||||
|
||||
# Install
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
$Dest = Join-Path $InstallDir $BinName
|
||||
|
||||
# Handle replace-if-running (rename-aside)
|
||||
if (Test-Path $Dest) {
|
||||
$OldDest = "$Dest.old"
|
||||
Remove-Item $OldDest -Force -ErrorAction SilentlyContinue
|
||||
try {
|
||||
Rename-Item $Dest $OldDest -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host "warning: could not rename existing binary (may be in use)"
|
||||
}
|
||||
# Verify the candidate before it asks all coordinated CBM processes to stop.
|
||||
try {
|
||||
$candidateVersion = & $DlBin --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0) { throw "candidate exited with $LASTEXITCODE" }
|
||||
Write-Host "Verified candidate: $candidateVersion"
|
||||
} catch {
|
||||
Write-Host "error: downloaded binary failed to run: $_" -ForegroundColor Red
|
||||
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
|
||||
Copy-Item $DlBin $Dest -Force
|
||||
$Dest = Join-Path $InstallDir $BinName
|
||||
$InstallArgs = @("install", "-y", "--force", "--dir=$InstallDir")
|
||||
if ($SkipConfig) { $InstallArgs += "--skip-config" }
|
||||
& $DlBin @InstallArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "error: coordinated activation failed (exit code $LASTEXITCODE)" -ForegroundColor Red
|
||||
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Verify
|
||||
try {
|
||||
$ver = & $Dest --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0) { throw "installed binary exited with $LASTEXITCODE" }
|
||||
Write-Host "Installed: $ver"
|
||||
} catch {
|
||||
Write-Host "error: installed binary failed to run" -ForegroundColor Red
|
||||
@@ -147,30 +229,15 @@ try {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Configure agents
|
||||
# Agent configuration was included in the candidate-owned activation window.
|
||||
if ($SkipConfig) {
|
||||
Write-Host ""
|
||||
Write-Host "Skipping agent configuration (--skip-config)"
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "Configuring coding agents..."
|
||||
& $Dest install -y 2>&1 | Write-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host ""
|
||||
Write-Host "error: agent configuration failed (exit code $LASTEXITCODE)" -ForegroundColor Red
|
||||
Write-Host "The binary was installed, but no coding agents were configured."
|
||||
Write-Host "Run manually to configure: `"$Dest`" install"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Add to PATH (user scope, no admin needed)
|
||||
$UserPath = [Environment]::GetEnvironmentVariable("PATH", "User")
|
||||
if ($UserPath -notlike "*$InstallDir*") {
|
||||
[Environment]::SetEnvironmentVariable("PATH", "$UserPath;$InstallDir", "User")
|
||||
$env:PATH = "$env:PATH;$InstallDir"
|
||||
Write-Host "Added $InstallDir to user PATH"
|
||||
}
|
||||
# The verified candidate persisted the current-user PATH while holding the
|
||||
# coordinated activation lease. Do not perform a second registry mutation here
|
||||
# after running sessions have been allowed to restart.
|
||||
|
||||
# Cleanup
|
||||
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
|
||||
|
||||
+131
-50
@@ -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
|
||||
download_file "$URL" "$DLDIR/$ARCHIVE" true
|
||||
|
||||
# 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"
|
||||
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
|
||||
|
||||
# Checksum verification
|
||||
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
|
||||
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"
|
||||
DEST="$INSTALL_DIR/codebase-memory-mcp"
|
||||
if [ -f "$DEST" ]; then
|
||||
rm -f "$DEST"
|
||||
# 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
|
||||
cp "$DLBIN" "$DEST"
|
||||
chmod 755 "$DEST"
|
||||
echo "Verified candidate: $CANDIDATE_VERSION"
|
||||
|
||||
DEST="$INSTALL_DIR/codebase-memory-mcp"
|
||||
INSTALL_ARGS=(-y --force "--dir=$INSTALL_DIR")
|
||||
if [ "$SKIP_CONFIG" = true ]; then
|
||||
INSTALL_ARGS+=(--skip-config)
|
||||
fi
|
||||
"$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
|
||||
|
||||
@@ -13,22 +13,33 @@ import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
repo = "DeusData/codebase-memory-mcp"
|
||||
version = "0.8.1"
|
||||
|
||||
maxRedirects = 5
|
||||
requestTimeout = 2 * time.Minute
|
||||
connectTimeout = 15 * time.Second
|
||||
responseHeaderTimeout = 30 * time.Second
|
||||
candidateTimeout = 15 * time.Second
|
||||
maxChecksumManifestSize = 1024 * 1024
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -117,7 +128,11 @@ func download(dest string) error {
|
||||
ext = "zip"
|
||||
}
|
||||
|
||||
archive := fmt.Sprintf("codebase-memory-mcp-%s-%s.%s", platform, arch, ext)
|
||||
portable := ""
|
||||
if platform == "linux" {
|
||||
portable = "-portable"
|
||||
}
|
||||
archive := fmt.Sprintf("codebase-memory-mcp-%s-%s%s.%s", platform, arch, portable, ext)
|
||||
url := fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", repo, version, archive)
|
||||
checksumURL := fmt.Sprintf("https://github.com/%s/releases/download/v%s/checksums.txt", repo, version)
|
||||
|
||||
@@ -134,13 +149,18 @@ func download(dest string) error {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Verify checksum if available (non-fatal if checksums.txt unreachable)
|
||||
if checksums, err := fetchChecksums(checksumURL); err == nil {
|
||||
if expected, ok := checksums[archive]; ok {
|
||||
if err := verifyChecksum(archivePath, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// A release binary is executable input, so checksum verification is a
|
||||
// mandatory precondition rather than a best-effort warning.
|
||||
checksums, err := fetchChecksums(checksumURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checksum manifest unavailable: %w", err)
|
||||
}
|
||||
expected, ok := checksums[archive]
|
||||
if !ok {
|
||||
return fmt.Errorf("checksum manifest has no entry for %s", archive)
|
||||
}
|
||||
if err := verifyChecksum(archivePath, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binName := "codebase-memory-mcp"
|
||||
@@ -157,25 +177,29 @@ func download(dest string) error {
|
||||
return fmt.Errorf("extraction failed: %w", err)
|
||||
}
|
||||
}
|
||||
extracted := filepath.Join(tmp, binName)
|
||||
if err := os.Chmod(extracted, 0755); err != nil {
|
||||
return fmt.Errorf("could not set candidate permissions: %w", err)
|
||||
}
|
||||
if err := verifyCandidate(extracted); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return fmt.Errorf("could not create cache dir: %w", err)
|
||||
}
|
||||
|
||||
if err := copyFile(filepath.Join(tmp, binName), dest); err != nil {
|
||||
if err := installCandidateAtomically(extracted, dest); err != nil {
|
||||
return fmt.Errorf("could not install binary: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(dest, 0755); err != nil {
|
||||
return fmt.Errorf("could not set permissions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateURLScheme rejects non-https URLs before any fetch (defense-in-depth).
|
||||
func validateURLScheme(rawURL string) error {
|
||||
if !strings.HasPrefix(rawURL, "https://") {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return fmt.Errorf("refusing non-https URL: %s", rawURL)
|
||||
}
|
||||
return nil
|
||||
@@ -183,11 +207,20 @@ func validateURLScheme(rawURL string) error {
|
||||
|
||||
// httpsOnlyClient returns an HTTP client that rejects non-HTTPS redirects.
|
||||
var httpsOnlyClient = &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: connectTimeout,
|
||||
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("refusing non-https redirect to %s", req.URL)
|
||||
if err := validateURLScheme(req.URL.String()); err != nil {
|
||||
return fmt.Errorf("refusing unsafe redirect: %w", err)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
if len(via) > maxRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
@@ -210,9 +243,12 @@ func httpGet(rawURL, dest string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
_, copyErr := io.Copy(f, resp.Body)
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func fetchChecksums(url string) (map[string]string, error) {
|
||||
@@ -227,21 +263,45 @@ func fetchChecksums(url string) (map[string]string, error) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumManifestSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxChecksumManifestSize {
|
||||
return nil, fmt.Errorf("checksums.txt exceeds the 1 MiB safety limit")
|
||||
}
|
||||
result := make(map[string]string)
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) == 2 {
|
||||
result[parts[1]] = parts[0]
|
||||
name := parts[1]
|
||||
if strings.HasPrefix(name, "*") {
|
||||
name = name[1:]
|
||||
}
|
||||
digest := strings.ToLower(parts[0])
|
||||
if len(digest) != sha256.Size*2 {
|
||||
return nil, fmt.Errorf("invalid SHA-256 checksum for %s", name)
|
||||
}
|
||||
if _, err := hex.DecodeString(digest); err != nil {
|
||||
return nil, fmt.Errorf("invalid SHA-256 checksum for %s: %w", name, err)
|
||||
}
|
||||
if prior, exists := result[name]; exists && prior != digest {
|
||||
return nil, fmt.Errorf("conflicting SHA-256 checksums for %s", name)
|
||||
}
|
||||
result[name] = digest
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func verifyChecksum(path, expected string) error {
|
||||
expected = strings.ToLower(expected)
|
||||
if len(expected) != sha256.Size*2 {
|
||||
return fmt.Errorf("invalid SHA-256 checksum length")
|
||||
}
|
||||
if _, err := hex.DecodeString(expected); err != nil {
|
||||
return fmt.Errorf("invalid SHA-256 checksum: %w", err)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -316,19 +376,60 @@ func extractZip(archivePath, destDir, targetFile string) error {
|
||||
return fmt.Errorf("%s not found in archive", targetFile)
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
func verifyCandidate(path string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), candidateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, path, "--version")
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("downloaded binary verification timed out: %w", ctx.Err())
|
||||
}
|
||||
return fmt.Errorf("downloaded binary failed to run: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installCandidateAtomically(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
pattern := "." + filepath.Base(dst) + ".*.tmp"
|
||||
if runtime.GOOS == "windows" {
|
||||
pattern += ".exe"
|
||||
}
|
||||
out, err := os.CreateTemp(filepath.Dir(dst), pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
staged := out.Name()
|
||||
defer os.Remove(staged)
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Chmod(0755); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verifyCandidate(staged); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(staged, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execBinary(bin string, args []string) error {
|
||||
|
||||
+171
-41
@@ -9,10 +9,15 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { pipeline } = require('stream');
|
||||
|
||||
const REPO = 'DeusData/codebase-memory-mcp';
|
||||
const VERSION = require('./package.json').version;
|
||||
const BIN_DIR = path.join(__dirname, 'bin');
|
||||
const MAX_REDIRECTS = 5;
|
||||
const DOWNLOAD_HOP_TIMEOUT_MS = 120_000;
|
||||
const CANDIDATE_TIMEOUT_MS = 15_000;
|
||||
const MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024;
|
||||
|
||||
function getPlatform() {
|
||||
switch (process.platform) {
|
||||
@@ -31,36 +36,145 @@ function getArch() {
|
||||
}
|
||||
}
|
||||
|
||||
// Security: only follow HTTPS URLs (defense-in-depth).
|
||||
function validateUrl(url) {
|
||||
if (!url.startsWith('https://')) {
|
||||
throw new Error(`Refusing non-HTTPS URL: ${url}`);
|
||||
// Security: only follow HTTPS URLs (defense-in-depth). Parse the URL instead
|
||||
// of relying on a string prefix so every redirect is checked unambiguously.
|
||||
function validateUrl(rawUrl) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid download URL ${rawUrl}: ${err.message}`);
|
||||
}
|
||||
if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password) {
|
||||
throw new Error(`Refusing non-HTTPS or credentialed URL: ${rawUrl}`);
|
||||
}
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
function downloadHop(url, dest, maxBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
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 download(url, dest) {
|
||||
validateUrl(url);
|
||||
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);
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
|
||||
}
|
||||
const file = fs.createWriteStream(dest);
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(resolve));
|
||||
file.on('error', reject);
|
||||
}).on('error', reject);
|
||||
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}`);
|
||||
}
|
||||
follow(url, 0);
|
||||
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 extractZipOnWindows(archivePath, destPath) {
|
||||
// -EncodedCommand is a constant program. Paths travel only through the child
|
||||
// environment and are consumed with -LiteralPath, so PowerShell never parses
|
||||
// user/TEMP path bytes as source code or wildcard syntax.
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'Expand-Archive -LiteralPath $env:CBM_NPM_ARCHIVE_PATH ' +
|
||||
'-DestinationPath $env:CBM_NPM_DEST_PATH -Force',
|
||||
].join('; ');
|
||||
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
||||
execFileSync('powershell', [
|
||||
'-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded,
|
||||
], {
|
||||
env: {
|
||||
...process.env,
|
||||
CBM_NPM_ARCHIVE_PATH: archivePath,
|
||||
CBM_NPM_DEST_PATH: destPath,
|
||||
},
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,11 +183,14 @@ async function verifyChecksum(archivePath, archiveName) {
|
||||
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/checksums.txt`;
|
||||
const tmpChecksums = archivePath + '.checksums';
|
||||
try {
|
||||
await download(url, tmpChecksums);
|
||||
const lines = fs.readFileSync(tmpChecksums, 'utf-8').split('\n');
|
||||
const match = lines.find((l) => l.includes(archiveName));
|
||||
if (!match) return; // checksum line not found — non-fatal
|
||||
const expected = match.split(/\s+/)[0];
|
||||
await download(url, tmpChecksums, MAX_CHECKSUM_MANIFEST_BYTES);
|
||||
const manifestSize = fs.statSync(tmpChecksums).size;
|
||||
if (manifestSize > MAX_CHECKSUM_MANIFEST_BYTES) {
|
||||
throw new Error('checksums.txt exceeds the 1 MiB safety limit');
|
||||
}
|
||||
const expected = parseExpectedChecksum(
|
||||
fs.readFileSync(tmpChecksums, 'utf-8'), archiveName,
|
||||
);
|
||||
const actual = crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(archivePath))
|
||||
@@ -84,9 +201,6 @@ async function verifyChecksum(archivePath, archiveName) {
|
||||
);
|
||||
}
|
||||
process.stdout.write('codebase-memory-mcp: checksum verified.\n');
|
||||
} catch (err) {
|
||||
if (err.message.startsWith('Checksum mismatch')) throw err;
|
||||
// Non-fatal: checksum unavailable (network issue, pre-release, etc.)
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpChecksums); } catch (_) { /* ignore */ }
|
||||
}
|
||||
@@ -100,7 +214,12 @@ async function main() {
|
||||
const binPath = path.join(BIN_DIR, binName);
|
||||
|
||||
if (fs.existsSync(binPath)) {
|
||||
return; // already installed, nothing to do
|
||||
try {
|
||||
verifyCandidate(binPath);
|
||||
return; // already installed and runnable, nothing to do
|
||||
} catch (_) {
|
||||
fs.rmSync(binPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
@@ -129,10 +248,7 @@ async function main() {
|
||||
if (ext === 'tar.gz') {
|
||||
execFileSync('tar', ['-xzf', tmpArchive, '-C', tmpDir, '--no-same-owner']);
|
||||
} else {
|
||||
execFileSync('powershell', [
|
||||
'-NoProfile', '-Command',
|
||||
`Expand-Archive -Path '${tmpArchive}' -DestinationPath '${tmpDir}' -Force`,
|
||||
]);
|
||||
extractZipOnWindows(tmpArchive, tmpDir);
|
||||
}
|
||||
|
||||
// Validate extracted path doesn't escape tmpDir (tar-slip defense).
|
||||
@@ -146,8 +262,22 @@ async function main() {
|
||||
throw new Error(`Binary not found after extraction at ${extracted}`);
|
||||
}
|
||||
|
||||
fs.copyFileSync(extracted, binPath);
|
||||
fs.chmodSync(binPath, 0o755);
|
||||
fs.chmodSync(extracted, 0o755);
|
||||
verifyCandidate(extracted);
|
||||
|
||||
const stagedSuffix = platform === 'windows' ? '.tmp.exe' : '.tmp';
|
||||
const staged = path.join(
|
||||
BIN_DIR,
|
||||
`.${binName}.${process.pid}.${crypto.randomBytes(8).toString('hex')}${stagedSuffix}`,
|
||||
);
|
||||
try {
|
||||
fs.copyFileSync(extracted, staged, fs.constants.COPYFILE_EXCL);
|
||||
fs.chmodSync(staged, 0o755);
|
||||
verifyCandidate(staged);
|
||||
fs.renameSync(staged, binPath);
|
||||
} finally {
|
||||
try { fs.unlinkSync(staged); } catch (_) { /* renamed or never created */ }
|
||||
}
|
||||
|
||||
process.stdout.write('codebase-memory-mcp: ready.\n');
|
||||
} finally {
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import stat
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO = "DeusData/codebase-memory-mcp"
|
||||
@@ -18,18 +20,101 @@ REPO = "DeusData/codebase-memory-mcp"
|
||||
# file://, ftp://, and custom schemes — a redirect or tainted URL source
|
||||
# could otherwise turn a download into an arbitrary-local-file read.
|
||||
_ALLOWED_SCHEMES = frozenset({"https"})
|
||||
_MAX_REDIRECTS = 5
|
||||
_NETWORK_TIMEOUT_SECONDS = 120
|
||||
_CANDIDATE_TIMEOUT_SECONDS = 15
|
||||
_MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024
|
||||
_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Expose redirects to _download_https for explicit per-hop validation."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
_HTTPS_OPENER = urllib.request.build_opener(_NoRedirectHandler())
|
||||
|
||||
|
||||
def _validate_url_scheme(url: str) -> None:
|
||||
"""Reject non-https URLs before any network fetch."""
|
||||
scheme = urllib.parse.urlparse(url).scheme
|
||||
if scheme not in _ALLOWED_SCHEMES:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if (
|
||||
parsed.scheme not in _ALLOWED_SCHEMES
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: refusing to fetch non-https URL "
|
||||
f"(scheme={scheme!r}): {url}"
|
||||
f"codebase-memory-mcp: refusing to fetch invalid, credentialed, or "
|
||||
f"non-https URL: {url}"
|
||||
)
|
||||
|
||||
|
||||
def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
|
||||
"""Download through a bounded HTTPS-only opener into dest."""
|
||||
current_url = url
|
||||
for redirect_count in range(_MAX_REDIRECTS + 1):
|
||||
_validate_url_scheme(current_url)
|
||||
request = urllib.request.Request(
|
||||
current_url, headers={"User-Agent": "codebase-memory-mcp-installer"}
|
||||
)
|
||||
try:
|
||||
response = _HTTPS_OPENER.open(
|
||||
request, timeout=_NETWORK_TIMEOUT_SECONDS
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code not in _REDIRECT_CODES:
|
||||
raise
|
||||
location = exc.headers.get("Location")
|
||||
exc.close()
|
||||
if not location:
|
||||
raise RuntimeError(f"redirect has no Location: {current_url}")
|
||||
if redirect_count == _MAX_REDIRECTS:
|
||||
raise RuntimeError("too many redirects")
|
||||
current_url = urllib.parse.urljoin(current_url, location)
|
||||
_validate_url_scheme(current_url)
|
||||
continue
|
||||
|
||||
with response:
|
||||
_validate_url_scheme(response.geturl())
|
||||
deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS
|
||||
total = 0
|
||||
with open(dest, "wb") as out:
|
||||
while True:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(
|
||||
f"download hop timed out: {current_url}"
|
||||
)
|
||||
chunk = response.read(65536)
|
||||
if not chunk:
|
||||
return
|
||||
total += len(chunk)
|
||||
if max_bytes and total > max_bytes:
|
||||
raise RuntimeError(
|
||||
f"download exceeds the {max_bytes}-byte safety limit"
|
||||
)
|
||||
out.write(chunk)
|
||||
|
||||
raise RuntimeError("too many redirects")
|
||||
|
||||
|
||||
def _verify_candidate(path: Path) -> None:
|
||||
"""Require a staged native candidate to execute successfully."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(path), "--version"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
timeout=_CANDIDATE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise RuntimeError(f"downloaded binary failed to run: {exc}") from exc
|
||||
|
||||
|
||||
def _safe_extract_tar(tf, dest: str) -> None:
|
||||
"""Extract a tarfile to dest, rejecting path-traversal entries.
|
||||
|
||||
@@ -73,37 +158,61 @@ def _safe_extract_zip(zf, dest: str) -> None:
|
||||
def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None:
|
||||
"""Verify SHA256 checksum against checksums.txt from the release."""
|
||||
url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt"
|
||||
tmp_path = None
|
||||
try:
|
||||
_validate_url_scheme(url)
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
urllib.request.urlretrieve(url, tmp_path) # noqa: S310 — scheme validated above
|
||||
with open(tmp_path) as f:
|
||||
_download_https(url, tmp_path, _MAX_CHECKSUM_MANIFEST_BYTES)
|
||||
expected = None
|
||||
with open(tmp_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if archive_name in line:
|
||||
expected = line.split()[0]
|
||||
h = hashlib.sha256()
|
||||
with open(archive_path, "rb") as af:
|
||||
for chunk in iter(lambda: af.read(65536), b""):
|
||||
h.update(chunk)
|
||||
actual = h.hexdigest()
|
||||
if expected != actual:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n"
|
||||
f" expected: {expected}\n"
|
||||
f" actual: {actual}"
|
||||
)
|
||||
print("codebase-memory-mcp: checksum verified.", file=sys.stderr)
|
||||
break
|
||||
fields = line.split()
|
||||
if len(fields) < 2 or fields[1] not in (
|
||||
archive_name,
|
||||
f"*{archive_name}",
|
||||
):
|
||||
continue
|
||||
digest = fields[0].lower()
|
||||
if len(digest) != 64 or any(
|
||||
ch not in "0123456789abcdef" for ch in digest
|
||||
):
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: invalid SHA256 checksum for "
|
||||
f"{archive_name}"
|
||||
)
|
||||
if expected is not None and expected != digest:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: conflicting SHA256 checksums for "
|
||||
f"{archive_name}"
|
||||
)
|
||||
expected = digest
|
||||
if expected is None:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: no checksum for {archive_name} in checksums.txt"
|
||||
)
|
||||
h = hashlib.sha256()
|
||||
with open(archive_path, "rb") as af:
|
||||
for chunk in iter(lambda: af.read(65536), b""):
|
||||
h.update(chunk)
|
||||
actual = h.hexdigest()
|
||||
if expected != actual:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n"
|
||||
f" expected: {expected}\n"
|
||||
f" actual: {actual}"
|
||||
)
|
||||
print("codebase-memory-mcp: checksum verified.", file=sys.stderr)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
pass # Non-fatal: checksum unavailable
|
||||
except Exception as exc:
|
||||
sys.exit(f"codebase-memory-mcp: checksum verification failed: {exc}")
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
if tmp_path is not None:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _version() -> str:
|
||||
@@ -175,8 +284,8 @@ def _download(version: str) -> Path:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_archive = os.path.join(tmp, f"cbm.{ext}")
|
||||
try:
|
||||
urllib.request.urlretrieve(url, tmp_archive) # noqa: S310 — scheme validated above
|
||||
except urllib.error.HTTPError as e:
|
||||
_download_https(url, tmp_archive)
|
||||
except (OSError, RuntimeError, urllib.error.URLError) as e:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: download failed ({e})\n"
|
||||
f"URL: {url}\n"
|
||||
@@ -199,9 +308,40 @@ def _download(version: str) -> Path:
|
||||
if not os.path.exists(extracted):
|
||||
sys.exit("codebase-memory-mcp: binary not found after extraction")
|
||||
|
||||
shutil.copy2(extracted, dest)
|
||||
current = dest.stat().st_mode
|
||||
dest.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
extracted_path = Path(extracted)
|
||||
current = extracted_path.stat().st_mode
|
||||
extracted_path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
try:
|
||||
_verify_candidate(extracted_path)
|
||||
except RuntimeError as exc:
|
||||
sys.exit(f"codebase-memory-mcp: {exc}")
|
||||
|
||||
staged_path = None
|
||||
try:
|
||||
staged_suffix = ".tmp.exe" if sys.platform == "win32" else ".tmp"
|
||||
with tempfile.NamedTemporaryFile(
|
||||
dir=str(dest.parent),
|
||||
prefix=f".{dest.name}.",
|
||||
suffix=staged_suffix,
|
||||
delete=False,
|
||||
) as staged:
|
||||
staged_path = Path(staged.name)
|
||||
shutil.copy2(extracted_path, staged_path)
|
||||
staged_mode = staged_path.stat().st_mode
|
||||
staged_path.chmod(
|
||||
staged_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
)
|
||||
_verify_candidate(staged_path)
|
||||
os.replace(staged_path, dest)
|
||||
staged_path = None
|
||||
except RuntimeError as exc:
|
||||
sys.exit(f"codebase-memory-mcp: {exc}")
|
||||
finally:
|
||||
if staged_path is not None:
|
||||
try:
|
||||
staged_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return dest
|
||||
|
||||
@@ -222,6 +362,5 @@ def main() -> None:
|
||||
if sys.platform != "win32":
|
||||
os.execv(str(bin_path), args) # noqa: S606 — list form, no shell
|
||||
else:
|
||||
import subprocess
|
||||
result = subprocess.run(args) # noqa: S603 — list form, no shell=True
|
||||
sys.exit(result.returncode)
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
+59
-18
@@ -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) ---"
|
||||
echo "--- Scanning for raw network calls ---"
|
||||
|
||||
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."
|
||||
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."
|
||||
|
||||
@@ -47,7 +47,6 @@ ea81fb7bd05882e0e0b92c4d60f677b205f7f1fbf085f218b12f0b5b3f0b9e48 vendored/sqlit
|
||||
eab23b8e79ee90f78e8495de64519afb61b627e062804fd4a622784e052a85fa vendored/tre/regerror.c
|
||||
26b0f550d491335cdaa3fecfe49213d68466befdf648ed281ccdaa631ea6d4f9 vendored/tre/regex.h
|
||||
fd6fe2789439d3d28140c27edfe6bcdde1d1c737cab4bd27b1287d3e759fa82d vendored/tre/regexec.c
|
||||
90f76dce41eade7e28c3477d8b45acb8d2ccbc6d4aaba0bb93f0d5ec5b160820 vendored/tre/tre_all.c
|
||||
e98c7732fdbb35ec182edfe043743d7e6b4ad7bcf57b815ec9f37f0d1065a062 vendored/tre/tre-ast.c
|
||||
f5d0374597a42f4bf0e7a80001a68bae9ea2622b80f760d8005200fd20acaf0f vendored/tre/tre-ast.h
|
||||
45407a83ef0151a977cb7f8a5275b2a9831ae570d6f27a43723c8da1e76c0261 vendored/tre/tre-compile.c
|
||||
@@ -67,6 +66,7 @@ a04e1bea47aff5d858460c1d08aac6ed3a3c8ee285500281dd3147ff0621095e vendored/tre/t
|
||||
4c9af903178f5f7030962b5708d4e656f8b060e795852e1eee883696b682849d vendored/tre/tre-stack.c
|
||||
aabe11f1b7c6c627dc9cfb62cfb9565ac9ebdf2c51c2d55c5320af5db76c5e3b vendored/tre/tre-stack.h
|
||||
1c2d81474d2b59b7a39f5b1592473adfb4109fad99156588088f2ccc56c654ab vendored/tre/tre.h
|
||||
90f76dce41eade7e28c3477d8b45acb8d2ccbc6d4aaba0bb93f0d5ec5b160820 vendored/tre/tre_all.c
|
||||
9632e5eeb20e3d328f8def0efb2e8230f5b5cb7d9f2e5680ad89caf065f8b3a6 vendored/tre/xmalloc.c
|
||||
22aee25e6892e97719ec4a5fad91345cd2145722ebbd3ec31397aff68108e3e6 vendored/tre/xmalloc.h
|
||||
5c3591fe6e6c86a619eb26760e9520e37a6fd5152882ab5ad93f912e2a855966 vendored/xxhash/xxhash.c
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* activation_transaction.h -- Transactional binary activation primitives.
|
||||
*
|
||||
* This is an internal CLI module. It deliberately knows nothing about daemon
|
||||
* coordination or editor configuration: callers must acquire the maintenance
|
||||
* barrier before commit and retain it until finalize/rollback completes.
|
||||
*/
|
||||
#ifndef CBM_ACTIVATION_TRANSACTION_H
|
||||
#define CBM_ACTIVATION_TRANSACTION_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
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 */
|
||||
+2070
-282
File diff suppressed because it is too large
Load Diff
+77
-1
@@ -12,6 +12,8 @@
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct cbm_mcp_server cbm_mcp_server_t;
|
||||
|
||||
/* ── Version ──────────────────────────────────────────────────── */
|
||||
|
||||
/* Set the version string (called from main). */
|
||||
@@ -36,6 +38,11 @@ char *cbm_cli_build_args_json(const char *tool_name, int argc, char **argv, char
|
||||
* non-zero (and prints nothing) if it is not. */
|
||||
int cbm_cli_print_tool_help(const char *tool_name);
|
||||
|
||||
/* Inspect a raw MCP tool-result envelope. Returns true only when the root
|
||||
* object carries the exact boolean field `isError: true`; malformed JSON,
|
||||
* strings, and nested lookalikes are not tool errors. */
|
||||
bool cbm_cli_mcp_result_is_error(const char *result);
|
||||
|
||||
/* ── Self-update: version comparison ──────────────────────────── */
|
||||
|
||||
/* Compare two semver strings (e.g. "0.2.1" vs "0.2.0").
|
||||
@@ -345,7 +352,8 @@ cbm_config_t *cbm_config_open(const char *cache_dir);
|
||||
/* Close the config store. */
|
||||
void cbm_config_close(cbm_config_t *cfg);
|
||||
|
||||
/* Get a config value. Returns default_val if key not found. */
|
||||
/* Get a config value. Returns default_val if key not found. Database-backed
|
||||
* results remain valid until the next call on the same thread. */
|
||||
const char *cbm_config_get(cbm_config_t *cfg, const char *key, const char *default_val);
|
||||
|
||||
/* Get a config value as bool. "true"/"1"/"on" → true. */
|
||||
@@ -366,6 +374,53 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key);
|
||||
#define CBM_CONFIG_AUTO_WATCH "auto_watch"
|
||||
#define CBM_CONFIG_UI_LANG "ui-lang"
|
||||
|
||||
/* ── Binary activation safety ─────────────────────────────────── */
|
||||
|
||||
typedef void *cbm_cli_activation_lock_t;
|
||||
typedef int (*cbm_cli_activation_mutation_fn)(void *context);
|
||||
|
||||
/* High-level mutation reservation seam. Production maps this to the
|
||||
* crash-safe version-cohort maintenance barrier, which requests bounded
|
||||
* quiescence of active modern CBM processes before returning an exclusive
|
||||
* lease. Tests use it to prove that mutation never runs before the cohort has
|
||||
* drained. Return 1 with a non-NULL lease on success, 0 when participants did
|
||||
* not drain before the deadline, or -1 on an unsafe/IO failure. */
|
||||
typedef int (*cbm_cli_activation_reserve_fn)(
|
||||
void *context, cbm_cli_activation_lock_t *lease_out);
|
||||
typedef void (*cbm_cli_activation_release_fn)(
|
||||
void *context, cbm_cli_activation_lock_t lease);
|
||||
|
||||
/* Injectable high-level boundary for deterministic drain/mutation ordering
|
||||
* tests. Production never acquires startup while asking participants to
|
||||
* quiesce: a bootstrap participant may already retain it. Only after lifetime
|
||||
* EX proves the cohort drained does production acquire startup, re-probe the
|
||||
* daemon generation, and retain startup through mutation. Startup is released
|
||||
* before the maintenance lease. */
|
||||
typedef struct {
|
||||
void *context;
|
||||
cbm_cli_activation_reserve_fn reserve_for_mutation;
|
||||
cbm_cli_activation_release_fn mutation_lease_release;
|
||||
void (*visible_diagnostic)(void *context, const char *message);
|
||||
} cbm_cli_activation_ops_t;
|
||||
|
||||
/* Reserve and drain the coordinated cohort, run the mutation only while its
|
||||
* lease is retained, then release it on every callback result. A NULL mutation
|
||||
* is a guarded no-op. Returns 0 on success, 1 when activation is refused, or
|
||||
* the mutation callback's non-zero error. */
|
||||
int cbm_cli_activation_guard_with_ops(const cbm_cli_activation_ops_t *ops,
|
||||
cbm_cli_activation_mutation_fn mutation,
|
||||
void *mutation_context);
|
||||
|
||||
/* Internal test seam used only by the C suite to route install/update/uninstall
|
||||
* through deterministic callbacks. NULL restores the production daemon IPC path. */
|
||||
void cbm_cli_set_activation_ops_for_test(const cbm_cli_activation_ops_t *ops);
|
||||
|
||||
/* Internal integration-test seam: isolate the stable endpoint beneath a
|
||||
* private runtime parent. NULL restores the platform default. This is not a
|
||||
* command-line or environment override. */
|
||||
void cbm_cli_set_activation_runtime_parent_for_test(
|
||||
const char *runtime_parent);
|
||||
|
||||
/* ── Subcommands (wired from main.c) ─────────────────────────── */
|
||||
|
||||
/* install: copy binary, install skills, install editor MCP configs, ensure PATH.
|
||||
@@ -398,6 +453,27 @@ char *cbm_hook_augment_lifecycle_json(const char *input);
|
||||
char *cbm_hook_augment_lifecycle_json_for(const char *input, const char *forced_event,
|
||||
bool copilot_dialect);
|
||||
|
||||
/* Thin daemon frontend support: preserve the hook's bounded stdin read and
|
||||
* hard fail-open deadline without constructing a local MCP/store instance. */
|
||||
void cbm_hook_augment_arm_deadline(void);
|
||||
char *cbm_hook_augment_read_stdin(void);
|
||||
|
||||
/* Process one already-read hook payload using a caller-owned MCP session.
|
||||
* Returns a malloc-owned hook output JSON string, or NULL for fail-open/no
|
||||
* augmentation. This is the daemon entry; it never arms a process-global
|
||||
* timer and never constructs a second store/session. */
|
||||
char *cbm_hook_augment_process(cbm_mcp_server_t *srv, const char *input_json);
|
||||
|
||||
/* Dialect-aware daemon entry. forced_event and dialect_name are borrowed and
|
||||
* may be NULL for the ordinary event dialect. Unsupported combinations fail
|
||||
* open with NULL, matching the direct hook command. */
|
||||
bool cbm_hook_augment_invocation_supported(const char *forced_event,
|
||||
const char *dialect_name);
|
||||
char *cbm_hook_augment_process_for(cbm_mcp_server_t *srv,
|
||||
const char *input_json,
|
||||
const char *forced_event,
|
||||
const char *dialect_name);
|
||||
|
||||
/* True for an absolute path the augmenter can walk up: POSIX "/..." or a
|
||||
* Windows drive root — "X:/..." or a bare "X:" (callers normalize '\\' to '/'
|
||||
* first). Exposed for tests — regression coverage for the Windows drive-letter
|
||||
|
||||
+145
-120
@@ -123,7 +123,7 @@ static void ha_open_crumb_log(int deadline_ms) {
|
||||
g_ha_crumb_len = (n > 0 && n < (int)sizeof(g_ha_crumb_msg)) ? (size_t)n : 0;
|
||||
}
|
||||
|
||||
static void ha_arm_deadline(void) {
|
||||
void cbm_hook_augment_arm_deadline(void) {
|
||||
int ms = ha_deadline_ms();
|
||||
ha_open_crumb_log(ms);
|
||||
|
||||
@@ -145,7 +145,7 @@ static VOID CALLBACK ha_deadline_exit_windows(PVOID context, BOOLEAN fired) {
|
||||
ExitProcess(0U);
|
||||
}
|
||||
|
||||
static void ha_arm_deadline(void) {
|
||||
void cbm_hook_augment_arm_deadline(void) {
|
||||
HANDLE timer = NULL;
|
||||
(void)CreateTimerQueueTimer(&timer, NULL, ha_deadline_exit_windows, NULL, HA_DEADLINE_MS, 0U,
|
||||
WT_EXECUTEONLYONCE);
|
||||
@@ -154,7 +154,7 @@ static void ha_arm_deadline(void) {
|
||||
|
||||
/* ── stdin ────────────────────────────────────────────────────────── */
|
||||
|
||||
static char *ha_read_stdin(void) {
|
||||
char *cbm_hook_augment_read_stdin(void) {
|
||||
char *buf = malloc(HA_STDIN_CAP + 1);
|
||||
if (!buf) {
|
||||
return NULL;
|
||||
@@ -646,14 +646,6 @@ static char *ha_build_cline_json(const char *text) {
|
||||
return json;
|
||||
}
|
||||
|
||||
/* Emit one context-only hook response. Never add decisions or tool rewrites. */
|
||||
static void ha_emit(const char *event, const char *text) {
|
||||
char *json = event && text ? ha_build_event_json(event, text) : NULL;
|
||||
if (json) {
|
||||
fputs(json, stdout);
|
||||
free(json);
|
||||
}
|
||||
}
|
||||
|
||||
/* True for an absolute path we can walk up: POSIX "/..." or a Windows drive
|
||||
* root — "X:/..." or a bare "X:" (callers normalize '\\' to '/' first).
|
||||
@@ -876,7 +868,10 @@ static const char *ha_hook_event_name(yyjson_val *root) {
|
||||
return event ? event : ha_obj_str(root, "hookEventName");
|
||||
}
|
||||
|
||||
static const char *ha_normalized_cwd(yyjson_val *root, char *buffer, size_t buffer_size) {
|
||||
static const char *ha_normalized_cwd_with_server(yyjson_val *root,
|
||||
cbm_mcp_server_t *srv,
|
||||
char *buffer,
|
||||
size_t buffer_size) {
|
||||
const char *cwd = ha_obj_str(root, "cwd");
|
||||
if (!cwd) {
|
||||
yyjson_val *roots = root ? yyjson_obj_get(root, "workspace_roots") : NULL;
|
||||
@@ -897,6 +892,18 @@ static const char *ha_normalized_cwd(yyjson_val *root, char *buffer, size_t buff
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
const char *session_root = srv ? cbm_mcp_server_session_root(srv) : NULL;
|
||||
if (session_root && strlen(session_root) < buffer_size) {
|
||||
snprintf(buffer, buffer_size, "%s", session_root);
|
||||
for (char *cursor = buffer; *cursor; cursor++) {
|
||||
if (*cursor == '\\') {
|
||||
*cursor = '/';
|
||||
}
|
||||
}
|
||||
if (cbm_hook_path_is_abs(buffer)) {
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
#ifndef _WIN32
|
||||
return getcwd(buffer, buffer_size) && cbm_hook_path_is_abs(buffer) ? buffer : NULL;
|
||||
#else
|
||||
@@ -904,6 +911,11 @@ static const char *ha_normalized_cwd(yyjson_val *root, char *buffer, size_t buff
|
||||
#endif
|
||||
}
|
||||
|
||||
static const char *ha_normalized_cwd(yyjson_val *root, char *buffer,
|
||||
size_t buffer_size) {
|
||||
return ha_normalized_cwd_with_server(root, NULL, buffer, buffer_size);
|
||||
}
|
||||
|
||||
static bool ha_lifecycle_event_supported(const char *event) {
|
||||
return event && (strcmp(event, "SessionStart") == 0 || strcmp(event, "SubagentStart") == 0);
|
||||
}
|
||||
@@ -1104,7 +1116,9 @@ static bool ha_invocation_supported(ha_lifecycle_dialect_t dialect, const char *
|
||||
return !forced_event || ha_dialect_event_supported(dialect, forced_event);
|
||||
}
|
||||
|
||||
static char *ha_lifecycle_json_from_root(yyjson_val *root, const char *forced_event,
|
||||
static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv,
|
||||
yyjson_val *root,
|
||||
const char *forced_event,
|
||||
ha_lifecycle_dialect_t dialect) {
|
||||
if (!root || !yyjson_is_obj(root)) {
|
||||
return NULL;
|
||||
@@ -1115,12 +1129,15 @@ static char *ha_lifecycle_json_from_root(yyjson_val *root, const char *forced_ev
|
||||
}
|
||||
|
||||
char cwd_buffer[4096];
|
||||
const char *cwd = ha_normalized_cwd(root, cwd_buffer, sizeof(cwd_buffer));
|
||||
cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
|
||||
char *project = server && cwd ? ha_resolve_indexed_project(server, cwd) : NULL;
|
||||
if (server) {
|
||||
cbm_mcp_server_free(server);
|
||||
cbm_mcp_server_t *owned_server = NULL;
|
||||
if (!srv) {
|
||||
owned_server = cbm_mcp_server_new(NULL);
|
||||
srv = owned_server;
|
||||
}
|
||||
const char *cwd = ha_normalized_cwd_with_server(
|
||||
root, srv, cwd_buffer, sizeof(cwd_buffer));
|
||||
char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL;
|
||||
cbm_mcp_server_free(owned_server);
|
||||
|
||||
char context[2048];
|
||||
const char *scope = "Session";
|
||||
@@ -1198,7 +1215,8 @@ char *cbm_hook_augment_lifecycle_json_for(const char *input, const char *forced_
|
||||
return NULL;
|
||||
}
|
||||
ha_lifecycle_dialect_t dialect = copilot_dialect ? HA_DIALECT_COPILOT : HA_DIALECT_EVENT;
|
||||
char *json = ha_lifecycle_json_from_root(yyjson_doc_get_root(doc), forced_event, dialect);
|
||||
char *json = ha_lifecycle_json_from_root(
|
||||
NULL, yyjson_doc_get_root(doc), forced_event, dialect);
|
||||
yyjson_doc_free(doc);
|
||||
return json;
|
||||
}
|
||||
@@ -1217,7 +1235,8 @@ char *cbm_hook_augment_lifecycle_json_for_dialect(const char *input, const char
|
||||
if (!doc) {
|
||||
return NULL;
|
||||
}
|
||||
char *json = ha_lifecycle_json_from_root(yyjson_doc_get_root(doc), forced_event, dialect);
|
||||
char *json = ha_lifecycle_json_from_root(
|
||||
NULL, yyjson_doc_get_root(doc), forced_event, dialect);
|
||||
yyjson_doc_free(doc);
|
||||
return json;
|
||||
}
|
||||
@@ -1268,8 +1287,107 @@ const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) {
|
||||
}
|
||||
#endif
|
||||
|
||||
static char *ha_process(cbm_mcp_server_t *srv, const char *input_json,
|
||||
const char *forced_event,
|
||||
ha_lifecycle_dialect_t dialect) {
|
||||
if (!srv || !input_json) {
|
||||
return NULL;
|
||||
}
|
||||
if (strlen(input_json) > HA_STDIN_CAP) {
|
||||
return NULL;
|
||||
}
|
||||
yyjson_doc *doc = yyjson_read(input_json, strlen(input_json), 0);
|
||||
if (!doc) {
|
||||
return NULL;
|
||||
}
|
||||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||||
|
||||
char *lifecycle =
|
||||
ha_lifecycle_json_from_root(srv, root, forced_event, dialect);
|
||||
if (lifecycle) {
|
||||
yyjson_doc_free(doc);
|
||||
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);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
yyjson_val *tin = yyjson_obj_get(root, "tool_input");
|
||||
|
||||
/* Post-read coverage adapters warn only when the exact path is not fully
|
||||
* represented. They never block or rewrite the completed tool call. */
|
||||
if (coverage) {
|
||||
char fpbuf[4096];
|
||||
if (ha_normalized_tool_path(root, tin, fpbuf, sizeof(fpbuf))) {
|
||||
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);
|
||||
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);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char cwdbuf[4096];
|
||||
const char *cwd = ha_normalized_cwd_with_server(
|
||||
root, srv, cwdbuf, sizeof(cwdbuf));
|
||||
if (!cwd) {
|
||||
yyjson_doc_free(doc);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
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) {
|
||||
ha_arm_deadline();
|
||||
cbm_hook_augment_arm_deadline();
|
||||
|
||||
const char *forced_event = NULL;
|
||||
ha_lifecycle_dialect_t dialect = HA_DIALECT_EVENT;
|
||||
@@ -1289,111 +1407,18 @@ int cbm_cmd_hook_augment(int argc, char **argv) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *input = ha_read_stdin();
|
||||
char *input = cbm_hook_augment_read_stdin();
|
||||
if (!input) {
|
||||
return 0;
|
||||
}
|
||||
yyjson_doc *doc = yyjson_read(input, strlen(input), 0);
|
||||
if (!doc) {
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||||
|
||||
char *lifecycle = ha_lifecycle_json_from_root(root, forced_event, dialect);
|
||||
if (lifecycle) {
|
||||
fputs(lifecycle, stdout);
|
||||
free(lifecycle);
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
yyjson_val *tin = yyjson_obj_get(root, "tool_input");
|
||||
|
||||
/* Post-read coverage adapters warn only when the exact path is not fully
|
||||
* represented. They never block or rewrite the completed tool call. */
|
||||
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);
|
||||
}
|
||||
}
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
#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 = '/';
|
||||
}
|
||||
}
|
||||
cwd = cwdbuf;
|
||||
}
|
||||
if (!cwd || !cbm_hook_path_is_abs(cwd)) {
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
if (!srv) {
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
char *output =
|
||||
srv ? ha_process(srv, input, forced_event, dialect) : NULL;
|
||||
if (output) {
|
||||
fputs(output, stdout);
|
||||
}
|
||||
|
||||
char *ctx = ha_resolve_and_query(srv, cwd, token);
|
||||
if (ctx) {
|
||||
ha_emit(event, ctx);
|
||||
free(ctx);
|
||||
}
|
||||
|
||||
free(output);
|
||||
cbm_mcp_server_free(srv);
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
|
||||
+157
-7
@@ -1,12 +1,16 @@
|
||||
/*
|
||||
* progress_sink.c — Human-readable progress for --progress CLI flag.
|
||||
* progress_sink.c — Human-readable progress for one-shot CLI commands.
|
||||
*
|
||||
* Maps structured log events (msg=pass.timing, msg=pipeline.done, etc.)
|
||||
* to phase labels on stderr. When installed, replaces default log output.
|
||||
* to phase labels on stderr. Interactive terminals enable it automatically;
|
||||
* --progress forces it for redirected stderr. When installed, it replaces
|
||||
* default log output.
|
||||
*
|
||||
* Thread-safe: fprintf has per-FILE* locking on POSIX.
|
||||
* Thread-safe: one sink mutex serializes shared summary state and each complete
|
||||
* output update, not merely the individual stdio calls.
|
||||
*/
|
||||
#include "progress_sink.h"
|
||||
#include "foundation/compat_thread.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "foundation/log.h"
|
||||
|
||||
@@ -15,14 +19,147 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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);
|
||||
|
||||
/* Extract value of "key=VALUE" from a structured log line. */
|
||||
/* 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 one string field from the logger's compact JSON format. Keys are
|
||||
* accepted only at object boundaries so worker-controlled values cannot spoof
|
||||
* a progress event by merely containing a key-shaped substring. */
|
||||
static const char *extract_json_field(const char *line, const char *key, char *buf,
|
||||
int buf_len) {
|
||||
char needle[CBM_SZ_64];
|
||||
int needle_len = snprintf(needle, sizeof(needle), "\"%s\":", key);
|
||||
if (needle_len <= 0 || needle_len >= (int)sizeof(needle)) {
|
||||
return NULL;
|
||||
}
|
||||
const char *candidate = line;
|
||||
while ((candidate = strstr(candidate, needle)) != NULL) {
|
||||
const char *before = candidate;
|
||||
while (before > line && (before[-1] == ' ' || before[-1] == '\t')) {
|
||||
before--;
|
||||
}
|
||||
if (before == line || (before[-1] != '{' && before[-1] != ',')) {
|
||||
candidate += needle_len;
|
||||
continue;
|
||||
}
|
||||
const char *value = candidate + needle_len;
|
||||
while (*value == ' ' || *value == '\t') {
|
||||
value++;
|
||||
}
|
||||
if (*value++ != '\"') {
|
||||
return NULL;
|
||||
}
|
||||
int offset = 0;
|
||||
bool escaped = false;
|
||||
while (*value && offset < buf_len - 1) {
|
||||
if (!escaped && *value == '\"') {
|
||||
buf[offset] = '\0';
|
||||
return buf;
|
||||
}
|
||||
if (!escaped && *value == '\\') {
|
||||
escaped = true;
|
||||
value++;
|
||||
continue;
|
||||
}
|
||||
if (escaped) {
|
||||
switch (*value) {
|
||||
case 'n':
|
||||
buf[offset++] = '\n';
|
||||
break;
|
||||
case 'r':
|
||||
buf[offset++] = '\r';
|
||||
break;
|
||||
case 't':
|
||||
buf[offset++] = '\t';
|
||||
break;
|
||||
default:
|
||||
buf[offset++] = *value;
|
||||
break;
|
||||
}
|
||||
escaped = false;
|
||||
} else {
|
||||
buf[offset++] = *value;
|
||||
}
|
||||
value++;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Extract a field from either structured text or JSON worker logs. */
|
||||
static const char *extract_kv(const char *line, const char *key, char *buf, int buf_len) {
|
||||
if (!line || !key || !buf || buf_len <= 0) {
|
||||
return NULL;
|
||||
@@ -42,24 +179,31 @@ static const char *extract_kv(const char *line, const char *key, char *buf, int
|
||||
}
|
||||
p++;
|
||||
}
|
||||
return NULL;
|
||||
const char *json_key = strcmp(key, "msg") == 0 ? "event" : key;
|
||||
return extract_json_field(line, json_key, buf, buf_len);
|
||||
}
|
||||
|
||||
void cbm_progress_sink_init(FILE *out) {
|
||||
progress_sink_mutex_ensure();
|
||||
cbm_mutex_lock(&s_sink_mutex);
|
||||
s_out = out ? out : stderr;
|
||||
atomic_store(&s_needs_newline, 0);
|
||||
s_gbuf_nodes = NOT_SET;
|
||||
s_gbuf_edges = NOT_SET;
|
||||
cbm_log_set_sink(cbm_progress_sink_fn);
|
||||
cbm_mutex_unlock(&s_sink_mutex);
|
||||
}
|
||||
|
||||
void cbm_progress_sink_fini(void) {
|
||||
progress_sink_mutex_ensure();
|
||||
cbm_mutex_lock(&s_sink_mutex);
|
||||
cbm_log_set_sink(NULL);
|
||||
if (atomic_load(&s_needs_newline) && s_out) {
|
||||
(void)fprintf(s_out, "\n");
|
||||
(void)fflush(s_out);
|
||||
}
|
||||
cbm_log_set_sink(NULL);
|
||||
s_out = NULL;
|
||||
cbm_mutex_unlock(&s_sink_mutex);
|
||||
}
|
||||
|
||||
/* Phase label table: maps pass names to display labels. */
|
||||
@@ -201,17 +345,23 @@ static const event_handler_t handlers[] = {
|
||||
enum { HANDLER_COUNT = sizeof(handlers) / sizeof(handlers[0]) };
|
||||
|
||||
void cbm_progress_sink_fn(const char *line) {
|
||||
progress_sink_mutex_ensure();
|
||||
cbm_mutex_lock(&s_sink_mutex);
|
||||
if (!line || !s_out) {
|
||||
cbm_mutex_unlock(&s_sink_mutex);
|
||||
return;
|
||||
}
|
||||
char msg[CBM_SZ_64] = {0};
|
||||
if (!extract_kv(line, "msg", msg, (int)sizeof(msg))) {
|
||||
cbm_mutex_unlock(&s_sink_mutex);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < HANDLER_COUNT; i++) {
|
||||
if (strcmp(msg, handlers[i].msg) == 0) {
|
||||
handlers[i].handler(line);
|
||||
cbm_mutex_unlock(&s_sink_mutex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
cbm_mutex_unlock(&s_sink_mutex);
|
||||
}
|
||||
|
||||
+12
-1
@@ -1,7 +1,9 @@
|
||||
/*
|
||||
* progress_sink.h — Human-readable progress output for --progress CLI flag.
|
||||
* progress_sink.h — Human-readable progress for one-shot CLI commands.
|
||||
*
|
||||
* Installs a log sink that maps structured pipeline events to phase labels.
|
||||
* Interactive terminals enable it automatically; --progress forces it when
|
||||
* stderr is redirected.
|
||||
* Usage:
|
||||
* cbm_progress_sink_init(stderr);
|
||||
* // ... run pipeline ...
|
||||
@@ -10,8 +12,17 @@
|
||||
#ifndef CBM_PROGRESS_SINK_H
|
||||
#define CBM_PROGRESS_SINK_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* 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);
|
||||
|
||||
+5
-4
@@ -6,6 +6,7 @@
|
||||
* RETURN with COUNT/ORDER BY/LIMIT/DISTINCT.
|
||||
*/
|
||||
#include "cypher/cypher.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "store/store.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/limits.h"
|
||||
@@ -2338,7 +2339,7 @@ static const char *json_extract_prop(const char *json, const char *key, char *bu
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* Get edge property by name. Uses rotating static buffers to allow
|
||||
/* Get edge property by name. Uses rotating thread-local buffers to allow
|
||||
* multiple concurrent calls (e.g. projecting r.url_path, r.confidence
|
||||
* in the same row). */
|
||||
static const char *edge_prop(const cbm_edge_t *e, const char *prop) {
|
||||
@@ -2348,9 +2349,9 @@ static const char *edge_prop(const cbm_edge_t *e, const char *prop) {
|
||||
if (strcmp(prop, "type") == 0) {
|
||||
return e->type ? e->type : "";
|
||||
}
|
||||
/* Rotate through 8 static buffers so multiple props can be accessed per row */
|
||||
static char ebufs[CYP_BUF_8][CBM_SZ_512];
|
||||
static int ebuf_idx = 0;
|
||||
/* Rotate through 8 thread-local buffers so multiple props can be accessed per row. */
|
||||
static CBM_TLS char ebufs[CYP_BUF_8][CBM_SZ_512];
|
||||
static CBM_TLS int ebuf_idx = 0;
|
||||
char *buf = ebufs[ebuf_idx++ & CYP_EBUF_MASK];
|
||||
json_extract_prop(e->properties_json, prop, buf, CBM_SZ_512);
|
||||
return buf;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* application.h — Daemon-owned CBM application sessions and thin-client wire.
|
||||
*
|
||||
* The runtime authenticates a local process and owns connection lifetime. This
|
||||
* layer owns everything above that boundary: one isolated MCP session per
|
||||
* connection, explicit workspace context, shared watcher subscriptions, and
|
||||
* daemon-owned index jobs. Frontends never construct stores or watchers.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_APPLICATION_H
|
||||
#define CBM_DAEMON_APPLICATION_H
|
||||
|
||||
#include "daemon/runtime.h"
|
||||
#include "daemon/project_lock.h"
|
||||
#include "mcp/index_supervisor.h"
|
||||
#include "mcp/mcp.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
struct cbm_config;
|
||||
struct cbm_watcher;
|
||||
|
||||
typedef struct cbm_daemon_application cbm_daemon_application_t;
|
||||
typedef void *cbm_daemon_application_worker_t;
|
||||
|
||||
/* Injectable physical-worker boundary. Production uses index_supervisor;
|
||||
* tests use this to deterministically hold/release one shared job. */
|
||||
typedef struct {
|
||||
void *context;
|
||||
int (*start)(void *context, const char *args_json, size_t memory_budget_bytes,
|
||||
const char *marker_file, const char *quarantine_file,
|
||||
cbm_daemon_application_worker_t *worker_out);
|
||||
cbm_index_worker_poll_t (*poll)(void *context, cbm_daemon_application_worker_t worker,
|
||||
const cbm_index_worker_result_t **result_out);
|
||||
bool (*cancel)(void *context, cbm_daemon_application_worker_t worker);
|
||||
const char *(*log_path)(void *context, cbm_daemon_application_worker_t worker);
|
||||
void (*destroy)(void *context, cbm_daemon_application_worker_t worker);
|
||||
} cbm_daemon_application_worker_ops_t;
|
||||
|
||||
typedef struct {
|
||||
struct cbm_watcher *watcher; /* borrowed; daemon lifetime */
|
||||
struct cbm_config *config; /* borrowed; daemon lifetime */
|
||||
const cbm_daemon_application_worker_ops_t *worker_ops; /* NULL = production */
|
||||
/* Maximum distinct, non-terminal physical index jobs. Zero selects the
|
||||
* conservative daemon default (4). Identical requests still coalesce even
|
||||
* while the limit is full. */
|
||||
size_t physical_job_limit;
|
||||
/* Aggregate budget shared by every concurrently admitted physical worker.
|
||||
* The daemon host supplies its already-resolved CBM process budget. */
|
||||
size_t aggregate_memory_budget_bytes;
|
||||
/* Borrowed native-lock manager for daemon-owned mutations (MCP tools,
|
||||
* watcher/UI operations). Index workers create and own their independent
|
||||
* process-local manager; the daemon job registry reserves index projects
|
||||
* in-process and must not hold the worker's OS lease. */
|
||||
cbm_project_lock_manager_t *project_locks;
|
||||
} cbm_daemon_application_config_t;
|
||||
|
||||
typedef enum {
|
||||
CBM_DAEMON_APPLICATION_REQUEST_SET_CONTEXT = 1,
|
||||
CBM_DAEMON_APPLICATION_REQUEST_MCP = 2,
|
||||
CBM_DAEMON_APPLICATION_REQUEST_TOOL = 3,
|
||||
CBM_DAEMON_APPLICATION_REQUEST_HOOK_AUGMENT = 4,
|
||||
CBM_DAEMON_APPLICATION_REQUEST_SET_UI_CONFIG = 5,
|
||||
} cbm_daemon_application_request_kind_t;
|
||||
|
||||
typedef enum {
|
||||
CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED = 1U << 0,
|
||||
CBM_DAEMON_APPLICATION_UI_CONFIG_PORT = 1U << 1,
|
||||
} cbm_daemon_application_ui_config_mask_t;
|
||||
|
||||
cbm_daemon_application_t *cbm_daemon_application_new(const cbm_daemon_application_config_t *config);
|
||||
|
||||
/* Cancel and reap all daemon-owned operations within timeout_ms. Normal final
|
||||
* client shutdown calls this before watcher/store teardown. Idempotent. */
|
||||
bool cbm_daemon_application_shutdown(cbm_daemon_application_t *application, uint32_t timeout_ms);
|
||||
void cbm_daemon_application_free(cbm_daemon_application_t *application);
|
||||
|
||||
/* Callbacks are borrowed from application and remain valid until free. */
|
||||
cbm_daemon_runtime_application_callbacks_t cbm_daemon_application_runtime_callbacks(
|
||||
cbm_daemon_application_t *application);
|
||||
|
||||
/* Thin-client request helpers. Every helper performs one bounded runtime
|
||||
* exchange. MCP notifications succeed with a NULL/zero response. Returned
|
||||
* response bytes are malloc-owned and include one trailing NUL for text use;
|
||||
* response_length excludes that terminator. */
|
||||
cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_context(
|
||||
cbm_daemon_runtime_client_t *client, const char *session_root,
|
||||
const char *allowed_root, cbm_mcp_tool_profile_t tool_profile,
|
||||
const char *hook_event, const char *hook_dialect, uint32_t timeout_ms);
|
||||
|
||||
/* Persist a masked UI configuration mutation in the daemon. A zero/unknown
|
||||
* mask, an invalid port, or a non-canonical unused field is rejected before
|
||||
* transport. */
|
||||
cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_ui_config(
|
||||
cbm_daemon_runtime_client_t *client, uint8_t update_mask, bool ui_enabled,
|
||||
int ui_port, uint32_t timeout_ms);
|
||||
|
||||
cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp(
|
||||
cbm_daemon_runtime_client_t *client, const char *message, uint8_t **response_out,
|
||||
uint32_t *response_length_out, uint32_t timeout_ms);
|
||||
|
||||
/* Cancellable frontend variant using a token reserved on the runtime client. */
|
||||
cbm_daemon_runtime_application_status_t
|
||||
cbm_daemon_application_client_mcp_tagged(
|
||||
cbm_daemon_runtime_client_t *client,
|
||||
cbm_daemon_runtime_application_token_t request_token,
|
||||
const char *message, uint8_t **response_out,
|
||||
uint32_t *response_length_out, uint32_t timeout_ms);
|
||||
|
||||
cbm_daemon_runtime_application_status_t cbm_daemon_application_client_tool(
|
||||
cbm_daemon_runtime_client_t *client, const char *tool_name, const char *args_json,
|
||||
uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms);
|
||||
|
||||
cbm_daemon_runtime_application_status_t cbm_daemon_application_client_hook_augment(
|
||||
cbm_daemon_runtime_client_t *client, const char *input_json, uint8_t **response_out,
|
||||
uint32_t *response_length_out, uint32_t timeout_ms);
|
||||
|
||||
/* Watcher callback: atomically validates project/root ownership and subscribes
|
||||
* the shared physical job to every exact live session watch. The callback is
|
||||
* only a result waiter, so disconnecting the final matching owner cancels the
|
||||
* job even while unrelated daemon sessions remain. Returns 0 on success,
|
||||
* positive when stale, cancelled, or busy (retry), and negative on a terminal
|
||||
* worker error. */
|
||||
int cbm_daemon_application_watcher_index(const char *project_name, const char *root_path,
|
||||
void *context);
|
||||
|
||||
/* Shared daemon/UI entry point for a default full index operation. */
|
||||
int cbm_daemon_application_index(cbm_daemon_application_t *application, const char *project_name,
|
||||
const char *root_path);
|
||||
|
||||
/* Non-blocking mutation lease for daemon-owned UI endpoints. A successful
|
||||
* begin must be paired with end. Returns false while the project (or global
|
||||
* project set "*") is reserved by indexing, another mutation, or shutdown. */
|
||||
bool cbm_daemon_application_project_mutation_try_begin(
|
||||
cbm_daemon_application_t *application, const char *project);
|
||||
void cbm_daemon_application_project_mutation_end(
|
||||
cbm_daemon_application_t *application, const char *project);
|
||||
|
||||
/* Read-only coordination metrics used by daemon diagnostics/tests. */
|
||||
size_t cbm_daemon_application_active_jobs(cbm_daemon_application_t *application);
|
||||
size_t cbm_daemon_application_job_subscribers(cbm_daemon_application_t *application,
|
||||
const char *project_key);
|
||||
|
||||
#endif /* CBM_DAEMON_APPLICATION_H */
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* application_internal.h — Private daemon application test seams.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_APPLICATION_INTERNAL_H
|
||||
#define CBM_DAEMON_APPLICATION_INTERNAL_H
|
||||
|
||||
#include "daemon/application.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Read-only test diagnostic for a live session. The caller must serialize
|
||||
* this check with request, cancellation, and close callbacks. */
|
||||
bool cbm_daemon_application_session_retains_store_for_test(
|
||||
const cbm_daemon_runtime_application_session_t *session);
|
||||
|
||||
#endif /* CBM_DAEMON_APPLICATION_INTERNAL_H */
|
||||
@@ -0,0 +1,811 @@
|
||||
/*
|
||||
* bootstrap.c — Mandatory per-account daemon startup policy.
|
||||
*/
|
||||
#include "daemon/bootstrap.h"
|
||||
|
||||
#include "daemon/ipc.h"
|
||||
#include "daemon/service.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/platform.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include "foundation/subprocess.h"
|
||||
#include "foundation/win_utf8.h"
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
enum {
|
||||
BOOTSTRAP_RETRY_NS = 1000000,
|
||||
BOOTSTRAP_COORDINATION_CLEANUP_MS = 500,
|
||||
BOOTSTRAP_PATH_CAP = 4096,
|
||||
};
|
||||
|
||||
static bool bootstrap_arg_is(const char *arg, const char *expected) {
|
||||
return arg && expected && strcmp(arg, expected) == 0;
|
||||
}
|
||||
|
||||
static int bootstrap_find_arg(int argc, char *const argv[], const char *expected) {
|
||||
for (int i = 1; argv && i < argc; i++) {
|
||||
if (bootstrap_arg_is(argv[i], expected)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static bool bootstrap_has_help_after(int argc, char *const argv[], int start) {
|
||||
for (int i = start; argv && i < argc; i++) {
|
||||
if (bootstrap_arg_is(argv[i], "--help") || bootstrap_arg_is(argv[i], "-h")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool bootstrap_worker_fingerprint_valid(const char *fingerprint) {
|
||||
if (!fingerprint || strlen(fingerprint) != 64U) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < 64U; i++) {
|
||||
char ch = fingerprint[i];
|
||||
if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool bootstrap_worker_budget_valid(const char *text) {
|
||||
if (!text || !text[0]) {
|
||||
return false;
|
||||
}
|
||||
size_t value = 0;
|
||||
for (const unsigned char *cursor = (const unsigned char *)text; *cursor; cursor++) {
|
||||
if (*cursor < '0' || *cursor > '9') {
|
||||
return false;
|
||||
}
|
||||
size_t digit = (size_t)(*cursor - '0');
|
||||
if (value > (SIZE_MAX - digit) / 10U) {
|
||||
return false;
|
||||
}
|
||||
value = value * 10U + digit;
|
||||
}
|
||||
return value > 0;
|
||||
}
|
||||
|
||||
/* Keep the bootstrap role boundary exact and fail closed before any client or
|
||||
* worker state is initialized. index_supervisor owns the matching builder and
|
||||
* performs the captured-build comparison after this syntax-only classification. */
|
||||
static bool bootstrap_worker_argv_exact(int argc, char *const argv[]) {
|
||||
if (argc < 9 || !argv || !bootstrap_arg_is(argv[1], "cli") ||
|
||||
!bootstrap_arg_is(argv[2], "--index-worker") ||
|
||||
!bootstrap_arg_is(argv[3], "--index-worker-build") ||
|
||||
!bootstrap_worker_fingerprint_valid(argv[4]) ||
|
||||
!bootstrap_arg_is(argv[5], "index_repository") || !argv[6] || !argv[6][0] ||
|
||||
!bootstrap_arg_is(argv[7], "--response-out") || !argv[8] || !argv[8][0]) {
|
||||
return false;
|
||||
}
|
||||
int next = 9;
|
||||
if (next < argc &&
|
||||
bootstrap_arg_is(argv[next], "--index-worker-memory-budget-bytes")) {
|
||||
if (next + 1 >= argc || !bootstrap_worker_budget_valid(argv[next + 1])) {
|
||||
return false;
|
||||
}
|
||||
next += 2;
|
||||
}
|
||||
if (next < argc && bootstrap_arg_is(argv[next], "--index-worker-single-thread")) {
|
||||
next++;
|
||||
}
|
||||
if (next < argc && bootstrap_arg_is(argv[next], "--index-worker-marker")) {
|
||||
if (next + 1 >= argc || !argv[next + 1] || !argv[next + 1][0]) {
|
||||
return false;
|
||||
}
|
||||
next += 2;
|
||||
}
|
||||
if (next < argc && bootstrap_arg_is(argv[next], "--index-worker-quarantine")) {
|
||||
if (next + 1 >= argc || !argv[next + 1] || !argv[next + 1][0]) {
|
||||
return false;
|
||||
}
|
||||
next += 2;
|
||||
}
|
||||
return next == argc;
|
||||
}
|
||||
|
||||
cbm_daemon_process_role_t cbm_daemon_process_role(int argc, char *const argv[]) {
|
||||
if (argc <= 0 || !argv || !argv[0] || argv[0][0] == '\0') {
|
||||
return CBM_DAEMON_PROCESS_INVALID;
|
||||
}
|
||||
|
||||
int daemon_arg = bootstrap_find_arg(argc, argv, CBM_DAEMON_INTERNAL_ARG);
|
||||
if (daemon_arg >= 0) {
|
||||
return argc == 2 && daemon_arg == 1 ? CBM_DAEMON_PROCESS_DAEMON
|
||||
: CBM_DAEMON_PROCESS_INVALID;
|
||||
}
|
||||
|
||||
int worker_arg = bootstrap_find_arg(argc, argv, "--index-worker");
|
||||
if (worker_arg >= 0) {
|
||||
return bootstrap_worker_argv_exact(argc, argv) ? CBM_DAEMON_PROCESS_WORKER
|
||||
: CBM_DAEMON_PROCESS_INVALID;
|
||||
}
|
||||
|
||||
static const char *const stateless_commands[] = {
|
||||
"install",
|
||||
"uninstall",
|
||||
"update",
|
||||
};
|
||||
/* Stop at the first top-level mode token. Tool names, flag values, and JSON
|
||||
* following `cli` are opaque user input: a search query named "install"
|
||||
* or containing "--version" must never bypass the mandatory daemon. */
|
||||
for (int arg = 1; arg < argc; arg++) {
|
||||
if (bootstrap_arg_is(argv[arg], "cli")) {
|
||||
if (bootstrap_has_help_after(argc, argv, arg + 1)) {
|
||||
return CBM_DAEMON_PROCESS_STATELESS;
|
||||
}
|
||||
return CBM_DAEMON_PROCESS_LOCAL_CLI;
|
||||
}
|
||||
if (bootstrap_arg_is(argv[arg], "hook-augment")) {
|
||||
return CBM_DAEMON_PROCESS_HOOK_CLIENT;
|
||||
}
|
||||
if (bootstrap_arg_is(argv[arg], "config")) {
|
||||
return bootstrap_has_help_after(argc, argv, arg + 1)
|
||||
? CBM_DAEMON_PROCESS_STATELESS
|
||||
: CBM_DAEMON_PROCESS_LOCAL_CLI;
|
||||
}
|
||||
if (bootstrap_arg_is(argv[arg], "--version") ||
|
||||
bootstrap_arg_is(argv[arg], "--help") ||
|
||||
bootstrap_arg_is(argv[arg], "-h")) {
|
||||
return CBM_DAEMON_PROCESS_STATELESS;
|
||||
}
|
||||
for (size_t command = 0;
|
||||
command < sizeof(stateless_commands) / sizeof(stateless_commands[0]);
|
||||
command++) {
|
||||
if (bootstrap_arg_is(argv[arg], stateless_commands[command])) {
|
||||
return CBM_DAEMON_PROCESS_STATELESS;
|
||||
}
|
||||
}
|
||||
}
|
||||
return CBM_DAEMON_PROCESS_MCP_CLIENT;
|
||||
}
|
||||
|
||||
bool cbm_daemon_process_role_requires_client(cbm_daemon_process_role_t role) {
|
||||
return role == CBM_DAEMON_PROCESS_MCP_CLIENT || role == CBM_DAEMON_PROCESS_HOOK_CLIENT;
|
||||
}
|
||||
|
||||
cbm_daemon_ipc_endpoint_t *cbm_daemon_bootstrap_endpoint_new(const char *runtime_parent) {
|
||||
char key[CBM_DAEMON_KEY_SIZE];
|
||||
if (!cbm_daemon_rendezvous_key(key)) {
|
||||
return NULL;
|
||||
}
|
||||
return cbm_daemon_ipc_endpoint_new(key, runtime_parent);
|
||||
}
|
||||
|
||||
bool cbm_daemon_bootstrap_launch_spec_init(const char *executable_path,
|
||||
cbm_daemon_bootstrap_launch_spec_t *spec_out) {
|
||||
if (!executable_path || executable_path[0] == '\0' || !spec_out) {
|
||||
return false;
|
||||
}
|
||||
memset(spec_out, 0, sizeof(*spec_out));
|
||||
spec_out->executable_path = executable_path;
|
||||
spec_out->argv[0] = executable_path;
|
||||
spec_out->argv[1] = CBM_DAEMON_INTERNAL_ARG;
|
||||
spec_out->argc = CBM_DAEMON_BOOTSTRAP_LAUNCH_ARGC;
|
||||
spec_out->detached = true;
|
||||
spec_out->inherit_standard_handles = false;
|
||||
spec_out->use_shell = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static uint64_t bootstrap_deadline_after(uint32_t timeout_ms) {
|
||||
uint64_t now = cbm_now_ms();
|
||||
return UINT64_MAX - now < timeout_ms ? UINT64_MAX : now + (uint64_t)timeout_ms;
|
||||
}
|
||||
|
||||
static _Noreturn void bootstrap_cleanup_fail_stop(const char *component) {
|
||||
(void)fprintf(stderr,
|
||||
"codebase-memory-mcp: coordination cleanup failed (%s); "
|
||||
"terminating so the OS releases retained claims\n",
|
||||
component ? component : "unknown");
|
||||
(void)fflush(stderr);
|
||||
#ifdef _WIN32
|
||||
(void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE);
|
||||
abort();
|
||||
#else
|
||||
_exit(EXIT_FAILURE);
|
||||
#endif
|
||||
}
|
||||
|
||||
static void bootstrap_pause(uint64_t deadline) {
|
||||
uint64_t now = cbm_now_ms();
|
||||
if (now >= deadline) {
|
||||
return;
|
||||
}
|
||||
uint64_t remaining_ms = deadline - now;
|
||||
struct timespec pause = {
|
||||
.tv_sec = 0,
|
||||
.tv_nsec = remaining_ms > 1
|
||||
? BOOTSTRAP_RETRY_NS
|
||||
: (long)(remaining_ms * 1000000ULL),
|
||||
};
|
||||
(void)cbm_nanosleep(&pause, NULL);
|
||||
}
|
||||
|
||||
static void bootstrap_startup_lock_release_complete(
|
||||
const cbm_daemon_bootstrap_ops_t *ops,
|
||||
cbm_daemon_bootstrap_lock_t *lock_io) {
|
||||
uint64_t deadline =
|
||||
bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS);
|
||||
while (ops && ops->startup_lock_release && lock_io && *lock_io) {
|
||||
(void)ops->startup_lock_release(ops->context, lock_io);
|
||||
if (!*lock_io) {
|
||||
return;
|
||||
}
|
||||
if (cbm_now_ms() >= deadline) {
|
||||
bootstrap_cleanup_fail_stop("startup_lock_cleanup");
|
||||
}
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
static void bootstrap_result_reset(cbm_daemon_bootstrap_result_t *result,
|
||||
cbm_daemon_process_role_t role) {
|
||||
memset(result, 0, sizeof(*result));
|
||||
result->status = cbm_daemon_process_role_requires_client(role) ? CBM_DAEMON_BOOTSTRAP_FAILED
|
||||
: CBM_DAEMON_BOOTSTRAP_BYPASSED;
|
||||
}
|
||||
|
||||
static cbm_daemon_bootstrap_status_t bootstrap_finish_probe(
|
||||
cbm_daemon_bootstrap_probe_status_t probe, cbm_daemon_runtime_client_t *client,
|
||||
const cbm_daemon_runtime_connect_result_t *connect_result,
|
||||
const cbm_daemon_bootstrap_ops_t *ops, cbm_daemon_bootstrap_result_t *result) {
|
||||
if (connect_result) {
|
||||
result->connect_result = *connect_result;
|
||||
}
|
||||
result->client = client;
|
||||
if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED && client) {
|
||||
result->status = CBM_DAEMON_BOOTSTRAP_CONNECTED;
|
||||
return result->status;
|
||||
}
|
||||
result->client = NULL;
|
||||
if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT) {
|
||||
result->status = CBM_DAEMON_BOOTSTRAP_CONFLICT;
|
||||
(void)snprintf(result->message, sizeof(result->message), "%s",
|
||||
connect_result && connect_result->message[0]
|
||||
? connect_result->message
|
||||
: "CBM could not start because a conflicting CBM process is active; close all CBM sessions and commands, then retry");
|
||||
if (ops->visible_diagnostic) {
|
||||
ops->visible_diagnostic(ops->context, result->message);
|
||||
}
|
||||
return result->status;
|
||||
}
|
||||
return CBM_DAEMON_BOOTSTRAP_FAILED;
|
||||
}
|
||||
|
||||
static cbm_daemon_bootstrap_probe_status_t bootstrap_probe(
|
||||
const cbm_daemon_bootstrap_config_t *config, const cbm_daemon_bootstrap_ops_t *ops,
|
||||
cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *connect_result) {
|
||||
memset(connect_result, 0, sizeof(*connect_result));
|
||||
*client_out = NULL;
|
||||
return ops->probe(ops->context, config->endpoint, config->identity, config->connect_timeout_ms,
|
||||
client_out, connect_result);
|
||||
}
|
||||
|
||||
static bool bootstrap_probe_is_finishable(cbm_daemon_bootstrap_probe_status_t probe) {
|
||||
return probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED ||
|
||||
probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT;
|
||||
}
|
||||
|
||||
static bool bootstrap_probe_is_waitable(cbm_daemon_bootstrap_probe_status_t probe) {
|
||||
return probe == CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE ||
|
||||
probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED ||
|
||||
probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL;
|
||||
}
|
||||
|
||||
static bool bootstrap_config_valid(const cbm_daemon_bootstrap_config_t *config,
|
||||
const cbm_daemon_bootstrap_ops_t *ops) {
|
||||
return config && ops && config->endpoint && config->identity && config->executable_path &&
|
||||
config->executable_path[0] && config->connect_timeout_ms > 0 &&
|
||||
config->startup_timeout_ms > 0 && ops->cohort_acquire &&
|
||||
ops->cohort_release && ops->probe && ops->startup_lock_try_acquire &&
|
||||
ops->startup_lock_prepare_handoff && ops->startup_lock_release &&
|
||||
ops->spawn_daemon;
|
||||
}
|
||||
|
||||
cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops(
|
||||
const cbm_daemon_bootstrap_config_t *config, const cbm_daemon_bootstrap_ops_t *ops,
|
||||
cbm_daemon_bootstrap_result_t *result_out) {
|
||||
if (!result_out) {
|
||||
return CBM_DAEMON_BOOTSTRAP_FAILED;
|
||||
}
|
||||
cbm_daemon_process_role_t role = config ? config->role : CBM_DAEMON_PROCESS_INVALID;
|
||||
bootstrap_result_reset(result_out, role);
|
||||
if (!cbm_daemon_process_role_requires_client(role)) {
|
||||
return role == CBM_DAEMON_PROCESS_INVALID ? CBM_DAEMON_BOOTSTRAP_FAILED
|
||||
: CBM_DAEMON_BOOTSTRAP_BYPASSED;
|
||||
}
|
||||
if (!bootstrap_config_valid(config, ops)) {
|
||||
return CBM_DAEMON_BOOTSTRAP_FAILED;
|
||||
}
|
||||
|
||||
uint64_t deadline = bootstrap_deadline_after(config->startup_timeout_ms);
|
||||
cbm_daemon_bootstrap_cohort_t cohort = NULL;
|
||||
cbm_daemon_conflict_t cohort_conflict;
|
||||
cbm_version_cohort_status_t cohort_status = ops->cohort_acquire(
|
||||
ops->context, config->endpoint, config->identity, deadline, &cohort,
|
||||
&cohort_conflict);
|
||||
if (cohort_status != CBM_VERSION_COHORT_OK) {
|
||||
result_out->status = cohort_status == CBM_VERSION_COHORT_CONFLICT
|
||||
? CBM_DAEMON_BOOTSTRAP_CONFLICT
|
||||
: CBM_DAEMON_BOOTSTRAP_FAILED;
|
||||
bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT &&
|
||||
cbm_daemon_conflict_format(
|
||||
&cohort_conflict, result_out->message,
|
||||
sizeof(result_out->message));
|
||||
if (!formatted) {
|
||||
const char *reason =
|
||||
cohort_status == CBM_VERSION_COHORT_BUSY
|
||||
? "another CBM activation is in progress"
|
||||
: "exact-build admission could not be verified";
|
||||
(void)snprintf(result_out->message, sizeof(result_out->message),
|
||||
"CBM daemon could not start: %s", reason);
|
||||
}
|
||||
if (ops->visible_diagnostic) {
|
||||
ops->visible_diagnostic(ops->context, result_out->message);
|
||||
}
|
||||
if (cohort) {
|
||||
ops->cohort_release(ops->context, cohort);
|
||||
}
|
||||
return result_out->status;
|
||||
}
|
||||
|
||||
cbm_daemon_runtime_client_t *client = NULL;
|
||||
cbm_daemon_runtime_connect_result_t connect_result;
|
||||
cbm_daemon_bootstrap_probe_status_t probe =
|
||||
bootstrap_probe(config, ops, &client, &connect_result);
|
||||
if (bootstrap_probe_is_finishable(probe)) {
|
||||
cbm_daemon_bootstrap_status_t status =
|
||||
bootstrap_finish_probe(probe, client, &connect_result, ops,
|
||||
result_out);
|
||||
ops->cohort_release(ops->context, cohort);
|
||||
return status;
|
||||
}
|
||||
if (!bootstrap_probe_is_waitable(probe)) {
|
||||
probe = CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
}
|
||||
|
||||
cbm_daemon_bootstrap_lock_t startup_lock = NULL;
|
||||
bool lock_acquired = false;
|
||||
bool generation_observed =
|
||||
probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED ||
|
||||
probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL;
|
||||
while (cbm_now_ms() < deadline) {
|
||||
if (!bootstrap_probe_is_waitable(probe)) {
|
||||
break;
|
||||
}
|
||||
if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED ||
|
||||
probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) {
|
||||
/* A live or stopping generation owns the transition for now. Its
|
||||
* disappearance is not sticky: after observing true absence, the
|
||||
* same bootstrap attempt may serialize and become the next first
|
||||
* client. The startup lock and the re-probe below prevent two
|
||||
* replacements from being launched. */
|
||||
generation_observed = true;
|
||||
bootstrap_pause(deadline);
|
||||
probe = bootstrap_probe(config, ops, &client, &connect_result);
|
||||
continue;
|
||||
}
|
||||
|
||||
int lock_status =
|
||||
ops->startup_lock_try_acquire(ops->context, config->endpoint, &startup_lock);
|
||||
if (lock_status < 0) {
|
||||
probe = CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
break;
|
||||
}
|
||||
if (lock_status == 0) {
|
||||
bootstrap_pause(deadline);
|
||||
probe = bootstrap_probe(config, ops, &client, &connect_result);
|
||||
continue;
|
||||
}
|
||||
if (lock_status != 1 || !startup_lock) {
|
||||
probe = CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
break;
|
||||
}
|
||||
|
||||
lock_acquired = true;
|
||||
probe = bootstrap_probe(config, ops, &client, &connect_result);
|
||||
if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED ||
|
||||
probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) {
|
||||
generation_observed = true;
|
||||
bootstrap_startup_lock_release_complete(ops, &startup_lock);
|
||||
lock_acquired = false;
|
||||
continue;
|
||||
}
|
||||
if (bootstrap_probe_is_finishable(probe) ||
|
||||
probe == CBM_DAEMON_BOOTSTRAP_PROBE_ERROR) {
|
||||
break;
|
||||
}
|
||||
if (probe != CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE) {
|
||||
probe = CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
break;
|
||||
}
|
||||
|
||||
cbm_daemon_bootstrap_launch_spec_t spec;
|
||||
if (!cbm_daemon_bootstrap_launch_spec_init(config->executable_path, &spec) ||
|
||||
!ops->startup_lock_prepare_handoff(ops->context, startup_lock) ||
|
||||
!ops->spawn_daemon(ops->context, &spec)) {
|
||||
probe = CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
break;
|
||||
}
|
||||
result_out->daemon_spawned = true;
|
||||
|
||||
/* Keep startup ownership until the child becomes observable. A
|
||||
* RESERVED response here belongs to the generation we just launched,
|
||||
* so it remains a wait state rather than a reason to release/spawn. */
|
||||
do {
|
||||
bootstrap_pause(deadline);
|
||||
probe = bootstrap_probe(config, ops, &client, &connect_result);
|
||||
if (!bootstrap_probe_is_waitable(probe)) {
|
||||
break;
|
||||
}
|
||||
} while (cbm_now_ms() < deadline);
|
||||
break;
|
||||
}
|
||||
|
||||
if (lock_acquired) {
|
||||
bootstrap_startup_lock_release_complete(ops, &startup_lock);
|
||||
}
|
||||
if (bootstrap_probe_is_finishable(probe)) {
|
||||
cbm_daemon_bootstrap_status_t status =
|
||||
bootstrap_finish_probe(probe, client, &connect_result, ops,
|
||||
result_out);
|
||||
ops->cohort_release(ops->context, cohort);
|
||||
return status;
|
||||
}
|
||||
|
||||
result_out->status = CBM_DAEMON_BOOTSTRAP_FAILED;
|
||||
if (generation_observed) {
|
||||
(void)snprintf(result_out->message, sizeof(result_out->message),
|
||||
"CBM daemon is active or starting but could not accept this client "
|
||||
"within %u ms",
|
||||
config->startup_timeout_ms);
|
||||
} else {
|
||||
(void)snprintf(result_out->message, sizeof(result_out->message),
|
||||
"CBM daemon could not start within %u ms", config->startup_timeout_ms);
|
||||
}
|
||||
if (ops->visible_diagnostic) {
|
||||
ops->visible_diagnostic(ops->context, result_out->message);
|
||||
}
|
||||
ops->cohort_release(ops->context, cohort);
|
||||
return result_out->status;
|
||||
}
|
||||
|
||||
cbm_daemon_bootstrap_probe_status_t cbm_daemon_bootstrap_classify_failed_connect(
|
||||
const cbm_daemon_runtime_connect_result_t *connect_result, int lifetime_status) {
|
||||
if (!connect_result) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
}
|
||||
if (connect_result->status == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT;
|
||||
}
|
||||
if (connect_result->status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED) {
|
||||
if (strstr(connect_result->message, "stopping") ||
|
||||
strstr(connect_result->message, "shutting down")) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL;
|
||||
}
|
||||
/* Capacity, admission, and other protocol-level rejections prove an
|
||||
* existing generation answered. Never reinterpret them as absence. */
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED;
|
||||
}
|
||||
if (lifetime_status == 1) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED;
|
||||
}
|
||||
if (lifetime_status != 0) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
}
|
||||
return connect_result->status == CBM_DAEMON_RUNTIME_CONNECT_ERROR
|
||||
? CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE
|
||||
: CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
}
|
||||
|
||||
typedef struct bootstrap_production_cohort {
|
||||
cbm_version_cohort_manager_t *manager;
|
||||
cbm_version_cohort_lease_t *lease;
|
||||
} bootstrap_production_cohort_t;
|
||||
|
||||
typedef struct {
|
||||
bootstrap_production_cohort_t *cohort;
|
||||
} bootstrap_production_context_t;
|
||||
|
||||
static cbm_daemon_bootstrap_probe_status_t bootstrap_production_probe(
|
||||
void *context, const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms,
|
||||
cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *result_out) {
|
||||
bootstrap_production_context_t *production = context;
|
||||
if (!production || !production->cohort ||
|
||||
!production->cohort->manager) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
}
|
||||
cbm_version_cohort_daemon_presence_t claim =
|
||||
cbm_version_cohort_daemon_claim_presence(
|
||||
production->cohort->manager);
|
||||
if (claim == CBM_VERSION_COHORT_DAEMON_ABSENT) {
|
||||
int lifetime = cbm_daemon_ipc_lifetime_reservation_probe(endpoint);
|
||||
if (lifetime == 0) {
|
||||
/* Do not spend the per-connect timeout polling a generation that
|
||||
* both independent ownership signals prove absent. The startup
|
||||
* lock and its mandatory re-probe serialize a concurrent launch. */
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE;
|
||||
}
|
||||
if (lifetime != 1) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
}
|
||||
} else if (claim != CBM_VERSION_COHORT_DAEMON_COORDINATED) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
}
|
||||
|
||||
*client_out = cbm_daemon_runtime_client_connect(endpoint, identity,
|
||||
timeout_ms, result_out);
|
||||
if (*client_out) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED;
|
||||
}
|
||||
|
||||
/* Ownership may turn over while the connection attempt is in flight.
|
||||
* Re-observe both signals so disappearance is not sticky and a live or
|
||||
* cleaning-up generation is never mistaken for absence. */
|
||||
claim = cbm_version_cohort_daemon_claim_presence(
|
||||
production->cohort->manager);
|
||||
if (claim == CBM_VERSION_COHORT_DAEMON_COORDINATED) {
|
||||
return cbm_daemon_bootstrap_classify_failed_connect(result_out, 1);
|
||||
}
|
||||
if (claim != CBM_VERSION_COHORT_DAEMON_ABSENT) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR;
|
||||
}
|
||||
int lifetime_status = cbm_daemon_ipc_lifetime_reservation_probe(endpoint);
|
||||
return cbm_daemon_bootstrap_classify_failed_connect(result_out, lifetime_status);
|
||||
}
|
||||
|
||||
static cbm_version_cohort_status_t bootstrap_production_cohort_acquire(
|
||||
void *context, const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms,
|
||||
cbm_daemon_bootstrap_cohort_t *cohort_out,
|
||||
cbm_daemon_conflict_t *conflict_out) {
|
||||
*cohort_out = NULL;
|
||||
bootstrap_production_cohort_t *cohort = calloc(1, sizeof(*cohort));
|
||||
if (cohort) {
|
||||
cohort->manager = cbm_version_cohort_manager_new(endpoint);
|
||||
}
|
||||
if (!cohort || !cohort->manager) {
|
||||
free(cohort);
|
||||
return CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
cbm_version_cohort_status_t status = cbm_version_cohort_acquire(
|
||||
cohort->manager, identity, deadline_ms, &cohort->lease, conflict_out);
|
||||
if (status == CBM_VERSION_COHORT_CONFLICT) {
|
||||
(void)cbm_version_cohort_log_conflict(conflict_out);
|
||||
}
|
||||
if (status == CBM_VERSION_COHORT_OK || cohort->lease) {
|
||||
*cohort_out = cohort;
|
||||
if (context) {
|
||||
bootstrap_production_context_t *production = context;
|
||||
production->cohort = cohort;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
uint64_t cleanup_deadline =
|
||||
bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS);
|
||||
cbm_private_file_lock_status_t cleanup = CBM_PRIVATE_FILE_LOCK_OK;
|
||||
while (cohort->manager) {
|
||||
cleanup = cbm_version_cohort_manager_free(&cohort->manager);
|
||||
if (!cohort->manager) {
|
||||
break;
|
||||
}
|
||||
if (cbm_now_ms() >= cleanup_deadline) {
|
||||
bootstrap_cleanup_fail_stop("cohort_manager_cleanup");
|
||||
}
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
free(cohort);
|
||||
return cleanup == CBM_PRIVATE_FILE_LOCK_OK ? status
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
|
||||
static void bootstrap_production_cohort_release(
|
||||
void *context, cbm_daemon_bootstrap_cohort_t opaque) {
|
||||
bootstrap_production_context_t *production = context;
|
||||
bootstrap_production_cohort_t *cohort = opaque;
|
||||
if (!cohort) {
|
||||
return;
|
||||
}
|
||||
uint64_t cleanup_deadline =
|
||||
bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS);
|
||||
while (cohort->lease) {
|
||||
(void)cbm_version_cohort_lease_release(&cohort->lease);
|
||||
if (!cohort->lease) {
|
||||
break;
|
||||
}
|
||||
if (cbm_now_ms() >= cleanup_deadline) {
|
||||
bootstrap_cleanup_fail_stop("cohort_lease_cleanup");
|
||||
}
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
cleanup_deadline =
|
||||
bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS);
|
||||
while (cohort->manager) {
|
||||
(void)cbm_version_cohort_manager_free(&cohort->manager);
|
||||
if (!cohort->manager) {
|
||||
break;
|
||||
}
|
||||
if (cbm_now_ms() >= cleanup_deadline) {
|
||||
bootstrap_cleanup_fail_stop("cohort_manager_cleanup");
|
||||
}
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
if (production && production->cohort == cohort) {
|
||||
production->cohort = NULL;
|
||||
}
|
||||
free(cohort);
|
||||
}
|
||||
|
||||
static int bootstrap_production_lock(void *context, const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
cbm_daemon_bootstrap_lock_t *lock_out) {
|
||||
(void)context;
|
||||
cbm_daemon_ipc_startup_lock_t *lock = NULL;
|
||||
int status = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &lock);
|
||||
*lock_out = lock;
|
||||
return status;
|
||||
}
|
||||
|
||||
static bool bootstrap_production_unlock(
|
||||
void *context, cbm_daemon_bootstrap_lock_t *lock_io) {
|
||||
(void)context;
|
||||
if (!lock_io) {
|
||||
return false;
|
||||
}
|
||||
cbm_daemon_ipc_startup_lock_t *lock = *lock_io;
|
||||
bool released = cbm_daemon_ipc_startup_lock_release(&lock);
|
||||
*lock_io = lock;
|
||||
return released;
|
||||
}
|
||||
|
||||
static bool bootstrap_production_handoff(void *context,
|
||||
cbm_daemon_bootstrap_lock_t lock) {
|
||||
(void)context;
|
||||
return cbm_daemon_ipc_startup_lock_prepare_handoff(
|
||||
(cbm_daemon_ipc_startup_lock_t *)lock);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
static bool bootstrap_production_spawn(void *context,
|
||||
const cbm_daemon_bootstrap_launch_spec_t *spec) {
|
||||
(void)context;
|
||||
if (!spec || !spec->detached || spec->inherit_standard_handles || spec->use_shell) {
|
||||
return false;
|
||||
}
|
||||
char command_line[BOOTSTRAP_PATH_CAP * 2];
|
||||
if (!cbm_build_win_cmdline(command_line, sizeof(command_line), spec->argv)) {
|
||||
return false;
|
||||
}
|
||||
wchar_t *application = cbm_utf8_to_wide(spec->executable_path);
|
||||
wchar_t *command = cbm_utf8_to_wide(command_line);
|
||||
if (!application || !command) {
|
||||
free(application);
|
||||
free(command);
|
||||
return false;
|
||||
}
|
||||
STARTUPINFOW startup;
|
||||
PROCESS_INFORMATION child;
|
||||
ZeroMemory(&startup, sizeof(startup));
|
||||
ZeroMemory(&child, sizeof(child));
|
||||
startup.cb = sizeof(startup);
|
||||
DWORD flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW;
|
||||
BOOL created = CreateProcessW(application, command, NULL, NULL, FALSE, flags, NULL, NULL,
|
||||
&startup, &child);
|
||||
free(application);
|
||||
free(command);
|
||||
if (!created) {
|
||||
return false;
|
||||
}
|
||||
(void)CloseHandle(child.hThread);
|
||||
(void)CloseHandle(child.hProcess);
|
||||
return true;
|
||||
}
|
||||
#else
|
||||
static void bootstrap_child_close_fds(void) {
|
||||
long open_max = sysconf(_SC_OPEN_MAX);
|
||||
if (open_max < 0 || open_max > 1048576L) {
|
||||
open_max = 65536L;
|
||||
}
|
||||
for (int fd = 3; fd < open_max; fd++) {
|
||||
(void)close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
static void bootstrap_daemon_grandchild(const cbm_daemon_bootstrap_launch_spec_t *spec) {
|
||||
(void)umask(077);
|
||||
sigset_t empty;
|
||||
(void)sigemptyset(&empty);
|
||||
(void)sigprocmask(SIG_SETMASK, &empty, NULL);
|
||||
int null_fd = open("/dev/null", O_RDWR);
|
||||
if (null_fd < 0 || dup2(null_fd, STDIN_FILENO) < 0 || dup2(null_fd, STDOUT_FILENO) < 0 ||
|
||||
dup2(null_fd, STDERR_FILENO) < 0) {
|
||||
_exit(127);
|
||||
}
|
||||
if (null_fd > STDERR_FILENO) {
|
||||
(void)close(null_fd);
|
||||
}
|
||||
bootstrap_child_close_fds();
|
||||
execv(spec->executable_path, (char *const *)spec->argv);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
static bool bootstrap_production_spawn(void *context,
|
||||
const cbm_daemon_bootstrap_launch_spec_t *spec) {
|
||||
(void)context;
|
||||
if (!spec || !spec->detached || spec->inherit_standard_handles || spec->use_shell) {
|
||||
return false;
|
||||
}
|
||||
pid_t first = fork();
|
||||
if (first < 0) {
|
||||
return false;
|
||||
}
|
||||
if (first == 0) {
|
||||
if (setsid() < 0) {
|
||||
_exit(127);
|
||||
}
|
||||
pid_t daemon = fork();
|
||||
if (daemon < 0) {
|
||||
_exit(127);
|
||||
}
|
||||
if (daemon > 0) {
|
||||
_exit(0);
|
||||
}
|
||||
bootstrap_daemon_grandchild(spec);
|
||||
}
|
||||
|
||||
int status = 0;
|
||||
pid_t waited;
|
||||
do {
|
||||
waited = waitpid(first, &status, 0);
|
||||
} while (waited < 0 && errno == EINTR);
|
||||
return waited == first && WIFEXITED(status) && WEXITSTATUS(status) == 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
static void bootstrap_production_diagnostic(void *context, const char *message) {
|
||||
(void)context;
|
||||
(void)fprintf(stderr, "codebase-memory-mcp: %s\n", message ? message : "daemon startup failed");
|
||||
(void)fflush(stderr);
|
||||
}
|
||||
|
||||
cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute(
|
||||
const cbm_daemon_bootstrap_config_t *config, cbm_daemon_bootstrap_result_t *result_out) {
|
||||
bootstrap_production_context_t context = {0};
|
||||
const cbm_daemon_bootstrap_ops_t ops = {
|
||||
.context = &context,
|
||||
.cohort_acquire = bootstrap_production_cohort_acquire,
|
||||
.cohort_release = bootstrap_production_cohort_release,
|
||||
.probe = bootstrap_production_probe,
|
||||
.startup_lock_try_acquire = bootstrap_production_lock,
|
||||
.startup_lock_prepare_handoff = bootstrap_production_handoff,
|
||||
.startup_lock_release = bootstrap_production_unlock,
|
||||
.spawn_daemon = bootstrap_production_spawn,
|
||||
.visible_diagnostic = bootstrap_production_diagnostic,
|
||||
};
|
||||
return cbm_daemon_bootstrap_execute_with_ops(config, &ops, result_out);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* bootstrap.h — Process-role policy and mandatory daemon bootstrap.
|
||||
*
|
||||
* Role classification must happen before any stateful initialization in
|
||||
* main(): no store, watcher, UI, diagnostics, or index supervisor may be
|
||||
* constructed until the process is known to be the daemon, one of its
|
||||
* internal workers, a thin client, or an explicitly stateless command.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_BOOTSTRAP_H
|
||||
#define CBM_DAEMON_BOOTSTRAP_H
|
||||
|
||||
#include "daemon/runtime.h"
|
||||
#include "daemon/version_cohort.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -0,0 +1,845 @@
|
||||
/*
|
||||
* daemon.c — Process-local coordination and wire framing for the CBM daemon.
|
||||
*/
|
||||
#include "daemon/daemon.h"
|
||||
|
||||
#include "foundation/compat_thread.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* daemon.h — Process-local coordination and wire framing for the CBM daemon.
|
||||
*
|
||||
* Transport and worker supervision live outside this module. The coordinator
|
||||
* binds clients and resource subscriptions to transport connections, coalesces
|
||||
* shared work, and defines the daemon's terminal shutdown transition.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_H
|
||||
#define CBM_DAEMON_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* 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 */
|
||||
@@ -0,0 +1,624 @@
|
||||
/*
|
||||
* frontend.c — Stateless stdio bridge for a daemon-backed MCP session.
|
||||
*/
|
||||
#include "daemon/frontend.h"
|
||||
|
||||
#include "daemon/application.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_thread.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/subprocess.h"
|
||||
#include "mcp/mcp.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
enum {
|
||||
FRONTEND_QUEUE_CAPACITY = 8,
|
||||
FRONTEND_QUEUE_BYTES_MAX = 12 * 1024 * 1024,
|
||||
FRONTEND_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000,
|
||||
FRONTEND_CLOSE_TIMEOUT_MS = 5000,
|
||||
FRONTEND_WAIT_US = 1000,
|
||||
FRONTEND_MAINTENANCE_POLL_MS = 10,
|
||||
/* The owner thread may be draining a supervised process tree. Preserve the
|
||||
* supervisor's complete graceful + forced-settle window before the monitor
|
||||
* fail-stops the process, plus scheduling/teardown margin. */
|
||||
FRONTEND_MAINTENANCE_GRACE_MS =
|
||||
CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS +
|
||||
CBM_SUBPROCESS_FORCE_SETTLE_MS + 1000,
|
||||
FRONTEND_PARTICIPANT_NAME_CAP = 64,
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
char *message;
|
||||
size_t length;
|
||||
bool content_length_framed;
|
||||
bool has_id;
|
||||
int64_t id;
|
||||
char *id_str;
|
||||
cbm_daemon_runtime_application_token_t request_token;
|
||||
bool cancelled;
|
||||
} frontend_item_t;
|
||||
|
||||
typedef struct {
|
||||
cbm_mutex_t mutex;
|
||||
cbm_daemon_runtime_client_t *client;
|
||||
cbm_version_cohort_manager_t *cohort_manager;
|
||||
FILE *out;
|
||||
frontend_item_t queue[FRONTEND_QUEUE_CAPACITY];
|
||||
size_t head;
|
||||
size_t count;
|
||||
size_t queued_bytes;
|
||||
bool stopping;
|
||||
bool in_request;
|
||||
bool active_has_id;
|
||||
int64_t active_id;
|
||||
const char *active_id_str;
|
||||
cbm_daemon_runtime_application_token_t active_request_token;
|
||||
bool failed;
|
||||
} frontend_state_t;
|
||||
|
||||
struct cbm_daemon_maintenance_monitor {
|
||||
cbm_thread_t thread;
|
||||
cbm_version_cohort_manager_t *manager;
|
||||
cbm_daemon_maintenance_cancel_fn cancel;
|
||||
void *cancel_context;
|
||||
atomic_bool stopping;
|
||||
int exit_code;
|
||||
char participant[FRONTEND_PARTICIPANT_NAME_CAP];
|
||||
};
|
||||
|
||||
static void frontend_flush_process_outputs(FILE *out) {
|
||||
if (out) {
|
||||
(void)fflush(out);
|
||||
}
|
||||
(void)fflush(stderr);
|
||||
}
|
||||
|
||||
static const char *frontend_maintenance_reason(
|
||||
cbm_version_cohort_maintenance_presence_t presence) {
|
||||
switch (presence) {
|
||||
case CBM_VERSION_COHORT_MAINTENANCE_REQUESTED:
|
||||
return "requested";
|
||||
case CBM_VERSION_COHORT_MAINTENANCE_UNSAFE:
|
||||
return "unsafe";
|
||||
case CBM_VERSION_COHORT_MAINTENANCE_IO:
|
||||
return "io";
|
||||
case CBM_VERSION_COHORT_MAINTENANCE_ABSENT:
|
||||
default:
|
||||
return "absent";
|
||||
}
|
||||
}
|
||||
|
||||
static void *frontend_maintenance_monitor_worker(void *opaque) {
|
||||
cbm_daemon_maintenance_monitor_t *monitor = opaque;
|
||||
while (!atomic_load_explicit(&monitor->stopping,
|
||||
memory_order_acquire)) {
|
||||
cbm_version_cohort_maintenance_presence_t presence =
|
||||
cbm_version_cohort_maintenance_presence(monitor->manager);
|
||||
if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) {
|
||||
cbm_usleep(FRONTEND_MAINTENANCE_POLL_MS * 1000U);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (presence == CBM_VERSION_COHORT_MAINTENANCE_REQUESTED) {
|
||||
bool cancellation_requested =
|
||||
monitor->cancel && monitor->cancel(monitor->cancel_context);
|
||||
cbm_log_info("participant.maintenance_requested", "participant",
|
||||
monitor->participant, "cooperative_cancel",
|
||||
cancellation_requested ? "requested" : "not_active");
|
||||
(void)fprintf(
|
||||
stderr,
|
||||
"codebase-memory-mcp: active %s is stopping for install/update/uninstall\n",
|
||||
monitor->participant);
|
||||
frontend_flush_process_outputs(stdout);
|
||||
|
||||
uint64_t now = cbm_now_ms();
|
||||
uint64_t deadline =
|
||||
now > UINT64_MAX - FRONTEND_MAINTENANCE_GRACE_MS
|
||||
? UINT64_MAX
|
||||
: now + FRONTEND_MAINTENANCE_GRACE_MS;
|
||||
while (!atomic_load_explicit(&monitor->stopping,
|
||||
memory_order_acquire) &&
|
||||
cbm_now_ms() < deadline) {
|
||||
cbm_usleep(FRONTEND_MAINTENANCE_POLL_MS * 1000U);
|
||||
}
|
||||
if (atomic_load_explicit(&monitor->stopping,
|
||||
memory_order_acquire)) {
|
||||
return NULL;
|
||||
}
|
||||
cbm_log_info("participant.maintenance_forced_exit", "participant",
|
||||
monitor->participant, "action", "process_exit");
|
||||
frontend_flush_process_outputs(stdout);
|
||||
_Exit(monitor->exit_code);
|
||||
}
|
||||
|
||||
/* An observer that cannot prove absence must not let local work
|
||||
* survive into a binary mutation window. Fail closed and let native
|
||||
* process teardown release SQLite and cohort ownership. */
|
||||
cbm_log_error("participant.maintenance_observation_failed",
|
||||
"participant", monitor->participant, "reason",
|
||||
frontend_maintenance_reason(presence));
|
||||
(void)fprintf(
|
||||
stderr,
|
||||
"codebase-memory-mcp: %s stopped because maintenance coordination could not be verified\n",
|
||||
monitor->participant);
|
||||
frontend_flush_process_outputs(stdout);
|
||||
_Exit(EXIT_FAILURE);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
cbm_daemon_maintenance_cancel_fn cancel, void *cancel_context,
|
||||
int exit_code, const char *participant) {
|
||||
if (!manager || exit_code < 0 || !participant || !participant[0]) {
|
||||
return NULL;
|
||||
}
|
||||
cbm_daemon_maintenance_monitor_t *monitor =
|
||||
calloc(1, sizeof(*monitor));
|
||||
if (!monitor) {
|
||||
return NULL;
|
||||
}
|
||||
monitor->manager = manager;
|
||||
monitor->cancel = cancel;
|
||||
monitor->cancel_context = cancel_context;
|
||||
monitor->exit_code = exit_code;
|
||||
atomic_init(&monitor->stopping, false);
|
||||
int written = snprintf(monitor->participant,
|
||||
sizeof(monitor->participant), "%s", participant);
|
||||
if (written <= 0 || written >= (int)sizeof(monitor->participant) ||
|
||||
cbm_thread_create(&monitor->thread, 0,
|
||||
frontend_maintenance_monitor_worker,
|
||||
monitor) != 0) {
|
||||
free(monitor);
|
||||
return NULL;
|
||||
}
|
||||
return monitor;
|
||||
}
|
||||
|
||||
bool cbm_daemon_maintenance_monitor_stop(
|
||||
cbm_daemon_maintenance_monitor_t **monitor_io) {
|
||||
if (!monitor_io || !*monitor_io) {
|
||||
return false;
|
||||
}
|
||||
cbm_daemon_maintenance_monitor_t *monitor = *monitor_io;
|
||||
atomic_store_explicit(&monitor->stopping, true, memory_order_release);
|
||||
if (cbm_thread_join(&monitor->thread) != 0) {
|
||||
return false;
|
||||
}
|
||||
free(monitor);
|
||||
*monitor_io = NULL;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void frontend_exit_for_maintenance(frontend_state_t *state) {
|
||||
cbm_version_cohort_maintenance_presence_t presence =
|
||||
cbm_version_cohort_maintenance_presence(state->cohort_manager);
|
||||
if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) {
|
||||
return;
|
||||
}
|
||||
bool requested =
|
||||
presence == CBM_VERSION_COHORT_MAINTENANCE_REQUESTED;
|
||||
if (requested) {
|
||||
cbm_log_info("daemon.frontend.maintenance_requested", "action",
|
||||
"exit_thin_frontend");
|
||||
(void)fputs(
|
||||
"codebase-memory-mcp: MCP session is stopping for install/update/uninstall; restart the coding-agent session afterwards\n",
|
||||
stderr);
|
||||
} else {
|
||||
cbm_log_error("daemon.frontend.maintenance_observation_failed",
|
||||
"reason", frontend_maintenance_reason(presence),
|
||||
"action", "exit_thin_frontend");
|
||||
}
|
||||
/* Do not fclose stdin across threads. Process exit closes the authenticated
|
||||
* kernel IPC handle, and daemon ownership then cancels only this session. */
|
||||
frontend_flush_process_outputs(state->out);
|
||||
_Exit(requested ? EXIT_SUCCESS : EXIT_FAILURE);
|
||||
}
|
||||
|
||||
static void frontend_item_free(frontend_item_t *item) {
|
||||
if (item) {
|
||||
free(item->message);
|
||||
free(item->id_str);
|
||||
memset(item, 0, sizeof(*item));
|
||||
}
|
||||
}
|
||||
|
||||
static bool frontend_should_stop(frontend_state_t *state) {
|
||||
cbm_mutex_lock(&state->mutex);
|
||||
bool stopping = state->stopping;
|
||||
cbm_mutex_unlock(&state->mutex);
|
||||
return stopping;
|
||||
}
|
||||
|
||||
/* Pop and publish the active request identity under one mutex acquisition, so
|
||||
* a cancellation reader can never observe the item as neither queued nor
|
||||
* active. */
|
||||
static bool frontend_pop_begin(frontend_state_t *state,
|
||||
frontend_item_t *item) {
|
||||
bool popped = false;
|
||||
cbm_mutex_lock(&state->mutex);
|
||||
if (!state->stopping && state->count > 0) {
|
||||
frontend_item_t *queued = &state->queue[state->head];
|
||||
cbm_daemon_runtime_application_token_t request_token =
|
||||
CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID;
|
||||
bool token_ready = queued->cancelled ||
|
||||
cbm_daemon_runtime_client_application_token_reserve(
|
||||
state->client, &request_token);
|
||||
if (!token_ready) {
|
||||
state->failed = true;
|
||||
state->stopping = true;
|
||||
cbm_mutex_unlock(&state->mutex);
|
||||
return false;
|
||||
}
|
||||
*item = state->queue[state->head];
|
||||
memset(&state->queue[state->head], 0, sizeof(state->queue[state->head]));
|
||||
state->head = (state->head + 1U) % FRONTEND_QUEUE_CAPACITY;
|
||||
state->count--;
|
||||
state->queued_bytes -= item->length;
|
||||
state->in_request = true;
|
||||
state->active_has_id = item->has_id;
|
||||
state->active_id = item->id;
|
||||
state->active_id_str = item->id_str;
|
||||
item->request_token = request_token;
|
||||
state->active_request_token = request_token;
|
||||
popped = true;
|
||||
}
|
||||
cbm_mutex_unlock(&state->mutex);
|
||||
return popped;
|
||||
}
|
||||
|
||||
static void frontend_end_request(frontend_state_t *state, bool failed) {
|
||||
cbm_mutex_lock(&state->mutex);
|
||||
state->in_request = false;
|
||||
state->active_has_id = false;
|
||||
state->active_id = 0;
|
||||
state->active_id_str = NULL;
|
||||
state->active_request_token =
|
||||
CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID;
|
||||
state->failed = state->failed || failed;
|
||||
cbm_mutex_unlock(&state->mutex);
|
||||
}
|
||||
|
||||
static bool frontend_write_response(FILE *out, const uint8_t *response,
|
||||
uint32_t response_length,
|
||||
bool content_length_framed) {
|
||||
bool written = false;
|
||||
if (content_length_framed) {
|
||||
written = fprintf(out, "Content-Length: %u\r\n\r\n",
|
||||
response_length) >= 0 &&
|
||||
fwrite(response, 1, response_length, out) == response_length;
|
||||
} else {
|
||||
written = fwrite(response, 1, response_length, out) == response_length &&
|
||||
fputc('\n', out) != EOF;
|
||||
}
|
||||
return fflush(out) == 0 && written;
|
||||
}
|
||||
|
||||
static bool frontend_write_cancelled_response(FILE *out,
|
||||
const frontend_item_t *item) {
|
||||
static const char cancelled_error[] =
|
||||
"{\"code\":-32800,\"message\":\"Request cancelled\"}";
|
||||
cbm_jsonrpc_response_t response = {
|
||||
.id = item->id,
|
||||
.id_str = item->id_str,
|
||||
.error_json = cancelled_error,
|
||||
.error_code = -32800,
|
||||
};
|
||||
char *encoded = cbm_jsonrpc_format_response(&response);
|
||||
bool written = encoded &&
|
||||
frontend_write_response(
|
||||
out, (const uint8_t *)encoded,
|
||||
(uint32_t)strlen(encoded),
|
||||
item->content_length_framed);
|
||||
free(encoded);
|
||||
return written;
|
||||
}
|
||||
|
||||
static bool frontend_parse_cancellation(const char *message,
|
||||
cbm_jsonrpc_request_t *request_out) {
|
||||
if (!message || !request_out) {
|
||||
return false;
|
||||
}
|
||||
memset(request_out, 0, sizeof(*request_out));
|
||||
if (cbm_jsonrpc_parse(message, request_out) != 0) {
|
||||
return false;
|
||||
}
|
||||
bool cancellation = !request_out->has_id && request_out->method &&
|
||||
strcmp(request_out->method,
|
||||
"notifications/cancelled") == 0;
|
||||
if (!cancellation) {
|
||||
cbm_jsonrpc_request_free(request_out);
|
||||
}
|
||||
return cancellation;
|
||||
}
|
||||
|
||||
bool cbm_daemon_frontend_is_cancellation_notification(const char *message) {
|
||||
cbm_jsonrpc_request_t request = {0};
|
||||
bool cancellation = frontend_parse_cancellation(message, &request);
|
||||
if (cancellation) {
|
||||
cbm_jsonrpc_request_free(&request);
|
||||
}
|
||||
return cancellation;
|
||||
}
|
||||
|
||||
bool cbm_daemon_frontend_cancellation_matches_request(
|
||||
const char *message, int64_t active_id, const char *active_id_str) {
|
||||
cbm_jsonrpc_request_t request = {0};
|
||||
bool cancellation = frontend_parse_cancellation(message, &request);
|
||||
bool matches = cancellation &&
|
||||
cbm_mcp_cancel_request_matches(
|
||||
request.params_raw, active_id, active_id_str);
|
||||
if (cancellation) {
|
||||
cbm_jsonrpc_request_free(&request);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
typedef enum {
|
||||
FRONTEND_CANCELLATION_NONE = 0,
|
||||
FRONTEND_CANCELLATION_STALE = 1,
|
||||
FRONTEND_CANCELLATION_QUEUED = 2,
|
||||
FRONTEND_CANCELLATION_ACTIVE = 3,
|
||||
} frontend_cancellation_route_t;
|
||||
|
||||
/* Cancellation is an out-of-band frontend control message. A matching active
|
||||
* request routes its exact runtime token without closing the authenticated
|
||||
* session. A queued request is marked and receives a cancellation error
|
||||
* without ever reaching the daemon. Stale/invalid targets are ignored. */
|
||||
static frontend_cancellation_route_t frontend_route_cancellation(
|
||||
frontend_state_t *state, const char *message,
|
||||
cbm_daemon_runtime_application_token_t *request_token_out) {
|
||||
if (request_token_out) {
|
||||
*request_token_out = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID;
|
||||
}
|
||||
cbm_jsonrpc_request_t request = {0};
|
||||
if (!frontend_parse_cancellation(message, &request)) {
|
||||
return FRONTEND_CANCELLATION_NONE;
|
||||
}
|
||||
|
||||
frontend_cancellation_route_t route = FRONTEND_CANCELLATION_STALE;
|
||||
cbm_mutex_lock(&state->mutex);
|
||||
if (state->in_request && state->active_has_id &&
|
||||
state->active_request_token !=
|
||||
CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID &&
|
||||
cbm_mcp_cancel_request_matches(
|
||||
request.params_raw, state->active_id, state->active_id_str)) {
|
||||
if (request_token_out) {
|
||||
*request_token_out = state->active_request_token;
|
||||
}
|
||||
route = FRONTEND_CANCELLATION_ACTIVE;
|
||||
} else {
|
||||
for (size_t offset = 0; offset < state->count; offset++) {
|
||||
size_t index =
|
||||
(state->head + offset) % FRONTEND_QUEUE_CAPACITY;
|
||||
frontend_item_t *item = &state->queue[index];
|
||||
if (item->has_id &&
|
||||
cbm_mcp_cancel_request_matches(
|
||||
request.params_raw, item->id, item->id_str)) {
|
||||
item->cancelled = true;
|
||||
route = FRONTEND_CANCELLATION_QUEUED;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
cbm_mutex_unlock(&state->mutex);
|
||||
cbm_jsonrpc_request_free(&request);
|
||||
return route;
|
||||
}
|
||||
|
||||
static void *frontend_worker(void *opaque) {
|
||||
frontend_state_t *state = opaque;
|
||||
uint64_t next_maintenance_check_ms = 0;
|
||||
for (;;) {
|
||||
uint64_t now_ms = cbm_now_ms();
|
||||
if (now_ms >= next_maintenance_check_ms) {
|
||||
frontend_exit_for_maintenance(state);
|
||||
next_maintenance_check_ms =
|
||||
now_ms > UINT64_MAX - FRONTEND_MAINTENANCE_POLL_MS
|
||||
? UINT64_MAX
|
||||
: now_ms + FRONTEND_MAINTENANCE_POLL_MS;
|
||||
}
|
||||
frontend_item_t item = {0};
|
||||
if (!frontend_pop_begin(state, &item)) {
|
||||
if (frontend_should_stop(state)) {
|
||||
break;
|
||||
}
|
||||
cbm_usleep(FRONTEND_WAIT_US);
|
||||
continue;
|
||||
}
|
||||
uint8_t *response = NULL;
|
||||
uint32_t response_length = 0;
|
||||
bool output_failed = false;
|
||||
bool failed = false;
|
||||
if (item.cancelled) {
|
||||
output_failed =
|
||||
!frontend_write_cancelled_response(state->out, &item);
|
||||
failed = output_failed;
|
||||
} else {
|
||||
cbm_daemon_runtime_application_status_t status =
|
||||
cbm_daemon_application_client_mcp_tagged(
|
||||
state->client, item.request_token, item.message, &response,
|
||||
&response_length, FRONTEND_REQUEST_TIMEOUT_MS);
|
||||
if (status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) {
|
||||
output_failed =
|
||||
!frontend_write_cancelled_response(state->out, &item);
|
||||
failed = output_failed;
|
||||
} else {
|
||||
failed = status != CBM_DAEMON_RUNTIME_APPLICATION_OK;
|
||||
}
|
||||
if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && !failed &&
|
||||
response && response_length > 0) {
|
||||
output_failed = !frontend_write_response(
|
||||
state->out, response, response_length,
|
||||
item.content_length_framed);
|
||||
failed = output_failed;
|
||||
}
|
||||
}
|
||||
free(response);
|
||||
bool expected_stop = failed && frontend_should_stop(state);
|
||||
frontend_end_request(state, failed && !expected_stop);
|
||||
frontend_item_free(&item);
|
||||
if (failed) {
|
||||
if (!expected_stop) {
|
||||
/* A failed daemon transport cannot wake a thread blocked in
|
||||
* stdio portably. This frontend owns no state: immediate
|
||||
* process exit closes the kernel IPC handle, which cancels
|
||||
* daemon session ownership without a detached reader or an
|
||||
* unsafe cross-thread fclose. */
|
||||
cbm_log_error("daemon.frontend.request_failed", "reason",
|
||||
output_failed ? "output" : "daemon_transport",
|
||||
"action", "exit_thin_frontend");
|
||||
frontend_flush_process_outputs(state->out);
|
||||
_Exit(EXIT_FAILURE);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool frontend_enqueue(frontend_state_t *state, char *message,
|
||||
bool content_length_framed) {
|
||||
size_t length = strlen(message);
|
||||
bool has_id = false;
|
||||
int64_t id = 0;
|
||||
char *id_str = NULL;
|
||||
cbm_jsonrpc_request_t request = {0};
|
||||
if (cbm_jsonrpc_parse(message, &request) == 0) {
|
||||
has_id = request.has_id;
|
||||
id = request.id;
|
||||
if (request.id_str) {
|
||||
id_str = cbm_strdup(request.id_str);
|
||||
}
|
||||
bool identity_copied = !request.id_str || id_str;
|
||||
cbm_jsonrpc_request_free(&request);
|
||||
if (!identity_copied) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (;;) {
|
||||
cbm_mutex_lock(&state->mutex);
|
||||
bool stopped = state->stopping || state->failed;
|
||||
bool capacity = state->count < FRONTEND_QUEUE_CAPACITY &&
|
||||
(state->count == 0 ||
|
||||
state->queued_bytes + length <= FRONTEND_QUEUE_BYTES_MAX);
|
||||
if (!stopped && capacity) {
|
||||
size_t tail = (state->head + state->count) % FRONTEND_QUEUE_CAPACITY;
|
||||
state->queue[tail] = (frontend_item_t){
|
||||
.message = message,
|
||||
.length = length,
|
||||
.content_length_framed = content_length_framed,
|
||||
.has_id = has_id,
|
||||
.id = id,
|
||||
.id_str = id_str,
|
||||
};
|
||||
state->count++;
|
||||
state->queued_bytes += length;
|
||||
cbm_mutex_unlock(&state->mutex);
|
||||
return true;
|
||||
}
|
||||
cbm_mutex_unlock(&state->mutex);
|
||||
if (stopped) {
|
||||
free(id_str);
|
||||
return false;
|
||||
}
|
||||
cbm_usleep(FRONTEND_WAIT_US);
|
||||
}
|
||||
}
|
||||
|
||||
static bool frontend_stop_begin(frontend_state_t *state) {
|
||||
cbm_mutex_lock(&state->mutex);
|
||||
state->stopping = true;
|
||||
cbm_mutex_unlock(&state->mutex);
|
||||
/* Retain the client allocation until the worker is joined. This covers the
|
||||
* boundary where the worker has claimed an item but has not yet entered the
|
||||
* runtime exchange: a late call observes closing instead of freed memory. */
|
||||
return cbm_daemon_runtime_client_close_begin(state->client);
|
||||
}
|
||||
|
||||
int cbm_daemon_frontend_mcp_run(
|
||||
cbm_daemon_runtime_client_t *client,
|
||||
cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out) {
|
||||
if (!client || !cohort_manager || !in || !out) {
|
||||
return -1;
|
||||
}
|
||||
frontend_state_t state = {
|
||||
.client = client,
|
||||
.cohort_manager = cohort_manager,
|
||||
.out = out,
|
||||
};
|
||||
cbm_mutex_init(&state.mutex);
|
||||
cbm_thread_t worker;
|
||||
if (cbm_thread_create(&worker, 0, frontend_worker, &state) != 0) {
|
||||
cbm_mutex_destroy(&state.mutex);
|
||||
(void)cbm_daemon_runtime_client_close(client,
|
||||
FRONTEND_CLOSE_TIMEOUT_MS);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int result = 0;
|
||||
bool close_begun = false;
|
||||
for (;;) {
|
||||
char *message = NULL;
|
||||
bool content_length_framed = false;
|
||||
int read_status =
|
||||
cbm_mcp_read_message(in, &message, &content_length_framed);
|
||||
if (read_status <= 0) {
|
||||
result = read_status < 0 ? -1 : 0;
|
||||
free(message);
|
||||
break;
|
||||
}
|
||||
cbm_daemon_runtime_application_token_t cancel_token =
|
||||
CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID;
|
||||
frontend_cancellation_route_t cancellation =
|
||||
frontend_route_cancellation(&state, message, &cancel_token);
|
||||
if (cancellation != FRONTEND_CANCELLATION_NONE) {
|
||||
free(message);
|
||||
if (cancellation == FRONTEND_CANCELLATION_ACTIVE) {
|
||||
cbm_daemon_runtime_cancel_result_t cancelled =
|
||||
cbm_daemon_runtime_client_application_cancel(
|
||||
state.client, cancel_token);
|
||||
if (cancelled == CBM_DAEMON_RUNTIME_CANCEL_ERROR) {
|
||||
result = -1;
|
||||
close_begun = frontend_stop_begin(&state);
|
||||
break;
|
||||
}
|
||||
}
|
||||
/* A queued cancellation is represented on that queue item; stale
|
||||
* and malformed targets are intentionally side-effect free. */
|
||||
continue;
|
||||
}
|
||||
if (!frontend_enqueue(&state, message, content_length_framed)) {
|
||||
free(message);
|
||||
result = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!close_begun) {
|
||||
close_begun = frontend_stop_begin(&state);
|
||||
}
|
||||
(void)cbm_thread_join(&worker);
|
||||
if (close_begun) {
|
||||
(void)cbm_daemon_runtime_client_close_finish(
|
||||
state.client, FRONTEND_CLOSE_TIMEOUT_MS);
|
||||
} else {
|
||||
result = -1;
|
||||
}
|
||||
for (size_t i = 0; i < FRONTEND_QUEUE_CAPACITY; i++) {
|
||||
frontend_item_free(&state.queue[i]);
|
||||
}
|
||||
if (state.failed) {
|
||||
result = -1;
|
||||
}
|
||||
cbm_mutex_destroy(&state.mutex);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* frontend.h — Stateless stdio bridge for a daemon-backed MCP session.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_FRONTEND_H
|
||||
#define CBM_DAEMON_FRONTEND_H
|
||||
|
||||
#include "daemon/runtime.h"
|
||||
#include "daemon/version_cohort.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
|
||||
typedef struct cbm_daemon_maintenance_monitor
|
||||
cbm_daemon_maintenance_monitor_t;
|
||||
|
||||
/* Called once when install/update/uninstall requests an active local command
|
||||
* to stop cooperatively. Returning false does not authorize the command to
|
||||
* outlive the bounded grace period. */
|
||||
typedef bool (*cbm_daemon_maintenance_cancel_fn)(void *context);
|
||||
|
||||
/* Parse one JSON-RPC message and recognize only the exact cancellation
|
||||
* notification method. String contents, nested fields, method prefixes, and
|
||||
* requests carrying an id are not cancellation notifications. */
|
||||
bool cbm_daemon_frontend_is_cancellation_notification(const char *message);
|
||||
|
||||
/* Return true only when message is an exact cancellation notification whose
|
||||
* params.requestId has the same JSON type and value as the active request.
|
||||
* Empty, stale, and numeric-vs-string targets never authorize request
|
||||
* cancellation. */
|
||||
bool cbm_daemon_frontend_cancellation_matches_request(
|
||||
const char *message, int64_t active_id, const char *active_id_str);
|
||||
|
||||
/* Start a temporary observer for a one-shot local CLI command or physical
|
||||
* supervised worker. manager and cancel_context are borrowed until stop. On
|
||||
* maintenance intent the observer invokes cancel once, permits a fixed bounded
|
||||
* grace large enough for supervised worker-tree containment, then
|
||||
* _Exit(exit_code) so OS/SQLite cleanup releases the participant's locks. The
|
||||
* caller must stop and join before freeing either borrowed object; a false stop
|
||||
* result means process-level exit is the only safe alternative to freeing
|
||||
* memory still visible to the observer. */
|
||||
cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
cbm_daemon_maintenance_cancel_fn cancel, void *cancel_context,
|
||||
int exit_code, const char *participant);
|
||||
bool cbm_daemon_maintenance_monitor_stop(
|
||||
cbm_daemon_maintenance_monitor_t **monitor_io);
|
||||
|
||||
/* Takes ownership of client and borrows cohort_manager for the complete call.
|
||||
* A dedicated reader keeps observing stdin while one joinable worker performs
|
||||
* daemon requests. The existing worker also observes maintenance while idle,
|
||||
* so install/update/uninstall terminates this stateless process even when the
|
||||
* reader remains blocked in stdio. Kernel IPC close then cancels only this
|
||||
* session's daemon work. EOF/parse failure closes the authenticated session.
|
||||
* An unexpected daemon transport failure likewise terminates the process so
|
||||
* an agent waiting with stdin still open observes server EOF promptly. */
|
||||
int cbm_daemon_frontend_mcp_run(
|
||||
cbm_daemon_runtime_client_t *client,
|
||||
cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out);
|
||||
|
||||
#endif /* CBM_DAEMON_FRONTEND_H */
|
||||
@@ -0,0 +1,671 @@
|
||||
/*
|
||||
* host.c — Lifecycle owner for the mandatory per-account CBM daemon.
|
||||
*/
|
||||
#include "daemon/host.h"
|
||||
#include "daemon/host_internal.h"
|
||||
|
||||
#include "daemon/application.h"
|
||||
#include "daemon/runtime.h"
|
||||
#include "daemon/project_lock.h"
|
||||
#include "daemon/version_cohort.h"
|
||||
#include "cli/cli.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/compat_thread.h"
|
||||
#include "foundation/diagnostics.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/mem.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "mcp/index_supervisor.h"
|
||||
#include "store/store.h"
|
||||
#include "ui/config.h"
|
||||
#include "ui/embedded_assets.h"
|
||||
#include "ui/http_server.h"
|
||||
#include "watcher/watcher.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
enum {
|
||||
HOST_MAX_CLIENTS = 64,
|
||||
HOST_LEASE_TIMEOUT_MS = 30000,
|
||||
HOST_REQUEST_TIMEOUT_MS = 5000,
|
||||
HOST_RUNTIME_SHUTDOWN_MS = 10000,
|
||||
HOST_APPLICATION_SHUTDOWN_MS = 10000,
|
||||
HOST_COORDINATION_CLEANUP_MS = 500,
|
||||
HOST_INITIAL_CLIENT_TIMEOUT_MS = 10000,
|
||||
HOST_WAIT_TICK_MS = 100,
|
||||
HOST_WATCH_INTERVAL_MS = 5000,
|
||||
HOST_CONFLICT_LOG_CAP = 1024 * 1024,
|
||||
HOST_OPERATION_LOG_CAP = 5 * 1024 * 1024,
|
||||
HOST_PATH_CAP = 4096,
|
||||
#ifdef _WIN32
|
||||
/* LockFileEx has no non-owning F_GETLK equivalent. A lifetime probe must
|
||||
* momentarily take and release the private file range. The child can also
|
||||
* briefly lose the legacy-mutex handoff to another waiting starter, so it
|
||||
* retries both bounded observation/compatibility windows. */
|
||||
HOST_WINDOWS_LIFETIME_ACQUIRE_MS = 250,
|
||||
#endif
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
cbm_daemon_application_t *application;
|
||||
cbm_watcher_t *watcher;
|
||||
cbm_store_t *watch_store;
|
||||
cbm_config_t *runtime_config;
|
||||
cbm_project_lock_manager_t *project_locks;
|
||||
cbm_http_server_t *http;
|
||||
cbm_thread_t watcher_thread;
|
||||
cbm_thread_t http_thread;
|
||||
bool watcher_started;
|
||||
bool http_started;
|
||||
bool http_config_enabled;
|
||||
int http_config_port;
|
||||
} host_state_t;
|
||||
|
||||
static FILE *g_host_log_file = NULL;
|
||||
static cbm_mutex_t g_host_log_mutex;
|
||||
static bool g_host_log_mutex_initialized = false;
|
||||
|
||||
static _Noreturn void host_force_terminate(const char *component);
|
||||
|
||||
static void host_log_sink(const char *line) {
|
||||
cbm_ui_log_append(line);
|
||||
if (!g_host_log_file || !g_host_log_mutex_initialized) {
|
||||
return;
|
||||
}
|
||||
cbm_mutex_lock(&g_host_log_mutex);
|
||||
(void)fprintf(g_host_log_file, "%s\n", line ? line : "");
|
||||
(void)fflush(g_host_log_file);
|
||||
cbm_mutex_unlock(&g_host_log_mutex);
|
||||
}
|
||||
|
||||
static bool host_log_open(char conflict_log_out[HOST_PATH_CAP]) {
|
||||
const char *cache = cbm_resolve_cache_dir();
|
||||
if (!cache || !cache[0] || !conflict_log_out) {
|
||||
return false;
|
||||
}
|
||||
char logs[HOST_PATH_CAP];
|
||||
int written = snprintf(logs, sizeof(logs), "%s/logs", cache);
|
||||
if (written <= 0 || written >= (int)sizeof(logs)) {
|
||||
return false;
|
||||
}
|
||||
written = snprintf(conflict_log_out, HOST_PATH_CAP,
|
||||
"%s/daemon-conflicts.ndjson", logs);
|
||||
if (written <= 0 || written >= HOST_PATH_CAP) {
|
||||
return false;
|
||||
}
|
||||
g_host_log_file = cbm_daemon_ipc_private_log_open(
|
||||
logs, "cbm-daemon.log", HOST_OPERATION_LOG_CAP);
|
||||
if (!g_host_log_file) {
|
||||
conflict_log_out[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
cbm_mutex_init(&g_host_log_mutex);
|
||||
g_host_log_mutex_initialized = true;
|
||||
cbm_log_set_sink(host_log_sink);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void host_log_close(void) {
|
||||
cbm_log_set_sink(NULL);
|
||||
if (g_host_log_mutex_initialized) {
|
||||
cbm_mutex_lock(&g_host_log_mutex);
|
||||
}
|
||||
FILE *file = g_host_log_file;
|
||||
g_host_log_file = NULL;
|
||||
if (file) {
|
||||
(void)fclose(file);
|
||||
}
|
||||
if (g_host_log_mutex_initialized) {
|
||||
cbm_mutex_unlock(&g_host_log_mutex);
|
||||
cbm_mutex_destroy(&g_host_log_mutex);
|
||||
g_host_log_mutex_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
static uint64_t host_deadline_after(uint32_t timeout_ms) {
|
||||
uint64_t now = cbm_now_ms();
|
||||
return now > UINT64_MAX - timeout_ms ? UINT64_MAX : now + timeout_ms;
|
||||
}
|
||||
|
||||
static _Noreturn void host_cleanup_force_terminate(const char *component) {
|
||||
/* Early startup failures can precede the ordinary operation-log setup.
|
||||
* Make one best-effort attempt to establish that same owner-only durable
|
||||
* sink before the process-level escape releases every native claim. */
|
||||
if (!g_host_log_file) {
|
||||
char conflict_log[HOST_PATH_CAP];
|
||||
cbm_ui_log_init();
|
||||
(void)host_log_open(conflict_log);
|
||||
}
|
||||
host_force_terminate(component);
|
||||
}
|
||||
|
||||
static void host_cleanup_release_until_complete(
|
||||
cbm_daemon_host_cleanup_release_for_test_fn release, void *context,
|
||||
const char *component) {
|
||||
if (!release) {
|
||||
return;
|
||||
}
|
||||
uint64_t deadline = host_deadline_after(HOST_COORDINATION_CLEANUP_MS);
|
||||
while (!release(context)) {
|
||||
if (cbm_now_ms() >= deadline) {
|
||||
host_cleanup_force_terminate(component);
|
||||
}
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
void cbm_daemon_host_cleanup_release_until_complete_for_test(
|
||||
cbm_daemon_host_cleanup_release_for_test_fn release, void *context) {
|
||||
host_cleanup_release_until_complete(release, context,
|
||||
"coordination_cleanup");
|
||||
}
|
||||
|
||||
static bool host_cohort_lease_release_once(void *context) {
|
||||
cbm_version_cohort_lease_t **lease = context;
|
||||
if (!lease || !*lease) {
|
||||
return true;
|
||||
}
|
||||
(void)cbm_version_cohort_lease_release(lease);
|
||||
return *lease == NULL;
|
||||
}
|
||||
|
||||
static bool host_cohort_manager_free_once(void *context) {
|
||||
cbm_version_cohort_manager_t **manager = context;
|
||||
if (!manager || !*manager) {
|
||||
return true;
|
||||
}
|
||||
(void)cbm_version_cohort_manager_free(manager);
|
||||
return *manager == NULL;
|
||||
}
|
||||
|
||||
static void host_cohort_close(cbm_version_cohort_lease_t **lease,
|
||||
cbm_version_cohort_manager_t **manager) {
|
||||
host_cleanup_release_until_complete(host_cohort_lease_release_once, lease,
|
||||
"cohort_lease_cleanup");
|
||||
host_cleanup_release_until_complete(host_cohort_manager_free_once, manager,
|
||||
"cohort_manager_cleanup");
|
||||
}
|
||||
|
||||
static bool host_daemon_claim_release_once(void *context) {
|
||||
cbm_version_cohort_daemon_claim_t **claim = context;
|
||||
if (!claim || !*claim) {
|
||||
return true;
|
||||
}
|
||||
(void)cbm_version_cohort_daemon_claim_release(claim);
|
||||
return *claim == NULL;
|
||||
}
|
||||
|
||||
static void host_daemon_claim_close(
|
||||
cbm_version_cohort_daemon_claim_t **claim) {
|
||||
host_cleanup_release_until_complete(host_daemon_claim_release_once,
|
||||
claim, "daemon_claim_cleanup");
|
||||
}
|
||||
|
||||
static bool host_participant_guard_release_once(void *context) {
|
||||
cbm_daemon_ipc_participant_guard_t **guard = context;
|
||||
if (!guard || !*guard) {
|
||||
return true;
|
||||
}
|
||||
(void)cbm_daemon_ipc_participant_guard_release(guard);
|
||||
return *guard == NULL;
|
||||
}
|
||||
|
||||
static void host_participant_guard_close(
|
||||
cbm_daemon_ipc_participant_guard_t **guard) {
|
||||
host_cleanup_release_until_complete(host_participant_guard_release_once,
|
||||
guard, "participant_guard_cleanup");
|
||||
}
|
||||
|
||||
static int host_lifetime_reservation_acquire(
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
cbm_daemon_ipc_lifetime_reservation_t **reservation_out) {
|
||||
#ifdef _WIN32
|
||||
uint64_t now = cbm_now_ms();
|
||||
uint64_t deadline =
|
||||
now > UINT64_MAX - HOST_WINDOWS_LIFETIME_ACQUIRE_MS
|
||||
? UINT64_MAX
|
||||
: now + HOST_WINDOWS_LIFETIME_ACQUIRE_MS;
|
||||
do {
|
||||
int status = cbm_daemon_ipc_lifetime_reservation_try_acquire(
|
||||
endpoint, reservation_out);
|
||||
if (status != 0) {
|
||||
return status;
|
||||
}
|
||||
cbm_usleep(1000);
|
||||
} while (cbm_now_ms() < deadline);
|
||||
return 0;
|
||||
#else
|
||||
return cbm_daemon_ipc_lifetime_reservation_try_acquire(
|
||||
endpoint, reservation_out);
|
||||
#endif
|
||||
}
|
||||
|
||||
static void *host_watcher_thread(void *opaque) {
|
||||
cbm_watcher_run(opaque, HOST_WATCH_INTERVAL_MS);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void *host_http_thread(void *opaque) {
|
||||
cbm_http_server_run(opaque);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int host_watcher_index(const char *project_name, const char *root_path,
|
||||
void *opaque) {
|
||||
host_state_t *host = opaque;
|
||||
return host && host->application
|
||||
? cbm_daemon_application_watcher_index(project_name, root_path,
|
||||
host->application)
|
||||
: -1;
|
||||
}
|
||||
|
||||
static int host_ui_index(void *opaque, const char *root_path,
|
||||
const char *project_name) {
|
||||
host_state_t *host = opaque;
|
||||
return host && host->application
|
||||
? cbm_daemon_application_index(host->application,
|
||||
project_name ? project_name : "",
|
||||
root_path)
|
||||
: -1;
|
||||
}
|
||||
|
||||
static bool host_ui_mutation_begin(void *opaque, const char *project) {
|
||||
host_state_t *host = opaque;
|
||||
return host && host->application &&
|
||||
cbm_daemon_application_project_mutation_try_begin(host->application,
|
||||
project);
|
||||
}
|
||||
|
||||
static void host_ui_mutation_end(void *opaque, const char *project) {
|
||||
host_state_t *host = opaque;
|
||||
if (host && host->application) {
|
||||
cbm_daemon_application_project_mutation_end(host->application, project);
|
||||
}
|
||||
}
|
||||
|
||||
static void host_http_stop_join_free(host_state_t *host) {
|
||||
if (host->http) {
|
||||
cbm_http_server_stop(host->http);
|
||||
}
|
||||
if (host->http_started) {
|
||||
(void)cbm_thread_join(&host->http_thread);
|
||||
host->http_started = false;
|
||||
}
|
||||
cbm_http_server_free(host->http);
|
||||
host->http = NULL;
|
||||
}
|
||||
|
||||
static void host_http_reconcile(host_state_t *host) {
|
||||
cbm_ui_config_t desired;
|
||||
cbm_ui_config_load(&desired);
|
||||
if (desired.ui_enabled == host->http_config_enabled &&
|
||||
desired.ui_port == host->http_config_port) {
|
||||
return;
|
||||
}
|
||||
host_http_stop_join_free(host);
|
||||
host->http_config_enabled = desired.ui_enabled;
|
||||
host->http_config_port = desired.ui_port;
|
||||
if (!desired.ui_enabled) {
|
||||
return;
|
||||
}
|
||||
if (CBM_EMBEDDED_FILE_COUNT == 0) {
|
||||
cbm_log_warn("ui.no_assets", "hint",
|
||||
"rebuild with: make -f Makefile.cbm cbm-with-ui");
|
||||
return;
|
||||
}
|
||||
host->http = cbm_http_server_new(desired.ui_port);
|
||||
if (!host->http) {
|
||||
return;
|
||||
}
|
||||
cbm_http_server_set_watcher(host->http, host->watcher);
|
||||
cbm_http_server_set_index_executor(host->http, host_ui_index, host);
|
||||
cbm_http_server_set_project_mutation_guard(
|
||||
host->http, host_ui_mutation_begin, host_ui_mutation_end, host);
|
||||
if (cbm_thread_create(&host->http_thread, 0, host_http_thread,
|
||||
host->http) == 0) {
|
||||
host->http_started = true;
|
||||
} else {
|
||||
cbm_http_server_free(host->http);
|
||||
host->http = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static void host_background_stop(host_state_t *host) {
|
||||
if (host->http) {
|
||||
cbm_http_server_stop(host->http);
|
||||
}
|
||||
if (host->watcher) {
|
||||
cbm_watcher_stop(host->watcher);
|
||||
}
|
||||
}
|
||||
|
||||
static void host_background_join(host_state_t *host) {
|
||||
if (host->http_started) {
|
||||
(void)cbm_thread_join(&host->http_thread);
|
||||
host->http_started = false;
|
||||
}
|
||||
if (host->watcher_started) {
|
||||
(void)cbm_thread_join(&host->watcher_thread);
|
||||
host->watcher_started = false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool host_project_lock_manager_free_once(void *context) {
|
||||
cbm_project_lock_manager_t **manager = context;
|
||||
if (!manager || !*manager) {
|
||||
return true;
|
||||
}
|
||||
(void)cbm_project_lock_manager_free(manager);
|
||||
return *manager == NULL;
|
||||
}
|
||||
|
||||
static void host_state_free(host_state_t *host) {
|
||||
host_http_stop_join_free(host);
|
||||
cbm_daemon_application_free(host->application);
|
||||
host->application = NULL;
|
||||
cbm_watcher_free(host->watcher);
|
||||
host->watcher = NULL;
|
||||
cbm_store_close(host->watch_store);
|
||||
host->watch_store = NULL;
|
||||
cbm_config_close(host->runtime_config);
|
||||
host->runtime_config = NULL;
|
||||
if (host->project_locks) {
|
||||
host_cleanup_release_until_complete(
|
||||
host_project_lock_manager_free_once, &host->project_locks,
|
||||
"project_lock_manager_cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
static bool host_state_prepare(host_state_t *host,
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint) {
|
||||
size_t aggregate_memory_budget_bytes = cbm_mem_budget();
|
||||
const char *cache = cbm_resolve_cache_dir();
|
||||
host->runtime_config = cache ? cbm_config_open(cache) : NULL;
|
||||
host->watch_store = cbm_store_open_memory();
|
||||
host->project_locks = cbm_project_lock_manager_new(endpoint);
|
||||
host->watcher = cbm_watcher_new(host->watch_store, host_watcher_index, host);
|
||||
cbm_daemon_application_config_t application_config = {
|
||||
.watcher = host->watcher,
|
||||
.config = host->runtime_config,
|
||||
.aggregate_memory_budget_bytes = aggregate_memory_budget_bytes,
|
||||
.project_locks = host->project_locks,
|
||||
};
|
||||
host->application = cbm_daemon_application_new(&application_config);
|
||||
if (!host->watch_store || !host->watcher || !host->project_locks ||
|
||||
!host->application) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool host_background_start(host_state_t *host) {
|
||||
if (cbm_thread_create(&host->watcher_thread, 0, host_watcher_thread,
|
||||
host->watcher) != 0) {
|
||||
return false;
|
||||
}
|
||||
host->watcher_started = true;
|
||||
|
||||
/* Force the first reconciliation even when defaults are false/9749. */
|
||||
host->http_config_port = -1;
|
||||
host_http_reconcile(host);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service,
|
||||
atomic_int *stop_requested,
|
||||
host_state_t *host) {
|
||||
uint64_t initial_deadline = cbm_now_ms() + HOST_INITIAL_CLIENT_TIMEOUT_MS;
|
||||
bool admitted = false;
|
||||
uint64_t stopping_deadline = 0;
|
||||
for (;;) {
|
||||
cbm_daemon_runtime_service_state_t state =
|
||||
cbm_daemon_runtime_service_state(service);
|
||||
if (state == CBM_DAEMON_RUNTIME_SERVICE_EXITED) {
|
||||
return true;
|
||||
}
|
||||
if (state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING) {
|
||||
if (stopping_deadline == 0) {
|
||||
stopping_deadline =
|
||||
cbm_now_ms() + HOST_RUNTIME_SHUTDOWN_MS;
|
||||
}
|
||||
if (cbm_now_ms() >= stopping_deadline) {
|
||||
return false;
|
||||
}
|
||||
(void)cbm_daemon_runtime_service_wait_exited(
|
||||
service, HOST_WAIT_TICK_MS);
|
||||
continue;
|
||||
}
|
||||
if (cbm_daemon_runtime_service_active_clients(service) > 0) {
|
||||
admitted = true;
|
||||
}
|
||||
bool stop = stop_requested && atomic_load(stop_requested);
|
||||
if (stop || (!admitted && cbm_now_ms() >= initial_deadline)) {
|
||||
return cbm_daemon_runtime_service_stop(
|
||||
service, HOST_RUNTIME_SHUTDOWN_MS);
|
||||
}
|
||||
host_http_reconcile(host);
|
||||
(void)cbm_daemon_runtime_service_wait_exited(service,
|
||||
HOST_WAIT_TICK_MS);
|
||||
}
|
||||
}
|
||||
|
||||
static bool host_application_shutdown(host_state_t *host) {
|
||||
if (cbm_daemon_application_shutdown(host->application,
|
||||
HOST_APPLICATION_SHUTDOWN_MS)) {
|
||||
return true;
|
||||
}
|
||||
cbm_log_error("daemon.shutdown_timeout", "component", "operations");
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool host_runtime_stop_free(cbm_daemon_runtime_service_t *service) {
|
||||
if (cbm_daemon_runtime_service_state(service) !=
|
||||
CBM_DAEMON_RUNTIME_SERVICE_EXITED) {
|
||||
if (!cbm_daemon_runtime_service_stop(
|
||||
service, HOST_RUNTIME_SHUTDOWN_MS)) {
|
||||
cbm_log_error("daemon.shutdown_timeout", "component", "runtime");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!cbm_daemon_runtime_service_free(service)) {
|
||||
cbm_log_error("daemon.cleanup_timeout", "component", "runtime");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static _Noreturn void host_force_terminate(const char *component) {
|
||||
/* The operation-log sink flushes every record before returning. Do not run
|
||||
* ordinary teardown after the cooperative deadline: a callback thread may
|
||||
* still own application storage. Process termination releases native
|
||||
* locks, and supervised worker groups observe their daemon parent's death. */
|
||||
cbm_log_error("daemon.forced_shutdown", "component", component);
|
||||
#ifdef _WIN32
|
||||
(void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE);
|
||||
abort();
|
||||
#else
|
||||
_exit(EXIT_FAILURE);
|
||||
#endif
|
||||
}
|
||||
|
||||
_Noreturn void cbm_daemon_host_force_terminate_for_test(
|
||||
const char *component) {
|
||||
char conflict_log[HOST_PATH_CAP];
|
||||
cbm_ui_log_init();
|
||||
if (host_log_open(conflict_log)) {
|
||||
host_force_terminate(component ? component : "test");
|
||||
}
|
||||
#ifdef _WIN32
|
||||
(void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE);
|
||||
abort();
|
||||
#else
|
||||
_exit(EXIT_FAILURE);
|
||||
#endif
|
||||
}
|
||||
|
||||
int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) {
|
||||
if (!config || !config->endpoint || !config->executable_path ||
|
||||
!config->identity.semantic_version ||
|
||||
!config->identity.build_fingerprint) {
|
||||
return -1;
|
||||
}
|
||||
cbm_version_cohort_manager_t *cohort_manager =
|
||||
cbm_version_cohort_manager_new(config->endpoint);
|
||||
cbm_version_cohort_lease_t *cohort_lease = NULL;
|
||||
cbm_daemon_conflict_t cohort_conflict;
|
||||
uint64_t now_ms = cbm_now_ms();
|
||||
uint64_t cohort_deadline =
|
||||
now_ms > UINT64_MAX - HOST_INITIAL_CLIENT_TIMEOUT_MS
|
||||
? UINT64_MAX
|
||||
: now_ms + HOST_INITIAL_CLIENT_TIMEOUT_MS;
|
||||
cbm_version_cohort_status_t cohort_status =
|
||||
cohort_manager
|
||||
? cbm_version_cohort_acquire(
|
||||
cohort_manager, &config->identity, cohort_deadline,
|
||||
&cohort_lease, &cohort_conflict)
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
if (cohort_status != CBM_VERSION_COHORT_OK) {
|
||||
char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE];
|
||||
bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT &&
|
||||
cbm_daemon_conflict_format(
|
||||
&cohort_conflict, message, sizeof(message));
|
||||
if (cohort_status == CBM_VERSION_COHORT_CONFLICT) {
|
||||
(void)cbm_version_cohort_log_conflict(&cohort_conflict);
|
||||
}
|
||||
(void)fprintf(stderr, "codebase-memory: %s\n",
|
||||
formatted ? message
|
||||
: "daemon exact-build admission failed");
|
||||
host_cohort_close(&cohort_lease, &cohort_manager);
|
||||
return -1;
|
||||
}
|
||||
cbm_daemon_ipc_participant_guard_t *participant_guard = NULL;
|
||||
if (cbm_daemon_ipc_participant_guard_try_join(
|
||||
config->endpoint, &participant_guard) != 1) {
|
||||
(void)fprintf(stderr,
|
||||
"codebase-memory: daemon participant admission failed\n");
|
||||
host_participant_guard_close(&participant_guard);
|
||||
host_cohort_close(&cohort_lease, &cohort_manager);
|
||||
return -1;
|
||||
}
|
||||
cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation = NULL;
|
||||
if (host_lifetime_reservation_acquire(
|
||||
config->endpoint, &lifetime_reservation) != 1) {
|
||||
host_participant_guard_close(&participant_guard);
|
||||
host_cohort_close(&cohort_lease, &cohort_manager);
|
||||
return -1;
|
||||
}
|
||||
cbm_version_cohort_daemon_claim_t *daemon_claim = NULL;
|
||||
if (cbm_version_cohort_daemon_claim_acquire(
|
||||
cohort_manager, &daemon_claim) != CBM_VERSION_COHORT_OK) {
|
||||
cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation);
|
||||
host_daemon_claim_close(&daemon_claim);
|
||||
host_participant_guard_close(&participant_guard);
|
||||
host_cohort_close(&cohort_lease, &cohort_manager);
|
||||
return -1;
|
||||
}
|
||||
cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram));
|
||||
cbm_ui_log_init();
|
||||
char conflict_log[HOST_PATH_CAP];
|
||||
if (!host_log_open(conflict_log)) {
|
||||
(void)fprintf(stderr,
|
||||
"codebase-memory: daemon log path is not private or safe\n");
|
||||
cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation);
|
||||
host_daemon_claim_close(&daemon_claim);
|
||||
host_participant_guard_close(&participant_guard);
|
||||
host_cohort_close(&cohort_lease, &cohort_manager);
|
||||
return -1;
|
||||
}
|
||||
cbm_http_server_set_binary_path(config->executable_path);
|
||||
cbm_index_supervisor_mark_host();
|
||||
|
||||
host_state_t host = {0};
|
||||
if (!host_state_prepare(&host, config->endpoint)) {
|
||||
cbm_log_error("daemon.start_failed", "component", "application");
|
||||
host_state_free(&host);
|
||||
host_log_close();
|
||||
cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation);
|
||||
host_daemon_claim_close(&daemon_claim);
|
||||
host_participant_guard_close(&participant_guard);
|
||||
host_cohort_close(&cohort_lease, &cohort_manager);
|
||||
return -1;
|
||||
}
|
||||
cbm_daemon_runtime_application_callbacks_t callbacks =
|
||||
cbm_daemon_application_runtime_callbacks(host.application);
|
||||
cbm_daemon_runtime_service_config_t runtime_config = {
|
||||
.endpoint = config->endpoint,
|
||||
.identity = config->identity,
|
||||
.conflict_log_path = conflict_log,
|
||||
.conflict_log_cap_bytes = HOST_CONFLICT_LOG_CAP,
|
||||
.max_clients = HOST_MAX_CLIENTS,
|
||||
.lease_timeout_ms = HOST_LEASE_TIMEOUT_MS,
|
||||
.request_timeout_ms = HOST_REQUEST_TIMEOUT_MS,
|
||||
.shutdown_timeout_ms = HOST_RUNTIME_SHUTDOWN_MS,
|
||||
.application = callbacks,
|
||||
};
|
||||
cbm_daemon_runtime_service_t *service =
|
||||
cbm_daemon_runtime_service_start_reserved(
|
||||
&runtime_config, &lifetime_reservation);
|
||||
cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation);
|
||||
lifetime_reservation = NULL;
|
||||
if (!service) {
|
||||
cbm_log_error("daemon.start_failed", "component", "runtime");
|
||||
host_state_free(&host);
|
||||
host_log_close();
|
||||
host_daemon_claim_close(&daemon_claim);
|
||||
host_participant_guard_close(&participant_guard);
|
||||
host_cohort_close(&cohort_lease, &cohort_manager);
|
||||
return -1;
|
||||
}
|
||||
if (!host_background_start(&host)) {
|
||||
cbm_log_error("daemon.start_failed", "component", "background");
|
||||
if (!host_application_shutdown(&host)) {
|
||||
host_force_terminate("operations");
|
||||
}
|
||||
host_background_stop(&host);
|
||||
host_background_join(&host);
|
||||
if (!host_runtime_stop_free(service)) {
|
||||
host_force_terminate("runtime");
|
||||
}
|
||||
host_state_free(&host);
|
||||
host_log_close();
|
||||
host_daemon_claim_close(&daemon_claim);
|
||||
host_participant_guard_close(&participant_guard);
|
||||
host_cohort_close(&cohort_lease, &cohort_manager);
|
||||
return -1;
|
||||
}
|
||||
cbm_diag_start();
|
||||
|
||||
cbm_log_info("daemon.start", "version", config->identity.semantic_version);
|
||||
if (!host_wait_for_lifetime(service, config->stop_requested, &host)) {
|
||||
host_force_terminate("runtime");
|
||||
}
|
||||
|
||||
/* Prevent any new UI/watcher operation, then cancel/reap every physical
|
||||
* job before those background loops and the daemon process disappear. */
|
||||
if (!host_application_shutdown(&host)) {
|
||||
host_force_terminate("operations");
|
||||
}
|
||||
host_background_stop(&host);
|
||||
host_background_join(&host);
|
||||
if (!host_runtime_stop_free(service)) {
|
||||
host_force_terminate("runtime");
|
||||
}
|
||||
host_state_free(&host);
|
||||
cbm_diag_stop();
|
||||
cbm_log_info("daemon.stop");
|
||||
host_log_close();
|
||||
/* Keep the exact-build lifetime lease through listener/lifetime teardown,
|
||||
* application destruction, diagnostics shutdown, the durable stop record,
|
||||
* daemon-claim release, and participant release. An activation transaction
|
||||
* may treat exclusive acquisition as proof that coordinated cleanup for
|
||||
* this generation has completed. */
|
||||
host_daemon_claim_close(&daemon_claim);
|
||||
host_participant_guard_close(&participant_guard);
|
||||
host_cohort_close(&cohort_lease, &cohort_manager);
|
||||
return 0;
|
||||
}
|
||||
@@ -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 <stdatomic.h>
|
||||
|
||||
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 */
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* host_internal.h — Private daemon-host test seams.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_HOST_INTERNAL_H
|
||||
#define CBM_DAEMON_HOST_INTERNAL_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
typedef bool (*cbm_daemon_host_cleanup_release_for_test_fn)(void *context);
|
||||
|
||||
/* Runs the same finite retry/fail-stop driver used by daemon-claim shutdown
|
||||
* around an injected release operation. The hook is process-local and inert
|
||||
* unless a focused subprocess test calls this seam. */
|
||||
void cbm_daemon_host_cleanup_release_until_complete_for_test(
|
||||
cbm_daemon_host_cleanup_release_for_test_fn release, void *context);
|
||||
|
||||
/* Opens the ordinary private daemon operation log, records the same forced
|
||||
* shutdown event used by production, flushes it, and terminates the process.
|
||||
* This exists only so an isolated child can verify the hard escape path. */
|
||||
_Noreturn void cbm_daemon_host_force_terminate_for_test(
|
||||
const char *component);
|
||||
|
||||
#endif /* CBM_DAEMON_HOST_INTERNAL_H */
|
||||
+6179
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* ipc.h — Authenticated local transport for the CBM daemon.
|
||||
*
|
||||
* Unix builds use owner-only Unix-domain sockets and advisory file locks.
|
||||
* Windows builds use generation-random current-user named pipes plus
|
||||
* handle-anchored private lock files in an owner-only runtime directory. All
|
||||
* implementation details stay opaque so callers cannot accidentally bypass
|
||||
* the transport checks.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_IPC_H
|
||||
#define CBM_DAEMON_IPC_H
|
||||
|
||||
#include "daemon/daemon.h"
|
||||
#include "foundation/private_file_lock.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
typedef struct cbm_daemon_ipc_endpoint cbm_daemon_ipc_endpoint_t;
|
||||
typedef struct cbm_daemon_ipc_listener cbm_daemon_ipc_listener_t;
|
||||
typedef struct cbm_daemon_ipc_connection cbm_daemon_ipc_connection_t;
|
||||
typedef struct cbm_daemon_ipc_startup_lock cbm_daemon_ipc_startup_lock_t;
|
||||
typedef struct cbm_daemon_ipc_local_transition
|
||||
cbm_daemon_ipc_local_transition_t;
|
||||
typedef struct cbm_daemon_ipc_participant_guard
|
||||
cbm_daemon_ipc_participant_guard_t;
|
||||
typedef struct cbm_daemon_ipc_lifetime_reservation
|
||||
cbm_daemon_ipc_lifetime_reservation_t;
|
||||
|
||||
/* A receive wait with no wall-clock expiry. The wait remains interruptible by
|
||||
* peer EOF and cbm_daemon_ipc_connection_interrupt(). This is intentionally a
|
||||
* named sentinel rather than a very large finite timeout: authenticated MCP
|
||||
* sessions routinely remain idle for days, and a long-running application
|
||||
* request must not lose its control connection merely because no new frame is
|
||||
* arriving. */
|
||||
#define CBM_DAEMON_IPC_WAIT_FOREVER UINT32_MAX
|
||||
|
||||
/* Create one endpoint namespace for an exact 16-lowercase-hex instance key.
|
||||
* POSIX addresses are deterministic; Windows resolves the current daemon
|
||||
* generation through an owner-only nonce record. runtime_parent may be NULL
|
||||
* to use the platform's secure local default. The returned runtime directory
|
||||
* is an owner-only child of that parent. */
|
||||
cbm_daemon_ipc_endpoint_t *cbm_daemon_ipc_endpoint_new(const char *instance_key,
|
||||
const char *runtime_parent);
|
||||
void cbm_daemon_ipc_endpoint_free(cbm_daemon_ipc_endpoint_t *endpoint);
|
||||
const char *cbm_daemon_ipc_endpoint_address(const cbm_daemon_ipc_endpoint_t *endpoint);
|
||||
const char *cbm_daemon_ipc_endpoint_runtime_dir(const cbm_daemon_ipc_endpoint_t *endpoint);
|
||||
|
||||
/* Duplicate the endpoint's already-validated owner-only runtime-directory
|
||||
* handle for native lock files. Callers never reopen the path themselves. */
|
||||
cbm_private_file_lock_status_t cbm_daemon_ipc_private_lock_directory_new(
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
cbm_private_lock_directory_t **directory_out);
|
||||
|
||||
/* Listener and connection handles are non-inheritable. */
|
||||
cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen(const cbm_daemon_ipc_endpoint_t *endpoint);
|
||||
|
||||
/* A daemon host may reserve endpoint ownership before constructing any
|
||||
* stateful subsystem, then transfer that exact reservation into its listener.
|
||||
* POSIX listener publication binds a private generation anchor, durably
|
||||
* records the pending anchor identity through a recoverable deterministic
|
||||
* temp link, hard-links the anchor into the stable socket name without
|
||||
* overwrite, commits an anchor-bearing identity marker through the same
|
||||
* protocol, and removes pending last. The anchor remains linked for the
|
||||
* listener lifetime.
|
||||
* listen_reserved consumes *reservation_io only on success and sets it to
|
||||
* NULL; on failure the caller still owns it. A reservation is bound to the
|
||||
* endpoint that created it and cannot be adopted by another endpoint. The
|
||||
* daemon host must retain a participant guard before taking the reservation;
|
||||
* the convenience listen() wrapper owns that guard automatically. */
|
||||
int cbm_daemon_ipc_lifetime_reservation_try_acquire(
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
cbm_daemon_ipc_lifetime_reservation_t **reservation_out);
|
||||
void cbm_daemon_ipc_lifetime_reservation_release(
|
||||
cbm_daemon_ipc_lifetime_reservation_t *reservation);
|
||||
cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved(
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
cbm_daemon_ipc_lifetime_reservation_t **reservation_io);
|
||||
void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener);
|
||||
|
||||
/* Create/validate an owner-only directory and securely open one regular
|
||||
* owner-only append log within it. User-controlled path components may not be
|
||||
* symlinks, junctions, or other reparse points; trusted root-owned macOS /tmp
|
||||
* and /var aliases are normalized before validation. A file larger than
|
||||
* rotate_cap_bytes is moved to <base_name>.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 */
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* ipc_internal.h — Private, platform-neutral seams used by daemon IPC tests.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_IPC_INTERNAL_H
|
||||
#define CBM_DAEMON_IPC_INTERNAL_H
|
||||
|
||||
#include "daemon/ipc.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* 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 */
|
||||
@@ -0,0 +1,202 @@
|
||||
/* project_lock.c — Shared daemon/local-CLI project mutation leases. */
|
||||
#include "daemon/project_lock.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* project_lock.h — Shared daemon/local-CLI project mutation leases. */
|
||||
#ifndef CBM_DAEMON_PROJECT_LOCK_H
|
||||
#define CBM_DAEMON_PROJECT_LOCK_H
|
||||
|
||||
#include "daemon/ipc.h"
|
||||
#include "foundation/lock_registry.h"
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
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 */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
/*
|
||||
* runtime.h — Mandatory per-account CBM daemon runtime.
|
||||
*
|
||||
* This layer combines the authenticated local IPC transport, exact-build
|
||||
* HELLO policy, and connection-owned coordinator leases. It deliberately
|
||||
* does not spawn the daemon process; executable bootstrap is a caller concern.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_RUNTIME_H
|
||||
#define CBM_DAEMON_RUNTIME_H
|
||||
|
||||
#include "daemon/daemon.h"
|
||||
#include "daemon/ipc.h"
|
||||
#include "daemon/service.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* 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/<pid>/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 */
|
||||
@@ -0,0 +1,978 @@
|
||||
/*
|
||||
* service.c — Stable daemon rendezvous, exact-build policy, and conflict log.
|
||||
*/
|
||||
#include "daemon/service.h"
|
||||
|
||||
#include "daemon/service_internal.h"
|
||||
#ifdef _WIN32
|
||||
#include "daemon/ipc.h"
|
||||
#endif
|
||||
|
||||
#include "foundation/sha256.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
enum {
|
||||
DAEMON_SERVICE_PATH_CAP = 4096,
|
||||
DAEMON_SERVICE_IO_CAP = 64 * 1024,
|
||||
DAEMON_SERVICE_ESCAPED_VERSION_CAP =
|
||||
(CBM_DAEMON_VERSION_TEXT_SIZE - 1) * 6 + 1,
|
||||
DAEMON_SERVICE_LOG_RECORD_CAP = 1536,
|
||||
};
|
||||
|
||||
static cbm_daemon_conflict_log_test_hook_fn g_conflict_log_test_hook;
|
||||
static void *g_conflict_log_test_context;
|
||||
|
||||
void cbm_daemon_conflict_log_set_test_hook(
|
||||
cbm_daemon_conflict_log_test_hook_fn hook, void *context) {
|
||||
g_conflict_log_test_context = context;
|
||||
g_conflict_log_test_hook = hook;
|
||||
}
|
||||
|
||||
static void conflict_log_test_hook(cbm_daemon_conflict_log_test_stage_t stage) {
|
||||
if (g_conflict_log_test_hook) {
|
||||
g_conflict_log_test_hook(g_conflict_log_test_context, stage);
|
||||
}
|
||||
}
|
||||
|
||||
static bool bounded_length(const char *value, size_t cap, size_t *length_out) {
|
||||
if (!value || cap == 0) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < cap; i++) {
|
||||
if (value[i] == '\0') {
|
||||
if (length_out) {
|
||||
*length_out = i;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool version_valid(const char *version) {
|
||||
size_t length = 0;
|
||||
if (!bounded_length(version, CBM_DAEMON_VERSION_TEXT_SIZE, &length) || length == 0) {
|
||||
return false;
|
||||
}
|
||||
/* Version text reaches stderr and a persistent JSON log. Keep it printable
|
||||
* ASCII so an untrusted HELLO cannot inject terminal control sequences or
|
||||
* malformed UTF-8 into either diagnostic channel. */
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
unsigned char ch = (unsigned char)version[i];
|
||||
if (ch < 0x20 || ch > 0x7e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool fingerprint_valid(const char *fingerprint) {
|
||||
size_t length = 0;
|
||||
if (!bounded_length(fingerprint, CBM_DAEMON_BUILD_FINGERPRINT_SIZE, &length) ||
|
||||
length != CBM_SHA256_HEX_LEN) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
char ch = fingerprint[i];
|
||||
if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool identity_valid(const cbm_daemon_build_identity_t *identity) {
|
||||
return identity && version_valid(identity->semantic_version) &&
|
||||
fingerprint_valid(identity->build_fingerprint) && identity->protocol_abi != 0 &&
|
||||
identity->store_abi != 0 && identity->feature_abi != 0;
|
||||
}
|
||||
|
||||
static bool conflict_status_valid(cbm_daemon_hello_status_t status) {
|
||||
return status >= CBM_DAEMON_HELLO_VERSION_CONFLICT &&
|
||||
status <= CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT;
|
||||
}
|
||||
|
||||
static bool conflict_valid(const cbm_daemon_conflict_t *conflict) {
|
||||
return conflict && conflict_status_valid(conflict->status) &&
|
||||
version_valid(conflict->active_version) &&
|
||||
fingerprint_valid(conflict->active_build_fingerprint) &&
|
||||
version_valid(conflict->requested_version) &&
|
||||
fingerprint_valid(conflict->requested_build_fingerprint);
|
||||
}
|
||||
|
||||
static const char *conflict_reason(cbm_daemon_hello_status_t status) {
|
||||
switch (status) {
|
||||
case CBM_DAEMON_HELLO_VERSION_CONFLICT:
|
||||
return "version";
|
||||
case CBM_DAEMON_HELLO_BUILD_CONFLICT:
|
||||
return "build";
|
||||
case CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT:
|
||||
return "protocol_abi";
|
||||
case CBM_DAEMON_HELLO_STORE_ABI_CONFLICT:
|
||||
return "store_abi";
|
||||
case CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT:
|
||||
return "feature_abi";
|
||||
default:
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static void digest_to_hex(const uint8_t digest[CBM_SHA256_DIGEST_LEN],
|
||||
char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) {
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < CBM_SHA256_DIGEST_LEN; i++) {
|
||||
out[i * 2] = hex[digest[i] >> 4];
|
||||
out[i * 2 + 1] = hex[digest[i] & 0x0f];
|
||||
}
|
||||
out[CBM_SHA256_HEX_LEN] = '\0';
|
||||
}
|
||||
|
||||
bool cbm_daemon_rendezvous_key(char out[CBM_DAEMON_KEY_SIZE]) {
|
||||
if (!out) {
|
||||
return false;
|
||||
}
|
||||
/* This product-domain string is intentionally the only key input. Account
|
||||
* isolation comes from the authenticated IPC runtime, not spoofable text. */
|
||||
static const unsigned char domain[] = "codebase-memory-mcp:coordination-daemon";
|
||||
uint64_t hash = 14695981039346656037ULL;
|
||||
for (size_t i = 0; i < sizeof(domain) - 1; i++) {
|
||||
hash ^= domain[i];
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
int written = snprintf(out, CBM_DAEMON_KEY_SIZE, "%016llx", (unsigned long long)hash);
|
||||
if (written != (int)CBM_DAEMON_KEY_SIZE - 1) {
|
||||
out[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
cbm_daemon_hello_status_t
|
||||
cbm_daemon_hello_compare(const cbm_daemon_build_identity_t *active,
|
||||
const cbm_daemon_build_identity_t *requested,
|
||||
cbm_daemon_conflict_t *conflict_out) {
|
||||
if (conflict_out) {
|
||||
memset(conflict_out, 0, sizeof(*conflict_out));
|
||||
conflict_out->status = CBM_DAEMON_HELLO_INVALID;
|
||||
}
|
||||
if (!conflict_out || !identity_valid(active) || !identity_valid(requested)) {
|
||||
return CBM_DAEMON_HELLO_INVALID;
|
||||
}
|
||||
|
||||
(void)snprintf(conflict_out->active_version, sizeof(conflict_out->active_version), "%s",
|
||||
active->semantic_version);
|
||||
(void)snprintf(conflict_out->active_build_fingerprint,
|
||||
sizeof(conflict_out->active_build_fingerprint), "%s",
|
||||
active->build_fingerprint);
|
||||
(void)snprintf(conflict_out->requested_version, sizeof(conflict_out->requested_version), "%s",
|
||||
requested->semantic_version);
|
||||
(void)snprintf(conflict_out->requested_build_fingerprint,
|
||||
sizeof(conflict_out->requested_build_fingerprint), "%s",
|
||||
requested->build_fingerprint);
|
||||
|
||||
cbm_daemon_hello_status_t status = CBM_DAEMON_HELLO_COMPATIBLE;
|
||||
if (strcmp(active->semantic_version, requested->semantic_version) != 0) {
|
||||
status = CBM_DAEMON_HELLO_VERSION_CONFLICT;
|
||||
} else if (strcmp(active->build_fingerprint, requested->build_fingerprint) != 0) {
|
||||
status = CBM_DAEMON_HELLO_BUILD_CONFLICT;
|
||||
} else if (active->protocol_abi != requested->protocol_abi) {
|
||||
status = CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT;
|
||||
} else if (active->store_abi != requested->store_abi) {
|
||||
status = CBM_DAEMON_HELLO_STORE_ABI_CONFLICT;
|
||||
} else if (active->feature_abi != requested->feature_abi) {
|
||||
status = CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT;
|
||||
}
|
||||
conflict_out->status = status;
|
||||
return status;
|
||||
}
|
||||
|
||||
bool cbm_daemon_conflict_format(const cbm_daemon_conflict_t *conflict, char *out,
|
||||
size_t out_size) {
|
||||
if (!out || out_size == 0) {
|
||||
return false;
|
||||
}
|
||||
out[0] = '\0';
|
||||
const char *reason = conflict ? conflict_reason(conflict->status) : NULL;
|
||||
if (!reason || !conflict_valid(conflict)) {
|
||||
return false;
|
||||
}
|
||||
int written = snprintf(
|
||||
out, out_size,
|
||||
"CBM could not start because a conflicting CBM process is active "
|
||||
"(%s; active version %s, build %s; requested version %s, build %s). "
|
||||
"Close all CBM sessions and commands, then retry.",
|
||||
reason, conflict->active_version, conflict->active_build_fingerprint,
|
||||
conflict->requested_version, conflict->requested_build_fingerprint);
|
||||
if (written < 0 || (size_t)written >= out_size) {
|
||||
out[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool json_escape_version(const char *value,
|
||||
char out[DAEMON_SERVICE_ESCAPED_VERSION_CAP]) {
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
size_t length = 0;
|
||||
if (!version_valid(value) ||
|
||||
!bounded_length(value, CBM_DAEMON_VERSION_TEXT_SIZE, &length)) {
|
||||
return false;
|
||||
}
|
||||
size_t used = 0;
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
unsigned char ch = (unsigned char)value[i];
|
||||
if (ch == '"' || ch == '\\') {
|
||||
if (used + 2 >= DAEMON_SERVICE_ESCAPED_VERSION_CAP) {
|
||||
return false;
|
||||
}
|
||||
out[used++] = '\\';
|
||||
out[used++] = (char)ch;
|
||||
} else if (ch < 0x20) {
|
||||
if (used + 6 >= DAEMON_SERVICE_ESCAPED_VERSION_CAP) {
|
||||
return false;
|
||||
}
|
||||
out[used++] = '\\';
|
||||
out[used++] = 'u';
|
||||
out[used++] = '0';
|
||||
out[used++] = '0';
|
||||
out[used++] = hex[ch >> 4];
|
||||
out[used++] = hex[ch & 0x0f];
|
||||
} else {
|
||||
if (used + 1 >= DAEMON_SERVICE_ESCAPED_VERSION_CAP) {
|
||||
return false;
|
||||
}
|
||||
out[used++] = (char)ch;
|
||||
}
|
||||
}
|
||||
out[used] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool conflict_log_record(const cbm_daemon_conflict_t *conflict,
|
||||
char out[DAEMON_SERVICE_LOG_RECORD_CAP], size_t *length_out) {
|
||||
char active[DAEMON_SERVICE_ESCAPED_VERSION_CAP];
|
||||
char requested[DAEMON_SERVICE_ESCAPED_VERSION_CAP];
|
||||
const char *reason = conflict ? conflict_reason(conflict->status) : NULL;
|
||||
if (!length_out || !reason || !conflict_valid(conflict) ||
|
||||
!json_escape_version(conflict->active_version, active) ||
|
||||
!json_escape_version(conflict->requested_version, requested)) {
|
||||
return false;
|
||||
}
|
||||
time_t timestamp = time(NULL);
|
||||
if (timestamp == (time_t)-1) {
|
||||
return false;
|
||||
}
|
||||
int written = snprintf(
|
||||
out, DAEMON_SERVICE_LOG_RECORD_CAP,
|
||||
"{\"event\":\"daemon.version_conflict\",\"timestamp_unix_s\":%lld,"
|
||||
"\"reason\":\"%s\","
|
||||
"\"active_version\":\"%s\",\"active_build\":\"%s\","
|
||||
"\"requested_version\":\"%s\",\"requested_build\":\"%s\"}\n",
|
||||
(long long)timestamp, reason, active, conflict->active_build_fingerprint, requested,
|
||||
conflict->requested_build_fingerprint);
|
||||
if (written < 0 || written >= DAEMON_SERVICE_LOG_RECORD_CAP) {
|
||||
return false;
|
||||
}
|
||||
*length_out = (size_t)written;
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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 <windows.h>
|
||||
#include <wchar.h>
|
||||
|
||||
#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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* service.h — Stable daemon rendezvous and build-compatibility policy.
|
||||
*
|
||||
* The rendezvous key deliberately excludes executable path, release version,
|
||||
* build fingerprint, cache directory, and ABI values. Every stateful CBM
|
||||
* frontend for one OS account must meet at one endpoint; the HELLO comparison
|
||||
* then either admits the exact build or returns an explicit conflict.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_SERVICE_H
|
||||
#define CBM_DAEMON_SERVICE_H
|
||||
|
||||
#include "daemon/daemon.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define CBM_DAEMON_VERSION_TEXT_SIZE 64U
|
||||
#define CBM_DAEMON_BUILD_FINGERPRINT_SIZE 65U
|
||||
#define CBM_DAEMON_CONFLICT_MESSAGE_SIZE 512U
|
||||
|
||||
typedef struct {
|
||||
const char *semantic_version;
|
||||
const char *build_fingerprint;
|
||||
uint32_t protocol_abi;
|
||||
uint32_t store_abi;
|
||||
uint32_t feature_abi;
|
||||
} cbm_daemon_build_identity_t;
|
||||
|
||||
typedef enum {
|
||||
CBM_DAEMON_HELLO_INVALID = 0,
|
||||
CBM_DAEMON_HELLO_COMPATIBLE,
|
||||
CBM_DAEMON_HELLO_VERSION_CONFLICT,
|
||||
CBM_DAEMON_HELLO_BUILD_CONFLICT,
|
||||
CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT,
|
||||
CBM_DAEMON_HELLO_STORE_ABI_CONFLICT,
|
||||
CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT,
|
||||
} cbm_daemon_hello_status_t;
|
||||
|
||||
typedef struct {
|
||||
cbm_daemon_hello_status_t status;
|
||||
char active_version[CBM_DAEMON_VERSION_TEXT_SIZE];
|
||||
char active_build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE];
|
||||
char requested_version[CBM_DAEMON_VERSION_TEXT_SIZE];
|
||||
char requested_build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE];
|
||||
} cbm_daemon_conflict_t;
|
||||
|
||||
/* Stable product key. OS-account isolation is supplied by the IPC runtime
|
||||
* directory / current-user ACL, never by caller-provided identity text. */
|
||||
bool cbm_daemon_rendezvous_key(char out[CBM_DAEMON_KEY_SIZE]);
|
||||
|
||||
/* SHA-256 of the exact executable bytes, encoded as 64 lowercase hex
|
||||
* characters plus NUL. This is captured once at process startup. */
|
||||
bool cbm_daemon_build_fingerprint_file(
|
||||
const char *path, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]);
|
||||
|
||||
cbm_daemon_hello_status_t
|
||||
cbm_daemon_hello_compare(const cbm_daemon_build_identity_t *active,
|
||||
const cbm_daemon_build_identity_t *requested,
|
||||
cbm_daemon_conflict_t *conflict_out);
|
||||
|
||||
bool cbm_daemon_conflict_format(const cbm_daemon_conflict_t *conflict, char *out,
|
||||
size_t out_size);
|
||||
|
||||
/* Append one secret-free NDJSON conflict event. A persistent owner-only
|
||||
* <log_path>.lock serializes validation, rotation, and append across daemon
|
||||
* processes; cap_bytes rotates one complete prior generation to <log_path>.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 */
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* service_internal.h — Internal daemon service helpers and test hooks.
|
||||
*
|
||||
* Production callers outside the daemon implementation must use
|
||||
* daemon/service.h. Runtime identity verification hashes an already-open,
|
||||
* kernel-bound file handle through this internal boundary; the test hook lets
|
||||
* concurrency tests establish an exact interleaving without scheduler luck.
|
||||
*/
|
||||
#ifndef CBM_DAEMON_SERVICE_INTERNAL_H
|
||||
#define CBM_DAEMON_SERVICE_INTERNAL_H
|
||||
|
||||
#include "daemon/service.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* 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 */
|
||||
@@ -0,0 +1,975 @@
|
||||
/* version_cohort.c — Short transition lock + process-lifetime SH/EX barrier. */
|
||||
#include "daemon/version_cohort.h"
|
||||
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_thread.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/private_file_lock_internal.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
enum {
|
||||
VERSION_COHORT_RETRY_US = 1000,
|
||||
VERSION_COHORT_CLEANUP_TIMEOUT_MS = 500,
|
||||
VERSION_COHORT_RECORD_MAGIC_SIZE = 8,
|
||||
VERSION_COHORT_RECORD_VERSION_OFFSET = VERSION_COHORT_RECORD_MAGIC_SIZE,
|
||||
VERSION_COHORT_RECORD_BUILD_OFFSET =
|
||||
VERSION_COHORT_RECORD_VERSION_OFFSET + CBM_DAEMON_VERSION_TEXT_SIZE,
|
||||
VERSION_COHORT_RECORD_PROTOCOL_OFFSET =
|
||||
VERSION_COHORT_RECORD_BUILD_OFFSET + CBM_DAEMON_BUILD_FINGERPRINT_SIZE,
|
||||
VERSION_COHORT_RECORD_STORE_OFFSET = VERSION_COHORT_RECORD_PROTOCOL_OFFSET + 4,
|
||||
VERSION_COHORT_RECORD_FEATURE_OFFSET = VERSION_COHORT_RECORD_STORE_OFFSET + 4,
|
||||
VERSION_COHORT_RECORD_SIZE = VERSION_COHORT_RECORD_FEATURE_OFFSET + 4,
|
||||
VERSION_COHORT_LOG_CAP = 1024 * 1024,
|
||||
VERSION_COHORT_PATH_CAP = 4096,
|
||||
};
|
||||
|
||||
static const unsigned char VERSION_COHORT_RECORD_MAGIC[VERSION_COHORT_RECORD_MAGIC_SIZE] = {
|
||||
'C', 'B', 'M', 'C', 'O', 'H', 1, 0,
|
||||
};
|
||||
static const char VERSION_COHORT_ADMISSION_FILE[] =
|
||||
"cbm-version-cohort-admission-v1.lock";
|
||||
static const char VERSION_COHORT_LIFETIME_FILE[] =
|
||||
"cbm-version-cohort-lifetime-v1.lock";
|
||||
static const char VERSION_COHORT_MAINTENANCE_FILE[] =
|
||||
"cbm-version-cohort-maintenance-v1.lock";
|
||||
static const char VERSION_COHORT_DAEMON_FILE[] =
|
||||
"cbm-version-cohort-daemon-v1.lock";
|
||||
|
||||
struct cbm_version_cohort_manager {
|
||||
cbm_private_lock_directory_t *directory;
|
||||
cbm_mutex_t mutex;
|
||||
size_t lease_count;
|
||||
uint64_t owner_pid;
|
||||
bool closing;
|
||||
};
|
||||
|
||||
struct cbm_version_cohort_lease {
|
||||
cbm_version_cohort_manager_t *manager;
|
||||
cbm_private_file_lock_t *maintenance;
|
||||
cbm_private_file_lock_t *admission;
|
||||
cbm_private_file_lock_t *lifetime;
|
||||
bool registered;
|
||||
};
|
||||
|
||||
struct cbm_version_cohort_daemon_claim {
|
||||
cbm_version_cohort_manager_t *manager;
|
||||
cbm_private_file_lock_t *marker;
|
||||
bool registered;
|
||||
};
|
||||
|
||||
static uint64_t version_cohort_current_pid(void) {
|
||||
#ifdef _WIN32
|
||||
return (uint64_t)GetCurrentProcessId();
|
||||
#else
|
||||
return (uint64_t)getpid();
|
||||
#endif
|
||||
}
|
||||
|
||||
static uint64_t version_cohort_deadline_after(uint32_t timeout_ms) {
|
||||
uint64_t now = cbm_now_ms();
|
||||
return now > UINT64_MAX - timeout_ms ? UINT64_MAX : now + timeout_ms;
|
||||
}
|
||||
|
||||
static cbm_private_file_lock_status_t version_cohort_lock_release_until(
|
||||
cbm_private_file_lock_t **lock_io, uint64_t deadline_ms) {
|
||||
cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK;
|
||||
if (!lock_io) {
|
||||
return CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
while (*lock_io) {
|
||||
cbm_private_file_lock_status_t status =
|
||||
cbm_private_file_lock_release(lock_io);
|
||||
if (status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
result = status;
|
||||
}
|
||||
if (!*lock_io) {
|
||||
break;
|
||||
}
|
||||
if (cbm_now_ms() >= deadline_ms) {
|
||||
return result == CBM_PRIVATE_FILE_LOCK_OK
|
||||
? CBM_PRIVATE_FILE_LOCK_IO
|
||||
: result;
|
||||
}
|
||||
cbm_usleep(VERSION_COHORT_RETRY_US);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static _Noreturn void version_cohort_cleanup_fail_stop(
|
||||
const char *component) {
|
||||
/* Observer APIs cannot return an opaque cleanup handle. Once their finite
|
||||
* retry budget is exhausted, process exit is the only way to avoid losing
|
||||
* retry authority while a coordination marker remains owned. */
|
||||
cbm_log_error("daemon.forced_shutdown", "component", component,
|
||||
"action", "coordination_cleanup");
|
||||
(void)fflush(stdout);
|
||||
(void)fflush(stderr);
|
||||
#ifdef _WIN32
|
||||
(void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE);
|
||||
abort();
|
||||
#else
|
||||
_exit(EXIT_FAILURE);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
static void version_cohort_startup_lock_release_complete(
|
||||
cbm_daemon_ipc_startup_lock_t **lock_io) {
|
||||
uint64_t deadline = version_cohort_deadline_after(
|
||||
VERSION_COHORT_CLEANUP_TIMEOUT_MS);
|
||||
while (lock_io && *lock_io) {
|
||||
(void)cbm_daemon_ipc_startup_lock_release(lock_io);
|
||||
if (!*lock_io) {
|
||||
return;
|
||||
}
|
||||
if (cbm_now_ms() >= deadline) {
|
||||
version_cohort_cleanup_fail_stop("startup_lock_cleanup");
|
||||
}
|
||||
cbm_usleep(VERSION_COHORT_RETRY_US);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static bool version_cohort_identity_valid(
|
||||
const cbm_daemon_build_identity_t *identity) {
|
||||
cbm_daemon_conflict_t comparison;
|
||||
return cbm_daemon_hello_compare(identity, identity, &comparison) ==
|
||||
CBM_DAEMON_HELLO_COMPATIBLE;
|
||||
}
|
||||
|
||||
static void version_cohort_u32_encode(unsigned char out[4], uint32_t value) {
|
||||
out[0] = (unsigned char)(value & 0xffU);
|
||||
out[1] = (unsigned char)((value >> 8) & 0xffU);
|
||||
out[2] = (unsigned char)((value >> 16) & 0xffU);
|
||||
out[3] = (unsigned char)((value >> 24) & 0xffU);
|
||||
}
|
||||
|
||||
static uint32_t version_cohort_u32_decode(const unsigned char in[4]) {
|
||||
return (uint32_t)in[0] | ((uint32_t)in[1] << 8) |
|
||||
((uint32_t)in[2] << 16) | ((uint32_t)in[3] << 24);
|
||||
}
|
||||
|
||||
static bool version_cohort_record_encode(
|
||||
const cbm_daemon_build_identity_t *identity,
|
||||
unsigned char out[VERSION_COHORT_RECORD_SIZE]) {
|
||||
if (!out || !version_cohort_identity_valid(identity)) {
|
||||
return false;
|
||||
}
|
||||
memset(out, 0, VERSION_COHORT_RECORD_SIZE);
|
||||
memcpy(out, VERSION_COHORT_RECORD_MAGIC,
|
||||
VERSION_COHORT_RECORD_MAGIC_SIZE);
|
||||
size_t version_length = strlen(identity->semantic_version);
|
||||
size_t build_length = strlen(identity->build_fingerprint);
|
||||
memcpy(out + VERSION_COHORT_RECORD_VERSION_OFFSET,
|
||||
identity->semantic_version, version_length + 1);
|
||||
memcpy(out + VERSION_COHORT_RECORD_BUILD_OFFSET,
|
||||
identity->build_fingerprint, build_length + 1);
|
||||
version_cohort_u32_encode(out + VERSION_COHORT_RECORD_PROTOCOL_OFFSET,
|
||||
identity->protocol_abi);
|
||||
version_cohort_u32_encode(out + VERSION_COHORT_RECORD_STORE_OFFSET,
|
||||
identity->store_abi);
|
||||
version_cohort_u32_encode(out + VERSION_COHORT_RECORD_FEATURE_OFFSET,
|
||||
identity->feature_abi);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool version_cohort_record_decode(
|
||||
const unsigned char record[VERSION_COHORT_RECORD_SIZE], size_t length,
|
||||
cbm_daemon_build_identity_t *identity_out,
|
||||
char version_out[CBM_DAEMON_VERSION_TEXT_SIZE],
|
||||
char build_out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) {
|
||||
if (!record || length != VERSION_COHORT_RECORD_SIZE || !identity_out ||
|
||||
!version_out || !build_out ||
|
||||
memcmp(record, VERSION_COHORT_RECORD_MAGIC,
|
||||
VERSION_COHORT_RECORD_MAGIC_SIZE) != 0) {
|
||||
return false;
|
||||
}
|
||||
memcpy(version_out, record + VERSION_COHORT_RECORD_VERSION_OFFSET,
|
||||
CBM_DAEMON_VERSION_TEXT_SIZE);
|
||||
memcpy(build_out, record + VERSION_COHORT_RECORD_BUILD_OFFSET,
|
||||
CBM_DAEMON_BUILD_FINGERPRINT_SIZE);
|
||||
cbm_daemon_build_identity_t identity = {
|
||||
.semantic_version = version_out,
|
||||
.build_fingerprint = build_out,
|
||||
.protocol_abi = version_cohort_u32_decode(
|
||||
record + VERSION_COHORT_RECORD_PROTOCOL_OFFSET),
|
||||
.store_abi = version_cohort_u32_decode(
|
||||
record + VERSION_COHORT_RECORD_STORE_OFFSET),
|
||||
.feature_abi = version_cohort_u32_decode(
|
||||
record + VERSION_COHORT_RECORD_FEATURE_OFFSET),
|
||||
};
|
||||
if (!version_cohort_identity_valid(&identity)) {
|
||||
return false;
|
||||
}
|
||||
*identity_out = identity;
|
||||
return true;
|
||||
}
|
||||
|
||||
static cbm_version_cohort_status_t version_cohort_status_from_lock(
|
||||
cbm_private_file_lock_status_t status) {
|
||||
switch (status) {
|
||||
case CBM_PRIVATE_FILE_LOCK_OK:
|
||||
return CBM_VERSION_COHORT_OK;
|
||||
case CBM_PRIVATE_FILE_LOCK_BUSY:
|
||||
return CBM_VERSION_COHORT_BUSY;
|
||||
case CBM_PRIVATE_FILE_LOCK_UNSAFE:
|
||||
return CBM_VERSION_COHORT_UNSAFE;
|
||||
case CBM_PRIVATE_FILE_LOCK_IO:
|
||||
default:
|
||||
return CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
}
|
||||
|
||||
static cbm_private_file_lock_status_t version_cohort_lock_until(
|
||||
cbm_version_cohort_manager_t *manager, const char *base_name,
|
||||
cbm_private_file_lock_mode_t mode, uint64_t deadline_ms,
|
||||
cbm_private_file_lock_t **lock_out) {
|
||||
for (;;) {
|
||||
cbm_private_file_lock_status_t status =
|
||||
cbm_private_file_lock_try_acquire(manager->directory, base_name,
|
||||
mode, lock_out);
|
||||
if (status != CBM_PRIVATE_FILE_LOCK_BUSY ||
|
||||
(deadline_ms != UINT64_MAX && cbm_now_ms() >= deadline_ms)) {
|
||||
return status;
|
||||
}
|
||||
cbm_usleep(VERSION_COHORT_RETRY_US);
|
||||
}
|
||||
}
|
||||
|
||||
static cbm_version_cohort_lease_t *version_cohort_lease_new(
|
||||
cbm_version_cohort_manager_t *manager) {
|
||||
if (!manager) {
|
||||
return NULL;
|
||||
}
|
||||
cbm_version_cohort_lease_t *lease = calloc(1, sizeof(*lease));
|
||||
if (!lease) {
|
||||
return NULL;
|
||||
}
|
||||
cbm_mutex_lock(&manager->mutex);
|
||||
bool admitted = !manager->closing &&
|
||||
manager->owner_pid == version_cohort_current_pid();
|
||||
if (admitted) {
|
||||
manager->lease_count++;
|
||||
}
|
||||
cbm_mutex_unlock(&manager->mutex);
|
||||
if (!admitted) {
|
||||
free(lease);
|
||||
return NULL;
|
||||
}
|
||||
lease->manager = manager;
|
||||
lease->registered = true;
|
||||
return lease;
|
||||
}
|
||||
|
||||
static bool version_cohort_manager_register(
|
||||
cbm_version_cohort_manager_t *manager) {
|
||||
if (!manager) {
|
||||
return false;
|
||||
}
|
||||
cbm_mutex_lock(&manager->mutex);
|
||||
bool admitted = !manager->closing &&
|
||||
manager->owner_pid == version_cohort_current_pid();
|
||||
if (admitted) {
|
||||
manager->lease_count++;
|
||||
}
|
||||
cbm_mutex_unlock(&manager->mutex);
|
||||
return admitted;
|
||||
}
|
||||
|
||||
static bool version_cohort_manager_unregister(
|
||||
cbm_version_cohort_manager_t *manager) {
|
||||
if (!manager) {
|
||||
return false;
|
||||
}
|
||||
cbm_mutex_lock(&manager->mutex);
|
||||
bool registered = manager->owner_pid == version_cohort_current_pid() &&
|
||||
manager->lease_count > 0;
|
||||
if (registered) {
|
||||
manager->lease_count--;
|
||||
}
|
||||
cbm_mutex_unlock(&manager->mutex);
|
||||
return registered;
|
||||
}
|
||||
|
||||
cbm_private_file_lock_status_t cbm_version_cohort_lease_release(
|
||||
cbm_version_cohort_lease_t **lease_io) {
|
||||
if (!lease_io || !*lease_io) {
|
||||
return CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
cbm_version_cohort_lease_t *lease = *lease_io;
|
||||
cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK;
|
||||
if (lease->lifetime) {
|
||||
cbm_private_file_lock_status_t status =
|
||||
cbm_private_file_lock_release(&lease->lifetime);
|
||||
if (status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
result = status;
|
||||
}
|
||||
}
|
||||
if (lease->lifetime) {
|
||||
return CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
if (lease->admission) {
|
||||
cbm_private_file_lock_status_t status =
|
||||
cbm_private_file_lock_release(&lease->admission);
|
||||
if (status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
result = status;
|
||||
}
|
||||
}
|
||||
if (lease->admission) {
|
||||
return CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
if (lease->maintenance) {
|
||||
cbm_private_file_lock_status_t status =
|
||||
cbm_private_file_lock_release(&lease->maintenance);
|
||||
if (status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
result = status;
|
||||
}
|
||||
}
|
||||
if (lease->maintenance) {
|
||||
return CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
if (lease->registered && lease->manager) {
|
||||
cbm_mutex_lock(&lease->manager->mutex);
|
||||
if (lease->manager->lease_count > 0) {
|
||||
lease->manager->lease_count--;
|
||||
} else {
|
||||
result = CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
cbm_mutex_unlock(&lease->manager->mutex);
|
||||
lease->registered = false;
|
||||
}
|
||||
free(lease);
|
||||
*lease_io = NULL;
|
||||
return result;
|
||||
}
|
||||
|
||||
static cbm_version_cohort_status_t version_cohort_failed(
|
||||
cbm_version_cohort_lease_t *lease, cbm_version_cohort_status_t status,
|
||||
cbm_version_cohort_lease_t **lease_out) {
|
||||
cbm_version_cohort_lease_t *cleanup = lease;
|
||||
cbm_private_file_lock_status_t cleanup_status =
|
||||
cbm_version_cohort_lease_release(&cleanup);
|
||||
if (cleanup) {
|
||||
*lease_out = cleanup;
|
||||
return CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? status
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
|
||||
cbm_version_cohort_manager_t *cbm_version_cohort_manager_new(
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint) {
|
||||
cbm_private_lock_directory_t *directory = NULL;
|
||||
cbm_private_file_lock_status_t directory_status =
|
||||
cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory);
|
||||
if (directory_status != CBM_PRIVATE_FILE_LOCK_OK || !directory) {
|
||||
if (directory) {
|
||||
cbm_private_lock_directory_close(directory);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
cbm_version_cohort_manager_t *manager = calloc(1, sizeof(*manager));
|
||||
if (!manager) {
|
||||
cbm_private_lock_directory_close(directory);
|
||||
return NULL;
|
||||
}
|
||||
manager->directory = directory;
|
||||
manager->owner_pid = version_cohort_current_pid();
|
||||
cbm_mutex_init(&manager->mutex);
|
||||
return manager;
|
||||
}
|
||||
|
||||
static cbm_version_cohort_status_t version_cohort_claim_new(
|
||||
cbm_version_cohort_lease_t *lease,
|
||||
const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms) {
|
||||
unsigned char record[VERSION_COHORT_RECORD_SIZE];
|
||||
if (!version_cohort_record_encode(identity, record) ||
|
||||
cbm_private_file_lock_payload_write(lease->lifetime, record,
|
||||
sizeof(record)) !=
|
||||
CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
return CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
cbm_private_file_lock_status_t release_status =
|
||||
cbm_private_file_lock_release(&lease->lifetime);
|
||||
if (release_status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
return CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
return version_cohort_status_from_lock(version_cohort_lock_until(
|
||||
lease->manager, VERSION_COHORT_LIFETIME_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_SH, deadline_ms, &lease->lifetime));
|
||||
}
|
||||
|
||||
cbm_version_cohort_status_t cbm_version_cohort_acquire(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms,
|
||||
cbm_version_cohort_lease_t **lease_out,
|
||||
cbm_daemon_conflict_t *conflict_out) {
|
||||
if (lease_out) {
|
||||
*lease_out = NULL;
|
||||
}
|
||||
if (conflict_out) {
|
||||
memset(conflict_out, 0, sizeof(*conflict_out));
|
||||
}
|
||||
if (!manager || !identity || !lease_out || !conflict_out ||
|
||||
!version_cohort_identity_valid(identity)) {
|
||||
return CBM_VERSION_COHORT_UNSAFE;
|
||||
}
|
||||
cbm_version_cohort_lease_t *lease = version_cohort_lease_new(manager);
|
||||
if (!lease) {
|
||||
return CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
/* Global order is maintenance -> admission -> lifetime. Retaining SH
|
||||
* through admission closes the probe/admission race: an activation cannot
|
||||
* publish EX intent until this already-started transition has finished,
|
||||
* while every later participant fails its non-blocking SH attempt. */
|
||||
cbm_private_file_lock_status_t lock_status =
|
||||
cbm_private_file_lock_try_acquire(
|
||||
manager->directory, VERSION_COHORT_MAINTENANCE_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_SH, &lease->maintenance);
|
||||
if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
return version_cohort_failed(
|
||||
lease, version_cohort_status_from_lock(lock_status), lease_out);
|
||||
}
|
||||
lock_status = version_cohort_lock_until(
|
||||
manager, VERSION_COHORT_ADMISSION_FILE, CBM_PRIVATE_FILE_LOCK_EX,
|
||||
deadline_ms, &lease->admission);
|
||||
if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
return version_cohort_failed(
|
||||
lease, version_cohort_status_from_lock(lock_status), lease_out);
|
||||
}
|
||||
|
||||
lock_status = cbm_private_file_lock_try_acquire(
|
||||
manager->directory, VERSION_COHORT_LIFETIME_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime);
|
||||
cbm_version_cohort_status_t status =
|
||||
version_cohort_status_from_lock(lock_status);
|
||||
if (status == CBM_VERSION_COHORT_OK) {
|
||||
status = version_cohort_claim_new(lease, identity, deadline_ms);
|
||||
} else if (status == CBM_VERSION_COHORT_BUSY) {
|
||||
lock_status = cbm_private_file_lock_try_acquire(
|
||||
manager->directory, VERSION_COHORT_LIFETIME_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_SH, &lease->lifetime);
|
||||
status = version_cohort_status_from_lock(lock_status);
|
||||
if (status == CBM_VERSION_COHORT_OK) {
|
||||
unsigned char record[VERSION_COHORT_RECORD_SIZE];
|
||||
size_t record_length = 0;
|
||||
char active_version[CBM_DAEMON_VERSION_TEXT_SIZE];
|
||||
char active_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE];
|
||||
cbm_daemon_build_identity_t active;
|
||||
if (cbm_private_file_lock_payload_read(
|
||||
lease->lifetime, record, sizeof(record),
|
||||
&record_length) != CBM_PRIVATE_FILE_LOCK_OK ||
|
||||
!version_cohort_record_decode(
|
||||
record, record_length, &active, active_version,
|
||||
active_build)) {
|
||||
status = CBM_VERSION_COHORT_UNSAFE;
|
||||
} else {
|
||||
cbm_daemon_hello_status_t comparison =
|
||||
cbm_daemon_hello_compare(&active, identity, conflict_out);
|
||||
if (comparison == CBM_DAEMON_HELLO_COMPATIBLE) {
|
||||
status = CBM_VERSION_COHORT_OK;
|
||||
} else if (comparison == CBM_DAEMON_HELLO_INVALID) {
|
||||
status = CBM_VERSION_COHORT_UNSAFE;
|
||||
} else {
|
||||
cbm_private_file_lock_status_t release_status =
|
||||
cbm_private_file_lock_release(&lease->lifetime);
|
||||
if (release_status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
status = CBM_VERSION_COHORT_IO;
|
||||
} else {
|
||||
lock_status = cbm_private_file_lock_try_acquire(
|
||||
manager->directory,
|
||||
VERSION_COHORT_LIFETIME_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime);
|
||||
status = version_cohort_status_from_lock(lock_status);
|
||||
if (status == CBM_VERSION_COHORT_OK) {
|
||||
status = version_cohort_claim_new(
|
||||
lease, identity, deadline_ms);
|
||||
} else if (status == CBM_VERSION_COHORT_BUSY) {
|
||||
status = CBM_VERSION_COHORT_CONFLICT;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (status != CBM_VERSION_COHORT_OK) {
|
||||
return version_cohort_failed(lease, status, lease_out);
|
||||
}
|
||||
cbm_private_file_lock_status_t admission_release =
|
||||
cbm_private_file_lock_release(&lease->admission);
|
||||
if (admission_release != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
return version_cohort_failed(lease, CBM_VERSION_COHORT_IO,
|
||||
lease_out);
|
||||
}
|
||||
cbm_private_file_lock_status_t maintenance_release =
|
||||
cbm_private_file_lock_release(&lease->maintenance);
|
||||
if (maintenance_release != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
return version_cohort_failed(lease, CBM_VERSION_COHORT_IO,
|
||||
lease_out);
|
||||
}
|
||||
*lease_out = lease;
|
||||
return CBM_VERSION_COHORT_OK;
|
||||
}
|
||||
|
||||
static cbm_version_cohort_status_t
|
||||
version_cohort_reserve_for_mutation_internal(
|
||||
cbm_version_cohort_manager_t *manager, uint64_t deadline_ms,
|
||||
cbm_version_cohort_quiesce_fn quiesce, void *quiesce_context,
|
||||
cbm_version_cohort_quiesce_result_t *quiesce_result_out,
|
||||
cbm_version_cohort_lease_t **lease_out, bool require_finite_deadline) {
|
||||
if (lease_out) {
|
||||
*lease_out = NULL;
|
||||
}
|
||||
if (quiesce_result_out) {
|
||||
*quiesce_result_out = CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED;
|
||||
}
|
||||
if (!manager || !quiesce_result_out || !lease_out ||
|
||||
(require_finite_deadline && deadline_ms == UINT64_MAX)) {
|
||||
return CBM_VERSION_COHORT_UNSAFE;
|
||||
}
|
||||
cbm_version_cohort_lease_t *lease = version_cohort_lease_new(manager);
|
||||
if (!lease) {
|
||||
return CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
/* Publish crash-safe intent before waiting for an admission already in
|
||||
* flight. Normal admissions retain maintenance SH until their admission
|
||||
* transition ends, giving every participant the same deadlock-free native
|
||||
* order: maintenance -> admission -> lifetime. */
|
||||
cbm_private_file_lock_status_t lock_status = version_cohort_lock_until(
|
||||
manager, VERSION_COHORT_MAINTENANCE_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, &lease->maintenance);
|
||||
if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
return version_cohort_failed(
|
||||
lease, version_cohort_status_from_lock(lock_status), lease_out);
|
||||
}
|
||||
lock_status = version_cohort_lock_until(
|
||||
manager, VERSION_COHORT_ADMISSION_FILE, CBM_PRIVATE_FILE_LOCK_EX,
|
||||
deadline_ms, &lease->admission);
|
||||
if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
return version_cohort_failed(
|
||||
lease, version_cohort_status_from_lock(lock_status), lease_out);
|
||||
}
|
||||
lock_status = cbm_private_file_lock_try_acquire(
|
||||
manager->directory, VERSION_COHORT_LIFETIME_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime);
|
||||
if (lock_status == CBM_PRIVATE_FILE_LOCK_BUSY) {
|
||||
if (!quiesce) {
|
||||
*quiesce_result_out = CBM_VERSION_COHORT_QUIESCE_REFUSED;
|
||||
return version_cohort_failed(lease, CBM_VERSION_COHORT_BUSY,
|
||||
lease_out);
|
||||
}
|
||||
|
||||
/* Admission remains exclusively locked across the callback and wait.
|
||||
* Consequently the active lifetime set can only shrink, and no new
|
||||
* participant can race mutation after accepting the quiesce request. */
|
||||
cbm_version_cohort_quiesce_result_t quiesce_result =
|
||||
quiesce(quiesce_context);
|
||||
*quiesce_result_out = quiesce_result;
|
||||
if (quiesce_result == CBM_VERSION_COHORT_QUIESCE_REFUSED) {
|
||||
return version_cohort_failed(lease, CBM_VERSION_COHORT_BUSY,
|
||||
lease_out);
|
||||
}
|
||||
if (quiesce_result == CBM_VERSION_COHORT_QUIESCE_ERROR) {
|
||||
return version_cohort_failed(lease, CBM_VERSION_COHORT_IO,
|
||||
lease_out);
|
||||
}
|
||||
if (quiesce_result != CBM_VERSION_COHORT_QUIESCE_REQUESTED) {
|
||||
return version_cohort_failed(lease, CBM_VERSION_COHORT_UNSAFE,
|
||||
lease_out);
|
||||
}
|
||||
lock_status = version_cohort_lock_until(
|
||||
manager, VERSION_COHORT_LIFETIME_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, &lease->lifetime);
|
||||
}
|
||||
if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
return version_cohort_failed(
|
||||
lease, version_cohort_status_from_lock(lock_status), lease_out);
|
||||
}
|
||||
|
||||
/* All three locks intentionally remain in the lease. Maintenance makes
|
||||
* admission fail fast, admission closes the transition, and lifetime
|
||||
* proves all earlier participants have drained. */
|
||||
*lease_out = lease;
|
||||
return CBM_VERSION_COHORT_OK;
|
||||
}
|
||||
|
||||
cbm_version_cohort_status_t cbm_version_cohort_reserve_for_mutation(
|
||||
cbm_version_cohort_manager_t *manager, uint64_t deadline_ms,
|
||||
cbm_version_cohort_quiesce_fn quiesce, void *quiesce_context,
|
||||
cbm_version_cohort_quiesce_result_t *quiesce_result_out,
|
||||
cbm_version_cohort_lease_t **lease_out) {
|
||||
return version_cohort_reserve_for_mutation_internal(
|
||||
manager, deadline_ms, quiesce, quiesce_context, quiesce_result_out,
|
||||
lease_out, true);
|
||||
}
|
||||
|
||||
cbm_version_cohort_status_t cbm_version_cohort_reserve_exclusive(
|
||||
cbm_version_cohort_manager_t *manager, uint64_t deadline_ms,
|
||||
cbm_version_cohort_lease_t **lease_out) {
|
||||
cbm_version_cohort_quiesce_result_t ignored_quiesce;
|
||||
return version_cohort_reserve_for_mutation_internal(
|
||||
manager, deadline_ms, NULL, NULL, &ignored_quiesce, lease_out, false);
|
||||
}
|
||||
|
||||
cbm_version_cohort_maintenance_presence_t
|
||||
cbm_version_cohort_maintenance_presence(
|
||||
cbm_version_cohort_manager_t *manager) {
|
||||
if (!manager || !version_cohort_manager_register(manager)) {
|
||||
return CBM_VERSION_COHORT_MAINTENANCE_UNSAFE;
|
||||
}
|
||||
|
||||
cbm_version_cohort_maintenance_presence_t presence =
|
||||
CBM_VERSION_COHORT_MAINTENANCE_IO;
|
||||
cbm_private_file_lock_t *observer = NULL;
|
||||
cbm_private_file_lock_status_t status =
|
||||
cbm_private_file_lock_try_acquire(
|
||||
manager->directory, VERSION_COHORT_MAINTENANCE_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_SH, &observer);
|
||||
if (status == CBM_PRIVATE_FILE_LOCK_OK && observer) {
|
||||
presence = CBM_VERSION_COHORT_MAINTENANCE_ABSENT;
|
||||
} else if (status == CBM_PRIVATE_FILE_LOCK_BUSY) {
|
||||
presence = CBM_VERSION_COHORT_MAINTENANCE_REQUESTED;
|
||||
} else if (status == CBM_PRIVATE_FILE_LOCK_UNSAFE) {
|
||||
presence = CBM_VERSION_COHORT_MAINTENANCE_UNSAFE;
|
||||
}
|
||||
|
||||
cbm_private_file_lock_status_t observer_release =
|
||||
version_cohort_lock_release_until(
|
||||
&observer, version_cohort_deadline_after(
|
||||
VERSION_COHORT_CLEANUP_TIMEOUT_MS));
|
||||
if (observer_release != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
presence = CBM_VERSION_COHORT_MAINTENANCE_IO;
|
||||
}
|
||||
if (observer) {
|
||||
version_cohort_cleanup_fail_stop("maintenance_observer_cleanup");
|
||||
}
|
||||
if (!version_cohort_manager_unregister(manager)) {
|
||||
presence = CBM_VERSION_COHORT_MAINTENANCE_IO;
|
||||
}
|
||||
return presence;
|
||||
}
|
||||
|
||||
cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_acquire(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
cbm_version_cohort_daemon_claim_t **claim_out) {
|
||||
if (claim_out) {
|
||||
*claim_out = NULL;
|
||||
}
|
||||
if (!manager || !claim_out) {
|
||||
return CBM_VERSION_COHORT_UNSAFE;
|
||||
}
|
||||
cbm_version_cohort_daemon_claim_t *claim = calloc(1, sizeof(*claim));
|
||||
if (!claim || !version_cohort_manager_register(manager)) {
|
||||
free(claim);
|
||||
return CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
claim->manager = manager;
|
||||
claim->registered = true;
|
||||
cbm_private_file_lock_status_t lock_status =
|
||||
cbm_private_file_lock_try_acquire(
|
||||
manager->directory, VERSION_COHORT_DAEMON_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_EX, &claim->marker);
|
||||
if (lock_status == CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
*claim_out = claim;
|
||||
return CBM_VERSION_COHORT_OK;
|
||||
}
|
||||
|
||||
cbm_private_file_lock_status_t cleanup_status =
|
||||
version_cohort_lock_release_until(
|
||||
&claim->marker, version_cohort_deadline_after(
|
||||
VERSION_COHORT_CLEANUP_TIMEOUT_MS));
|
||||
if (claim->marker) {
|
||||
*claim_out = claim;
|
||||
return CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
bool unregistered = version_cohort_manager_unregister(manager);
|
||||
free(claim);
|
||||
return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK && unregistered
|
||||
? version_cohort_status_from_lock(lock_status)
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
}
|
||||
|
||||
cbm_private_file_lock_status_t cbm_version_cohort_daemon_claim_release(
|
||||
cbm_version_cohort_daemon_claim_t **claim_io) {
|
||||
if (!claim_io || !*claim_io) {
|
||||
return CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
cbm_version_cohort_daemon_claim_t *claim = *claim_io;
|
||||
cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK;
|
||||
if (claim->marker) {
|
||||
result = cbm_private_file_lock_release(&claim->marker);
|
||||
}
|
||||
if (claim->marker) {
|
||||
return CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
if (claim->registered) {
|
||||
if (!version_cohort_manager_unregister(claim->manager)) {
|
||||
result = CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
claim->registered = false;
|
||||
}
|
||||
free(claim);
|
||||
*claim_io = NULL;
|
||||
return result;
|
||||
}
|
||||
|
||||
cbm_version_cohort_daemon_presence_t
|
||||
cbm_version_cohort_daemon_claim_presence(
|
||||
cbm_version_cohort_manager_t *manager) {
|
||||
if (!manager || !version_cohort_manager_register(manager)) {
|
||||
return CBM_VERSION_COHORT_DAEMON_UNSAFE;
|
||||
}
|
||||
|
||||
cbm_version_cohort_daemon_presence_t presence =
|
||||
CBM_VERSION_COHORT_DAEMON_IO;
|
||||
cbm_private_file_lock_t *marker = NULL;
|
||||
cbm_private_file_lock_status_t marker_status =
|
||||
cbm_private_file_lock_try_acquire(
|
||||
manager->directory, VERSION_COHORT_DAEMON_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_EX, &marker);
|
||||
if (marker_status == CBM_PRIVATE_FILE_LOCK_BUSY) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_COORDINATED;
|
||||
} else if (marker_status == CBM_PRIVATE_FILE_LOCK_OK && marker) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_ABSENT;
|
||||
} else if (marker_status == CBM_PRIVATE_FILE_LOCK_UNSAFE) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_UNSAFE;
|
||||
}
|
||||
|
||||
cbm_private_file_lock_status_t marker_release =
|
||||
version_cohort_lock_release_until(
|
||||
&marker, version_cohort_deadline_after(
|
||||
VERSION_COHORT_CLEANUP_TIMEOUT_MS));
|
||||
if (marker_release != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_IO;
|
||||
}
|
||||
if (marker) {
|
||||
version_cohort_cleanup_fail_stop("daemon_marker_observer_cleanup");
|
||||
}
|
||||
if (!version_cohort_manager_unregister(manager)) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_IO;
|
||||
}
|
||||
return presence;
|
||||
}
|
||||
|
||||
static cbm_version_cohort_daemon_presence_t
|
||||
version_cohort_active_daemon_presence(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
const cbm_daemon_ipc_local_transition_t *transition) {
|
||||
cbm_version_cohort_daemon_presence_t presence =
|
||||
CBM_VERSION_COHORT_DAEMON_IO;
|
||||
cbm_private_file_lock_t *marker = NULL;
|
||||
cbm_private_file_lock_status_t marker_status =
|
||||
cbm_private_file_lock_try_acquire(
|
||||
manager->directory, VERSION_COHORT_DAEMON_FILE,
|
||||
CBM_PRIVATE_FILE_LOCK_EX, &marker);
|
||||
if (marker_status == CBM_PRIVATE_FILE_LOCK_BUSY) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_COORDINATED;
|
||||
} else if (marker_status == CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
if (!transition) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_UNCOORDINATED;
|
||||
} else {
|
||||
/* The sealed transition prevents a replacement daemon from
|
||||
* starting. Recheck lifetime while retaining the marker EX lock:
|
||||
* shutdown may have released lifetime after our first observation
|
||||
* but before the marker became available. */
|
||||
int lifetime =
|
||||
cbm_daemon_ipc_local_transition_lifetime_probe(
|
||||
endpoint, transition);
|
||||
presence = lifetime == 0
|
||||
? CBM_VERSION_COHORT_DAEMON_ABSENT
|
||||
: lifetime == 1
|
||||
? CBM_VERSION_COHORT_DAEMON_UNCOORDINATED
|
||||
: CBM_VERSION_COHORT_DAEMON_UNSAFE;
|
||||
}
|
||||
} else if (marker_status == CBM_PRIVATE_FILE_LOCK_UNSAFE) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_UNSAFE;
|
||||
}
|
||||
cbm_private_file_lock_status_t marker_release =
|
||||
version_cohort_lock_release_until(
|
||||
&marker, version_cohort_deadline_after(
|
||||
VERSION_COHORT_CLEANUP_TIMEOUT_MS));
|
||||
if (marker_release != CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_IO;
|
||||
}
|
||||
if (marker) {
|
||||
version_cohort_cleanup_fail_stop("active_daemon_marker_cleanup");
|
||||
}
|
||||
return presence;
|
||||
}
|
||||
|
||||
cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint) {
|
||||
if (!manager || !endpoint || !version_cohort_manager_register(manager)) {
|
||||
return CBM_VERSION_COHORT_DAEMON_UNSAFE;
|
||||
}
|
||||
|
||||
cbm_version_cohort_daemon_presence_t presence =
|
||||
CBM_VERSION_COHORT_DAEMON_IO;
|
||||
cbm_version_cohort_daemon_presence_t claim =
|
||||
cbm_version_cohort_daemon_claim_presence(manager);
|
||||
if (claim == CBM_VERSION_COHORT_DAEMON_COORDINATED) {
|
||||
presence = claim;
|
||||
} else if (claim != CBM_VERSION_COHORT_DAEMON_ABSENT) {
|
||||
presence = claim;
|
||||
} else {
|
||||
int lifetime = cbm_daemon_ipc_lifetime_reservation_probe(endpoint);
|
||||
if (lifetime == 0) {
|
||||
#ifdef _WIN32
|
||||
int legacy = cbm_daemon_ipc_legacy_generation_probe(endpoint);
|
||||
if (legacy == 0) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_ABSENT;
|
||||
} else if (legacy == 1) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_UNCOORDINATED;
|
||||
} else {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_UNSAFE;
|
||||
}
|
||||
#else
|
||||
cbm_daemon_ipc_startup_lock_t *startup = NULL;
|
||||
int startup_status = cbm_daemon_ipc_startup_lock_try_acquire(
|
||||
endpoint, &startup);
|
||||
if (startup_status == 0) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_UNCOORDINATED;
|
||||
} else if (startup_status < 0 || !startup) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_UNSAFE;
|
||||
} else {
|
||||
int cleanup = cbm_daemon_ipc_stale_generation_cleanup(
|
||||
endpoint, startup);
|
||||
if (cleanup == 1) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_ABSENT;
|
||||
} else if (cleanup == 0) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_UNCOORDINATED;
|
||||
} else {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_UNSAFE;
|
||||
}
|
||||
}
|
||||
version_cohort_startup_lock_release_complete(&startup);
|
||||
#endif
|
||||
} else if (lifetime == 1) {
|
||||
presence = version_cohort_active_daemon_presence(manager, NULL,
|
||||
NULL);
|
||||
}
|
||||
}
|
||||
if (!version_cohort_manager_unregister(manager)) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_IO;
|
||||
}
|
||||
return presence;
|
||||
}
|
||||
|
||||
cbm_version_cohort_daemon_presence_t
|
||||
cbm_version_cohort_daemon_presence_under_transition(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
const cbm_daemon_ipc_local_transition_t *transition) {
|
||||
if (!manager || !endpoint || !transition ||
|
||||
!version_cohort_manager_register(manager)) {
|
||||
return CBM_VERSION_COHORT_DAEMON_UNSAFE;
|
||||
}
|
||||
|
||||
int transition_status =
|
||||
cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, transition);
|
||||
/* After validating the guard, classify through the marker. If it is free,
|
||||
* keep it exclusively locked while observing lifetime so daemon shutdown
|
||||
* cannot become a false conflict. The retained startup transition prevents
|
||||
* a replacement generation. */
|
||||
cbm_version_cohort_daemon_presence_t presence =
|
||||
transition_status < 0
|
||||
? CBM_VERSION_COHORT_DAEMON_UNSAFE
|
||||
: version_cohort_active_daemon_presence(
|
||||
manager, endpoint, transition);
|
||||
if (!version_cohort_manager_unregister(manager)) {
|
||||
presence = CBM_VERSION_COHORT_DAEMON_IO;
|
||||
}
|
||||
return presence;
|
||||
}
|
||||
|
||||
cbm_private_file_lock_status_t cbm_version_cohort_manager_free(
|
||||
cbm_version_cohort_manager_t **manager_io) {
|
||||
if (!manager_io || !*manager_io) {
|
||||
return CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
cbm_version_cohort_manager_t *manager = *manager_io;
|
||||
cbm_mutex_lock(&manager->mutex);
|
||||
bool leases_active = manager->lease_count != 0;
|
||||
bool can_free = manager->owner_pid == version_cohort_current_pid() &&
|
||||
!leases_active && !manager->closing;
|
||||
if (can_free) {
|
||||
manager->closing = true;
|
||||
}
|
||||
cbm_mutex_unlock(&manager->mutex);
|
||||
if (!can_free) {
|
||||
return leases_active ? CBM_PRIVATE_FILE_LOCK_BUSY
|
||||
: CBM_PRIVATE_FILE_LOCK_IO;
|
||||
}
|
||||
cbm_private_lock_directory_close(manager->directory);
|
||||
manager->directory = NULL;
|
||||
cbm_mutex_destroy(&manager->mutex);
|
||||
free(manager);
|
||||
*manager_io = NULL;
|
||||
return CBM_PRIVATE_FILE_LOCK_OK;
|
||||
}
|
||||
|
||||
bool cbm_version_cohort_log_conflict(const cbm_daemon_conflict_t *conflict) {
|
||||
const char *cache = cbm_resolve_cache_dir();
|
||||
if (!cache || !cache[0] || !conflict) {
|
||||
return false;
|
||||
}
|
||||
char logs[VERSION_COHORT_PATH_CAP];
|
||||
char path[VERSION_COHORT_PATH_CAP];
|
||||
int logs_written = snprintf(logs, sizeof(logs), "%s/logs", cache);
|
||||
int path_written = snprintf(path, sizeof(path),
|
||||
"%s/daemon-conflicts.ndjson", logs);
|
||||
if (logs_written <= 0 || logs_written >= (int)sizeof(logs) ||
|
||||
path_written <= 0 || path_written >= (int)sizeof(path)) {
|
||||
return false;
|
||||
}
|
||||
FILE *seed = cbm_daemon_ipc_private_log_open(
|
||||
logs, "daemon-conflicts.ndjson", VERSION_COHORT_LOG_CAP);
|
||||
if (!seed) {
|
||||
return false;
|
||||
}
|
||||
bool seeded = fclose(seed) == 0;
|
||||
return seeded && cbm_daemon_conflict_log_append(
|
||||
path, conflict, VERSION_COHORT_LOG_CAP);
|
||||
}
|
||||
|
||||
bool cbm_version_cohort_log_uncoordinated_daemon(
|
||||
const cbm_daemon_build_identity_t *requested) {
|
||||
if (!version_cohort_identity_valid(requested)) {
|
||||
return false;
|
||||
}
|
||||
cbm_daemon_conflict_t conflict = {
|
||||
.status = CBM_DAEMON_HELLO_BUILD_CONFLICT,
|
||||
};
|
||||
(void)snprintf(conflict.active_version,
|
||||
sizeof(conflict.active_version),
|
||||
"%s", "pre-cohort/unknown");
|
||||
memset(conflict.active_build_fingerprint, '0',
|
||||
sizeof(conflict.active_build_fingerprint) - 1);
|
||||
conflict.active_build_fingerprint[
|
||||
sizeof(conflict.active_build_fingerprint) - 1] = '\0';
|
||||
(void)snprintf(conflict.requested_version,
|
||||
sizeof(conflict.requested_version), "%s",
|
||||
requested->semantic_version);
|
||||
(void)snprintf(conflict.requested_build_fingerprint,
|
||||
sizeof(conflict.requested_build_fingerprint), "%s",
|
||||
requested->build_fingerprint);
|
||||
return cbm_version_cohort_log_conflict(&conflict);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* version_cohort.h — Crash-safe exact-build admission across CBM processes. */
|
||||
#ifndef CBM_DAEMON_VERSION_COHORT_H
|
||||
#define CBM_DAEMON_VERSION_COHORT_H
|
||||
|
||||
#include "daemon/ipc.h"
|
||||
#include "daemon/service.h"
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct cbm_version_cohort_manager cbm_version_cohort_manager_t;
|
||||
typedef struct cbm_version_cohort_lease cbm_version_cohort_lease_t;
|
||||
typedef struct cbm_version_cohort_daemon_claim
|
||||
cbm_version_cohort_daemon_claim_t;
|
||||
|
||||
typedef enum {
|
||||
CBM_VERSION_COHORT_OK = 0,
|
||||
CBM_VERSION_COHORT_CONFLICT = 1,
|
||||
CBM_VERSION_COHORT_BUSY = 2,
|
||||
CBM_VERSION_COHORT_UNSAFE = 3,
|
||||
CBM_VERSION_COHORT_IO = 4,
|
||||
} cbm_version_cohort_status_t;
|
||||
|
||||
typedef enum {
|
||||
CBM_VERSION_COHORT_DAEMON_ABSENT = 0,
|
||||
CBM_VERSION_COHORT_DAEMON_COORDINATED = 1,
|
||||
CBM_VERSION_COHORT_DAEMON_UNCOORDINATED = 2,
|
||||
CBM_VERSION_COHORT_DAEMON_UNSAFE = 3,
|
||||
CBM_VERSION_COHORT_DAEMON_IO = 4,
|
||||
} cbm_version_cohort_daemon_presence_t;
|
||||
|
||||
typedef enum {
|
||||
CBM_VERSION_COHORT_MAINTENANCE_ABSENT = 0,
|
||||
CBM_VERSION_COHORT_MAINTENANCE_REQUESTED = 1,
|
||||
CBM_VERSION_COHORT_MAINTENANCE_UNSAFE = 2,
|
||||
CBM_VERSION_COHORT_MAINTENANCE_IO = 3,
|
||||
} cbm_version_cohort_maintenance_presence_t;
|
||||
|
||||
/* A mutation barrier invokes its quiesce callback only after retaining the
|
||||
* admission lock and observing active lifetime participants. The callback
|
||||
* must return promptly: the barrier itself bounds only its native lock wait.
|
||||
* It returns REQUESTED, REFUSED, or ERROR; NOT_NEEDED is reserved for the API
|
||||
* output when lifetime was already free. REFUSED leaves active work untouched;
|
||||
* ERROR reports an inability to request orderly quiescence. */
|
||||
typedef enum {
|
||||
CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED = 0,
|
||||
CBM_VERSION_COHORT_QUIESCE_REQUESTED = 1,
|
||||
CBM_VERSION_COHORT_QUIESCE_REFUSED = 2,
|
||||
CBM_VERSION_COHORT_QUIESCE_ERROR = 3,
|
||||
} cbm_version_cohort_quiesce_result_t;
|
||||
|
||||
typedef cbm_version_cohort_quiesce_result_t (*cbm_version_cohort_quiesce_fn)(
|
||||
void *context);
|
||||
|
||||
/* Managers independently reopen no paths: the endpoint duplicates its
|
||||
* already-validated owner-only runtime-directory handle. All managers for one
|
||||
* account therefore meet at the same stable native lock files. */
|
||||
cbm_version_cohort_manager_t *cbm_version_cohort_manager_new(
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint);
|
||||
|
||||
/* Admission first takes the maintenance gate SH without waiting, holds it
|
||||
* across the short admission EX transition, then retains SH on the cohort
|
||||
* lifetime file. Active maintenance therefore fails fast with BUSY. Exact
|
||||
* identity peers share SH; a different version, build, or ABI returns
|
||||
* CONFLICT with conflict_out populated. deadline_ms is an absolute
|
||||
* cbm_now_ms() deadline; UINT64_MAX waits indefinitely. Every non-NULL
|
||||
* lease_out, including cleanup-only IO state, must be released. */
|
||||
cbm_version_cohort_status_t cbm_version_cohort_acquire(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms,
|
||||
cbm_version_cohort_lease_t **lease_out,
|
||||
cbm_daemon_conflict_t *conflict_out);
|
||||
|
||||
/* Binary activation takes the lifetime file EX. It therefore refuses while
|
||||
* any CLI/bootstrap/daemon participant is active and blocks new admissions
|
||||
* for the complete install/update/uninstall mutation window. */
|
||||
cbm_version_cohort_status_t cbm_version_cohort_reserve_exclusive(
|
||||
cbm_version_cohort_manager_t *manager, uint64_t deadline_ms,
|
||||
cbm_version_cohort_lease_t **lease_out);
|
||||
|
||||
/* Coordinated install/update/uninstall barrier for modern CBM participants.
|
||||
* It first publishes maintenance intent EX, then retains admission EX so no
|
||||
* new participant can enter, and finally probes lifetime EX. If lifetime is
|
||||
* busy, quiesce is invoked exactly once and the barrier waits no later than
|
||||
* the finite absolute deadline_ms. On success the returned mutation lease
|
||||
* retains maintenance EX, admission EX, and lifetime EX until release. On
|
||||
* timeout, callback refusal/error, or lock failure it grants no mutation
|
||||
* authority and releases every safely releasable guard. UINT64_MAX is
|
||||
* rejected because mutation draining must always be bounded. A non-NULL
|
||||
* cleanup-only lease_out after IO must still be released by the caller. */
|
||||
cbm_version_cohort_status_t cbm_version_cohort_reserve_for_mutation(
|
||||
cbm_version_cohort_manager_t *manager, uint64_t deadline_ms,
|
||||
cbm_version_cohort_quiesce_fn quiesce, void *quiesce_context,
|
||||
cbm_version_cohort_quiesce_result_t *quiesce_result_out,
|
||||
cbm_version_cohort_lease_t **lease_out);
|
||||
|
||||
/* Cheap, non-blocking observation of the crash-released maintenance intent.
|
||||
* Participants use the same native gate before admission, so REQUESTED is an
|
||||
* authoritative fail-fast signal rather than a filesystem-presence guess. */
|
||||
cbm_version_cohort_maintenance_presence_t
|
||||
cbm_version_cohort_maintenance_presence(
|
||||
cbm_version_cohort_manager_t *manager);
|
||||
|
||||
/* The daemon holds this separate EX marker for its generation after taking
|
||||
* the stable daemon lifetime reservation. A local CLI can therefore
|
||||
* distinguish a current coordinated daemon from a pre-cohort daemon without
|
||||
* opening an IPC connection or registering a daemon session. A non-NULL
|
||||
* claim_out accompanying IO is cleanup-only authority and must be released. */
|
||||
cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_acquire(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
cbm_version_cohort_daemon_claim_t **claim_out);
|
||||
cbm_private_file_lock_status_t cbm_version_cohort_daemon_claim_release(
|
||||
cbm_version_cohort_daemon_claim_t **claim_io);
|
||||
|
||||
/* Non-owning marker observation for startup turnover. COORDINATED means a
|
||||
* current daemon still holds its generation claim, including the cleanup
|
||||
* interval after its listener/lifetime reservation has closed. ABSENT means
|
||||
* the marker was acquired and released safely; UNSAFE/IO fail closed. */
|
||||
cbm_version_cohort_daemon_presence_t
|
||||
cbm_version_cohort_daemon_claim_presence(
|
||||
cbm_version_cohort_manager_t *manager);
|
||||
|
||||
/* Native marker ownership, never file existence, establishes a coordinated
|
||||
* daemon. A retained marker remains authoritative during final cleanup even
|
||||
* after listener/lifetime teardown. */
|
||||
cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint);
|
||||
|
||||
/* Standalone CLI presence check under its successfully sealed and retained
|
||||
* startup transition. An absent daemon lifetime is authoritative because no
|
||||
* current or legacy daemon can start through that guard; an active lifetime
|
||||
* must still own the current-generation daemon marker to be coordinated. */
|
||||
cbm_version_cohort_daemon_presence_t
|
||||
cbm_version_cohort_daemon_presence_under_transition(
|
||||
cbm_version_cohort_manager_t *manager,
|
||||
const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
const cbm_daemon_ipc_local_transition_t *transition);
|
||||
|
||||
cbm_private_file_lock_status_t cbm_version_cohort_lease_release(
|
||||
cbm_version_cohort_lease_t **lease_io);
|
||||
|
||||
/* Refuses teardown while a lease or retryable cleanup handle remains. */
|
||||
cbm_private_file_lock_status_t cbm_version_cohort_manager_free(
|
||||
cbm_version_cohort_manager_t **manager_io);
|
||||
|
||||
/* Best-effort persistent conflict record in the same owner-only daemon log
|
||||
* used by HELLO conflicts. Admission remains safely rejected if logging fails. */
|
||||
bool cbm_version_cohort_log_conflict(const cbm_daemon_conflict_t *conflict);
|
||||
|
||||
/* Persist the fail-closed migration case where the stable daemon reservation
|
||||
* is active but no current-generation coordination marker can be verified. */
|
||||
bool cbm_version_cohort_log_uncoordinated_daemon(
|
||||
const cbm_daemon_build_identity_t *requested);
|
||||
|
||||
#endif /* CBM_DAEMON_VERSION_COHORT_H */
|
||||
+13
-4
@@ -7,6 +7,7 @@
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/constants.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#ifdef _WIN32
|
||||
@@ -91,20 +92,28 @@ char *cbm_mkdtemp(char *tmpl) {
|
||||
#ifdef _WIN32
|
||||
int cbm_mkstemp(char *tmpl) {
|
||||
/* Rewrite /tmp/ to %TEMP%\ like cbm_mkdtemp */
|
||||
static char buf[CBM_SZ_512];
|
||||
/* Per-call storage: daemon project workers can create staging files
|
||||
* concurrently, so a process-global scratch buffer is a data race. */
|
||||
char buf[CBM_SZ_4K];
|
||||
int written;
|
||||
if (strncmp(tmpl, "/tmp/", 5) == 0) {
|
||||
const char *tmp = getenv("TEMP");
|
||||
if (!tmp)
|
||||
tmp = getenv("TMP");
|
||||
if (!tmp)
|
||||
tmp = ".";
|
||||
snprintf(buf, sizeof(buf), "%s\\%s", tmp, tmpl + 5);
|
||||
written = snprintf(buf, sizeof(buf), "%s\\%s", tmp, tmpl + 5);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%s", tmpl);
|
||||
written = snprintf(buf, sizeof(buf), "%s", tmpl);
|
||||
}
|
||||
if (written < 0 || (size_t)written >= sizeof(buf)) {
|
||||
errno = ENAMETOOLONG;
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
if (!_mktemp(buf))
|
||||
return CBM_NOT_FOUND;
|
||||
int fd = _open(buf, _O_CREAT | _O_RDWR | _O_BINARY, _S_IREAD | _S_IWRITE);
|
||||
int fd =
|
||||
_open(buf, _O_CREAT | _O_EXCL | _O_RDWR | _O_BINARY, _S_IREAD | _S_IWRITE);
|
||||
if (fd >= 0)
|
||||
strcpy(tmpl, buf);
|
||||
return fd;
|
||||
|
||||
+86
-26
@@ -802,13 +802,14 @@ int cbm_exec_no_shell(const char *const *argv) {
|
||||
|
||||
#endif /* _WIN32 */
|
||||
|
||||
/* Canonicalize an EXISTING path (collapse `..`, resolve per-OS): realpath on
|
||||
* POSIX; on Windows a wide-path GetFileAttributesW existence check +
|
||||
* GetFullPathNameW. The previous callers used the ANSI CRT (_access/
|
||||
* _fullpath) on UTF-8 input — locale-dependent by construction: on a CJK
|
||||
* system codepage (e.g. Big5) the UTF-8 bytes of a CJK path re-decode into
|
||||
* different characters and canonicalization corrupts the path (#973).
|
||||
* Returns 0 when the path does not exist or cannot be resolved. */
|
||||
/* Canonicalize an EXISTING path (collapse `..`, resolve links/junctions):
|
||||
* realpath on POSIX; a final path queried from an opened handle on Windows.
|
||||
* The previous Windows callers used the ANSI CRT (_access/_fullpath) on UTF-8
|
||||
* input — locale-dependent by construction: on a CJK system codepage (e.g.
|
||||
* Big5) the UTF-8 bytes of a CJK path re-decode into different characters and
|
||||
* canonicalization corrupts the path (#973). GetFullPathNameW alone is also
|
||||
* only lexical and would let an allowed-root check follow a junction outside
|
||||
* the root. Returns 0 when the path does not exist or cannot be resolved. */
|
||||
int cbm_canonical_path(const char *path, char *out, size_t out_sz) {
|
||||
if (!path || !out || out_sz == 0) {
|
||||
return 0;
|
||||
@@ -818,18 +819,46 @@ int cbm_canonical_path(const char *path, char *out, size_t out_sz) {
|
||||
if (!wpath) {
|
||||
return 0;
|
||||
}
|
||||
if (GetFileAttributesW(wpath) == INVALID_FILE_ATTRIBUTES) {
|
||||
free(wpath);
|
||||
HANDLE handle = CreateFileW(wpath, FILE_READ_ATTRIBUTES,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL,
|
||||
OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL);
|
||||
free(wpath);
|
||||
if (handle == INVALID_HANDLE_VALUE) {
|
||||
return 0;
|
||||
}
|
||||
enum { CANON_WIDE_MAX = 4096 };
|
||||
wchar_t wfull[CANON_WIDE_MAX];
|
||||
DWORD n = GetFullPathNameW(wpath, CANON_WIDE_MAX, wfull, NULL);
|
||||
free(wpath);
|
||||
if (n == 0 || n >= CANON_WIDE_MAX) {
|
||||
DWORD needed =
|
||||
GetFinalPathNameByHandleW(handle, NULL, 0, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
|
||||
if (needed == 0 || needed == MAXDWORD || (size_t)needed > SIZE_MAX / sizeof(wchar_t) - 1) {
|
||||
(void)CloseHandle(handle);
|
||||
return 0;
|
||||
}
|
||||
size_t capacity = (size_t)needed + 1;
|
||||
wchar_t *wfull = calloc(capacity, sizeof(*wfull));
|
||||
if (!wfull) {
|
||||
(void)CloseHandle(handle);
|
||||
return 0;
|
||||
}
|
||||
DWORD n = GetFinalPathNameByHandleW(handle, wfull, (DWORD)capacity,
|
||||
FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
|
||||
(void)CloseHandle(handle);
|
||||
if (n == 0 || (size_t)n >= capacity) {
|
||||
free(wfull);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Preserve the conventional DOS/UNC form returned by the old API while
|
||||
* retaining the handle-based resolution. */
|
||||
if (wcsncmp(wfull, L"\\\\?\\UNC\\", 8) == 0) {
|
||||
size_t tail_length = wcslen(wfull + 8);
|
||||
wmemmove(wfull + 2, wfull + 8, tail_length + 1);
|
||||
wfull[0] = L'\\';
|
||||
wfull[1] = L'\\';
|
||||
} else if (wcsncmp(wfull, L"\\\\?\\", 4) == 0) {
|
||||
size_t tail_length = wcslen(wfull + 4);
|
||||
wmemmove(wfull, wfull + 4, tail_length + 1);
|
||||
}
|
||||
char *utf8 = cbm_wide_to_utf8(wfull);
|
||||
free(wfull);
|
||||
if (!utf8) {
|
||||
return 0;
|
||||
}
|
||||
@@ -859,7 +888,7 @@ int cbm_rename_replace(const char *src, const char *dst) {
|
||||
if (wsrc && wdst) {
|
||||
ret =
|
||||
MoveFileExW(wsrc, wdst,
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED | MOVEFILE_WRITE_THROUGH)
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)
|
||||
? 0
|
||||
: CBM_NOT_FOUND;
|
||||
}
|
||||
@@ -871,23 +900,54 @@ int cbm_rename_replace(const char *src, const char *dst) {
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Remove a SQLite database's -wal/-shm sidecars (both platforms). Any code
|
||||
* path that installs a FRESH database file where a previous generation lived
|
||||
* must remove them before the new generation can be opened: SQLite decides
|
||||
* whether to replay a WAL purely from the sidecar's own header/checksums, so
|
||||
* a leftover WAL can splice old-generation pages into the new file (#897). */
|
||||
void cbm_remove_db_sidecars(const char *db_path) {
|
||||
/* Remove a SQLite database's -wal/-shm/-journal sidecars (both platforms). Any code
|
||||
* path that installs a FRESH database file at a path where a previous
|
||||
* generation lived must call this first: SQLite decides whether to replay a
|
||||
* WAL purely from the sidecar's own header/checksums, so a leftover WAL
|
||||
* from a crashed session is recovered ON TOP of the freshly installed file
|
||||
* at the next open, splicing old-generation pages into it (#897). */
|
||||
int cbm_remove_db_sidecars(const char *db_path) {
|
||||
if (!db_path || !db_path[0]) {
|
||||
return;
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
enum { SIDECAR_PATH_MAX = 4096 };
|
||||
char side[SIDECAR_PATH_MAX];
|
||||
/* Validate the longest suffix before unlinking anything. Otherwise a
|
||||
* near-limit path can remove -wal/-shm, silently skip a truncated
|
||||
* -journal, and report success after partially mutating the generation. */
|
||||
if (strlen(db_path) > sizeof(side) - sizeof("-journal")) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
int result = 0;
|
||||
int n = snprintf(side, sizeof(side), "%s-wal", db_path);
|
||||
if (n > 0 && (size_t)n < sizeof(side)) {
|
||||
(void)cbm_unlink(side);
|
||||
if (n <= 0 || (size_t)n >= sizeof(side)) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
errno = 0;
|
||||
int unlink_rc = cbm_unlink(side);
|
||||
int unlink_error = errno;
|
||||
if (unlink_rc != 0 && unlink_error != ENOENT) {
|
||||
result = CBM_NOT_FOUND;
|
||||
}
|
||||
n = snprintf(side, sizeof(side), "%s-shm", db_path);
|
||||
if (n > 0 && (size_t)n < sizeof(side)) {
|
||||
(void)cbm_unlink(side);
|
||||
if (n <= 0 || (size_t)n >= sizeof(side)) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
errno = 0;
|
||||
unlink_rc = cbm_unlink(side);
|
||||
unlink_error = errno;
|
||||
if (unlink_rc != 0 && unlink_error != ENOENT) {
|
||||
result = CBM_NOT_FOUND;
|
||||
}
|
||||
n = snprintf(side, sizeof(side), "%s-journal", db_path);
|
||||
if (n <= 0 || (size_t)n >= sizeof(side)) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
errno = 0;
|
||||
unlink_rc = cbm_unlink(side);
|
||||
unlink_error = errno;
|
||||
if (unlink_rc != 0 && unlink_error != ENOENT) {
|
||||
result = CBM_NOT_FOUND;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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 <db_path>-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 <db_path>-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. */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* lock_registry.h — Generic writer-preference registry over private file locks.
|
||||
*/
|
||||
#ifndef CBM_LOCK_REGISTRY_H
|
||||
#define CBM_LOCK_REGISTRY_H
|
||||
|
||||
#include "foundation/private_file_lock.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -0,0 +1,63 @@
|
||||
#ifndef CBM_LOCK_REGISTRY_INTERNAL_H
|
||||
#define CBM_LOCK_REGISTRY_INTERNAL_H
|
||||
|
||||
#include "foundation/lock_registry.h"
|
||||
#include "foundation/private_file_lock_internal.h"
|
||||
|
||||
typedef enum {
|
||||
CBM_LOCK_REGISTRY_STAGE_TURN_BUSY = 1,
|
||||
CBM_LOCK_REGISTRY_STAGE_TURN_HELD = 2,
|
||||
CBM_LOCK_REGISTRY_STAGE_RW_BUSY = 3,
|
||||
CBM_LOCK_REGISTRY_STAGE_NATIVE_READY = 4,
|
||||
} cbm_lock_registry_stage_t;
|
||||
|
||||
typedef void (*cbm_lock_registry_stage_hook_fn)(void *context, cbm_private_file_lock_mode_t mode,
|
||||
cbm_lock_registry_stage_t stage);
|
||||
|
||||
bool cbm_lock_registry_resource_names(const char *resource_key,
|
||||
char turn_out[CBM_LOCK_REGISTRY_NAME_CAP],
|
||||
char rw_out[CBM_LOCK_REGISTRY_NAME_CAP]);
|
||||
|
||||
size_t cbm_lock_registry_waiter_count(cbm_lock_registry_t *registry);
|
||||
size_t cbm_lock_registry_active_lease_count_for_test(cbm_lock_registry_t *registry);
|
||||
size_t cbm_lock_registry_pending_cleanup_count_for_test(cbm_lock_registry_t *registry);
|
||||
bool cbm_lock_registry_is_retired_for_test(const cbm_lock_registry_t *registry);
|
||||
size_t cbm_lock_registry_attempting_waiter_count_for_test(cbm_lock_registry_t *registry);
|
||||
uint64_t cbm_lock_registry_condition_wait_call_count_for_test(
|
||||
const cbm_lock_registry_t *registry);
|
||||
size_t cbm_lock_registry_condition_waiter_count_for_test(const cbm_lock_registry_t *registry);
|
||||
|
||||
typedef enum {
|
||||
CBM_LOCK_REGISTRY_RELEASE_RW = 1,
|
||||
CBM_LOCK_REGISTRY_RELEASE_TURN = 2,
|
||||
} cbm_lock_registry_release_handle_t;
|
||||
|
||||
bool cbm_lock_lease_fail_next_release_step_for_test(cbm_lock_lease_t *lease,
|
||||
cbm_lock_registry_release_handle_t handle,
|
||||
cbm_private_file_lock_release_step_t step);
|
||||
bool cbm_lock_lease_has_release_handle_for_test(const cbm_lock_lease_t *lease,
|
||||
cbm_lock_registry_release_handle_t handle);
|
||||
bool cbm_lock_lease_used_abort_lock_failure_path_for_test(const cbm_lock_lease_t *lease);
|
||||
#ifndef _WIN32
|
||||
bool cbm_lock_lease_fail_close_after_consuming_for_test(cbm_lock_lease_t *lease,
|
||||
cbm_lock_registry_release_handle_t handle);
|
||||
#endif
|
||||
bool cbm_lock_registry_fail_next_native_release_step_for_test(
|
||||
cbm_lock_registry_t *registry, cbm_lock_registry_release_handle_t handle,
|
||||
cbm_private_file_lock_release_step_t step);
|
||||
|
||||
typedef enum {
|
||||
CBM_LOCK_REGISTRY_ABORT_FAIL_LOCK = 1,
|
||||
CBM_LOCK_REGISTRY_ABORT_FAIL_REMOVE = 2,
|
||||
} cbm_lock_registry_abort_failure_t;
|
||||
|
||||
/* One-shot abort-bookkeeping fault seam, armable only while idle. */
|
||||
bool cbm_lock_registry_fail_next_abort_bookkeeping_for_test(
|
||||
cbm_lock_registry_t *registry, cbm_lock_registry_abort_failure_t failure);
|
||||
|
||||
/* Deterministic test seam. May only be changed while the registry is idle;
|
||||
* callbacks run without the fork guard or registry mutex held. */
|
||||
bool cbm_lock_registry_set_stage_hook_for_test(cbm_lock_registry_t *registry,
|
||||
cbm_lock_registry_stage_hook_fn hook, void *context);
|
||||
|
||||
#endif /* CBM_LOCK_REGISTRY_INTERNAL_H */
|
||||
@@ -0,0 +1,60 @@
|
||||
/* macos_acl.c — Darwin extended-ACL operations anchored to an open fd. */
|
||||
#include "foundation/macos_acl.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __APPLE__
|
||||
|
||||
#include <errno.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/acl.h>
|
||||
|
||||
bool cbm_macos_extended_acl_fd_is_empty(int fd) {
|
||||
if (fd < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
acl_t acl = acl_get_fd_np(fd, ACL_TYPE_EXTENDED);
|
||||
if (!acl) {
|
||||
/* Darwin reports an object with no extended ACL as ENOENT on some
|
||||
* filesystems. All other retrieval failures are fail-closed. */
|
||||
return errno == ENOENT;
|
||||
}
|
||||
|
||||
acl_entry_t entry = NULL;
|
||||
errno = 0;
|
||||
int entry_result = acl_get_entry(acl, ACL_FIRST_ENTRY, &entry);
|
||||
int entry_error = errno;
|
||||
int free_result = acl_free(acl);
|
||||
|
||||
/* Darwin returns 0 for an entry and -1/EINVAL when an empty ACL (or the
|
||||
* end of an ACL) is reached. Looking only at the first entry makes every
|
||||
* nonempty ACL unsafe, independent of its allow/deny permissions. */
|
||||
return entry_result == -1 && entry_error == EINVAL && free_result == 0;
|
||||
}
|
||||
|
||||
bool cbm_macos_extended_acl_fd_clear(int fd) {
|
||||
if (fd < 0) {
|
||||
return false;
|
||||
}
|
||||
acl_t empty = acl_init(0);
|
||||
if (!empty) {
|
||||
return false;
|
||||
}
|
||||
bool cleared = acl_set_fd_np(fd, empty, ACL_TYPE_EXTENDED) == 0;
|
||||
bool freed = acl_free(empty) == 0;
|
||||
return cleared && freed && cbm_macos_extended_acl_fd_is_empty(fd);
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
bool cbm_macos_extended_acl_fd_is_empty(int fd) {
|
||||
return fd >= 0;
|
||||
}
|
||||
|
||||
bool cbm_macos_extended_acl_fd_clear(int fd) {
|
||||
return fd >= 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,15 @@
|
||||
/* macos_acl.h — Anchored extended-ACL checks for private filesystem objects. */
|
||||
#ifndef CBM_FOUNDATION_MACOS_ACL_H
|
||||
#define CBM_FOUNDATION_MACOS_ACL_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
/* On macOS, succeeds only when fd has no extended ACL entries. Other
|
||||
* platforms have no Darwin extended ACL surface, so a valid fd is sufficient. */
|
||||
bool cbm_macos_extended_acl_fd_is_empty(int fd);
|
||||
|
||||
/* On macOS, replaces fd's extended ACL with an empty ACL and verifies the
|
||||
* result through the same anchored descriptor. Other platforms are a no-op. */
|
||||
bool cbm_macos_extended_acl_fd_clear(int fd);
|
||||
|
||||
#endif
|
||||
+25
-2
@@ -134,6 +134,7 @@ cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction,
|
||||
.source = "ram_fraction",
|
||||
.clamped = false,
|
||||
.invalid = false,
|
||||
.hard_capped = false,
|
||||
};
|
||||
|
||||
if (budget_mb == NULL || budget_mb[0] == '\0') {
|
||||
@@ -173,7 +174,19 @@ cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction,
|
||||
return result;
|
||||
}
|
||||
|
||||
void cbm_mem_init(double ram_fraction) {
|
||||
cbm_mem_budget_t cbm_mem_resolve_budget_capped(size_t total_ram, double ram_fraction,
|
||||
const char *budget_mb,
|
||||
size_t hard_cap_bytes) {
|
||||
cbm_mem_budget_t result = cbm_mem_resolve_budget(total_ram, ram_fraction, budget_mb);
|
||||
if (hard_cap_bytes > 0 && (result.budget == 0 || result.budget > hard_cap_bytes)) {
|
||||
result.budget = hard_cap_bytes;
|
||||
result.source = "daemon_worker_cap";
|
||||
result.hard_capped = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void cbm_mem_init_with_cap(double ram_fraction, size_t hard_cap_bytes) {
|
||||
int expected = 0;
|
||||
if (!atomic_compare_exchange_strong(&g_initialized, &expected, 1)) {
|
||||
return;
|
||||
@@ -210,7 +223,8 @@ void cbm_mem_init(double ram_fraction) {
|
||||
|
||||
char env_buf[CBM_SZ_32];
|
||||
const char *env = cbm_safe_getenv("CBM_MEM_BUDGET_MB", env_buf, sizeof(env_buf), NULL);
|
||||
cbm_mem_budget_t resolved = cbm_mem_resolve_budget(info.total_ram, ram_fraction, env);
|
||||
cbm_mem_budget_t resolved =
|
||||
cbm_mem_resolve_budget_capped(info.total_ram, ram_fraction, env, hard_cap_bytes);
|
||||
g_budget = resolved.budget;
|
||||
|
||||
/* The resolver is the single source of truth for the parse + clamp; this
|
||||
@@ -222,6 +236,11 @@ void cbm_mem_init(double ram_fraction) {
|
||||
snprintf(cap_mb, sizeof(cap_mb), "%zu", info.total_ram / MB_DIVISOR);
|
||||
cbm_log_warn("mem.budget.clamped", "requested_mb", env, "cap_mb", cap_mb);
|
||||
}
|
||||
if (resolved.hard_capped) {
|
||||
char cap_bytes[CBM_SZ_32];
|
||||
snprintf(cap_bytes, sizeof(cap_bytes), "%zu", hard_cap_bytes);
|
||||
cbm_log_info("mem.budget.worker_cap", "cap_bytes", cap_bytes);
|
||||
}
|
||||
|
||||
char budget_mb[CBM_SZ_32];
|
||||
char ram_mb[CBM_SZ_32];
|
||||
@@ -231,6 +250,10 @@ void cbm_mem_init(double ram_fraction) {
|
||||
resolved.source);
|
||||
}
|
||||
|
||||
void cbm_mem_init(double ram_fraction) {
|
||||
cbm_mem_init_with_cap(ram_fraction, 0);
|
||||
}
|
||||
|
||||
size_t cbm_mem_rss(void) {
|
||||
#if defined(__linux__)
|
||||
/* Linux: mimalloc's _mi_prim_process_info() (vendored/mimalloc/src/prim/
|
||||
|
||||
@@ -21,6 +21,11 @@ double cbm_mem_ram_fraction_for_total(size_t total_ram_bytes);
|
||||
* Configures mimalloc options for reduced upfront memory. */
|
||||
void cbm_mem_init(double ram_fraction);
|
||||
|
||||
/* Worker-only initialization cap carried on the build-bound internal argv.
|
||||
* The existing user override is still resolved first; a lower explicit
|
||||
* CBM_MEM_BUDGET_MB wins, while a larger/default budget is capped. */
|
||||
void cbm_mem_init_with_cap(double ram_fraction, size_t hard_cap_bytes);
|
||||
|
||||
/* Result of cbm_mem_resolve_budget: the resolved budget plus the metadata
|
||||
* cbm_mem_init logs — so the parse/clamp logic lives in exactly ONE place and
|
||||
* the caller never re-parses the env string. */
|
||||
@@ -29,6 +34,7 @@ typedef struct {
|
||||
const char *source; /* log token: "ram_fraction" | "CBM_MEM_BUDGET_MB" */
|
||||
bool clamped; /* override was valid but exceeded total_ram → clamped down */
|
||||
bool invalid; /* override was present but unparseable / out-of-range / ≤0 */
|
||||
bool hard_capped; /* internal worker hard cap reduced the resolved budget */
|
||||
} cbm_mem_budget_t;
|
||||
|
||||
/* Pure budget resolver shared by cbm_mem_init (exposed for testing).
|
||||
@@ -40,6 +46,11 @@ typedef struct {
|
||||
cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction,
|
||||
const char *budget_mb);
|
||||
|
||||
/* Pure capped variant used by supervised workers and deterministic tests. */
|
||||
cbm_mem_budget_t cbm_mem_resolve_budget_capped(size_t total_ram, double ram_fraction,
|
||||
const char *budget_mb,
|
||||
size_t hard_cap_bytes);
|
||||
|
||||
/* Current RSS in bytes via mi_process_info().
|
||||
* Falls back to OS-specific queries when MI_OVERRIDE=0 (ASan builds). */
|
||||
size_t cbm_mem_rss(void);
|
||||
|
||||
+63
-11
@@ -5,12 +5,58 @@
|
||||
*/
|
||||
#include "platform.h"
|
||||
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "foundation/platform_internal.h"
|
||||
#include <fcntl.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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 <mach/mach_time.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/sysctl.h>
|
||||
#else
|
||||
#include <sched.h>
|
||||
@@ -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;
|
||||
|
||||
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) {
|
||||
if (!timebase_init) {
|
||||
mach_timebase_info(&timebase_info);
|
||||
timebase_init = SKIP_ONE;
|
||||
}
|
||||
(void)pthread_once(&timebase_once, cbm_timebase_initialize);
|
||||
uint64_t ticks = mach_absolute_time();
|
||||
return ticks * timebase_info.numer / timebase_info.denom;
|
||||
}
|
||||
@@ -361,7 +413,7 @@ const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const ch
|
||||
/* ── Home directory (cross-platform) ───────────────────── */
|
||||
|
||||
const char *cbm_get_home_dir(void) {
|
||||
static char buf[CBM_SZ_1K];
|
||||
static CBM_TLS char buf[CBM_SZ_1K];
|
||||
char tmp[CBM_SZ_256] = "";
|
||||
|
||||
cbm_safe_getenv("HOME", tmp, sizeof(tmp), NULL);
|
||||
@@ -383,7 +435,7 @@ const char *cbm_get_home_dir(void) {
|
||||
/* ── App config directories (cross-platform) ────────── */
|
||||
|
||||
const char *cbm_app_config_dir(void) {
|
||||
static char buf[CBM_SZ_1K];
|
||||
static CBM_TLS char buf[CBM_SZ_1K];
|
||||
char tmp[CBM_SZ_256] = "";
|
||||
#ifdef _WIN32
|
||||
cbm_safe_getenv("APPDATA", tmp, sizeof(tmp), NULL);
|
||||
@@ -416,7 +468,7 @@ const char *cbm_app_config_dir(void) {
|
||||
|
||||
const char *cbm_app_local_dir(void) {
|
||||
#ifdef _WIN32
|
||||
static char buf[CBM_SZ_1K];
|
||||
static CBM_TLS char buf[CBM_SZ_1K];
|
||||
char tmp[CBM_SZ_256] = "";
|
||||
cbm_safe_getenv("LOCALAPPDATA", tmp, sizeof(tmp), NULL);
|
||||
if (tmp[0]) {
|
||||
@@ -438,7 +490,7 @@ const char *cbm_app_local_dir(void) {
|
||||
/* ── Cache directory ────────────────────────── */
|
||||
|
||||
const char *cbm_resolve_cache_dir(void) {
|
||||
static char buf[CBM_SZ_1K];
|
||||
static CBM_TLS char buf[CBM_SZ_1K];
|
||||
char tmp[CBM_SZ_256] = "";
|
||||
cbm_safe_getenv("CBM_CACHE_DIR", tmp, sizeof(tmp), NULL);
|
||||
if (tmp[0]) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* Internal seams shared by platform implementations and focused tests. */
|
||||
#ifndef CBM_PLATFORM_INTERNAL_H
|
||||
#define CBM_PLATFORM_INTERNAL_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* 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 */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <stdint.h>
|
||||
|
||||
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 */
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Internal constructors for platform runtime-path code and focused tests. */
|
||||
#ifndef CBM_PRIVATE_FILE_LOCK_INTERNAL_H
|
||||
#define CBM_PRIVATE_FILE_LOCK_INTERNAL_H
|
||||
|
||||
#include "foundation/private_file_lock.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
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 */
|
||||
+754
-145
File diff suppressed because it is too large
Load Diff
@@ -36,8 +36,12 @@ typedef enum {
|
||||
|
||||
typedef struct {
|
||||
cbm_proc_outcome_t outcome;
|
||||
int exit_code; /* WEXITSTATUS / GetExitCodeProcess; -1 when terminated by a POSIX signal */
|
||||
int term_signal; /* WTERMSIG on POSIX; 0 otherwise */
|
||||
int exit_code; /* WEXITSTATUS / GetExitCodeProcess; -1 for a POSIX signal */
|
||||
int term_signal; /* WTERMSIG on POSIX; 0 otherwise */
|
||||
bool cancellation_requested; /* an explicit cancel request was accepted before terminal */
|
||||
bool forced; /* force was needed after grace expiry or to reap descendants after root exit */
|
||||
bool tree_quiesced; /* the owned process tree has no surviving processes */
|
||||
bool supervision_failed; /* the bounded containment deadline expired; tree_quiesced is false */
|
||||
} cbm_proc_result_t;
|
||||
|
||||
/* Called for each newly-completed (newline-terminated) log line while the child
|
||||
@@ -53,12 +57,67 @@ typedef struct {
|
||||
void *log_ud; /* user data for on_log_line */
|
||||
int quiet_timeout_ms; /* <= 0 => no timeout; else kill+HANG after this many
|
||||
* ms with no new completed log line */
|
||||
int cancel_grace_ms; /* graceful tree-termination window; <= 0 uses the finite
|
||||
* CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS */
|
||||
bool delete_log_on_exit; /* unlink log_file after reaping */
|
||||
} cbm_proc_opts_t;
|
||||
|
||||
#define CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS 1000
|
||||
#define CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS 1000
|
||||
#define CBM_SUBPROCESS_FORCE_SETTLE_MS 1000
|
||||
|
||||
/* Opaque, owned supervisor for one child and its contained descendant tree.
|
||||
* POSIX implementations contain the child in its own process group; Windows
|
||||
* implementations use a Job Object. The containment is what makes a terminal
|
||||
* result stronger than merely observing that the direct child exited. */
|
||||
typedef struct cbm_subprocess cbm_subprocess_t;
|
||||
|
||||
typedef enum {
|
||||
CBM_PROC_POLL_ERROR = -1, /* invalid handle/arguments */
|
||||
CBM_PROC_POLL_RUNNING = 0,
|
||||
CBM_PROC_POLL_TERMINAL = 1
|
||||
} cbm_proc_poll_t;
|
||||
|
||||
/* Spawn opts->bin and return immediately with a supervisor handle. On success,
|
||||
* *out owns the process until a terminal poll followed by destroy. Spawn copies
|
||||
* the option strings/argv it needs after return; log_ud remains caller-owned until
|
||||
* terminal. Returns 0 on success or -1 on validation/spawn failure (*out == NULL).
|
||||
*
|
||||
* A quiet timeout begins at successful spawn and is reset by each completed log
|
||||
* line. Expiry starts the same graceful->forced tree shutdown as explicit cancel,
|
||||
* but the terminal outcome remains CBM_PROC_HANG. */
|
||||
int cbm_subprocess_spawn(const cbm_proc_opts_t *opts, cbm_subprocess_t **out);
|
||||
|
||||
/* Advance supervision without sleeping or waiting for the child. RUNNING means
|
||||
* the caller must poll again. TERMINAL is returned only after the direct child is
|
||||
* reaped AND the entire owned process tree is quiescent; *out is then filled with
|
||||
* the cached immutable result. Every later poll returns TERMINAL with that same
|
||||
* result. If an OS containment operation remains failed through the bounded
|
||||
* force-settle deadline, TERMINAL is still returned but supervision_failed is
|
||||
* true and tree_quiesced is false; callers must log this as a critical teardown
|
||||
* failure. *out is optional and is not modified for RUNNING or ERROR.
|
||||
*
|
||||
* Poll performs the graceful->force state transition: after explicit cancel (or
|
||||
* quiet-timeout), it requests graceful termination once, then force-terminates the
|
||||
* tree when cancel_grace_ms elapses. Callers must keep polling to make progress. */
|
||||
cbm_proc_poll_t cbm_subprocess_poll(cbm_subprocess_t *process, cbm_proc_result_t *out);
|
||||
|
||||
/* Record an explicit cancellation request without waiting. Safe to repeat and
|
||||
* safe to call from a cancellation thread while one owner thread polls. The
|
||||
* owner must stop cancellation producers before destroying the handle. true
|
||||
* means the process is live and cancellation is now/already pending; false means
|
||||
* process is NULL or already terminal. Poll performs signal delivery/escalation. */
|
||||
bool cbm_subprocess_request_cancel(cbm_subprocess_t *process);
|
||||
|
||||
/* Release a terminal handle. This never waits or implicitly cancels; passing a
|
||||
* still-running handle violates the API contract. NULL is a no-op. */
|
||||
void cbm_subprocess_destroy(cbm_subprocess_t *process);
|
||||
|
||||
/* Spawn opts->bin, supervise (tail + optional quiet-timeout), block until it ends,
|
||||
* and classify the result into *out. Returns 0 if a child was spawned and reaped
|
||||
* (out filled), or -1 if the spawn itself failed (out->outcome == CBM_PROC_SPAWN_FAILED). */
|
||||
* and classify the result into *out. Compatibility wrapper around spawn + repeated
|
||||
* poll + bounded sleeps. Returns 0 if a child was spawned and its tree reached
|
||||
* terminal (out filled), or -1 if spawning/supervision failed
|
||||
* (out->outcome == CBM_PROC_SPAWN_FAILED). */
|
||||
int cbm_subprocess_run(const cbm_proc_opts_t *opts, cbm_proc_result_t *out);
|
||||
|
||||
/* Pure outcome classifier — exposed so the platform-specific exit-code mapping
|
||||
|
||||
+1269
-311
File diff suppressed because it is too large
Load Diff
+698
-154
@@ -3,29 +3,45 @@
|
||||
*/
|
||||
#include "index_supervisor.h"
|
||||
|
||||
#include "foundation/compat.h" /* cbm_setenv, cbm_unsetenv */
|
||||
#include "daemon/runtime.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h" /* cbm_mkdir_p, cbm_fopen */
|
||||
#include "foundation/constants.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/platform.h" /* cbm_resolve_cache_dir */
|
||||
#include "foundation/platform.h" /* cbm_safe_getenv, path normalization */
|
||||
#include "foundation/profile.h" /* cbm_profile_active (keep worker log under CBM_PROFILE) */
|
||||
#include "ui/http_server.h" /* cbm_http_server_resolve_binary_path */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdatomic.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <process.h> /* _getpid */
|
||||
#define cbm_getpid _getpid
|
||||
#include <io.h>
|
||||
#include <process.h>
|
||||
#define worker_close _close
|
||||
#define worker_getpid _getpid
|
||||
#else
|
||||
#include <unistd.h> /* getpid */
|
||||
#define cbm_getpid getpid
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#define worker_close close
|
||||
#define worker_getpid getpid
|
||||
#endif
|
||||
|
||||
_Static_assert(CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE ==
|
||||
CBM_DAEMON_BUILD_FINGERPRINT_SIZE,
|
||||
"worker and daemon build fingerprint sizes must match");
|
||||
|
||||
/* ── Worker-role state ────────────────────────────────────────────── */
|
||||
|
||||
static bool g_worker_active = false;
|
||||
static char g_worker_response_out[1024] = {0};
|
||||
static char g_worker_response_out[CBM_SZ_4K] = {0};
|
||||
static size_t g_worker_memory_budget_bytes = 0;
|
||||
static bool g_build_fingerprint_capture_attempted = false;
|
||||
static char g_build_fingerprint[CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE] = {0};
|
||||
|
||||
void cbm_index_set_worker_role(bool is_worker, const char *response_out) {
|
||||
g_worker_active = is_worker;
|
||||
@@ -36,6 +52,27 @@ void cbm_index_set_worker_role(bool is_worker, const char *response_out) {
|
||||
}
|
||||
}
|
||||
|
||||
static void worker_set_local_env(const char *name, const char *value) {
|
||||
if (value && value[0]) {
|
||||
(void)cbm_setenv(name, value, 1);
|
||||
} else {
|
||||
(void)cbm_unsetenv(name);
|
||||
}
|
||||
}
|
||||
|
||||
void cbm_index_set_worker_role_options(bool is_worker, const char *response_out, bool single_thread,
|
||||
const char *marker_file, const char *quarantine_file,
|
||||
size_t memory_budget_bytes) {
|
||||
cbm_index_set_worker_role(is_worker, response_out);
|
||||
g_worker_memory_budget_bytes = is_worker ? memory_budget_bytes : 0;
|
||||
if (!is_worker) {
|
||||
return;
|
||||
}
|
||||
worker_set_local_env("CBM_INDEX_SINGLE_THREAD", single_thread ? "1" : NULL);
|
||||
worker_set_local_env("CBM_INDEX_MARKER_FILE", marker_file);
|
||||
worker_set_local_env("CBM_INDEX_QUARANTINE_FILE", quarantine_file);
|
||||
}
|
||||
|
||||
bool cbm_index_worker_active(void) {
|
||||
return g_worker_active;
|
||||
}
|
||||
@@ -44,23 +81,168 @@ const char *cbm_index_worker_response_out(void) {
|
||||
return g_worker_response_out[0] ? g_worker_response_out : NULL;
|
||||
}
|
||||
|
||||
/* Test hook (#845): counts spawn ATTEMPTS (entry to cbm_index_spawn_worker),
|
||||
* including ones that fail to resolve the self binary — an embedder must never
|
||||
* even try to spawn. */
|
||||
static int g_spawn_count = 0;
|
||||
size_t cbm_index_worker_memory_budget_bytes(void) {
|
||||
return g_worker_memory_budget_bytes;
|
||||
}
|
||||
|
||||
bool cbm_index_supervisor_capture_build_fingerprint(void) {
|
||||
if (g_build_fingerprint_capture_attempted) {
|
||||
return g_build_fingerprint[0] != '\0';
|
||||
}
|
||||
g_build_fingerprint_capture_attempted = true;
|
||||
char captured[CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE] = {0};
|
||||
if (!cbm_daemon_runtime_process_build_fingerprint((uint64_t)worker_getpid(), captured)) {
|
||||
return false;
|
||||
}
|
||||
(void)snprintf(g_build_fingerprint, sizeof(g_build_fingerprint), "%s", captured);
|
||||
return true;
|
||||
}
|
||||
|
||||
const char *cbm_index_supervisor_build_fingerprint(void) {
|
||||
return g_build_fingerprint[0] ? g_build_fingerprint : NULL;
|
||||
}
|
||||
|
||||
static bool worker_fingerprint_valid(const char *fingerprint) {
|
||||
if (!fingerprint || strlen(fingerprint) != CBM_INDEX_WORKER_BUILD_FINGERPRINT_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < CBM_INDEX_WORKER_BUILD_FINGERPRINT_LENGTH; i++) {
|
||||
char ch = fingerprint[i];
|
||||
if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool worker_parse_positive_size(const char *text, size_t *value_out) {
|
||||
if (!text || !text[0] || !value_out) {
|
||||
return false;
|
||||
}
|
||||
size_t value = 0;
|
||||
for (const unsigned char *cursor = (const unsigned char *)text; *cursor; cursor++) {
|
||||
if (*cursor < '0' || *cursor > '9') {
|
||||
return false;
|
||||
}
|
||||
size_t digit = (size_t)(*cursor - '0');
|
||||
if (value > (SIZE_MAX - digit) / 10U) {
|
||||
return false;
|
||||
}
|
||||
value = value * 10U + digit;
|
||||
}
|
||||
if (value == 0) {
|
||||
return false;
|
||||
}
|
||||
*value_out = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
cbm_index_worker_argv_status_t
|
||||
cbm_index_worker_parse_process_argv(int argc, char *const argv[],
|
||||
cbm_index_worker_invocation_t *invocation_out) {
|
||||
if (invocation_out) {
|
||||
memset(invocation_out, 0, sizeof(*invocation_out));
|
||||
}
|
||||
bool contains_worker_role = false;
|
||||
for (int index = 1; argv && index < argc; index++) {
|
||||
if (argv[index] && strcmp(argv[index], "--index-worker") == 0) {
|
||||
contains_worker_role = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!contains_worker_role) {
|
||||
return CBM_INDEX_WORKER_ARGV_NOT_WORKER;
|
||||
}
|
||||
if (!invocation_out || argc < 9 || !argv || !argv[0] || !argv[0][0] ||
|
||||
!argv[1] || strcmp(argv[1], "cli") != 0 || !argv[2] ||
|
||||
strcmp(argv[2], "--index-worker") != 0 || !argv[3] ||
|
||||
strcmp(argv[3], CBM_INDEX_WORKER_BUILD_ARG) != 0 ||
|
||||
!worker_fingerprint_valid(argv[4]) || !argv[5] ||
|
||||
strcmp(argv[5], "index_repository") != 0 || !argv[6] || !argv[6][0] ||
|
||||
!argv[7] || strcmp(argv[7], "--response-out") != 0 || !argv[8] || !argv[8][0]) {
|
||||
return CBM_INDEX_WORKER_ARGV_INVALID;
|
||||
}
|
||||
|
||||
cbm_index_worker_invocation_t parsed = {
|
||||
.expected_build_fingerprint = argv[4],
|
||||
.args_json = argv[6],
|
||||
.response_out = argv[8],
|
||||
};
|
||||
int next = 9;
|
||||
if (next < argc && argv[next] &&
|
||||
strcmp(argv[next], CBM_INDEX_WORKER_MEMORY_BUDGET_ARG) == 0) {
|
||||
if (next + 1 >= argc ||
|
||||
!worker_parse_positive_size(argv[next + 1], &parsed.memory_budget_bytes)) {
|
||||
return CBM_INDEX_WORKER_ARGV_INVALID;
|
||||
}
|
||||
next += 2;
|
||||
}
|
||||
if (next < argc && argv[next] &&
|
||||
strcmp(argv[next], CBM_INDEX_WORKER_SINGLE_THREAD_ARG) == 0) {
|
||||
parsed.single_thread = true;
|
||||
next++;
|
||||
}
|
||||
if (next < argc && argv[next] && strcmp(argv[next], CBM_INDEX_WORKER_MARKER_ARG) == 0) {
|
||||
if (next + 1 >= argc || !argv[next + 1] || !argv[next + 1][0]) {
|
||||
return CBM_INDEX_WORKER_ARGV_INVALID;
|
||||
}
|
||||
parsed.marker_file = argv[next + 1];
|
||||
next += 2;
|
||||
}
|
||||
if (next < argc && argv[next] &&
|
||||
strcmp(argv[next], CBM_INDEX_WORKER_QUARANTINE_ARG) == 0) {
|
||||
if (next + 1 >= argc || !argv[next + 1] || !argv[next + 1][0]) {
|
||||
return CBM_INDEX_WORKER_ARGV_INVALID;
|
||||
}
|
||||
parsed.quarantine_file = argv[next + 1];
|
||||
next += 2;
|
||||
}
|
||||
if (next != argc) {
|
||||
return CBM_INDEX_WORKER_ARGV_INVALID;
|
||||
}
|
||||
if (!g_build_fingerprint[0]) {
|
||||
return CBM_INDEX_WORKER_ARGV_BUILD_UNAVAILABLE;
|
||||
}
|
||||
if (strcmp(parsed.expected_build_fingerprint, g_build_fingerprint) != 0) {
|
||||
return CBM_INDEX_WORKER_ARGV_BUILD_MISMATCH;
|
||||
}
|
||||
*invocation_out = parsed;
|
||||
return CBM_INDEX_WORKER_ARGV_VALID;
|
||||
}
|
||||
|
||||
const char *cbm_index_worker_argv_status_message(cbm_index_worker_argv_status_t status) {
|
||||
switch (status) {
|
||||
case CBM_INDEX_WORKER_ARGV_VALID:
|
||||
return "worker invocation accepted";
|
||||
case CBM_INDEX_WORKER_ARGV_INVALID:
|
||||
return "invalid internal worker arguments";
|
||||
case CBM_INDEX_WORKER_ARGV_BUILD_UNAVAILABLE:
|
||||
return "worker executable fingerprint could not be verified";
|
||||
case CBM_INDEX_WORKER_ARGV_BUILD_MISMATCH:
|
||||
return "worker executable build conflicts with its supervisor; close all CBM sessions "
|
||||
"and retry";
|
||||
case CBM_INDEX_WORKER_ARGV_NOT_WORKER:
|
||||
default:
|
||||
return "not an internal worker invocation";
|
||||
}
|
||||
}
|
||||
|
||||
/* Test hook (#845): counts worker-start ATTEMPTS, including ones that fail to
|
||||
* resolve the self binary — an embedder must never even try to spawn. */
|
||||
static atomic_int g_spawn_count = 0;
|
||||
|
||||
int cbm_index_supervisor_spawn_count(void) {
|
||||
return g_spawn_count;
|
||||
return atomic_load_explicit(&g_spawn_count, memory_order_relaxed);
|
||||
}
|
||||
|
||||
/* Test hook: counts SINGLE-THREADED spawns. Production recovery is parallel-
|
||||
* only (there are no sequential production runs); this must stay ZERO on
|
||||
* every supervised path — any nonzero count means a recovery/probe regressed
|
||||
* to the sequential crawl that ground an 81k-file TS corpus for hours. */
|
||||
static int g_spawn_st_count = 0;
|
||||
static atomic_int g_spawn_st_count = 0;
|
||||
|
||||
int cbm_index_supervisor_spawn_st_count(void) {
|
||||
return g_spawn_st_count;
|
||||
return atomic_load_explicit(&g_spawn_st_count, memory_order_relaxed);
|
||||
}
|
||||
|
||||
/* #845: opt-in host mark — see the header. Set once from the real binary's
|
||||
@@ -78,13 +260,18 @@ bool cbm_index_supervisor_should_wrap(void) {
|
||||
if (g_worker_active) {
|
||||
return false; /* I am the worker — run in-process, never re-supervise */
|
||||
}
|
||||
const char *sv = getenv("CBM_INDEX_SUPERVISOR");
|
||||
if (sv && strcmp(sv, "0") == 0) {
|
||||
return false; /* kill switch → in-process */
|
||||
}
|
||||
/* Supervision is a safety boundary for the physical CBM host. An ambient
|
||||
* variable must never turn a long-lived MCP/CLI parent into the indexer. */
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool supervisor_disable_requested(void) {
|
||||
char supervisor_setting[CBM_SZ_32] = {0};
|
||||
return cbm_safe_getenv("CBM_INDEX_SUPERVISOR", supervisor_setting,
|
||||
sizeof(supervisor_setting), NULL) &&
|
||||
strcmp(supervisor_setting, "0") == 0;
|
||||
}
|
||||
|
||||
/* Quiet-timeout (ms) for a supervised worker: killed + reported as a hang if it
|
||||
* emits no NEW log line within the window. This is a NO-PROGRESS timeout — every
|
||||
* completed log line the worker tails (per-batch parallel.extract.progress every
|
||||
@@ -94,9 +281,11 @@ bool cbm_index_supervisor_should_wrap(void) {
|
||||
* CBM_INDEX_WORKER_TIMEOUT_S override (seconds → ms) tightens it for tests. */
|
||||
static int worker_quiet_timeout_ms(void) {
|
||||
enum { DEFAULT_QUIET_TIMEOUT_MS = 900000 }; /* 15 min with no progress */
|
||||
const char *e = getenv("CBM_INDEX_WORKER_TIMEOUT_S");
|
||||
if (e && e[0]) {
|
||||
long s = atol(e);
|
||||
char timeout_seconds[CBM_SZ_32] = {0};
|
||||
if (cbm_safe_getenv("CBM_INDEX_WORKER_TIMEOUT_S", timeout_seconds, sizeof(timeout_seconds),
|
||||
NULL) &&
|
||||
timeout_seconds[0]) {
|
||||
long s = atol(timeout_seconds);
|
||||
if (s > 0) {
|
||||
return (int)(s * 1000);
|
||||
}
|
||||
@@ -104,8 +293,17 @@ static int worker_quiet_timeout_ms(void) {
|
||||
return DEFAULT_QUIET_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/* Read an entire file into a heap string (NUL-terminated). NULL on error. */
|
||||
static char *slurp_file(const char *path) {
|
||||
typedef enum {
|
||||
WORKER_RESPONSE_READ_OK = 0,
|
||||
WORKER_RESPONSE_READ_ERROR,
|
||||
WORKER_RESPONSE_READ_TOO_LARGE,
|
||||
} worker_response_read_status_t;
|
||||
|
||||
/* Read at most one daemon-application payload. The worker is already reaped,
|
||||
* but the byte count remains an explicit allocation/read boundary even if an
|
||||
* escaped process still has the response file open. */
|
||||
static char *slurp_worker_response(const char *path, worker_response_read_status_t *status_out) {
|
||||
*status_out = WORKER_RESPONSE_READ_ERROR;
|
||||
FILE *f = cbm_fopen(path, "rb");
|
||||
if (!f) {
|
||||
return NULL;
|
||||
@@ -119,153 +317,499 @@ 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 <cache_dir>/logs/.worker-<pid><suffix>. */
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
static bool worker_unique_file(char *out, size_t out_size, const char *kind) {
|
||||
if (!out || out_size == 0 || !kind || !kind[0]) {
|
||||
return false;
|
||||
}
|
||||
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 (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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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]) {
|
||||
argv[argc++] = CBM_INDEX_WORKER_MARKER_ARG;
|
||||
argv[argc++] = marker_file;
|
||||
}
|
||||
if (quarantine_file && quarantine_file[0]) {
|
||||
argv[argc++] = CBM_INDEX_WORKER_QUARANTINE_ARG;
|
||||
argv[argc++] = quarantine_file;
|
||||
}
|
||||
argv[argc] = NULL;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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 */
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (marker_file && marker_file[0]) {
|
||||
cbm_setenv("CBM_INDEX_MARKER_FILE", marker_file, 1);
|
||||
}
|
||||
if (quarantine_file && quarantine_file[0]) {
|
||||
cbm_setenv("CBM_INDEX_QUARANTINE_FILE", quarantine_file, 1);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
cbm_proc_result_t r;
|
||||
int run_rc = cbm_subprocess_run(&opts, &r);
|
||||
|
||||
if (single_thread) {
|
||||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||||
}
|
||||
if (marker_file && marker_file[0]) {
|
||||
cbm_unsetenv("CBM_INDEX_MARKER_FILE");
|
||||
}
|
||||
if (quarantine_file && quarantine_file[0]) {
|
||||
cbm_unsetenv("CBM_INDEX_QUARANTINE_FILE");
|
||||
}
|
||||
|
||||
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");
|
||||
return -1;
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
cbm_log_warn("index.supervisor.worker_failed", "outcome", cbm_proc_outcome_str(r.outcome),
|
||||
"exit_code", exit_buf, "log", log_path);
|
||||
}
|
||||
return 0;
|
||||
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) {
|
||||
|
||||
+138
-22
@@ -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 <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdatomic.h>
|
||||
|
||||
#include "foundation/subprocess.h" /* cbm_proc_outcome_t */
|
||||
|
||||
@@ -29,8 +31,56 @@
|
||||
* (the gate must NOT re-supervise). response_out (may be NULL) is the file the
|
||||
* worker writes its final result string to, for the parent to read back. */
|
||||
void cbm_index_set_worker_role(bool is_worker, const char *response_out);
|
||||
|
||||
/* Hidden recovery values arrive on the worker argv, then are installed as
|
||||
* worker-local environment only after exec. The supervisor process must never
|
||||
* mutate these process-wide variables around a concurrent spawn. */
|
||||
#define CBM_INDEX_WORKER_SINGLE_THREAD_ARG "--index-worker-single-thread"
|
||||
#define CBM_INDEX_WORKER_MARKER_ARG "--index-worker-marker"
|
||||
#define CBM_INDEX_WORKER_QUARANTINE_ARG "--index-worker-quarantine"
|
||||
#define CBM_INDEX_WORKER_MEMORY_BUDGET_ARG "--index-worker-memory-budget-bytes"
|
||||
#define CBM_INDEX_WORKER_BUILD_ARG "--index-worker-build"
|
||||
#define CBM_INDEX_WORKER_BUILD_FINGERPRINT_LENGTH 64U
|
||||
#define CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE 65U
|
||||
void cbm_index_set_worker_role_options(bool is_worker, const char *response_out, bool single_thread,
|
||||
const char *marker_file, const char *quarantine_file,
|
||||
size_t memory_budget_bytes);
|
||||
bool cbm_index_worker_active(void);
|
||||
const char *cbm_index_worker_response_out(void);
|
||||
size_t cbm_index_worker_memory_budget_bytes(void);
|
||||
|
||||
/* Capture the exact executable-image fingerprint once, during process startup
|
||||
* before any worker can be launched. Repeated calls return the original capture
|
||||
* and never re-hash a pathname that an installer may since have replaced. */
|
||||
bool cbm_index_supervisor_capture_build_fingerprint(void);
|
||||
const char *cbm_index_supervisor_build_fingerprint(void);
|
||||
|
||||
typedef struct {
|
||||
const char *expected_build_fingerprint;
|
||||
const char *args_json;
|
||||
const char *response_out;
|
||||
bool single_thread;
|
||||
const char *marker_file;
|
||||
const char *quarantine_file;
|
||||
size_t memory_budget_bytes;
|
||||
} cbm_index_worker_invocation_t;
|
||||
|
||||
typedef enum {
|
||||
CBM_INDEX_WORKER_ARGV_NOT_WORKER = 0,
|
||||
CBM_INDEX_WORKER_ARGV_VALID,
|
||||
CBM_INDEX_WORKER_ARGV_INVALID,
|
||||
CBM_INDEX_WORKER_ARGV_BUILD_UNAVAILABLE,
|
||||
CBM_INDEX_WORKER_ARGV_BUILD_MISMATCH,
|
||||
} cbm_index_worker_argv_status_t;
|
||||
|
||||
/* Parse the one internal worker grammar. Any argv containing --index-worker
|
||||
* outside this exact shape is INVALID, never an ordinary CLI request. A valid
|
||||
* shape is admitted only when its expected fingerprint matches the image
|
||||
* captured by this process before stateful initialization. */
|
||||
cbm_index_worker_argv_status_t
|
||||
cbm_index_worker_parse_process_argv(int argc, char *const argv[],
|
||||
cbm_index_worker_invocation_t *invocation_out);
|
||||
const char *cbm_index_worker_argv_status_message(cbm_index_worker_argv_status_t status);
|
||||
|
||||
/* Host marking (#845): the supervisor gate is OPT-IN per process. Only the real
|
||||
* codebase-memory-mcp binary calls this (first thing in main(), before any
|
||||
@@ -43,15 +93,16 @@ const char *cbm_index_worker_response_out(void);
|
||||
* VM-map pressure during the 2026-07-04 host panics). */
|
||||
void cbm_index_supervisor_mark_host(void);
|
||||
|
||||
/* True when handle_index_repository should wrap the run in a supervised child:
|
||||
/* True when handle_index_repository must wrap the run in a supervised child:
|
||||
* this process called cbm_index_supervisor_mark_host() (i.e. it IS the real
|
||||
* binary, not an embedder), is not itself a worker, AND the kill switch
|
||||
* (CBM_INDEX_SUPERVISOR=0) is not set. */
|
||||
* binary, not an embedder) and is not itself a worker. Supervision is mandatory
|
||||
* for a marked host; ambient configuration cannot select in-process indexing. */
|
||||
bool cbm_index_supervisor_should_wrap(void);
|
||||
|
||||
/* TEST HOOK (#845): process-wide count of worker-spawn attempts, incremented on
|
||||
* entry to cbm_index_spawn_worker. Embedder tests assert the count is unchanged
|
||||
* across an index_repository call to prove indexing ran IN-PROCESS. */
|
||||
/* TEST HOOK (#845): process-wide count of worker-start attempts, incremented on
|
||||
* entry to cbm_index_worker_start (including calls made by the synchronous
|
||||
* wrapper). Embedder tests assert the count is unchanged across an
|
||||
* index_repository call to prove indexing ran IN-PROCESS. */
|
||||
int cbm_index_supervisor_spawn_count(void);
|
||||
|
||||
/* Test hook: single-threaded spawn count — must stay ZERO (production
|
||||
@@ -62,29 +113,94 @@ typedef struct {
|
||||
cbm_proc_outcome_t outcome; /* how the worker ended */
|
||||
int exit_code; /* worker exit code (-1 if signalled) */
|
||||
int term_signal; /* POSIX terminating signal, else 0 */
|
||||
char *response; /* worker's result string on CLEAN exit (caller frees); else NULL */
|
||||
bool cancellation_requested;
|
||||
bool forced;
|
||||
bool tree_quiesced;
|
||||
bool supervision_failed;
|
||||
bool response_rejected; /* clean worker exceeded the bounded response protocol */
|
||||
char *response; /* worker result only after a contained, uncancelled CLEAN exit;
|
||||
* borrowed for async polls, caller-owned from the sync wrapper */
|
||||
} cbm_index_worker_result_t;
|
||||
|
||||
/* Spawn `<self> cli --index-worker index_repository <args_json> --response-out <tmp>`,
|
||||
/* 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 `<self> cli --index-worker --index-worker-build <sha256>
|
||||
* index_repository <args_json> --response-out <tmp>`,
|
||||
* 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 */
|
||||
|
||||
+1158
-146
File diff suppressed because it is too large
Load Diff
+82
-4
@@ -11,6 +11,8 @@
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "mcp/index_supervisor.h"
|
||||
|
||||
/* ── Forward declarations ─────────────────────────────────────── */
|
||||
|
||||
typedef struct cbm_store cbm_store_t; /* from store/store.h */
|
||||
@@ -108,6 +110,21 @@ int cbm_mcp_parse_tool_profile_args(int argc, const char *const argv[const],
|
||||
/* Restricted servers must not start a second unrestricted HTTP/RPC surface. */
|
||||
bool cbm_mcp_tool_profile_allows_http(cbm_mcp_tool_profile_t profile);
|
||||
|
||||
/* Optional daemon-owned physical index executor. repo_path is canonical and
|
||||
* already authorized against this session; args_json contains that canonical
|
||||
* path plus all caller options. The callback returns a complete malloc-owned
|
||||
* MCP tool result. A NULL result is reported as an error and never degrades to
|
||||
* an uncoordinated in-process index. */
|
||||
typedef char *(*cbm_mcp_index_executor_fn)(void *context, const char *repo_path,
|
||||
const char *args_json);
|
||||
|
||||
/* Daemon-owned exclusive lease for operations that mutate a published project
|
||||
* database. begin may wait, but must remain cancellable by its session owner;
|
||||
* every successful begin is paired with exactly one end. Standalone/worker
|
||||
* servers leave these callbacks unset. */
|
||||
typedef bool (*cbm_mcp_project_mutation_begin_fn)(void *context, const char *project);
|
||||
typedef void (*cbm_mcp_project_mutation_end_fn)(void *context, const char *project);
|
||||
|
||||
/* Create an MCP server. store_path is the SQLite database directory. */
|
||||
cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path);
|
||||
|
||||
@@ -123,6 +140,46 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w);
|
||||
/* Set external config store reference (for auto_index setting). Not owned. */
|
||||
void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg);
|
||||
|
||||
/* Set an explicit session context for an embedded/daemon-backed server.
|
||||
* session_root is copied and its project name is derived using the same naming
|
||||
* rule as indexing. allowed_root is copied when non-NULL; an explicit NULL
|
||||
* disables the workspace boundary for this server instead of falling back to
|
||||
* CBM_ALLOWED_ROOT. Returns false when the context is invalid or cannot be
|
||||
* copied. */
|
||||
bool cbm_mcp_server_set_session_context(cbm_mcp_server_t *srv, const char *session_root,
|
||||
const char *allowed_root);
|
||||
|
||||
/* Read-only session context accessors. Returned strings are owned by srv. */
|
||||
const char *cbm_mcp_server_session_root(const cbm_mcp_server_t *srv);
|
||||
const char *cbm_mcp_server_session_project(const cbm_mcp_server_t *srv);
|
||||
const char *cbm_mcp_server_allowed_root(const cbm_mcp_server_t *srv);
|
||||
|
||||
/* Enable/disable per-server update checks and automatic indexing. Enabled by
|
||||
* default for standalone servers; daemon sessions disable these so the shared
|
||||
* coordinator owns background work. */
|
||||
void cbm_mcp_server_set_background_tasks(cbm_mcp_server_t *srv, bool enabled);
|
||||
|
||||
void cbm_mcp_server_set_index_executor(cbm_mcp_server_t *srv,
|
||||
cbm_mcp_index_executor_fn executor,
|
||||
void *context);
|
||||
|
||||
/* Relay supervised worker logs to one local request (for CLI progress). The
|
||||
* callback/context are borrowed until the synchronous tool call returns. */
|
||||
void cbm_mcp_server_set_index_log_callback(cbm_mcp_server_t *srv,
|
||||
cbm_proc_log_cb callback,
|
||||
void *context);
|
||||
|
||||
void cbm_mcp_server_set_project_mutation_guard(
|
||||
cbm_mcp_server_t *srv, cbm_mcp_project_mutation_begin_fn begin,
|
||||
cbm_mcp_project_mutation_end_fn end, void *context);
|
||||
|
||||
/* Read one complete MCP message from in. Supports newline-delimited JSON and
|
||||
* Content-Length framing, including additional headers. Returns 1 on success,
|
||||
* 0 on clean EOF, and -1 on invalid input or an I/O/allocation error. On
|
||||
* success, *message is heap-allocated and *content_length_framed identifies the
|
||||
* transport framing used by the request. */
|
||||
int cbm_mcp_read_message(FILE *in, char **message, bool *content_length_framed);
|
||||
|
||||
/* Run the MCP server event loop on the given streams (typically stdin/stdout).
|
||||
* Blocks until EOF on input. Returns 0 on success, -1 on error. */
|
||||
int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out);
|
||||
@@ -138,14 +195,30 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch
|
||||
|
||||
/* ── Supervised background index (RSS isolation, #832) ────────── */
|
||||
|
||||
/* One shared policy gate for supervised-index callers. Only an explicitly safe
|
||||
* terminal can yield a result or enter crash/hang recovery; cancellation,
|
||||
* failed tree containment, and unavailable worker startup are terminal. The
|
||||
* FALLBACK disposition is an internal classification used to build an explicit
|
||||
* error response, never permission for a marked host to index in-process.
|
||||
* Exposed so the security boundary is directly regression-tested. */
|
||||
typedef enum {
|
||||
CBM_MCP_SUPERVISED_RESULT_FALLBACK = 0,
|
||||
CBM_MCP_SUPERVISED_RESULT_SUCCESS,
|
||||
CBM_MCP_SUPERVISED_RESULT_CONTAINED_FAILURE,
|
||||
CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL,
|
||||
} cbm_mcp_supervised_result_disposition_t;
|
||||
|
||||
cbm_mcp_supervised_result_disposition_t cbm_mcp_supervised_result_disposition(
|
||||
int spawn_result, const cbm_index_worker_result_t *worker_result);
|
||||
|
||||
/* Run a full index of root_path in a supervised worker SUBPROCESS (the same
|
||||
* crash/hang-isolating runner used by handle_index_repository), so the child
|
||||
* returns 100% of its RSS to the OS on exit instead of ratcheting the long-lived
|
||||
* parent. Builds {"repo_path": root_path} internally. Returns the worker's
|
||||
* response string (caller frees) on success, or NULL to signal the caller must
|
||||
* degrade to the in-process path (kill switch set, spawn failure, or the process
|
||||
* is not a supervisor host). This is the shared entry the watcher re-index
|
||||
* (main.c) and the session auto-index (mcp.c) route through. */
|
||||
* response or an explicit contained-error response (caller frees). NULL is
|
||||
* reserved for invalid input/request construction failure; a marked host must
|
||||
* stop rather than use it as permission to index in-process. This is the shared
|
||||
* entry the session auto-index routes through. */
|
||||
char *cbm_mcp_index_run_supervised_path(const char *root_path);
|
||||
|
||||
/* ── Idle store eviction ──────────────────────────────────────── */
|
||||
@@ -175,6 +248,11 @@ struct cbm_pipeline; /* forward decl */
|
||||
* Returns NULL if no pipeline is running. */
|
||||
struct cbm_pipeline *cbm_mcp_server_active_pipeline(cbm_mcp_server_t *srv);
|
||||
|
||||
/* Thread-safe cancellation for daemon connection teardown. The active
|
||||
* pipeline pointer is protected until cbm_pipeline_cancel() has recorded its
|
||||
* atomic request, so the request thread cannot clear/free it concurrently. */
|
||||
bool cbm_mcp_server_cancel_active(cbm_mcp_server_t *srv);
|
||||
|
||||
/* ── URI helpers ───────────────────────────────────────────────── */
|
||||
|
||||
/* Parse a file:// URI and extract the filesystem path.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#ifndef CBM_MCP_INTERNAL_H
|
||||
#define CBM_MCP_INTERNAL_H
|
||||
|
||||
#include "mcp/mcp.h"
|
||||
|
||||
/* White-box fault injection for deterministic cross-platform quarantine
|
||||
* safety tests. This header is internal and is not part of the MCP API. */
|
||||
typedef bool (*cbm_mcp_quarantine_test_hook_fn)(void *context, const char *step);
|
||||
|
||||
void cbm_mcp_server_set_quarantine_test_hook(cbm_mcp_server_t *srv,
|
||||
cbm_mcp_quarantine_test_hook_fn hook,
|
||||
void *context);
|
||||
|
||||
/* Release only the constructor-created pristine in-memory store. Public
|
||||
* cbm_mcp_server_new(NULL) semantics remain unchanged; daemon sessions use
|
||||
* this immediately before publication so idle sessions retain no SQLite DB. */
|
||||
bool cbm_mcp_server_release_pristine_memory_store(cbm_mcp_server_t *srv);
|
||||
|
||||
#endif
|
||||
+709
-202
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@
|
||||
|
||||
#include "store/store.h"
|
||||
|
||||
#include <stdatomic.h>
|
||||
|
||||
/* 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 */
|
||||
|
||||
+338
-49
@@ -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 <errno.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -81,7 +82,8 @@ struct cbm_pipeline {
|
||||
cbm_git_context_t git_ctx;
|
||||
char *branch_qn;
|
||||
cbm_index_mode_t mode;
|
||||
atomic_int cancelled;
|
||||
atomic_int cancelled_storage;
|
||||
atomic_int *cancelled;
|
||||
bool persistence; /* write .codebase-memory/graph.db.zst after indexing */
|
||||
|
||||
/* Indexing state (set during run) */
|
||||
@@ -120,6 +122,13 @@ struct cbm_pipeline {
|
||||
/* ADR (project_summaries) captured before a full-reindex DB delete, so it
|
||||
* can be restored after the rebuild. NULL when no ADR existed. Issue #516. */
|
||||
char *saved_adr;
|
||||
|
||||
/* Deterministic test-only seam at the final publication boundary. Kept
|
||||
* per pipeline so concurrent test/process activity cannot cross-trigger. */
|
||||
void (*before_publish_hook)(cbm_pipeline_t *, const char *, void *);
|
||||
void *before_publish_hook_ctx;
|
||||
int (*rename_hook)(const char *, const char *, void *);
|
||||
void *rename_hook_ctx;
|
||||
};
|
||||
|
||||
/* ── Global pkgmap (one active pipeline at a time) ─────────────── */
|
||||
@@ -183,7 +192,8 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path,
|
||||
p->persistence = false;
|
||||
p->committed_nodes = -1;
|
||||
p->committed_edges = -1;
|
||||
atomic_init(&p->cancelled, 0);
|
||||
atomic_init(&p->cancelled_storage, 0);
|
||||
p->cancelled = &p->cancelled_storage;
|
||||
|
||||
return p;
|
||||
}
|
||||
@@ -254,8 +264,30 @@ void cbm_pipeline_free(cbm_pipeline_t *p) {
|
||||
}
|
||||
|
||||
void cbm_pipeline_cancel(cbm_pipeline_t *p) {
|
||||
if (p && p->cancelled) {
|
||||
atomic_store(p->cancelled, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void cbm_pipeline_bind_cancel_flag(cbm_pipeline_t *p, atomic_int *cancelled) {
|
||||
if (p && cancelled) {
|
||||
p->cancelled = cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
void cbm_pipeline_set_before_publish_hook_for_tests(
|
||||
cbm_pipeline_t *p, void (*hook)(cbm_pipeline_t *, const char *, void *), void *ctx) {
|
||||
if (p) {
|
||||
atomic_store(&p->cancelled, 1);
|
||||
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) {
|
||||
p->rename_hook = hook;
|
||||
p->rename_hook_ctx = ctx;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +300,7 @@ const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p) {
|
||||
}
|
||||
|
||||
atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p) {
|
||||
return p ? &p->cancelled : NULL;
|
||||
return p ? p->cancelled : NULL;
|
||||
}
|
||||
|
||||
int cbm_pipeline_get_mode(const cbm_pipeline_t *p) {
|
||||
@@ -373,20 +405,42 @@ static int effective_worker_count(bool initial) {
|
||||
|
||||
/* Resolve the DB path for this pipeline. Caller must free(). */
|
||||
static char *resolve_db_path(const cbm_pipeline_t *p) {
|
||||
char *path = malloc(CBM_SZ_1K);
|
||||
if (!path) {
|
||||
if (!p) {
|
||||
return NULL;
|
||||
}
|
||||
if (p->db_path) {
|
||||
snprintf(path, 1024, "%s", p->db_path);
|
||||
} else {
|
||||
snprintf(path, 1024, "%s/%s.db", cbm_resolve_cache_dir(), p->project_name);
|
||||
return strdup(p->db_path);
|
||||
}
|
||||
|
||||
const char *cache_dir = cbm_resolve_cache_dir();
|
||||
cache_dir = cache_dir ? cache_dir : cbm_tmpdir();
|
||||
if (!cache_dir || !p->project_name) {
|
||||
return NULL;
|
||||
}
|
||||
size_t cache_len = strlen(cache_dir);
|
||||
size_t project_len = strlen(p->project_name);
|
||||
if (project_len > SIZE_MAX - cache_len) {
|
||||
return NULL;
|
||||
}
|
||||
size_t stem_len = cache_len + project_len;
|
||||
if (stem_len > SIZE_MAX - sizeof("/.db")) {
|
||||
return NULL;
|
||||
}
|
||||
size_t path_size = stem_len + sizeof("/.db");
|
||||
char *path = malloc(path_size);
|
||||
if (!path) {
|
||||
return NULL;
|
||||
}
|
||||
int n = snprintf(path, path_size, "%s/%s.db", cache_dir, p->project_name);
|
||||
if (n < 0 || (size_t)n >= path_size) {
|
||||
free(path);
|
||||
return NULL;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
static int check_cancel(const cbm_pipeline_t *p) {
|
||||
return atomic_load(&p->cancelled) ? CBM_NOT_FOUND : 0;
|
||||
return atomic_load(p->cancelled) ? CBM_NOT_FOUND : 0;
|
||||
}
|
||||
|
||||
/* ── Hash table cleanup callback ─────────────────────────────────── */
|
||||
@@ -1150,13 +1204,8 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file
|
||||
cbm_store_close(adr_store);
|
||||
}
|
||||
}
|
||||
cbm_unlink(db_path);
|
||||
char wal[PL_WAL_BUF];
|
||||
char shm[PL_WAL_BUF];
|
||||
snprintf(wal, sizeof(wal), "%s-wal", db_path);
|
||||
snprintf(shm, sizeof(shm), "%s-shm", db_path);
|
||||
cbm_unlink(wal);
|
||||
cbm_unlink(shm);
|
||||
(void)cbm_unlink(db_path);
|
||||
(void)cbm_remove_db_sidecars(db_path);
|
||||
free(db_path);
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
@@ -1190,23 +1239,27 @@ static const char *pipeline_mode_name(cbm_index_mode_t mode) {
|
||||
static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *files, int file_count,
|
||||
struct timespec *t) {
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, t);
|
||||
char db_path[CBM_SZ_1K];
|
||||
if (p->db_path) {
|
||||
snprintf(db_path, sizeof(db_path), "%s", p->db_path);
|
||||
} else {
|
||||
const char *cdir = cbm_resolve_cache_dir();
|
||||
if (!cdir) {
|
||||
cdir = cbm_tmpdir();
|
||||
}
|
||||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cdir, p->project_name);
|
||||
char *db_path = resolve_db_path(p);
|
||||
if (!db_path) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
char *db_dir = strdup(db_path);
|
||||
if (!db_dir) {
|
||||
free(db_path);
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
char db_dir[CBM_SZ_1K];
|
||||
snprintf(db_dir, sizeof(db_dir), "%s", db_path);
|
||||
char *last_slash = strrchr(db_dir, '/');
|
||||
#ifdef _WIN32
|
||||
char *last_backslash = strrchr(db_dir, '\\');
|
||||
if (last_backslash && (!last_slash || last_backslash > last_slash)) {
|
||||
last_slash = last_backslash;
|
||||
}
|
||||
#endif
|
||||
if (last_slash) {
|
||||
*last_slash = '\0';
|
||||
cbm_mkdir_p(db_dir, CBM_DIR_PERMS);
|
||||
}
|
||||
free(db_dir);
|
||||
/* Capture committed counts BEFORE the dump. cbm_gbuf_dump_to_sqlite calls
|
||||
* release_gbuf_indexes(), which frees node_by_qn (graph_buffer.c), after
|
||||
* which cbm_gbuf_node_count() returns 0. Reading these post-dump left
|
||||
@@ -1216,6 +1269,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil
|
||||
int rc = cbm_gbuf_dump_to_sqlite(p->gbuf, db_path);
|
||||
if (rc != 0) {
|
||||
cbm_log_error("pipeline.err", "phase", "dump");
|
||||
free(db_path);
|
||||
return rc;
|
||||
}
|
||||
cbm_log_info("pass.timing", "pass", "dump", "elapsed_ms", itoa_buf((int)elapsed_ms(*t)));
|
||||
@@ -1224,8 +1278,13 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil
|
||||
CBM_PROF_START(t_reopen);
|
||||
cbm_store_t *hash_store = cbm_store_open_path(db_path);
|
||||
CBM_PROF_END("persist", "1_reopen", t_reopen);
|
||||
if (hash_store) {
|
||||
bool hash_records_complete = true;
|
||||
if (!hash_store) {
|
||||
cbm_log_error("pipeline.err", "phase", "persist_open", "path", db_path);
|
||||
free(db_path);
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
bool hash_records_complete = true;
|
||||
{
|
||||
CBM_PROF_START(t_delhash);
|
||||
if (cbm_store_delete_file_hashes(hash_store, p->project_name) != CBM_STORE_OK) {
|
||||
hash_records_complete = false;
|
||||
@@ -1376,19 +1435,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil
|
||||
}
|
||||
free(p->saved_adr);
|
||||
p->saved_adr = NULL;
|
||||
|
||||
/* Export persistent artifact if enabled */
|
||||
if (p->persistence) {
|
||||
CBM_PROF_START(t_art);
|
||||
int arc = cbm_artifact_export(db_path, p->repo_path, p->project_name, CBM_ARTIFACT_BEST);
|
||||
CBM_PROF_END("persist", "6_artifact_export", t_art);
|
||||
if (arc != 0) {
|
||||
const char *err = cbm_artifact_export_last_error();
|
||||
cbm_log_error("pipeline.err", "phase", "artifact_export", "err", err ? err : "unknown");
|
||||
/* A failed persistence export intentionally fails the run; this used to be ignored. */
|
||||
return arc;
|
||||
}
|
||||
}
|
||||
free(db_path);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1505,10 +1552,11 @@ static int run_extraction_phase(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx,
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
static int cbm_pipeline_run_staged(cbm_pipeline_t *p, bool *was_incremental) {
|
||||
if (!p) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
*was_incremental = false;
|
||||
|
||||
CBM_PROF_START(t_pipeline_total);
|
||||
struct timespec t0;
|
||||
@@ -1562,9 +1610,12 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
|
||||
/* Check for existing DB → try incremental or delete for reindex */
|
||||
rc = try_incremental_or_delete_db(p, files, file_count);
|
||||
if (rc == CBM_PIPELINE_ABORT_PRESERVE_DB || rc >= 0) {
|
||||
cbm_discover_free(files, file_count);
|
||||
return rc;
|
||||
if (rc == CBM_PIPELINE_ABORT_PRESERVE_DB) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (rc >= 0) {
|
||||
*was_incremental = true;
|
||||
goto cleanup;
|
||||
}
|
||||
cbm_log_info("pipeline.route", "path", "full");
|
||||
|
||||
@@ -1583,7 +1634,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
.repo_path = p->repo_path,
|
||||
.gbuf = p->gbuf,
|
||||
.registry = p->registry,
|
||||
.cancelled = &p->cancelled,
|
||||
.cancelled = p->cancelled,
|
||||
.pipeline = p, /* so passes can record per-file skips (Track B) */
|
||||
.mode = (int)p->mode,
|
||||
.path_aliases = path_aliases,
|
||||
@@ -1621,3 +1672,241 @@ cleanup:
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
|
||||
#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);
|
||||
|
||||
@@ -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 <stdio.h>
|
||||
#include <time.h>
|
||||
#include "pipeline/pipeline_internal.h"
|
||||
@@ -449,18 +448,12 @@ static void incr_free_edge_capture(cbm_edge_capture_t *cap) {
|
||||
/* Persist file hash rows for the current discovery and any mode-skipped
|
||||
* files preserved from the previous DB.
|
||||
*
|
||||
* Partial-failure policy: an `upsert` failure on any single row is logged
|
||||
* as a warning and the loop continues. We deliberately do NOT abort the
|
||||
* whole reindex on a single bad row — partial preservation is better than
|
||||
* total loss, and a transient failure on one file should not invalidate
|
||||
* the entire incremental update. The trade-off is that a silently-failed
|
||||
* row produces the same downstream effect as if the file were never
|
||||
* indexed at all (forced re-parse on the next run for current-files,
|
||||
* potential orphaned-node revival for mode_skipped). The warning surface
|
||||
* is the only signal that something went wrong. */
|
||||
static bool persist_hashes(cbm_store_t *store, const char *project, cbm_file_info_t *files,
|
||||
int file_count, const cbm_file_hash_t *mode_skipped,
|
||||
int mode_skipped_count) {
|
||||
* Every row is attempted so logs identify all failures, but any failed
|
||||
* upsert rejects the staging generation. Atomic publication makes preserving
|
||||
* the complete previous generation safer than installing partial metadata. */
|
||||
static int persist_hashes(cbm_store_t *store, const char *project, cbm_file_info_t *files,
|
||||
int file_count, const cbm_file_hash_t *mode_skipped,
|
||||
int mode_skipped_count) {
|
||||
int current_failed = 0;
|
||||
int ms_failed = 0;
|
||||
|
||||
@@ -511,8 +504,9 @@ static bool persist_hashes(cbm_store_t *store, const char *project, cbm_file_inf
|
||||
if (current_failed > 0 || ms_failed > 0) {
|
||||
cbm_log_warn("incremental.persist_summary", "current_failed", itoa_buf(current_failed),
|
||||
"mode_skipped_failed", itoa_buf(ms_failed));
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
return current_failed == 0 && ms_failed == 0;
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
/* ── Registry seed visitor ────────────────────────────────────────── */
|
||||
@@ -643,68 +637,72 @@ static void run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_fil
|
||||
* Mode-skipped hash rows are preserved across the rebuild so subsequent
|
||||
* reindexes can correctly distinguish "never indexed" from "indexed but
|
||||
* not visited this pass". */
|
||||
static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project,
|
||||
cbm_file_info_t *files, int file_count,
|
||||
const cbm_file_hash_t *mode_skipped, int mode_skipped_count,
|
||||
const char *repo_path, const cbm_coverage_row_t *cov, int cov_count,
|
||||
const cbm_coverage_meta_t *meta_template) {
|
||||
static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project,
|
||||
cbm_file_info_t *files, int file_count,
|
||||
const cbm_file_hash_t *mode_skipped, int mode_skipped_count,
|
||||
const cbm_coverage_row_t *cov, int cov_count,
|
||||
const cbm_coverage_meta_t *meta_template) {
|
||||
struct timespec t;
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
|
||||
cbm_unlink(db_path);
|
||||
char wal[INCR_WAL_BUF];
|
||||
char shm[INCR_WAL_BUF];
|
||||
snprintf(wal, sizeof(wal), "%s-wal", db_path);
|
||||
snprintf(shm, sizeof(shm), "%s-shm", db_path);
|
||||
cbm_unlink(wal);
|
||||
cbm_unlink(shm);
|
||||
if ((cbm_unlink(db_path) != 0 && errno != ENOENT) || cbm_remove_db_sidecars(db_path) != 0) {
|
||||
cbm_log_error("incremental.err", "msg", "clear_staging_failed", "path", db_path);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
int dump_rc = cbm_gbuf_dump_to_sqlite(gbuf, db_path);
|
||||
cbm_log_info("incremental.dump", "rc", itoa_buf(dump_rc), "elapsed_ms",
|
||||
itoa_buf((int)elapsed_ms(t)));
|
||||
if (dump_rc != 0) {
|
||||
return dump_rc;
|
||||
}
|
||||
|
||||
cbm_store_t *hash_store = cbm_store_open_path(db_path);
|
||||
if (hash_store) {
|
||||
bool hash_records_complete = persist_hashes(hash_store, project, files, file_count,
|
||||
mode_skipped, mode_skipped_count);
|
||||
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 = 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);
|
||||
}
|
||||
/* 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. */
|
||||
cbm_store_exec(hash_store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');");
|
||||
/* FTS5 rebuild after incremental dump. The btree dump path bypasses
|
||||
* any triggers that could have kept nodes_fts synchronized, so we
|
||||
* rebuild from the nodes table here. See the full-dump path in
|
||||
* pipeline.c for the matching logic. */
|
||||
if (cbm_store_exec(hash_store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');") !=
|
||||
CBM_STORE_OK) {
|
||||
rc = CBM_STORE_ERR;
|
||||
}
|
||||
if (cbm_store_exec(hash_store,
|
||||
"INSERT INTO nodes_fts(rowid, name, qualified_name, label, file_path) "
|
||||
"SELECT id, cbm_camel_split(name), qualified_name, label, file_path "
|
||||
"FROM nodes;") != CBM_STORE_OK) {
|
||||
if (cbm_store_exec(hash_store,
|
||||
"INSERT INTO nodes_fts(rowid, name, qualified_name, label, file_path) "
|
||||
"SELECT id, cbm_camel_split(name), qualified_name, label, file_path "
|
||||
"FROM nodes;") != CBM_STORE_OK) {
|
||||
cbm_store_exec(hash_store,
|
||||
"INSERT INTO nodes_fts(rowid, name, qualified_name, label, file_path) "
|
||||
"SELECT id, name, qualified_name, label, file_path FROM nodes;");
|
||||
"SELECT id, name, qualified_name, label, file_path FROM nodes;") !=
|
||||
CBM_STORE_OK) {
|
||||
rc = CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
cbm_store_close(hash_store);
|
||||
}
|
||||
|
||||
/* Auto-update artifact if one already exists (persistence was enabled previously) */
|
||||
if (repo_path && cbm_artifact_exists(repo_path)) {
|
||||
cbm_artifact_export(db_path, repo_path, project, CBM_ARTIFACT_FAST);
|
||||
}
|
||||
cbm_store_close(hash_store);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* ── Incremental pipeline entry point ────────────────────────────── */
|
||||
@@ -990,10 +988,9 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
itoa_buf(edge_cap.count), "elapsed_ms", itoa_buf((int)elapsed_ms(t)));
|
||||
incr_free_edge_capture(&edge_cap);
|
||||
|
||||
/* Step 7: Dump to disk (preserves mode-skipped hash rows so the next
|
||||
/* Step 7: Dump to staging (preserves mode-skipped hash rows so the next
|
||||
* reindex can correctly classify those files instead of seeing them
|
||||
* as never-existed; also exports a fast-mode artifact when one is
|
||||
* already present alongside the repo). */
|
||||
* as never-existed). Artifact refresh happens only after publication. */
|
||||
/* Record committed counts before dump_and_persist (whose dump frees the
|
||||
* gbuf node index, zeroing the count) so the #334 plausibility gate also
|
||||
* covers incremental reindexes, not just full ones. */
|
||||
@@ -1010,13 +1007,13 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
.ignored_files_total = run_ignored_total,
|
||||
.coverage_version = 1,
|
||||
};
|
||||
dump_and_persist(existing, db_path, project, files, file_count, mode_skipped,
|
||||
mode_skipped_count, cbm_pipeline_repo_path(p), cov, cov_n, &coverage_meta);
|
||||
int persist_rc = dump_and_persist(existing, db_path, project, files, file_count, mode_skipped,
|
||||
mode_skipped_count, cov, cov_n, &coverage_meta);
|
||||
free(cov);
|
||||
cbm_store_free_coverage(old_cov, old_cov_count);
|
||||
free_mode_skipped(mode_skipped, mode_skipped_count);
|
||||
cbm_gbuf_free(existing);
|
||||
|
||||
cbm_log_info("incremental.done", "elapsed_ms", itoa_buf((int)elapsed_ms(t0)));
|
||||
return 0;
|
||||
return persist_rc;
|
||||
}
|
||||
|
||||
@@ -631,6 +631,13 @@ atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p);
|
||||
* which cannot see the opaque cbm_pipeline struct. Call before the dump. */
|
||||
void cbm_pipeline_set_committed_counts(cbm_pipeline_t *p, int nodes, int edges);
|
||||
|
||||
/* Test seam: invoked after a complete staging DB is sealed and immediately
|
||||
* before the cancellation check + atomic replace. Not part of the public API. */
|
||||
void cbm_pipeline_set_before_publish_hook_for_tests(
|
||||
cbm_pipeline_t *p, void (*hook)(cbm_pipeline_t *, const char *, void *), void *ctx);
|
||||
void cbm_pipeline_set_rename_hook_for_tests(
|
||||
cbm_pipeline_t *p, int (*hook)(const char *, const char *, void *), void *ctx);
|
||||
|
||||
/* Parse a gRPC stub call "<service-stub>.<method>" 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).
|
||||
|
||||
+22
-6
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
#include "semantic/rotsq.h"
|
||||
|
||||
#include <stdatomic.h>
|
||||
#include <string.h>
|
||||
|
||||
#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) ─────────── */
|
||||
|
||||
+106
-5
@@ -635,19 +635,20 @@ static int store_authorizer(void *user_data, int action, const char *p3, const c
|
||||
}
|
||||
}
|
||||
|
||||
static cbm_store_t *store_open_internal(const char *path, bool in_memory) {
|
||||
static cbm_store_t *store_open_internal(const char *path, bool in_memory, bool create) {
|
||||
cbm_store_t *s = calloc(CBM_ALLOC_ONE, sizeof(cbm_store_t));
|
||||
if (!s) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
|
||||
int flags = SQLITE_OPEN_READWRITE | (create ? SQLITE_OPEN_CREATE : 0);
|
||||
if (in_memory) {
|
||||
flags |= SQLITE_OPEN_MEMORY;
|
||||
}
|
||||
|
||||
int rc = sqlite3_open_v2(path, &s->db, flags, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
sqlite3_close(s->db);
|
||||
free(s);
|
||||
return NULL;
|
||||
}
|
||||
@@ -685,14 +686,21 @@ static cbm_store_t *store_open_internal(const char *path, bool in_memory) {
|
||||
}
|
||||
|
||||
cbm_store_t *cbm_store_open_memory(void) {
|
||||
return store_open_internal(":memory:", true);
|
||||
return store_open_internal(":memory:", true, true);
|
||||
}
|
||||
|
||||
cbm_store_t *cbm_store_open_path(const char *db_path) {
|
||||
if (!db_path) {
|
||||
return NULL;
|
||||
}
|
||||
return store_open_internal(db_path, false);
|
||||
return store_open_internal(db_path, false, true);
|
||||
}
|
||||
|
||||
cbm_store_t *cbm_store_open_path_existing(const char *db_path) {
|
||||
if (!db_path) {
|
||||
return NULL;
|
||||
}
|
||||
return store_open_internal(db_path, false, false);
|
||||
}
|
||||
|
||||
const char *cbm_store_db_path(const cbm_store_t *s) {
|
||||
@@ -933,7 +941,7 @@ cbm_store_t *cbm_store_open(const char *project) {
|
||||
}
|
||||
char path[CBM_SZ_1K];
|
||||
snprintf(path, sizeof(path), "%s/%s.db", cdir, project);
|
||||
return store_open_internal(path, false);
|
||||
return store_open_internal(path, false, true);
|
||||
}
|
||||
|
||||
static void finalize_stmt(sqlite3_stmt **s) {
|
||||
@@ -1081,6 +1089,99 @@ int cbm_store_checkpoint(cbm_store_t *s) {
|
||||
return exec_sql(s, "PRAGMA optimize;");
|
||||
}
|
||||
|
||||
static int prepare_sqlite_for_publish(sqlite3 *db) {
|
||||
if (!db) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
int log_frames = -1;
|
||||
int checkpointed_frames = -1;
|
||||
int rc = sqlite3_wal_checkpoint_v2(db, NULL, SQLITE_CHECKPOINT_TRUNCATE, &log_frames,
|
||||
&checkpointed_frames);
|
||||
if (rc != SQLITE_OK || (log_frames >= 0 && checkpointed_frames != log_frames)) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
rc = sqlite3_prepare_v2(db, "PRAGMA journal_mode=DELETE;", CBM_NOT_FOUND, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
bool delete_mode = false;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *mode = (const char *)sqlite3_column_text(stmt, 0);
|
||||
delete_mode = mode && strcmp(mode, "delete") == 0;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return delete_mode ? CBM_STORE_OK : CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
int cbm_store_prepare_for_publish(cbm_store_t *s) {
|
||||
if (!s || !s->db) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
return prepare_sqlite_for_publish(s->db);
|
||||
}
|
||||
|
||||
int cbm_store_prepare_path_for_replace(const char *path) {
|
||||
if (!path) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
sqlite3 *db = NULL;
|
||||
int rc = sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
sqlite3_busy_timeout(db, 10000);
|
||||
int result = prepare_sqlite_for_publish(db);
|
||||
if (sqlite3_close(db) != SQLITE_OK) {
|
||||
result = CBM_STORE_ERR;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int cbm_store_backup_path(const char *source_path, const char *staging_path) {
|
||||
if (!source_path || !staging_path) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
if (cbm_remove_db_sidecars(staging_path) != 0) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
sqlite3 *source = NULL;
|
||||
sqlite3 *dest = NULL;
|
||||
int rc = sqlite3_open_v2(source_path, &source, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
sqlite3_close(source);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
sqlite3_busy_timeout(source, 10000);
|
||||
rc = sqlite3_open_v2(staging_path, &dest, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
sqlite3_close(dest);
|
||||
sqlite3_close(source);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
sqlite3_busy_timeout(dest, 10000);
|
||||
|
||||
sqlite3_backup *backup = sqlite3_backup_init(dest, "main", source, "main");
|
||||
int result = CBM_STORE_ERR;
|
||||
if (backup) {
|
||||
int step_rc = sqlite3_backup_step(backup, CBM_NOT_FOUND);
|
||||
int finish_rc = sqlite3_backup_finish(backup);
|
||||
if (step_rc == SQLITE_DONE && finish_rc == SQLITE_OK) {
|
||||
result = CBM_STORE_OK;
|
||||
}
|
||||
}
|
||||
if (sqlite3_close(dest) != SQLITE_OK) {
|
||||
result = CBM_STORE_ERR;
|
||||
}
|
||||
if (sqlite3_close(source) != SQLITE_OK) {
|
||||
result = CBM_STORE_ERR;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ── Dump ───────────────────────────────────────────────────────── */
|
||||
|
||||
/* Dump entire in-memory database to a file via sqlite3_backup.
|
||||
|
||||
@@ -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
|
||||
|
||||
+308
-45
@@ -14,10 +14,22 @@
|
||||
|
||||
#include <yyjson/yyjson.h>
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include "foundation/win_utf8.h"
|
||||
|
||||
#include <wchar.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
/* ── Path ────────────────────────────────────────────────────── */
|
||||
|
||||
void cbm_ui_config_path(char *buf, int bufsz) {
|
||||
@@ -30,43 +42,103 @@ void cbm_ui_config_path(char *buf, int bufsz) {
|
||||
|
||||
/* ── Load ────────────────────────────────────────────────────── */
|
||||
|
||||
static char *config_read_file(const char *path, size_t *length_out,
|
||||
bool *opened_out) {
|
||||
if (length_out) {
|
||||
*length_out = 0;
|
||||
}
|
||||
if (opened_out) {
|
||||
*opened_out = false;
|
||||
}
|
||||
if (!path || !length_out || !opened_out) {
|
||||
return NULL;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
wchar_t *wide_path = cbm_utf8_to_wide(path);
|
||||
if (!wide_path) {
|
||||
return NULL;
|
||||
}
|
||||
HANDLE file = CreateFileW(wide_path, GENERIC_READ,
|
||||
FILE_SHARE_READ | FILE_SHARE_DELETE,
|
||||
NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
|
||||
free(wide_path);
|
||||
if (file == INVALID_HANDLE_VALUE) {
|
||||
return NULL;
|
||||
}
|
||||
*opened_out = true;
|
||||
LARGE_INTEGER size;
|
||||
if (!GetFileSizeEx(file, &size) || size.QuadPart <= 0 ||
|
||||
size.QuadPart > 4096) {
|
||||
(void)CloseHandle(file);
|
||||
return NULL;
|
||||
}
|
||||
size_t length = (size_t)size.QuadPart;
|
||||
char *buffer = malloc(length + 1U);
|
||||
DWORD read_length = 0;
|
||||
bool read_ok = buffer &&
|
||||
ReadFile(file, buffer, (DWORD)length, &read_length, NULL) &&
|
||||
read_length == (DWORD)length;
|
||||
(void)CloseHandle(file);
|
||||
if (!read_ok) {
|
||||
free(buffer);
|
||||
return NULL;
|
||||
}
|
||||
#else
|
||||
FILE *file = cbm_fopen(path, "rb");
|
||||
if (!file) {
|
||||
return NULL;
|
||||
}
|
||||
*opened_out = true;
|
||||
if (fseek(file, 0, SEEK_END) != 0) {
|
||||
(void)fclose(file);
|
||||
return NULL;
|
||||
}
|
||||
long file_length = ftell(file);
|
||||
if (file_length <= 0 || file_length > 4096 ||
|
||||
fseek(file, 0, SEEK_SET) != 0) {
|
||||
(void)fclose(file);
|
||||
return NULL;
|
||||
}
|
||||
size_t length = (size_t)file_length;
|
||||
char *buffer = malloc(length + 1U);
|
||||
bool read_ok = buffer && fread(buffer, 1, length, file) == length;
|
||||
(void)fclose(file);
|
||||
if (!read_ok) {
|
||||
free(buffer);
|
||||
return NULL;
|
||||
}
|
||||
#endif
|
||||
buffer[length] = '\0';
|
||||
*length_out = length;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
void cbm_ui_config_load(cbm_ui_config_t *cfg) {
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
cfg->ui_enabled = CBM_UI_DEFAULT_ENABLED;
|
||||
cfg->ui_port = CBM_UI_DEFAULT_PORT;
|
||||
|
||||
char path[CBM_SZ_1K];
|
||||
cbm_ui_config_path(path, (int)sizeof(path));
|
||||
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
size_t length = 0;
|
||||
bool opened = false;
|
||||
char *buffer = config_read_file(path, &length, &opened);
|
||||
if (!opened) {
|
||||
/* No config file — auto-enable UI if binary has embedded assets */
|
||||
if (CBM_EMBEDDED_FILE_COUNT > 0) {
|
||||
cfg->ui_enabled = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fseek(f, 0, SEEK_END);
|
||||
long len = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
|
||||
if (len <= 0 || len > 4096) {
|
||||
fclose(f);
|
||||
return; /* empty or suspiciously large → defaults */
|
||||
}
|
||||
|
||||
char *buf = malloc((size_t)len + SKIP_ONE);
|
||||
if (!buf) {
|
||||
fclose(f);
|
||||
if (!buffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
size_t nread = fread(buf, SKIP_ONE, (size_t)len, f);
|
||||
fclose(f);
|
||||
buf[nread] = '\0';
|
||||
|
||||
yyjson_doc *doc = yyjson_read(buf, nread, 0);
|
||||
free(buf);
|
||||
yyjson_doc *doc = yyjson_read(buffer, length, 0);
|
||||
free(buffer);
|
||||
if (!doc) {
|
||||
cbm_log_warn("ui.config.corrupt", "path", path);
|
||||
return; /* corrupt JSON → defaults */
|
||||
@@ -85,7 +157,10 @@ void cbm_ui_config_load(cbm_ui_config_t *cfg) {
|
||||
|
||||
yyjson_val *v_port = yyjson_obj_get(root, "ui_port");
|
||||
if (yyjson_is_int(v_port)) {
|
||||
cfg->ui_port = (int)yyjson_get_int(v_port);
|
||||
int64_t port = yyjson_get_int(v_port);
|
||||
if (port > 0 && port <= 65535) {
|
||||
cfg->ui_port = (int)port;
|
||||
}
|
||||
}
|
||||
|
||||
yyjson_doc_free(doc);
|
||||
@@ -93,45 +168,233 @@ void cbm_ui_config_load(cbm_ui_config_t *cfg) {
|
||||
|
||||
/* ── Save ────────────────────────────────────────────────────── */
|
||||
|
||||
void cbm_ui_config_save(const cbm_ui_config_t *cfg) {
|
||||
static bool config_parent_directory(const char *path, char *directory,
|
||||
size_t directory_size) {
|
||||
int written = snprintf(directory, directory_size, "%s", path ? path : "");
|
||||
if (written <= 0 || (size_t)written >= directory_size) {
|
||||
return false;
|
||||
}
|
||||
char *slash = strrchr(directory, '/');
|
||||
char *backslash = strrchr(directory, '\\');
|
||||
if (backslash && (!slash || backslash > slash)) {
|
||||
slash = backslash;
|
||||
}
|
||||
if (!slash) {
|
||||
return snprintf(directory, directory_size, ".") == 1;
|
||||
}
|
||||
if (slash == directory) {
|
||||
slash[1] = '\0';
|
||||
} else {
|
||||
*slash = '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
static volatile LONG g_config_temp_sequence = 0;
|
||||
|
||||
static bool config_write_atomic(const char *path, const char *json,
|
||||
size_t json_length) {
|
||||
wchar_t *wide_path = cbm_utf8_to_wide(path);
|
||||
if (!wide_path) {
|
||||
return false;
|
||||
}
|
||||
size_t temporary_capacity = wcslen(wide_path) + 40U;
|
||||
wchar_t *temporary = calloc(temporary_capacity, sizeof(*temporary));
|
||||
HANDLE file = INVALID_HANDLE_VALUE;
|
||||
if (temporary) {
|
||||
for (unsigned int attempt = 0; attempt < 128U; attempt++) {
|
||||
ULONG sequence = (ULONG)InterlockedIncrement(&g_config_temp_sequence);
|
||||
int written = swprintf(temporary, temporary_capacity,
|
||||
L"%ls.tmp.%08lX.%08lX", wide_path,
|
||||
(unsigned long)GetCurrentProcessId(),
|
||||
(unsigned long)sequence);
|
||||
if (written <= 0 || (size_t)written >= temporary_capacity) {
|
||||
break;
|
||||
}
|
||||
file = CreateFileW(temporary, GENERIC_WRITE, 0, NULL, CREATE_NEW,
|
||||
FILE_ATTRIBUTE_NORMAL |
|
||||
FILE_FLAG_WRITE_THROUGH,
|
||||
NULL);
|
||||
if (file != INVALID_HANDLE_VALUE ||
|
||||
GetLastError() != ERROR_FILE_EXISTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bool ok = file != INVALID_HANDLE_VALUE;
|
||||
size_t offset = 0;
|
||||
while (ok && offset < json_length) {
|
||||
size_t remaining = json_length - offset;
|
||||
DWORD chunk = remaining > UINT32_MAX ? UINT32_MAX : (DWORD)remaining;
|
||||
DWORD written = 0;
|
||||
ok = WriteFile(file, json + offset, chunk, &written, NULL) != 0 &&
|
||||
written > 0;
|
||||
offset += written;
|
||||
}
|
||||
if (ok) {
|
||||
ok = FlushFileBuffers(file) != 0;
|
||||
}
|
||||
if (file != INVALID_HANDLE_VALUE && !CloseHandle(file)) {
|
||||
ok = false;
|
||||
}
|
||||
if (ok) {
|
||||
ok = MoveFileExW(temporary, wide_path,
|
||||
MOVEFILE_REPLACE_EXISTING |
|
||||
MOVEFILE_WRITE_THROUGH) != 0;
|
||||
}
|
||||
if (!ok && temporary) {
|
||||
(void)DeleteFileW(temporary);
|
||||
}
|
||||
free(temporary);
|
||||
free(wide_path);
|
||||
return ok;
|
||||
}
|
||||
#else
|
||||
static bool config_write_all(int descriptor, const char *json,
|
||||
size_t json_length) {
|
||||
size_t offset = 0;
|
||||
while (offset < json_length) {
|
||||
ssize_t written = write(descriptor, json + offset,
|
||||
json_length - offset);
|
||||
if (written < 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if (written <= 0) {
|
||||
return false;
|
||||
}
|
||||
offset += (size_t)written;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool config_sync_descriptor(int descriptor) {
|
||||
int result;
|
||||
do {
|
||||
result = fsync(descriptor);
|
||||
} while (result != 0 && errno == EINTR);
|
||||
return result == 0;
|
||||
}
|
||||
|
||||
static bool config_sync_parent_directory(const char *path) {
|
||||
char directory[CBM_SZ_1K];
|
||||
if (!config_parent_directory(path, directory, sizeof(directory))) {
|
||||
return false;
|
||||
}
|
||||
int flags = O_RDONLY;
|
||||
#ifdef O_DIRECTORY
|
||||
flags |= O_DIRECTORY;
|
||||
#endif
|
||||
int descriptor = open(directory, flags);
|
||||
if (descriptor < 0) {
|
||||
return false;
|
||||
}
|
||||
bool synced = config_sync_descriptor(descriptor);
|
||||
int sync_error = synced ? 0 : errno;
|
||||
(void)close(descriptor);
|
||||
#ifdef ENOTSUP
|
||||
if (!synced && sync_error == ENOTSUP) {
|
||||
synced = true;
|
||||
}
|
||||
#endif
|
||||
#ifdef EOPNOTSUPP
|
||||
if (!synced && sync_error == EOPNOTSUPP) {
|
||||
synced = true;
|
||||
}
|
||||
#endif
|
||||
if (!synced && sync_error == EINVAL) {
|
||||
/* Some POSIX filesystems do not implement directory fsync. The temp
|
||||
* file itself was already synced and rename publication is atomic. */
|
||||
synced = true;
|
||||
}
|
||||
return synced;
|
||||
}
|
||||
|
||||
static bool config_write_atomic(const char *path, const char *json,
|
||||
size_t json_length) {
|
||||
char temporary[CBM_SZ_2K];
|
||||
int written = snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path);
|
||||
if (written <= 0 || (size_t)written >= sizeof(temporary)) {
|
||||
return false;
|
||||
}
|
||||
int descriptor = cbm_mkstemp(temporary);
|
||||
if (descriptor < 0) {
|
||||
return false;
|
||||
}
|
||||
bool ok = fchmod(descriptor, 0600) == 0;
|
||||
int descriptor_flags = fcntl(descriptor, F_GETFD);
|
||||
ok = ok && descriptor_flags >= 0 &&
|
||||
fcntl(descriptor, F_SETFD, descriptor_flags | FD_CLOEXEC) == 0;
|
||||
ok = ok && config_write_all(descriptor, json, json_length) &&
|
||||
config_sync_descriptor(descriptor);
|
||||
if (close(descriptor) != 0) {
|
||||
ok = false;
|
||||
}
|
||||
if (ok) {
|
||||
ok = rename(temporary, path) == 0;
|
||||
}
|
||||
if (ok) {
|
||||
ok = config_sync_parent_directory(path);
|
||||
}
|
||||
if (!ok) {
|
||||
(void)cbm_unlink(temporary);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool cbm_ui_config_save(const cbm_ui_config_t *cfg) {
|
||||
if (!cfg || cfg->ui_port <= 0 || cfg->ui_port > 65535) {
|
||||
cbm_log_error("ui.config.write_fail", "reason", "invalid_config");
|
||||
return false;
|
||||
}
|
||||
char path[CBM_SZ_1K];
|
||||
cbm_ui_config_path(path, (int)sizeof(path));
|
||||
|
||||
/* Ensure directory exists (recursive) */
|
||||
char dir[CBM_SZ_1K];
|
||||
snprintf(dir, sizeof(dir), "%s", path);
|
||||
char *slash = strrchr(dir, '/');
|
||||
if (slash) {
|
||||
*slash = '\0';
|
||||
cbm_mkdir_p(dir, 0750);
|
||||
bool directory_ready = config_parent_directory(path, dir, sizeof(dir));
|
||||
if (directory_ready && !cbm_is_dir(dir)) {
|
||||
directory_ready = cbm_mkdir_p(dir, 0750) || cbm_is_dir(dir);
|
||||
}
|
||||
if (!directory_ready) {
|
||||
cbm_log_error("ui.config.write_fail", "path", path, "reason",
|
||||
"create_directory");
|
||||
return false;
|
||||
}
|
||||
|
||||
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
||||
yyjson_mut_doc_set_root(doc, root);
|
||||
|
||||
yyjson_mut_obj_add_bool(doc, root, "ui_enabled", cfg->ui_enabled);
|
||||
yyjson_mut_obj_add_int(doc, root, "ui_port", cfg->ui_port);
|
||||
yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL;
|
||||
bool serialized = doc && root;
|
||||
if (serialized) {
|
||||
yyjson_mut_doc_set_root(doc, root);
|
||||
serialized =
|
||||
yyjson_mut_obj_add_bool(doc, root, "ui_enabled",
|
||||
cfg->ui_enabled) &&
|
||||
yyjson_mut_obj_add_int(doc, root, "ui_port", cfg->ui_port);
|
||||
}
|
||||
|
||||
size_t json_len = 0;
|
||||
char *json = yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, &json_len);
|
||||
yyjson_mut_doc_free(doc);
|
||||
char *json = serialized
|
||||
? yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, &json_len)
|
||||
: NULL;
|
||||
if (doc) {
|
||||
yyjson_mut_doc_free(doc);
|
||||
}
|
||||
|
||||
if (!json) {
|
||||
cbm_log_error("ui.config.write_fail", "reason", "serialize");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) {
|
||||
cbm_log_error("ui.config.write_fail", "path", path);
|
||||
free(json);
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite(json, SKIP_ONE, json_len, f);
|
||||
fclose(f);
|
||||
bool saved = config_write_atomic(path, json, json_len);
|
||||
free(json);
|
||||
if (!saved) {
|
||||
cbm_log_error("ui.config.write_fail", "path", path, "reason",
|
||||
"atomic_publish");
|
||||
return false;
|
||||
}
|
||||
|
||||
cbm_log_debug("ui.config.saved", "path", path);
|
||||
return true;
|
||||
}
|
||||
|
||||
+3
-2
@@ -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. */
|
||||
|
||||
+136
-274
@@ -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 <dirent.h>
|
||||
@@ -775,7 +707,7 @@ static void handle_adr_get(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
char db_path[1024];
|
||||
db_path_for_project(name, db_path, sizeof(db_path));
|
||||
|
||||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||||
cbm_store_t *store = cbm_store_open_path_query(db_path);
|
||||
if (!store) {
|
||||
cbm_http_replyf(c, 200, g_cors_json, "{\"has_adr\":false}");
|
||||
return;
|
||||
@@ -824,7 +756,8 @@ static void handle_adr_get(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
}
|
||||
|
||||
/* POST /api/adr — save ADR content. Body: {"project":"...","content":"..."} */
|
||||
static void handle_adr_save(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
static void handle_adr_save(cbm_http_server_t *srv, cbm_http_conn_t *c,
|
||||
const cbm_http_req_t *req) {
|
||||
if (req->body_len == 0 || req->body_len > 16384) {
|
||||
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}");
|
||||
return;
|
||||
@@ -848,18 +781,36 @@ static void handle_adr_save(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
const char *proj = yyjson_get_str(v_proj);
|
||||
const char *content = yyjson_get_str(v_content);
|
||||
|
||||
if (srv->mutation_begin &&
|
||||
!srv->mutation_begin(srv->mutation_context, proj)) {
|
||||
yyjson_doc_free(doc);
|
||||
cbm_http_replyf(c, 423, g_cors_json,
|
||||
"{\"error\":\"project is busy; retry after indexing\"}");
|
||||
return;
|
||||
}
|
||||
bool mutation_held = srv->mutation_begin != NULL;
|
||||
|
||||
char db_path[1024];
|
||||
db_path_for_project(proj, db_path, sizeof(db_path));
|
||||
|
||||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||||
yyjson_doc_free(doc);
|
||||
if (!store) {
|
||||
if (mutation_held) {
|
||||
srv->mutation_end(srv->mutation_context, proj);
|
||||
}
|
||||
yyjson_doc_free(doc);
|
||||
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
int rc = cbm_store_adr_store(store, proj, content);
|
||||
cbm_store_close(store);
|
||||
if (mutation_held) {
|
||||
srv->mutation_end(srv->mutation_context, proj);
|
||||
}
|
||||
/* proj/content are owned by doc; keep it alive through both SQLite and
|
||||
* the mutation-end callback. */
|
||||
yyjson_doc_free(doc);
|
||||
|
||||
if (rc == CBM_STORE_OK) {
|
||||
cbm_http_replyf(c, 200, g_cors_json, "{\"saved\":true}");
|
||||
@@ -976,182 +927,37 @@ void cbm_http_server_set_binary_path(const char *path) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Index via subprocess — isolates crashes from the main process. */
|
||||
/* Execute through the daemon's shared job registry. The thread is retained in
|
||||
* its slot and joined before reuse/free; no detached operation can outlive the
|
||||
* daemon application that owns its callback context. */
|
||||
static void *index_thread_fn(void *arg) {
|
||||
index_job_t *job = arg;
|
||||
cbm_log_info("ui.index.start", "path", job->root_path);
|
||||
|
||||
/* Use stored binary path, or try to find it */
|
||||
const char *bin = g_binary_path;
|
||||
char self_path[1024] = {0};
|
||||
if (!bin[0]) {
|
||||
cbm_http_server_resolve_binary_path(NULL, self_path, sizeof(self_path));
|
||||
bin = self_path[0] ? self_path : "codebase-memory-mcp";
|
||||
}
|
||||
|
||||
char log_file[256];
|
||||
|
||||
/* JSON-escape root_path and optional project name. */
|
||||
char escaped_path[2048];
|
||||
cbm_json_escape(escaped_path, (int)sizeof(escaped_path), job->root_path);
|
||||
char escaped_name[512];
|
||||
cbm_json_escape(escaped_name, (int)sizeof(escaped_name), job->project_name);
|
||||
char json_arg[4096];
|
||||
if (job->project_name[0]) {
|
||||
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\",\"name\":\"%s\"}", escaped_path,
|
||||
escaped_name);
|
||||
} else {
|
||||
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\"}", escaped_path);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
snprintf(log_file, sizeof(log_file), "%s\\cbm_index_%d.log",
|
||||
getenv("TEMP") ? getenv("TEMP") : ".", (int)_getpid());
|
||||
|
||||
/* Build command line for CreateProcess through the shared MS-CRT quoter so the
|
||||
* JSON arg's embedded quotes survive the child's argv re-parse — a naive
|
||||
* `"%s"` wrap dropped them, corrupting {"repo_path":"…"} into {repo_path:…}.
|
||||
* --index-worker: this http_server spawn is already the crash-isolation layer,
|
||||
* so the child runs indexing in-process rather than spawning its own supervisor
|
||||
* (avoids redundant process nesting). */
|
||||
char cmdline[2048];
|
||||
const char *const idx_argv[] = {bin, "cli", "--index-worker", "index_repository",
|
||||
json_arg, NULL};
|
||||
if (!cbm_build_win_cmdline(cmdline, sizeof(cmdline), idx_argv)) {
|
||||
snprintf(job->error_msg, sizeof(job->error_msg), "index command line too long");
|
||||
atomic_store(&job->status, 3);
|
||||
return NULL;
|
||||
}
|
||||
/* Wide command line: CreateProcessA would re-mangle the UTF-8 repo path through the
|
||||
* ANSI code page at the spawn boundary, so a non-ASCII repo path never reaches the
|
||||
* worker intact (#423/#20). Convert and spawn via CreateProcessW. */
|
||||
wchar_t *wcmd = cbm_utf8_to_wide(cmdline);
|
||||
if (!wcmd) {
|
||||
snprintf(job->error_msg, sizeof(job->error_msg), "index command line conversion failed");
|
||||
atomic_store(&job->status, 3);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cbm_log_info("ui.index.spawn", "bin", bin, "log", log_file);
|
||||
|
||||
HANDLE hlog = CreateFileA(log_file, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS,
|
||||
FILE_ATTRIBUTE_NORMAL, NULL);
|
||||
STARTUPINFOW si_proc = {.cb = sizeof(si_proc)};
|
||||
if (hlog != INVALID_HANDLE_VALUE) {
|
||||
si_proc.dwFlags = STARTF_USESTDHANDLES;
|
||||
si_proc.hStdError = hlog;
|
||||
si_proc.hStdOutput = hlog;
|
||||
}
|
||||
PROCESS_INFORMATION pi = {0};
|
||||
BOOL spawned = CreateProcessW(NULL, wcmd, NULL, NULL, TRUE, 0, NULL, NULL, &si_proc, &pi);
|
||||
free(wcmd);
|
||||
if (!spawned) {
|
||||
snprintf(job->error_msg, sizeof(job->error_msg), "CreateProcess failed");
|
||||
atomic_store(&job->status, 3);
|
||||
if (hlog != INVALID_HANDLE_VALUE)
|
||||
CloseHandle(hlog);
|
||||
return NULL;
|
||||
}
|
||||
if (hlog != INVALID_HANDLE_VALUE)
|
||||
CloseHandle(hlog);
|
||||
|
||||
/* Poll log file while child runs */
|
||||
long tail_pos = 0;
|
||||
for (;;) {
|
||||
DWORD wait = WaitForSingleObject(pi.hProcess, 500);
|
||||
FILE *lf = fopen(log_file, "r");
|
||||
if (lf) {
|
||||
fseek(lf, tail_pos, SEEK_SET);
|
||||
char line[512];
|
||||
while (fgets(line, sizeof(line), lf)) {
|
||||
size_t l = strlen(line);
|
||||
if (l > 0 && line[l - 1] == '\n')
|
||||
line[l - 1] = '\0';
|
||||
if (line[0])
|
||||
cbm_ui_log_append(line);
|
||||
}
|
||||
tail_pos = ftell(lf);
|
||||
fclose(lf);
|
||||
}
|
||||
if (wait == WAIT_OBJECT_0)
|
||||
break;
|
||||
}
|
||||
|
||||
DWORD win_exit = 1;
|
||||
GetExitCodeProcess(pi.hProcess, &win_exit);
|
||||
int exit_code = (int)win_exit;
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
(void)DeleteFileA(log_file);
|
||||
#else
|
||||
snprintf(log_file, sizeof(log_file), "/tmp/cbm_index_%d.log", (int)getpid());
|
||||
|
||||
cbm_log_info("ui.index.fork", "bin", bin, "log", log_file);
|
||||
|
||||
pid_t child_pid = fork();
|
||||
if (child_pid < 0) {
|
||||
snprintf(job->error_msg, sizeof(job->error_msg), "fork failed");
|
||||
atomic_store(&job->status, 3);
|
||||
return NULL;
|
||||
}
|
||||
job->child_pid = child_pid;
|
||||
|
||||
if (child_pid == 0) {
|
||||
FILE *lf = freopen(log_file, "w", stderr);
|
||||
(void)lf;
|
||||
freopen("/dev/null", "w", stdout);
|
||||
execl(bin, bin, "cli", "--index-worker", "index_repository", json_arg, (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
long tail_pos = 0;
|
||||
for (;;) {
|
||||
int wstatus = 0;
|
||||
pid_t wr = waitpid(child_pid, &wstatus, WNOHANG);
|
||||
bool child_done = (wr == child_pid);
|
||||
|
||||
FILE *lf = fopen(log_file, "r");
|
||||
if (lf) {
|
||||
fseek(lf, tail_pos, SEEK_SET);
|
||||
char line[512];
|
||||
while (fgets(line, sizeof(line), lf)) {
|
||||
size_t l = strlen(line);
|
||||
if (l > 0 && line[l - 1] == '\n')
|
||||
line[l - 1] = '\0';
|
||||
if (line[0])
|
||||
cbm_ui_log_append(line);
|
||||
}
|
||||
tail_pos = ftell(lf);
|
||||
fclose(lf);
|
||||
}
|
||||
|
||||
if (child_done)
|
||||
break;
|
||||
|
||||
struct timespec ts = {0, 500000000};
|
||||
cbm_nanosleep(&ts, NULL);
|
||||
}
|
||||
|
||||
int wstatus = 0;
|
||||
waitpid(child_pid, &wstatus, 0);
|
||||
int exit_code = WIFEXITED(wstatus) ? WEXITSTATUS(wstatus) : -1;
|
||||
|
||||
(void)unlink(log_file);
|
||||
#endif
|
||||
|
||||
if (exit_code != 0) {
|
||||
snprintf(job->error_msg, sizeof(job->error_msg), "indexing failed (exit code %d)",
|
||||
exit_code);
|
||||
cbm_http_server_t *server = job->server;
|
||||
int result = server && server->index_executor
|
||||
? server->index_executor(server->index_executor_context,
|
||||
job->root_path, job->project_name)
|
||||
: -1;
|
||||
if (result != 0) {
|
||||
snprintf(job->error_msg, sizeof(job->error_msg),
|
||||
"daemon index operation failed");
|
||||
atomic_store(&job->status, 3);
|
||||
} else {
|
||||
atomic_store(&job->status, 2);
|
||||
}
|
||||
cbm_log_info("ui.index.done", "path", job->root_path, "rc", exit_code == 0 ? "ok" : "err");
|
||||
cbm_log_info("ui.index.done", "path", job->root_path, "rc",
|
||||
result == 0 ? "ok" : "err");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* POST /api/index — body: {"root_path": "/abs/path", "project_name": "..."} */
|
||||
static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c,
|
||||
const cbm_http_req_t *req) {
|
||||
if (!server || !server->index_executor) {
|
||||
cbm_http_replyf(c, 503, g_cors_json,
|
||||
"{\"error\":\"daemon index coordinator unavailable\"}");
|
||||
return;
|
||||
}
|
||||
if (req->body_len == 0 || req->body_len > 4096) {
|
||||
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}");
|
||||
return;
|
||||
@@ -1183,7 +989,7 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
/* Find free job slot */
|
||||
int slot = -1;
|
||||
for (int i = 0; i < MAX_INDEX_JOBS; i++) {
|
||||
int st = atomic_load(&g_index_jobs[i].status);
|
||||
int st = atomic_load(&server->index_jobs[i].status);
|
||||
if (st == 0 || st == 2 || st == 3) {
|
||||
slot = i;
|
||||
break;
|
||||
@@ -1195,7 +1001,12 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
return;
|
||||
}
|
||||
|
||||
index_job_t *job = &g_index_jobs[slot];
|
||||
index_job_t *job = &server->index_jobs[slot];
|
||||
if (job->thread_started) {
|
||||
(void)cbm_thread_join(&job->thread);
|
||||
job->thread_started = false;
|
||||
}
|
||||
job->server = server;
|
||||
snprintf(job->root_path, sizeof(job->root_path), "%s", rpath);
|
||||
snprintf(job->project_name, sizeof(job->project_name), "%s", project_name);
|
||||
job->error_msg[0] = '\0';
|
||||
@@ -1203,25 +1014,24 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
yyjson_doc_free(doc);
|
||||
|
||||
/* Spawn background thread */
|
||||
cbm_thread_t tid;
|
||||
if (cbm_thread_create(&tid, 0, index_thread_fn, job) != 0) {
|
||||
if (cbm_thread_create(&job->thread, 0, index_thread_fn, job) != 0) {
|
||||
atomic_store(&job->status, 3);
|
||||
snprintf(job->error_msg, sizeof(job->error_msg), "thread creation failed");
|
||||
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"thread creation failed\"}");
|
||||
return;
|
||||
}
|
||||
cbm_thread_detach(&tid); /* Don't leak thread handle */
|
||||
job->thread_started = true;
|
||||
|
||||
cbm_http_replyf(c, 202, g_cors_json, "{\"status\":\"indexing\",\"slot\":%d,\"path\":\"%s\"}",
|
||||
slot, job->root_path);
|
||||
}
|
||||
|
||||
/* GET /api/index-status — returns status of all index jobs */
|
||||
static void handle_index_status(cbm_http_conn_t *c) {
|
||||
static void handle_index_status(cbm_http_server_t *server, cbm_http_conn_t *c) {
|
||||
char buf[2048] = "[";
|
||||
int pos = 1;
|
||||
for (int i = 0; i < MAX_INDEX_JOBS; i++) {
|
||||
int st = atomic_load(&g_index_jobs[i].status);
|
||||
int st = atomic_load(&server->index_jobs[i].status);
|
||||
if (st == 0)
|
||||
continue;
|
||||
if (pos > 1)
|
||||
@@ -1229,7 +1039,8 @@ static void handle_index_status(cbm_http_conn_t *c) {
|
||||
const char *ss = st == 1 ? "indexing" : st == 2 ? "done" : "error";
|
||||
http_appendf(buf, sizeof(buf), &pos,
|
||||
"{\"slot\":%d,\"status\":\"%s\",\"path\":\"%s\",\"error\":\"%s\"}", i, ss,
|
||||
g_index_jobs[i].root_path, st == 3 ? g_index_jobs[i].error_msg : "");
|
||||
server->index_jobs[i].root_path,
|
||||
st == 3 ? server->index_jobs[i].error_msg : "");
|
||||
}
|
||||
buf[pos++] = ']';
|
||||
buf[pos] = '\0';
|
||||
@@ -1258,12 +1069,26 @@ static void handle_delete_project(cbm_http_server_t *srv, cbm_http_conn_t *c,
|
||||
return;
|
||||
}
|
||||
|
||||
if (srv->mutation_begin &&
|
||||
!srv->mutation_begin(srv->mutation_context, name)) {
|
||||
cbm_http_replyf(c, 423, g_cors_json,
|
||||
"{\"error\":\"project is busy; retry after indexing\"}");
|
||||
return;
|
||||
}
|
||||
bool mutation_held = srv->mutation_begin != NULL;
|
||||
|
||||
if (unlink(db_path) != 0) {
|
||||
if (errno == ENOENT) {
|
||||
unwatch_project(srv, name);
|
||||
if (mutation_held) {
|
||||
srv->mutation_end(srv->mutation_context, name);
|
||||
}
|
||||
cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}");
|
||||
return;
|
||||
}
|
||||
if (mutation_held) {
|
||||
srv->mutation_end(srv->mutation_context, name);
|
||||
}
|
||||
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"failed to delete\"}");
|
||||
return;
|
||||
}
|
||||
@@ -1277,6 +1102,9 @@ static void handle_delete_project(cbm_http_server_t *srv, cbm_http_conn_t *c,
|
||||
|
||||
unwatch_project(srv, name);
|
||||
cbm_log_info("ui.project.deleted", "name", name);
|
||||
if (mutation_held) {
|
||||
srv->mutation_end(srv->mutation_context, name);
|
||||
}
|
||||
cbm_http_replyf(c, 200, g_cors_json, "{\"deleted\":true}");
|
||||
}
|
||||
|
||||
@@ -1296,7 +1124,7 @@ static void handle_project_health(cbm_http_conn_t *c, const cbm_http_req_t *req)
|
||||
return;
|
||||
}
|
||||
|
||||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||||
cbm_store_t *store = cbm_store_open_path_query(db_path);
|
||||
if (!store) {
|
||||
cbm_http_replyf(c, 200, g_cors_json, "{\"status\":\"corrupt\",\"reason\":\"cannot open\"}");
|
||||
return;
|
||||
@@ -1470,7 +1298,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
return;
|
||||
}
|
||||
|
||||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||||
cbm_store_t *store = cbm_store_open_path_query(db_path);
|
||||
if (!store) {
|
||||
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}");
|
||||
return;
|
||||
@@ -1539,7 +1367,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cbm_store_t *lp_store = cbm_store_open_path(lp_path);
|
||||
cbm_store_t *lp_store = cbm_store_open_path_query(lp_path);
|
||||
if (!lp_store) {
|
||||
free(linked[li]);
|
||||
continue;
|
||||
@@ -1761,13 +1589,13 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
|
||||
|
||||
/* POST /api/index → start background indexing */
|
||||
if (is_post && cbm_http_path_match(req->path, "/api/index")) {
|
||||
handle_index_start(c, req);
|
||||
handle_index_start(srv, c, req);
|
||||
return;
|
||||
}
|
||||
|
||||
/* GET /api/index-status → check indexing progress */
|
||||
if (is_get && cbm_http_path_match(req->path, "/api/index-status")) {
|
||||
handle_index_status(c);
|
||||
handle_index_status(srv, c);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1797,7 +1625,7 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
|
||||
|
||||
/* POST /api/adr → save ADR for project */
|
||||
if (is_post && cbm_http_path_match(req->path, "/api/adr")) {
|
||||
handle_adr_save(c, req);
|
||||
handle_adr_save(srv, c, req);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1819,12 +1647,6 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
|
||||
return;
|
||||
}
|
||||
|
||||
/* POST /api/process-kill → kill a process */
|
||||
if (is_post && cbm_http_path_match(req->path, "/api/process-kill")) {
|
||||
handle_process_kill(c, req);
|
||||
return;
|
||||
}
|
||||
|
||||
/* GET / → index.html (no-cache so browser always gets latest) */
|
||||
if (cbm_http_path_match(req->path, "/")) {
|
||||
const cbm_embedded_file_t *f = cbm_embedded_lookup("/index.html");
|
||||
@@ -1848,6 +1670,15 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
|
||||
|
||||
/* ── Public API ───────────────────────────────────────────────── */
|
||||
|
||||
static char *http_read_only_index_rejected(void *context, const char *repo_path,
|
||||
const char *args_json) {
|
||||
(void)context;
|
||||
(void)repo_path;
|
||||
(void)args_json;
|
||||
return cbm_mcp_text_result(
|
||||
"UI RPC indexing is disabled; use the coordinated /api/index route", true);
|
||||
}
|
||||
|
||||
cbm_http_server_t *cbm_http_server_new(int port) {
|
||||
cbm_http_server_t *srv = calloc(1, sizeof(*srv));
|
||||
if (!srv)
|
||||
@@ -1863,6 +1694,9 @@ cbm_http_server_t *cbm_http_server_new(int port) {
|
||||
free(srv);
|
||||
return NULL;
|
||||
}
|
||||
cbm_mcp_server_set_background_tasks(srv->mcp, false);
|
||||
cbm_mcp_server_set_index_executor(srv->mcp,
|
||||
http_read_only_index_rejected, srv);
|
||||
|
||||
/* Bind to localhost only (httpd refuses anything else by construction) */
|
||||
srv->listener = cbm_httpd_listen(port);
|
||||
@@ -1891,6 +1725,12 @@ cbm_http_server_t *cbm_http_server_new(int port) {
|
||||
void cbm_http_server_free(cbm_http_server_t *srv) {
|
||||
if (!srv)
|
||||
return;
|
||||
for (int i = 0; i < MAX_INDEX_JOBS; i++) {
|
||||
if (srv->index_jobs[i].thread_started) {
|
||||
(void)cbm_thread_join(&srv->index_jobs[i].thread);
|
||||
srv->index_jobs[i].thread_started = false;
|
||||
}
|
||||
}
|
||||
cbm_httpd_close(srv->listener);
|
||||
cbm_mcp_server_free(srv->mcp);
|
||||
free(srv);
|
||||
@@ -1951,3 +1791,25 @@ void cbm_http_server_set_watcher(cbm_http_server_t *srv, struct cbm_watcher *wat
|
||||
srv->watcher = watcher;
|
||||
}
|
||||
}
|
||||
|
||||
void cbm_http_server_set_index_executor(cbm_http_server_t *srv,
|
||||
cbm_http_index_executor_fn executor,
|
||||
void *context) {
|
||||
if (srv) {
|
||||
srv->index_executor = executor;
|
||||
srv->index_executor_context = context;
|
||||
}
|
||||
}
|
||||
|
||||
void cbm_http_server_set_project_mutation_guard(
|
||||
cbm_http_server_t *srv, cbm_http_project_mutation_begin_fn begin,
|
||||
cbm_http_project_mutation_end_fn end, void *context) {
|
||||
if (!srv || ((begin == NULL) != (end == NULL))) {
|
||||
return;
|
||||
}
|
||||
srv->mutation_begin = begin;
|
||||
srv->mutation_end = end;
|
||||
srv->mutation_context = begin ? context : NULL;
|
||||
cbm_mcp_server_set_project_mutation_guard(srv->mcp, begin, end,
|
||||
begin ? context : NULL);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+166
-29
@@ -44,6 +44,10 @@
|
||||
typedef struct {
|
||||
char *project_name;
|
||||
char *root_path;
|
||||
/* poll_once snapshots retain state pointers after projects_lock is
|
||||
* released. Unwatch/replacement tombstones a removed state so a later
|
||||
* snapshot entry cannot admit new indexing work. */
|
||||
atomic_bool registered;
|
||||
char last_head[CBM_SZ_64]; /* git HEAD hash (committed baseline) */
|
||||
bool is_git; /* false → skip polling */
|
||||
bool baseline_done; /* true after first poll */
|
||||
@@ -70,6 +74,13 @@ struct cbm_watcher {
|
||||
void *user_data;
|
||||
CBMHashTable *projects; /* name → project_state_t* */
|
||||
cbm_mutex_t projects_lock;
|
||||
/* Serializes callback replacement with the entire destructive prune
|
||||
* transaction so a borrowed daemon context cannot be freed mid-callback. */
|
||||
cbm_mutex_t coordination_lock;
|
||||
cbm_watcher_project_mutation_begin_fn mutation_begin;
|
||||
cbm_watcher_project_mutation_end_fn mutation_end;
|
||||
cbm_watcher_project_pruned_fn project_pruned;
|
||||
void *mutation_context;
|
||||
atomic_int stopped;
|
||||
/* Deferred-free list: freed after the next poll_once. */
|
||||
project_state_t **pending_free;
|
||||
@@ -331,6 +342,8 @@ static int git_file_count(const char *root_path) {
|
||||
|
||||
/* ── Project state lifecycle ────────────────────────────────────── */
|
||||
|
||||
static void state_free(project_state_t *s);
|
||||
|
||||
static project_state_t *state_new(const char *name, const char *root_path) {
|
||||
project_state_t *s = calloc(CBM_ALLOC_ONE, sizeof(*s));
|
||||
if (!s) {
|
||||
@@ -338,6 +351,11 @@ static project_state_t *state_new(const char *name, const char *root_path) {
|
||||
}
|
||||
s->project_name = strdup(name);
|
||||
s->root_path = strdup(root_path);
|
||||
if (!s->project_name || !s->root_path) {
|
||||
state_free(s);
|
||||
return NULL;
|
||||
}
|
||||
atomic_init(&s->registered, true);
|
||||
s->interval_ms = POLL_BASE_MS;
|
||||
return s;
|
||||
}
|
||||
@@ -432,25 +450,63 @@ static const char *itoa_buf(int v) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
static void delete_cached_project_db(const char *project_name) {
|
||||
static bool watcher_unlink_cached_file(const char *project_name, const char *path,
|
||||
const char *artifact) {
|
||||
if (cbm_unlink(path) == 0 || errno == ENOENT) {
|
||||
return true;
|
||||
}
|
||||
int unlink_errno = errno;
|
||||
char errno_text[CBM_SZ_32];
|
||||
snprintf(errno_text, sizeof(errno_text), "%d", unlink_errno);
|
||||
cbm_log_warn("watcher.root_prune_delete_failed", "project", project_name,
|
||||
"artifact", artifact, "path", path, "errno", errno_text);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool delete_cached_project_db(const char *project_name) {
|
||||
if (!cbm_validate_project_name(project_name)) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *cache_dir = cbm_resolve_cache_dir();
|
||||
if (!cache_dir) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
char path[CBM_SZ_1K];
|
||||
char wal[CBM_SZ_1K];
|
||||
char shm[CBM_SZ_1K];
|
||||
snprintf(path, sizeof(path), "%s/%s.db", cache_dir, project_name);
|
||||
snprintf(wal, sizeof(wal), "%s-wal", path);
|
||||
snprintf(shm, sizeof(shm), "%s-shm", path);
|
||||
(void)cbm_unlink(path);
|
||||
(void)cbm_unlink(wal);
|
||||
(void)cbm_unlink(shm);
|
||||
size_t cache_length = strlen(cache_dir);
|
||||
size_t project_length = strlen(project_name);
|
||||
if (cache_length > SIZE_MAX - project_length - sizeof("/.db")) {
|
||||
return false;
|
||||
}
|
||||
size_t path_capacity = cache_length + project_length + sizeof("/.db");
|
||||
char *path = malloc(path_capacity);
|
||||
char *sidecar = malloc(path_capacity + sizeof("-wal"));
|
||||
if (!path || !sidecar) {
|
||||
free(path);
|
||||
free(sidecar);
|
||||
return false;
|
||||
}
|
||||
int written = snprintf(path, path_capacity, "%s/%s.db", cache_dir, project_name);
|
||||
bool formatted = written > 0 && (size_t)written < path_capacity;
|
||||
bool removed = formatted &&
|
||||
watcher_unlink_cached_file(project_name, path, "database");
|
||||
/* If the main DB could not be removed, preserve WAL/SHM: together they
|
||||
* may be the only recoverable committed generation. */
|
||||
if (removed) {
|
||||
written = snprintf(sidecar, path_capacity + sizeof("-wal"), "%s-wal", path);
|
||||
removed = written > 0 &&
|
||||
(size_t)written < path_capacity + sizeof("-wal") &&
|
||||
watcher_unlink_cached_file(project_name, sidecar, "wal");
|
||||
}
|
||||
if (removed) {
|
||||
written = snprintf(sidecar, path_capacity + sizeof("-wal"), "%s-shm", path);
|
||||
removed = written > 0 &&
|
||||
(size_t)written < path_capacity + sizeof("-wal") &&
|
||||
watcher_unlink_cached_file(project_name, sidecar, "shm");
|
||||
}
|
||||
free(path);
|
||||
free(sidecar);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/* Hash table foreach callback to free state entries */
|
||||
@@ -476,6 +532,7 @@ cbm_watcher_t *cbm_watcher_new(cbm_store_t *store, cbm_index_fn index_fn, void *
|
||||
return NULL;
|
||||
}
|
||||
cbm_mutex_init(&w->projects_lock);
|
||||
cbm_mutex_init(&w->coordination_lock);
|
||||
atomic_init(&w->stopped, 0);
|
||||
return w;
|
||||
}
|
||||
@@ -487,6 +544,7 @@ void cbm_watcher_free(cbm_watcher_t *w) {
|
||||
/* Safety net: ensure stopped is set before draining pending_free.
|
||||
* In production the caller should cbm_watcher_stop() + join first. */
|
||||
atomic_store(&w->stopped, 1);
|
||||
cbm_mutex_lock(&w->coordination_lock);
|
||||
cbm_mutex_lock(&w->projects_lock);
|
||||
cbm_ht_foreach(w->projects, free_state_entry, NULL);
|
||||
cbm_ht_free(w->projects);
|
||||
@@ -495,10 +553,28 @@ void cbm_watcher_free(cbm_watcher_t *w) {
|
||||
}
|
||||
free(w->pending_free);
|
||||
cbm_mutex_unlock(&w->projects_lock);
|
||||
cbm_mutex_unlock(&w->coordination_lock);
|
||||
cbm_mutex_destroy(&w->projects_lock);
|
||||
cbm_mutex_destroy(&w->coordination_lock);
|
||||
free(w);
|
||||
}
|
||||
|
||||
void cbm_watcher_set_project_mutation_guard(
|
||||
cbm_watcher_t *w, cbm_watcher_project_mutation_begin_fn begin,
|
||||
cbm_watcher_project_mutation_end_fn end, cbm_watcher_project_pruned_fn pruned,
|
||||
void *context) {
|
||||
if (!w) {
|
||||
return;
|
||||
}
|
||||
cbm_mutex_lock(&w->coordination_lock);
|
||||
bool valid = begin && end;
|
||||
w->mutation_begin = valid ? begin : NULL;
|
||||
w->mutation_end = valid ? end : NULL;
|
||||
w->project_pruned = valid ? pruned : NULL;
|
||||
w->mutation_context = valid ? context : NULL;
|
||||
cbm_mutex_unlock(&w->coordination_lock);
|
||||
}
|
||||
|
||||
/* ── Watch list management ──────────────────────────────────────── */
|
||||
|
||||
void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *root_path) {
|
||||
@@ -513,20 +589,34 @@ void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *r
|
||||
return;
|
||||
}
|
||||
|
||||
/* Remove old entry first (key points to state's project_name) */
|
||||
cbm_mutex_lock(&w->projects_lock);
|
||||
project_state_t *old = cbm_ht_get(w->projects, project_name);
|
||||
if (old) {
|
||||
cbm_ht_delete(w->projects, project_name);
|
||||
state_free(old);
|
||||
}
|
||||
|
||||
project_state_t *s = state_new(project_name, root_path);
|
||||
if (!s) {
|
||||
cbm_mutex_unlock(&w->projects_lock);
|
||||
cbm_log_warn("watcher.watch.oom", "project", project_name, "path", root_path);
|
||||
return;
|
||||
}
|
||||
|
||||
cbm_mutex_lock(&w->projects_lock);
|
||||
project_state_t *old = cbm_ht_get(w->projects, project_name);
|
||||
if (old && strcmp(old->root_path, s->root_path) == 0) {
|
||||
/* Multiple daemon sessions may subscribe to the same canonical
|
||||
* project/root. Re-registering that identical watch must not discard
|
||||
* its git baseline, pending dirty signature, or immediate-touch state. */
|
||||
cbm_mutex_unlock(&w->projects_lock);
|
||||
state_free(s);
|
||||
return;
|
||||
}
|
||||
if (old) {
|
||||
/* A poll snapshot may still be using the old state, including while
|
||||
* its index callback calls back into this function. Queue it before
|
||||
* removing the table entry; on OOM, preserve the existing watch. */
|
||||
if (!defer_state_free(w, old)) {
|
||||
cbm_mutex_unlock(&w->projects_lock);
|
||||
state_free(s);
|
||||
return;
|
||||
}
|
||||
atomic_store_explicit(&old->registered, false, memory_order_release);
|
||||
cbm_ht_delete(w->projects, project_name);
|
||||
}
|
||||
cbm_ht_set(w->projects, s->project_name, s);
|
||||
cbm_mutex_unlock(&w->projects_lock);
|
||||
cbm_log_info("watcher.watch", "project", project_name, "path", root_path);
|
||||
@@ -542,6 +632,7 @@ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name) {
|
||||
if (s && defer_state_free(w, s)) {
|
||||
/* The entry leaves the table only once its state is safely on
|
||||
* the deferred-free list; on OOM the watch stays registered. */
|
||||
atomic_store_explicit(&s->registered, false, memory_order_release);
|
||||
cbm_ht_delete(w->projects, project_name);
|
||||
removed = true;
|
||||
}
|
||||
@@ -656,32 +747,72 @@ static void prune_missing_project(cbm_watcher_t *w, project_state_t *s) {
|
||||
return;
|
||||
}
|
||||
|
||||
char project_name[CBM_SZ_1K];
|
||||
snprintf(project_name, sizeof(project_name), "%s", s->project_name);
|
||||
char *project_name = cbm_strdup(s->project_name);
|
||||
char *root_path = cbm_strdup(s->root_path);
|
||||
if (!project_name || !root_path) {
|
||||
free(project_name);
|
||||
free(root_path);
|
||||
return;
|
||||
}
|
||||
|
||||
bool removed = false;
|
||||
cbm_mutex_lock(&w->coordination_lock);
|
||||
bool mutation_acquired = !w->mutation_begin ||
|
||||
w->mutation_begin(w->mutation_context, project_name);
|
||||
if (!mutation_acquired) {
|
||||
cbm_mutex_unlock(&w->coordination_lock);
|
||||
free(project_name);
|
||||
free(root_path);
|
||||
return;
|
||||
}
|
||||
cbm_mutex_lock(&w->projects_lock);
|
||||
project_state_t *current = cbm_ht_get(w->projects, project_name);
|
||||
/* Deferred free (same discipline as cbm_watcher_unwatch): this state
|
||||
* is referenced by the poll_once snapshot iterating us. On OOM the
|
||||
* watch stays registered and pruning retries on the next cycle. */
|
||||
if (current == s && defer_state_free(w, s)) {
|
||||
delete_cached_project_db(project_name);
|
||||
cbm_ht_delete(w->projects, project_name);
|
||||
removed = true;
|
||||
* watch stays registered and pruning retries on the next cycle. Re-stat
|
||||
* under the mutation lease so a root restored after the poll snapshot is
|
||||
* never pruned. */
|
||||
int stat_errno = 0;
|
||||
uint64_t now_ms = cbm_now_ms();
|
||||
bool still_eligible = current == s && strcmp(current->root_path, root_path) == 0 &&
|
||||
current->missing_root_count >= MISSING_ROOT_DELETE_AFTER &&
|
||||
current->first_missing_ms > 0 &&
|
||||
now_ms - current->first_missing_ms >=
|
||||
(uint64_t)prune_grace_s() * CBM_MSEC_PER_SEC &&
|
||||
root_status(root_path, &stat_errno) == ROOT_MISSING;
|
||||
if (still_eligible && defer_state_free(w, s)) {
|
||||
if (delete_cached_project_db(project_name)) {
|
||||
atomic_store_explicit(&s->registered, false, memory_order_release);
|
||||
cbm_ht_delete(w->projects, project_name);
|
||||
removed = true;
|
||||
} else {
|
||||
/* The state stays registered; undo the just-added deferred-free
|
||||
* entry so the next poll can safely retry the failed deletion. */
|
||||
w->pending_free[--w->pending_free_count] = NULL;
|
||||
}
|
||||
}
|
||||
cbm_mutex_unlock(&w->projects_lock);
|
||||
|
||||
if (removed && w->project_pruned) {
|
||||
w->project_pruned(w->mutation_context, project_name);
|
||||
}
|
||||
if (w->mutation_begin) {
|
||||
w->mutation_end(w->mutation_context, project_name);
|
||||
}
|
||||
cbm_mutex_unlock(&w->coordination_lock);
|
||||
|
||||
if (removed) {
|
||||
cbm_log_info("watcher.root_pruned", "project", project_name);
|
||||
}
|
||||
free(project_name);
|
||||
free(root_path);
|
||||
}
|
||||
|
||||
static void poll_project(const char *key, void *val, void *ud) {
|
||||
(void)key;
|
||||
poll_ctx_t *ctx = ud;
|
||||
project_state_t *s = val;
|
||||
if (!s) {
|
||||
if (!s || !atomic_load_explicit(&s->registered, memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -747,6 +878,12 @@ static void poll_project(const char *key, void *val, void *ud) {
|
||||
}
|
||||
|
||||
/* Trigger reindex */
|
||||
if (!atomic_load_explicit(&s->registered, memory_order_acquire)) {
|
||||
/* Git inspection can take long enough for the final owning daemon
|
||||
* session to disconnect. Unwatch tombstones the snapshot state; do
|
||||
* not admit an index callback after that ownership boundary. */
|
||||
return;
|
||||
}
|
||||
cbm_log_info("watcher.changed", "project", s->project_name, "strategy", "git");
|
||||
if (ctx->w->index_fn) {
|
||||
int rc = ctx->w->index_fn(s->project_name, s->root_path, ctx->w->user_data);
|
||||
|
||||
+19
-1
@@ -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). */
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
*/
|
||||
|
||||
#include <foundation/compat.h>
|
||||
#include <foundation/compat_fs.h>
|
||||
#include "test_framework.h"
|
||||
|
||||
#include <store/store.h>
|
||||
@@ -96,6 +97,97 @@ static int file_exists(const char *path) {
|
||||
return (stat(path, &st) == 0) ? 1 : 0;
|
||||
}
|
||||
|
||||
/* Unique quarantine generations use:
|
||||
*
|
||||
* <project>.db.corrupt.<16-hex-token>
|
||||
*
|
||||
* Only the main DB generation proves recoverability. SQLite sidecars and
|
||||
* reservation artifacts (for example, -wal, -shm, or .pending) must not make
|
||||
* the primary survival assertion pass on their own. */
|
||||
static int is_hex_digit(char ch) {
|
||||
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') ||
|
||||
(ch >= 'A' && ch <= 'F');
|
||||
}
|
||||
|
||||
static int is_unique_corrupt_main_name(const char *name) {
|
||||
char prefix[CBM_DIRENT_NAME_MAX];
|
||||
snprintf(prefix, sizeof(prefix), "%s.db.corrupt.", REPRO557_PROJECT);
|
||||
size_t prefix_len = strlen(prefix);
|
||||
if (strncmp(name, prefix, prefix_len) != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *token = name + prefix_len;
|
||||
if (strlen(token) != 16) {
|
||||
return 0;
|
||||
}
|
||||
for (size_t i = 0; i < 16; i++) {
|
||||
if (!is_hex_digit(token[i])) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int unique_corrupt_main_exists(const char *cache_dir) {
|
||||
cbm_dir_t *dir = cbm_opendir(cache_dir);
|
||||
if (!dir) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int found = 0;
|
||||
cbm_dirent_t *entry;
|
||||
while ((entry = cbm_readdir(dir)) != NULL) {
|
||||
if (!is_unique_corrupt_main_name(entry->name)) {
|
||||
continue;
|
||||
}
|
||||
char path[800];
|
||||
snprintf(path, sizeof(path), "%s/%s", cache_dir, entry->name);
|
||||
if (file_exists(path)) {
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
cbm_closedir(dir);
|
||||
return found;
|
||||
}
|
||||
|
||||
static int is_backup_artifact_name(const char *name) {
|
||||
char corrupt_prefix[CBM_DIRENT_NAME_MAX];
|
||||
char bak_prefix[CBM_DIRENT_NAME_MAX];
|
||||
snprintf(corrupt_prefix, sizeof(corrupt_prefix), "%s.db.corrupt", REPRO557_PROJECT);
|
||||
snprintf(bak_prefix, sizeof(bak_prefix), "%s.db.bak", REPRO557_PROJECT);
|
||||
|
||||
size_t corrupt_len = strlen(corrupt_prefix);
|
||||
if (strncmp(name, corrupt_prefix, corrupt_len) == 0 &&
|
||||
(name[corrupt_len] == '\0' || name[corrupt_len] == '.' ||
|
||||
name[corrupt_len] == '-')) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
size_t bak_len = strlen(bak_prefix);
|
||||
return strncmp(name, bak_prefix, bak_len) == 0 &&
|
||||
(name[bak_len] == '\0' || name[bak_len] == '.' || name[bak_len] == '-');
|
||||
}
|
||||
|
||||
static void cleanup_backup_artifacts(const char *cache_dir) {
|
||||
cbm_dir_t *dir = cbm_opendir(cache_dir);
|
||||
if (!dir) {
|
||||
return;
|
||||
}
|
||||
|
||||
cbm_dirent_t *entry;
|
||||
while ((entry = cbm_readdir(dir)) != NULL) {
|
||||
if (!is_backup_artifact_name(entry->name)) {
|
||||
continue;
|
||||
}
|
||||
char path[800];
|
||||
snprintf(path, sizeof(path), "%s/%s", cache_dir, entry->name);
|
||||
cbm_unlink(path);
|
||||
}
|
||||
cbm_closedir(dir);
|
||||
}
|
||||
|
||||
/* ── Test ─────────────────────────────────────────────────────────────
|
||||
*
|
||||
* repro_issue557_corrupt_db_not_silently_deleted
|
||||
@@ -230,9 +322,10 @@ TEST(repro_issue557_corrupt_db_not_silently_deleted) {
|
||||
* (a) the original DB still exists at db_path (zero deletion), or
|
||||
* (b) a backup file exists at a conventional backup path.
|
||||
*
|
||||
* Two conventional backup suffixes from the suggested fix in #557:
|
||||
* "<db_path>.corrupt" -- timestamped or plain rename
|
||||
* "<db_path>.bak" -- simpler alternative
|
||||
* Accepted backup forms from the suggested fix in #557:
|
||||
* "<db_path>.corrupt.<16-hex-token>" -- unique quarantine generation
|
||||
* "<db_path>.corrupt" -- legacy plain rename
|
||||
* "<db_path>.bak" -- simpler alternative
|
||||
*
|
||||
* WHY RED on buggy code:
|
||||
* cbm_unlink(path) at mcp.c:803 removes the file.
|
||||
@@ -245,18 +338,18 @@ TEST(repro_issue557_corrupt_db_not_silently_deleted) {
|
||||
char backup_corrupt[720], backup_bak[720];
|
||||
snprintf(backup_corrupt, sizeof(backup_corrupt), "%s.corrupt", db_path);
|
||||
snprintf(backup_bak, sizeof(backup_bak), "%s.bak", db_path);
|
||||
int backup_exists = file_exists(backup_corrupt) || file_exists(backup_bak);
|
||||
int backup_exists = unique_corrupt_main_exists(tmp_cache) ||
|
||||
file_exists(backup_corrupt) || file_exists(backup_bak);
|
||||
|
||||
/* Clean up temp dir (best effort -- before the assertion so the dir
|
||||
* is removed even when the assertion fails and longjmp unwinds). */
|
||||
unlink(db_path);
|
||||
unlink(backup_corrupt);
|
||||
unlink(backup_bak);
|
||||
char wal[730], shm[730];
|
||||
snprintf(wal, sizeof(wal), "%s-wal", db_path);
|
||||
snprintf(shm, sizeof(shm), "%s-shm", db_path);
|
||||
unlink(wal);
|
||||
unlink(shm);
|
||||
cleanup_backup_artifacts(tmp_cache);
|
||||
rmdir(tmp_cache);
|
||||
|
||||
#if defined(_WIN32)
|
||||
@@ -269,7 +362,7 @@ TEST(repro_issue557_corrupt_db_not_silently_deleted) {
|
||||
* THE KEY ASSERTION -- must be RED on unpatched code:
|
||||
*
|
||||
* db_still_exists -- 1 if the DB was preserved in-place (zero-delete fix)
|
||||
* backup_exists -- 1 if a .corrupt or .bak rename was made (quarantine fix)
|
||||
* backup_exists -- 1 if a main .corrupt generation or .bak rename exists
|
||||
*
|
||||
* On buggy code: both are 0 because cbm_unlink() ran with no backup.
|
||||
* On fixed code: at least one is 1.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1679
-7
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,548 @@
|
||||
/*
|
||||
* test_cross_repo.c — Input, work-bound, and write-failure guards for the
|
||||
* cross-repository matching pass.
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include "test_helpers.h"
|
||||
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_thread.h"
|
||||
#include "pipeline/pass_cross_repo.h"
|
||||
|
||||
#include <sqlite3/sqlite3.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#if !defined(_WIN32)
|
||||
#include <sys/wait.h>
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
char cache[256];
|
||||
char *saved_cache;
|
||||
} cross_repo_fixture_t;
|
||||
|
||||
static bool cross_repo_fixture_begin(cross_repo_fixture_t *fixture) {
|
||||
memset(fixture, 0, sizeof(*fixture));
|
||||
const char *saved = getenv("CBM_CACHE_DIR");
|
||||
if (saved) {
|
||||
fixture->saved_cache = strdup(saved);
|
||||
if (!fixture->saved_cache) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
snprintf(fixture->cache, sizeof(fixture->cache), "/tmp/cbm-cross-hardening-XXXXXX");
|
||||
return cbm_mkdtemp(fixture->cache) != NULL &&
|
||||
cbm_setenv("CBM_CACHE_DIR", fixture->cache, 1) == 0;
|
||||
}
|
||||
|
||||
static void cross_repo_fixture_end(cross_repo_fixture_t *fixture) {
|
||||
if (fixture->saved_cache) {
|
||||
(void)cbm_setenv("CBM_CACHE_DIR", fixture->saved_cache, 1);
|
||||
} else {
|
||||
(void)cbm_unsetenv("CBM_CACHE_DIR");
|
||||
}
|
||||
if (fixture->cache[0]) {
|
||||
th_rmtree(fixture->cache);
|
||||
}
|
||||
free(fixture->saved_cache);
|
||||
memset(fixture, 0, sizeof(*fixture));
|
||||
}
|
||||
|
||||
static bool cross_repo_project_path(const cross_repo_fixture_t *fixture, const char *project,
|
||||
char *out, size_t out_size) {
|
||||
int written = snprintf(out, out_size, "%s/%s.db", fixture->cache, project);
|
||||
return written > 0 && (size_t)written < out_size;
|
||||
}
|
||||
|
||||
static bool cross_repo_create_project(const cross_repo_fixture_t *fixture, const char *project) {
|
||||
char path[512];
|
||||
if (!cross_repo_project_path(fixture, project, path, sizeof(path))) {
|
||||
return false;
|
||||
}
|
||||
cbm_store_t *store = cbm_store_open_path(path);
|
||||
if (!store) {
|
||||
return false;
|
||||
}
|
||||
bool ok = cbm_store_upsert_project(store, project, fixture->cache) == CBM_STORE_OK;
|
||||
cbm_store_close(store);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Seed one HTTP_CALLS/HANDLES pair into two exact project stores. The suffix
|
||||
* keeps node QNs unique when a source is linked to more than one target. */
|
||||
static bool cross_repo_seed_http_pair(const cross_repo_fixture_t *fixture,
|
||||
const char *source_project, const char *target_project,
|
||||
const char *route_path, const char *suffix) {
|
||||
char source_path[512];
|
||||
char target_path[512];
|
||||
if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path)) ||
|
||||
!cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) {
|
||||
return false;
|
||||
}
|
||||
cbm_store_t *source = cbm_store_open_path(source_path);
|
||||
cbm_store_t *target = cbm_store_open_path(target_path);
|
||||
if (!source || !target) {
|
||||
cbm_store_close(source);
|
||||
cbm_store_close(target);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK &&
|
||||
cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK;
|
||||
char caller_qn[256];
|
||||
char local_route_qn[256];
|
||||
char target_route_qn[256];
|
||||
char handler_qn[256];
|
||||
char route_name[128];
|
||||
char edge_props[256];
|
||||
snprintf(caller_qn, sizeof(caller_qn), "%s.call.%s", source_project, suffix);
|
||||
snprintf(local_route_qn, sizeof(local_route_qn), "%s.local-route.%s", source_project, suffix);
|
||||
snprintf(target_route_qn, sizeof(target_route_qn), "__route__GET__%s", route_path);
|
||||
snprintf(handler_qn, sizeof(handler_qn), "%s.handle.%s", target_project, suffix);
|
||||
snprintf(route_name, sizeof(route_name), "GET %s", route_path);
|
||||
snprintf(edge_props, sizeof(edge_props), "{\"url_path\":\"%s\",\"method\":\"GET\"}",
|
||||
route_path);
|
||||
|
||||
cbm_node_t caller = {.project = source_project,
|
||||
.label = "Function",
|
||||
.name = "call_remote",
|
||||
.qualified_name = caller_qn,
|
||||
.file_path = "client.c"};
|
||||
cbm_node_t local_route = {.project = source_project,
|
||||
.label = "Route",
|
||||
.name = route_name,
|
||||
.qualified_name = local_route_qn,
|
||||
.file_path = "client.c"};
|
||||
int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0;
|
||||
int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0;
|
||||
cbm_edge_t http_call = {.project = source_project,
|
||||
.source_id = caller_id,
|
||||
.target_id = local_route_id,
|
||||
.type = "HTTP_CALLS",
|
||||
.properties_json = edge_props};
|
||||
ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0;
|
||||
|
||||
cbm_node_t target_route = {.project = target_project,
|
||||
.label = "Route",
|
||||
.name = route_name,
|
||||
.qualified_name = target_route_qn,
|
||||
.file_path = "server.c"};
|
||||
cbm_node_t handler = {.project = target_project,
|
||||
.label = "Function",
|
||||
.name = "handle_remote",
|
||||
.qualified_name = handler_qn,
|
||||
.file_path = "server.c"};
|
||||
int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0;
|
||||
int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0;
|
||||
cbm_edge_t handles = {.project = target_project,
|
||||
.source_id = handler_id,
|
||||
.target_id = target_route_id,
|
||||
.type = "HANDLES"};
|
||||
ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0;
|
||||
|
||||
cbm_store_close(source);
|
||||
cbm_store_close(target);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool cross_repo_exec(const cross_repo_fixture_t *fixture, const char *project,
|
||||
const char *sql) {
|
||||
char path[512];
|
||||
if (!cross_repo_project_path(fixture, project, path, sizeof(path))) {
|
||||
return false;
|
||||
}
|
||||
cbm_store_t *store = cbm_store_open_path_existing(path);
|
||||
if (!store) {
|
||||
return false;
|
||||
}
|
||||
char *error = NULL;
|
||||
int rc = sqlite3_exec(cbm_store_get_db(store), sql, NULL, NULL, &error);
|
||||
sqlite3_free(error);
|
||||
cbm_store_close(store);
|
||||
return rc == SQLITE_OK;
|
||||
}
|
||||
|
||||
static int cross_repo_count_edges(const cross_repo_fixture_t *fixture, const char *project,
|
||||
const char *edge_type) {
|
||||
char path[512];
|
||||
if (!cross_repo_project_path(fixture, project, path, sizeof(path))) {
|
||||
return -1;
|
||||
}
|
||||
cbm_store_t *store = cbm_store_open_path_query(path);
|
||||
if (!store) {
|
||||
return -1;
|
||||
}
|
||||
int count = cbm_store_count_edges_by_type(store, project, edge_type);
|
||||
cbm_store_close(store);
|
||||
return count;
|
||||
}
|
||||
|
||||
TEST(cross_repo_null_target_fails_without_dereference) {
|
||||
cross_repo_fixture_t fixture;
|
||||
if (!cross_repo_fixture_begin(&fixture) ||
|
||||
!cross_repo_create_project(&fixture, "null-target-source")) {
|
||||
cross_repo_fixture_end(&fixture);
|
||||
FAIL("failed to create isolated source project");
|
||||
}
|
||||
|
||||
bool rejected = false;
|
||||
#if defined(_WIN32)
|
||||
const char *targets[] = {NULL};
|
||||
cbm_cross_repo_result_t result = cbm_cross_repo_match("null-target-source", targets, 1);
|
||||
rejected = result.failed;
|
||||
#else
|
||||
fflush(NULL);
|
||||
pid_t child = fork();
|
||||
if (child == 0) {
|
||||
const char *targets[] = {NULL};
|
||||
cbm_cross_repo_result_t result = cbm_cross_repo_match("null-target-source", targets, 1);
|
||||
_exit(result.failed ? 0 : 2);
|
||||
}
|
||||
int status = 0;
|
||||
rejected = child > 0 && waitpid(child, &status, 0) == child && WIFEXITED(status) &&
|
||||
WEXITSTATUS(status) == 0;
|
||||
#endif
|
||||
|
||||
cross_repo_fixture_end(&fixture);
|
||||
ASSERT_TRUE(rejected);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens) {
|
||||
cross_repo_fixture_t fixture;
|
||||
bool setup = cross_repo_fixture_begin(&fixture) &&
|
||||
cross_repo_seed_http_pair(&fixture, "wildcard-source", "orders_config_service",
|
||||
"/config-orders", "a") &&
|
||||
cross_repo_seed_http_pair(&fixture, "wildcard-source", "orders_cross_repo_service",
|
||||
"/cross-orders", "b") &&
|
||||
cross_repo_seed_http_pair(&fixture, "wildcard-source", "orders-wal-service",
|
||||
"/wal-orders", "c") &&
|
||||
cross_repo_seed_http_pair(&fixture, "wildcard-source", "orders-shm-service",
|
||||
"/shm-orders", "d");
|
||||
if (!setup) {
|
||||
cross_repo_fixture_end(&fixture);
|
||||
FAIL("failed to seed wildcard fixture");
|
||||
}
|
||||
|
||||
const char *targets[] = {"*"};
|
||||
cbm_cross_repo_result_t result = cbm_cross_repo_match("wildcard-source", targets, 1);
|
||||
cross_repo_fixture_end(&fixture);
|
||||
|
||||
ASSERT_FALSE(result.failed);
|
||||
ASSERT_EQ(result.projects_scanned, 4);
|
||||
ASSERT_EQ(result.http_edges, 4);
|
||||
PASS();
|
||||
}
|
||||
|
||||
static bool cross_repo_seed_bounded_scan(const cross_repo_fixture_t *fixture,
|
||||
const char *source_project, const char *target_project) {
|
||||
enum { TEST_SCAN_ROWS = 4097 };
|
||||
char source_path[512];
|
||||
char target_path[512];
|
||||
if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path)) ||
|
||||
!cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) {
|
||||
return false;
|
||||
}
|
||||
cbm_store_t *source = cbm_store_open_path(source_path);
|
||||
cbm_store_t *target = cbm_store_open_path(target_path);
|
||||
if (!source || !target) {
|
||||
cbm_store_close(source);
|
||||
cbm_store_close(target);
|
||||
return false;
|
||||
}
|
||||
bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK &&
|
||||
cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK;
|
||||
cbm_node_t caller = {.project = source_project,
|
||||
.label = "Function",
|
||||
.name = "bounded_caller",
|
||||
.qualified_name = "bounded.source.caller",
|
||||
.file_path = "client.c"};
|
||||
int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0;
|
||||
ok = ok && caller_id > 0 &&
|
||||
sqlite3_exec(cbm_store_get_db(source), "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK;
|
||||
for (int i = 0; ok && i < TEST_SCAN_ROWS; i++) {
|
||||
char name[64];
|
||||
char qn[96];
|
||||
snprintf(name, sizeof(name), "local_route_%d", i);
|
||||
snprintf(qn, sizeof(qn), "bounded.source.route.%d", i);
|
||||
cbm_node_t local_route = {.project = source_project,
|
||||
.label = "Route",
|
||||
.name = name,
|
||||
.qualified_name = qn,
|
||||
.file_path = "client.c"};
|
||||
int64_t route_id = cbm_store_upsert_node(source, &local_route);
|
||||
cbm_edge_t edge = {
|
||||
.project = source_project,
|
||||
.source_id = caller_id,
|
||||
.target_id = route_id,
|
||||
.type = "HTTP_CALLS",
|
||||
.properties_json = i == TEST_SCAN_ROWS - 1
|
||||
? "{\"url_path\":\"/after-bound\",\"method\":\"GET\"}"
|
||||
: "{}",
|
||||
};
|
||||
ok = route_id > 0 && cbm_store_insert_edge(source, &edge) > 0;
|
||||
}
|
||||
if (ok) {
|
||||
ok = sqlite3_exec(cbm_store_get_db(source), "COMMIT", NULL, NULL, NULL) == SQLITE_OK;
|
||||
} else {
|
||||
(void)sqlite3_exec(cbm_store_get_db(source), "ROLLBACK", NULL, NULL, NULL);
|
||||
}
|
||||
|
||||
cbm_node_t target_route = {.project = target_project,
|
||||
.label = "Route",
|
||||
.name = "GET /after-bound",
|
||||
.qualified_name = "__route__GET__/after-bound",
|
||||
.file_path = "server.c"};
|
||||
cbm_node_t handler = {.project = target_project,
|
||||
.label = "Function",
|
||||
.name = "bounded_handler",
|
||||
.qualified_name = "bounded.target.handler",
|
||||
.file_path = "server.c"};
|
||||
int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0;
|
||||
int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0;
|
||||
cbm_edge_t handles = {.project = target_project,
|
||||
.source_id = handler_id,
|
||||
.target_id = target_route_id,
|
||||
.type = "HANDLES"};
|
||||
ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0;
|
||||
cbm_store_close(source);
|
||||
cbm_store_close(target);
|
||||
return ok;
|
||||
}
|
||||
|
||||
TEST(cross_repo_scan_bound_counts_examined_rows_not_matches) {
|
||||
cross_repo_fixture_t fixture;
|
||||
bool setup = cross_repo_fixture_begin(&fixture) &&
|
||||
cross_repo_seed_bounded_scan(&fixture, "bounded-source", "bounded-target");
|
||||
if (!setup) {
|
||||
cross_repo_fixture_end(&fixture);
|
||||
FAIL("failed to seed bounded scan fixture");
|
||||
}
|
||||
const char *target = "bounded-target";
|
||||
cbm_cross_repo_result_t result = cbm_cross_repo_match("bounded-source", &target, 1);
|
||||
cross_repo_fixture_end(&fixture);
|
||||
|
||||
ASSERT_FALSE(result.failed);
|
||||
ASSERT_EQ(result.projects_scanned, 1);
|
||||
ASSERT_EQ(result.http_edges, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(cross_repo_propagates_delete_failure) {
|
||||
cross_repo_fixture_t fixture;
|
||||
bool setup = cross_repo_fixture_begin(&fixture) &&
|
||||
cross_repo_seed_http_pair(&fixture, "delete-source", "delete-target",
|
||||
"/delete-failure", "delete");
|
||||
if (!setup) {
|
||||
cross_repo_fixture_end(&fixture);
|
||||
FAIL("failed to seed delete failure fixture");
|
||||
}
|
||||
const char *target = "delete-target";
|
||||
cbm_cross_repo_result_t initial = cbm_cross_repo_match("delete-source", &target, 1);
|
||||
bool trigger_created =
|
||||
!initial.failed && initial.http_edges == 1 &&
|
||||
cross_repo_exec(&fixture, "delete-source",
|
||||
"CREATE TRIGGER fail_cross_delete BEFORE DELETE ON edges "
|
||||
"WHEN OLD.type = 'CROSS_HTTP_CALLS' BEGIN "
|
||||
"SELECT RAISE(ABORT, 'forced cross delete failure'); END;");
|
||||
cbm_cross_repo_result_t failed = {0};
|
||||
if (trigger_created) {
|
||||
failed = cbm_cross_repo_match("delete-source", &target, 1);
|
||||
}
|
||||
cross_repo_fixture_end(&fixture);
|
||||
|
||||
ASSERT_TRUE(trigger_created);
|
||||
ASSERT_TRUE(failed.failed);
|
||||
ASSERT_EQ(failed.http_edges, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(cross_repo_failed_bidirectional_insert_is_not_counted) {
|
||||
cross_repo_fixture_t fixture;
|
||||
bool setup = cross_repo_fixture_begin(&fixture) &&
|
||||
cross_repo_seed_http_pair(&fixture, "insert-source", "insert-target",
|
||||
"/insert-failure", "insert") &&
|
||||
cross_repo_exec(&fixture, "insert-target",
|
||||
"CREATE TRIGGER fail_cross_insert BEFORE INSERT ON edges "
|
||||
"WHEN NEW.type = 'CROSS_HTTP_CALLS' BEGIN "
|
||||
"SELECT RAISE(ABORT, 'forced cross insert failure'); END;");
|
||||
if (!setup) {
|
||||
cross_repo_fixture_end(&fixture);
|
||||
FAIL("failed to seed insert failure fixture");
|
||||
}
|
||||
const char *target = "insert-target";
|
||||
cbm_cross_repo_result_t result = cbm_cross_repo_match("insert-source", &target, 1);
|
||||
cross_repo_fixture_end(&fixture);
|
||||
|
||||
ASSERT_TRUE(result.failed);
|
||||
ASSERT_EQ(result.http_edges, 0);
|
||||
ASSERT_EQ(result.projects_scanned, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Keep the source scan active after the target-b match is published. This
|
||||
* gives the watcher a deterministic observable hand-off point without adding
|
||||
* a test hook to the production pass. */
|
||||
static bool cross_repo_seed_cancel_scan_tail(const cross_repo_fixture_t *fixture,
|
||||
const char *source_project) {
|
||||
enum { CANCEL_SCAN_TOTAL_ROWS = 4096, SEEDED_MATCH_ROWS = 3 };
|
||||
char source_path[512];
|
||||
if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) {
|
||||
return false;
|
||||
}
|
||||
cbm_store_t *source = cbm_store_open_path_existing(source_path);
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
bool ok =
|
||||
sqlite3_exec(cbm_store_get_db(source), "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK;
|
||||
cbm_node_t caller = {.project = source_project,
|
||||
.label = "Function",
|
||||
.name = "cancel_tail_caller",
|
||||
.qualified_name = "cancel.source.tail-caller",
|
||||
.file_path = "cancel-tail.c"};
|
||||
int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0;
|
||||
ok = ok && caller_id > 0;
|
||||
for (int i = SEEDED_MATCH_ROWS; ok && i < CANCEL_SCAN_TOTAL_ROWS; i++) {
|
||||
char name[64];
|
||||
char qn[96];
|
||||
snprintf(name, sizeof(name), "cancel_tail_route_%d", i);
|
||||
snprintf(qn, sizeof(qn), "%s.cancel-tail.%d", source_project, i);
|
||||
cbm_node_t route = {.project = source_project,
|
||||
.label = "Route",
|
||||
.name = name,
|
||||
.qualified_name = qn,
|
||||
.file_path = "cancel-tail.c"};
|
||||
int64_t route_id = cbm_store_upsert_node(source, &route);
|
||||
cbm_edge_t edge = {.project = source_project,
|
||||
.source_id = caller_id,
|
||||
.target_id = route_id,
|
||||
.type = "HTTP_CALLS",
|
||||
.properties_json = "{}"};
|
||||
ok = route_id > 0 && caller_id > 0 && cbm_store_insert_edge(source, &edge) > 0;
|
||||
}
|
||||
if (ok) {
|
||||
ok = sqlite3_exec(cbm_store_get_db(source), "COMMIT", NULL, NULL, NULL) == SQLITE_OK;
|
||||
} else {
|
||||
(void)sqlite3_exec(cbm_store_get_db(source), "ROLLBACK", NULL, NULL, NULL);
|
||||
}
|
||||
cbm_store_close(source);
|
||||
return ok;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const cross_repo_fixture_t *fixture;
|
||||
atomic_int *cancelled;
|
||||
atomic_bool observed_target_write;
|
||||
} cross_repo_cancel_watcher_t;
|
||||
|
||||
static void *cross_repo_cancel_after_target_write(void *arg) {
|
||||
enum { CANCEL_POLL_LIMIT = 10000 };
|
||||
cross_repo_cancel_watcher_t *watcher = arg;
|
||||
for (int i = 0; i < CANCEL_POLL_LIMIT; i++) {
|
||||
int count = cross_repo_count_edges(watcher->fixture, "cancel-target-b", "CROSS_HTTP_CALLS");
|
||||
if (count > 0) {
|
||||
atomic_store_explicit(&watcher->observed_target_write, true, memory_order_release);
|
||||
atomic_store_explicit(watcher->cancelled, 1, memory_order_release);
|
||||
return NULL;
|
||||
}
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
/* Bound a broken implementation too: the test should fail, not hang. */
|
||||
atomic_store_explicit(watcher->cancelled, 1, memory_order_release);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_target) {
|
||||
cross_repo_fixture_t fixture;
|
||||
bool setup =
|
||||
cross_repo_fixture_begin(&fixture) &&
|
||||
cross_repo_seed_http_pair(&fixture, "cancel-source", "cancel-target-a", "/cancel-a", "a") &&
|
||||
cross_repo_seed_http_pair(&fixture, "cancel-source", "cancel-target-b", "/cancel-b", "b") &&
|
||||
cross_repo_seed_http_pair(&fixture, "cancel-source", "cancel-target-c", "/cancel-c", "c") &&
|
||||
cross_repo_seed_cancel_scan_tail(&fixture, "cancel-source");
|
||||
if (!setup) {
|
||||
cross_repo_fixture_end(&fixture);
|
||||
FAIL("failed to seed cancellation fixture");
|
||||
}
|
||||
|
||||
atomic_int cancelled;
|
||||
atomic_init(&cancelled, 0);
|
||||
cross_repo_cancel_watcher_t watcher = {
|
||||
.fixture = &fixture,
|
||||
.cancelled = &cancelled,
|
||||
};
|
||||
atomic_init(&watcher.observed_target_write, false);
|
||||
cbm_thread_t watcher_thread;
|
||||
bool watcher_started =
|
||||
cbm_thread_create(&watcher_thread, 0, cross_repo_cancel_after_target_write, &watcher) == 0;
|
||||
|
||||
const char *targets[] = {"cancel-target-c", "cancel-target-a", "cancel-target-b"};
|
||||
cbm_cross_repo_result_t result = {0};
|
||||
if (watcher_started) {
|
||||
result = cbm_cross_repo_match_cancellable("cancel-source", targets, 3, &cancelled);
|
||||
(void)cbm_thread_join(&watcher_thread);
|
||||
}
|
||||
|
||||
bool observed = atomic_load_explicit(&watcher.observed_target_write, memory_order_acquire);
|
||||
int completed_target_edges =
|
||||
cross_repo_count_edges(&fixture, "cancel-target-a", "CROSS_HTTP_CALLS");
|
||||
int interrupted_target_edges =
|
||||
cross_repo_count_edges(&fixture, "cancel-target-b", "CROSS_HTTP_CALLS");
|
||||
int later_target_edges =
|
||||
cross_repo_count_edges(&fixture, "cancel-target-c", "CROSS_HTTP_CALLS");
|
||||
cross_repo_fixture_end(&fixture);
|
||||
|
||||
ASSERT_TRUE(watcher_started);
|
||||
ASSERT_TRUE(observed);
|
||||
ASSERT_TRUE(result.cancelled);
|
||||
ASSERT_TRUE(result.partial_results);
|
||||
ASSERT_FALSE(result.failed);
|
||||
ASSERT_EQ(result.projects_scanned, 1);
|
||||
ASSERT_TRUE(completed_target_edges > 0);
|
||||
ASSERT_TRUE(interrupted_target_edges > 0);
|
||||
ASSERT_EQ(later_target_edges, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(cross_repo_pre_cancel_preserves_existing_cross_edges) {
|
||||
cross_repo_fixture_t fixture;
|
||||
bool setup = cross_repo_fixture_begin(&fixture) &&
|
||||
cross_repo_seed_http_pair(&fixture, "pre-cancel-source", "pre-cancel-target",
|
||||
"/pre-cancel", "pre");
|
||||
if (!setup) {
|
||||
cross_repo_fixture_end(&fixture);
|
||||
FAIL("failed to seed pre-cancel fixture");
|
||||
}
|
||||
|
||||
const char *target = "pre-cancel-target";
|
||||
cbm_cross_repo_result_t initial = cbm_cross_repo_match("pre-cancel-source", &target, 1);
|
||||
int before = cross_repo_count_edges(&fixture, "pre-cancel-source", "CROSS_HTTP_CALLS");
|
||||
atomic_int cancelled;
|
||||
atomic_init(&cancelled, 1);
|
||||
cbm_cross_repo_result_t result =
|
||||
cbm_cross_repo_match_cancellable("pre-cancel-source", &target, 1, &cancelled);
|
||||
int after = cross_repo_count_edges(&fixture, "pre-cancel-source", "CROSS_HTTP_CALLS");
|
||||
cross_repo_fixture_end(&fixture);
|
||||
|
||||
ASSERT_FALSE(initial.failed);
|
||||
ASSERT_TRUE(before > 0);
|
||||
ASSERT_TRUE(result.cancelled);
|
||||
ASSERT_FALSE(result.partial_results);
|
||||
ASSERT_FALSE(result.failed);
|
||||
ASSERT_EQ(result.projects_scanned, 0);
|
||||
ASSERT_EQ(after, before);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(cross_repo) {
|
||||
RUN_TEST(cross_repo_null_target_fails_without_dereference);
|
||||
RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens);
|
||||
RUN_TEST(cross_repo_scan_bound_counts_examined_rows_not_matches);
|
||||
RUN_TEST(cross_repo_propagates_delete_failure);
|
||||
RUN_TEST(cross_repo_failed_bidirectional_insert_is_not_counted);
|
||||
RUN_TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_target);
|
||||
RUN_TEST(cross_repo_pre_cancel_preserves_existing_cross_edges);
|
||||
}
|
||||
@@ -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 <cypher/cypher.h>
|
||||
#include <store/store.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
/*
|
||||
* test_daemon.c — Guards for shared MCP-daemon coordination and framing.
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
|
||||
#include "daemon/daemon.h"
|
||||
#include "mcp/mcp.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
/* RED contract for early process-role classification. */
|
||||
#include "test_framework.h"
|
||||
|
||||
#include "daemon/bootstrap.h"
|
||||
#include "daemon/ipc.h"
|
||||
#include "daemon/service.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/compat_thread.h"
|
||||
#include "foundation/platform.h"
|
||||
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
enum {
|
||||
BOOTSTRAP_TEST_PATH_CAP = 1024,
|
||||
BOOTSTRAP_TEST_TIMEOUT_MS = 2000,
|
||||
BOOTSTRAP_TEST_SHORT_TIMEOUT_MS = 20,
|
||||
};
|
||||
|
||||
static const char BOOTSTRAP_BUILD_A[] =
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
static const char BOOTSTRAP_BUILD_B[] =
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
typedef struct {
|
||||
char parent[BOOTSTRAP_TEST_PATH_CAP];
|
||||
char runtime_dir[BOOTSTRAP_TEST_PATH_CAP];
|
||||
cbm_daemon_ipc_endpoint_t *endpoint;
|
||||
} bootstrap_endpoint_fixture_t;
|
||||
|
||||
typedef struct {
|
||||
atomic_int cohort_acquire_count;
|
||||
atomic_int cohort_release_count;
|
||||
atomic_int lock_held;
|
||||
atomic_int spawn_count;
|
||||
atomic_int probe_count;
|
||||
atomic_int lock_attempt_count;
|
||||
atomic_int handoff_count;
|
||||
atomic_int diagnostic_count;
|
||||
atomic_int reserved_probes_remaining;
|
||||
atomic_int terminal_probes_remaining;
|
||||
atomic_bool available;
|
||||
atomic_bool connect_after_reserved;
|
||||
cbm_daemon_bootstrap_probe_status_t forced_probe;
|
||||
cbm_version_cohort_status_t forced_cohort;
|
||||
char diagnostic[CBM_DAEMON_CONFLICT_MESSAGE_SIZE];
|
||||
} bootstrap_fake_ops_t;
|
||||
|
||||
typedef struct {
|
||||
const cbm_daemon_bootstrap_config_t *config;
|
||||
const cbm_daemon_bootstrap_ops_t *ops;
|
||||
atomic_int *ready;
|
||||
atomic_bool *go;
|
||||
cbm_daemon_bootstrap_result_t result;
|
||||
cbm_daemon_bootstrap_status_t status;
|
||||
} bootstrap_thread_call_t;
|
||||
|
||||
static cbm_daemon_build_identity_t bootstrap_identity(const char *version, const char *build) {
|
||||
cbm_daemon_build_identity_t identity = {
|
||||
.semantic_version = version,
|
||||
.build_fingerprint = build,
|
||||
.protocol_abi = 3,
|
||||
.store_abi = 11,
|
||||
.feature_abi = 7,
|
||||
};
|
||||
return identity;
|
||||
}
|
||||
|
||||
static bool bootstrap_endpoint_fixture_start(bootstrap_endpoint_fixture_t *fixture,
|
||||
const char *tag) {
|
||||
memset(fixture, 0, sizeof(*fixture));
|
||||
int written = snprintf(fixture->parent, sizeof(fixture->parent), "%s/cbm-bootstrap-%s-XXXXXX",
|
||||
cbm_tmpdir(), tag);
|
||||
if (written <= 0 || written >= (int)sizeof(fixture->parent) || !cbm_mkdtemp(fixture->parent)) {
|
||||
return false;
|
||||
}
|
||||
fixture->endpoint = cbm_daemon_bootstrap_endpoint_new(fixture->parent);
|
||||
const char *runtime_dir =
|
||||
fixture->endpoint ? cbm_daemon_ipc_endpoint_runtime_dir(fixture->endpoint) : NULL;
|
||||
if (!runtime_dir) {
|
||||
return false;
|
||||
}
|
||||
written = snprintf(fixture->runtime_dir, sizeof(fixture->runtime_dir), "%s", runtime_dir);
|
||||
return written > 0 && written < (int)sizeof(fixture->runtime_dir);
|
||||
}
|
||||
|
||||
static void bootstrap_endpoint_fixture_finish(bootstrap_endpoint_fixture_t *fixture) {
|
||||
cbm_daemon_ipc_endpoint_free(fixture->endpoint);
|
||||
if (fixture->runtime_dir[0] != '\0') {
|
||||
(void)cbm_rmdir(fixture->runtime_dir);
|
||||
}
|
||||
if (fixture->parent[0] != '\0') {
|
||||
(void)cbm_rmdir(fixture->parent);
|
||||
}
|
||||
memset(fixture, 0, sizeof(*fixture));
|
||||
}
|
||||
|
||||
static cbm_daemon_bootstrap_probe_status_t bootstrap_fake_probe(
|
||||
void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms,
|
||||
cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *result_out) {
|
||||
(void)endpoint;
|
||||
(void)identity;
|
||||
(void)timeout_ms;
|
||||
bootstrap_fake_ops_t *fake = opaque;
|
||||
atomic_fetch_add(&fake->probe_count, 1);
|
||||
memset(result_out, 0, sizeof(*result_out));
|
||||
*client_out = NULL;
|
||||
int reserved_remaining = atomic_load(&fake->reserved_probes_remaining);
|
||||
while (reserved_remaining > 0 &&
|
||||
!atomic_compare_exchange_weak(&fake->reserved_probes_remaining,
|
||||
&reserved_remaining, reserved_remaining - 1)) {
|
||||
}
|
||||
if (reserved_remaining > 0) {
|
||||
if (reserved_remaining == 1 && atomic_load(&fake->connect_after_reserved)) {
|
||||
atomic_store(&fake->available, true);
|
||||
}
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED;
|
||||
}
|
||||
int terminal_remaining = atomic_load(&fake->terminal_probes_remaining);
|
||||
while (terminal_remaining > 0 &&
|
||||
!atomic_compare_exchange_weak(&fake->terminal_probes_remaining,
|
||||
&terminal_remaining,
|
||||
terminal_remaining - 1)) {
|
||||
}
|
||||
if (terminal_remaining > 0) {
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL;
|
||||
}
|
||||
if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED) {
|
||||
return fake->forced_probe;
|
||||
}
|
||||
if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT) {
|
||||
result_out->status = CBM_DAEMON_RUNTIME_CONNECT_CONFLICT;
|
||||
result_out->hello_status = CBM_DAEMON_HELLO_BUILD_CONFLICT;
|
||||
snprintf(result_out->message, sizeof(result_out->message),
|
||||
"CBM daemon could not start: conflicting versions");
|
||||
return fake->forced_probe;
|
||||
}
|
||||
if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) {
|
||||
return fake->forced_probe;
|
||||
}
|
||||
if (atomic_load(&fake->available)) {
|
||||
result_out->status = CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED;
|
||||
result_out->hello_status = CBM_DAEMON_HELLO_COMPATIBLE;
|
||||
result_out->client_id = 1;
|
||||
*client_out = (cbm_daemon_runtime_client_t *)(uintptr_t)1;
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED;
|
||||
}
|
||||
return CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE;
|
||||
}
|
||||
|
||||
static cbm_version_cohort_status_t bootstrap_fake_cohort_acquire(
|
||||
void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms,
|
||||
cbm_daemon_bootstrap_cohort_t *cohort_out,
|
||||
cbm_daemon_conflict_t *conflict_out) {
|
||||
(void)endpoint;
|
||||
(void)deadline_ms;
|
||||
bootstrap_fake_ops_t *fake = opaque;
|
||||
atomic_fetch_add(&fake->cohort_acquire_count, 1);
|
||||
*cohort_out = NULL;
|
||||
memset(conflict_out, 0, sizeof(*conflict_out));
|
||||
if (fake->forced_cohort == CBM_VERSION_COHORT_CONFLICT) {
|
||||
cbm_daemon_build_identity_t active =
|
||||
bootstrap_identity("2.3.0", BOOTSTRAP_BUILD_A);
|
||||
(void)cbm_daemon_hello_compare(&active, identity, conflict_out);
|
||||
return fake->forced_cohort;
|
||||
}
|
||||
if (fake->forced_cohort != CBM_VERSION_COHORT_OK) {
|
||||
return fake->forced_cohort;
|
||||
}
|
||||
*cohort_out = fake;
|
||||
return CBM_VERSION_COHORT_OK;
|
||||
}
|
||||
|
||||
static void bootstrap_fake_cohort_release(
|
||||
void *opaque, cbm_daemon_bootstrap_cohort_t cohort) {
|
||||
bootstrap_fake_ops_t *fake = opaque;
|
||||
if (cohort == fake) {
|
||||
atomic_fetch_add(&fake->cohort_release_count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
static int bootstrap_fake_lock(void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
cbm_daemon_bootstrap_lock_t *lock_out) {
|
||||
(void)endpoint;
|
||||
bootstrap_fake_ops_t *fake = opaque;
|
||||
atomic_fetch_add(&fake->lock_attempt_count, 1);
|
||||
int expected = 0;
|
||||
if (!atomic_compare_exchange_strong(&fake->lock_held, &expected, 1)) {
|
||||
return 0;
|
||||
}
|
||||
*lock_out = fake;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static bool bootstrap_fake_unlock(
|
||||
void *opaque, cbm_daemon_bootstrap_lock_t *lock_io) {
|
||||
bootstrap_fake_ops_t *fake = opaque;
|
||||
if (lock_io && *lock_io == fake) {
|
||||
atomic_store(&fake->lock_held, 0);
|
||||
*lock_io = NULL;
|
||||
return true;
|
||||
}
|
||||
return lock_io && !*lock_io;
|
||||
}
|
||||
|
||||
static bool bootstrap_fake_handoff(void *opaque,
|
||||
cbm_daemon_bootstrap_lock_t lock) {
|
||||
bootstrap_fake_ops_t *fake = opaque;
|
||||
if (lock != fake || atomic_load(&fake->lock_held) != 1) {
|
||||
return false;
|
||||
}
|
||||
atomic_fetch_add(&fake->handoff_count, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool bootstrap_fake_spawn(void *opaque, const cbm_daemon_bootstrap_launch_spec_t *spec) {
|
||||
bootstrap_fake_ops_t *fake = opaque;
|
||||
bool exact = spec && spec->argc == CBM_DAEMON_BOOTSTRAP_LAUNCH_ARGC && spec->argv[0] &&
|
||||
spec->argv[1] && !spec->argv[2] &&
|
||||
strcmp(spec->argv[1], CBM_DAEMON_INTERNAL_ARG) == 0 && spec->detached &&
|
||||
!spec->inherit_standard_handles && !spec->use_shell &&
|
||||
atomic_load(&fake->handoff_count) > 0 &&
|
||||
atomic_load(&fake->lock_held) == 1;
|
||||
if (!exact) {
|
||||
return false;
|
||||
}
|
||||
atomic_fetch_add(&fake->spawn_count, 1);
|
||||
atomic_store(&fake->available, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void bootstrap_fake_diagnostic(void *opaque, const char *message) {
|
||||
bootstrap_fake_ops_t *fake = opaque;
|
||||
atomic_fetch_add(&fake->diagnostic_count, 1);
|
||||
snprintf(fake->diagnostic, sizeof(fake->diagnostic), "%s", message ? message : "");
|
||||
}
|
||||
|
||||
static cbm_daemon_bootstrap_ops_t bootstrap_fake_callbacks(bootstrap_fake_ops_t *fake) {
|
||||
cbm_daemon_bootstrap_ops_t ops = {
|
||||
.context = fake,
|
||||
.cohort_acquire = bootstrap_fake_cohort_acquire,
|
||||
.cohort_release = bootstrap_fake_cohort_release,
|
||||
.probe = bootstrap_fake_probe,
|
||||
.startup_lock_try_acquire = bootstrap_fake_lock,
|
||||
.startup_lock_prepare_handoff = bootstrap_fake_handoff,
|
||||
.startup_lock_release = bootstrap_fake_unlock,
|
||||
.spawn_daemon = bootstrap_fake_spawn,
|
||||
.visible_diagnostic = bootstrap_fake_diagnostic,
|
||||
};
|
||||
return ops;
|
||||
}
|
||||
|
||||
static void *bootstrap_thread_execute(void *opaque) {
|
||||
bootstrap_thread_call_t *call = opaque;
|
||||
atomic_fetch_add(call->ready, 1);
|
||||
while (!atomic_load(call->go)) {
|
||||
const struct timespec pause = {0, 1000000L};
|
||||
(void)cbm_nanosleep(&pause, NULL);
|
||||
}
|
||||
call->status = cbm_daemon_bootstrap_execute_with_ops(call->config, call->ops, &call->result);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static cbm_daemon_process_role_t classify(int argc, char **argv) {
|
||||
return cbm_daemon_process_role(argc, argv);
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_classifies_default_and_ui_as_mcp_clients) {
|
||||
char *plain[] = {"codebase-memory-mcp", NULL};
|
||||
char *ui[] = {"codebase-memory-mcp", "--ui=true", "--port=9750", NULL};
|
||||
ASSERT_EQ(classify(1, plain), CBM_DAEMON_PROCESS_MCP_CLIENT);
|
||||
ASSERT_EQ(classify(3, ui), CBM_DAEMON_PROCESS_MCP_CLIENT);
|
||||
ASSERT_TRUE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_MCP_CLIENT));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_classifies_stateless_commands_without_client) {
|
||||
char *version[] = {"codebase-memory-mcp", "--version", NULL};
|
||||
char *help[] = {"codebase-memory-mcp", "--profile", "--help", NULL};
|
||||
char *install[] = {"codebase-memory-mcp", "install", "--dry-run", NULL};
|
||||
char *uninstall[] = {"codebase-memory-mcp", "uninstall", NULL};
|
||||
char *update[] = {"codebase-memory-mcp", "update", "-n", NULL};
|
||||
ASSERT_EQ(classify(2, version), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_EQ(classify(3, help), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_EQ(classify(3, install), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_EQ(classify(2, uninstall), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_EQ(classify(3, update), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_STATELESS));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_classifies_config_as_coordinated_local_cli) {
|
||||
char *list[] = {"codebase-memory-mcp", "config", "list", NULL};
|
||||
char *set[] = {"codebase-memory-mcp", "config", "set", "auto_watch",
|
||||
"false", NULL};
|
||||
char *help[] = {"codebase-memory-mcp", "config", "--help", NULL};
|
||||
ASSERT_EQ(classify(3, list), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
||||
ASSERT_EQ(classify(5, set), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
||||
ASSERT_EQ(classify(3, help), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_FALSE(cbm_daemon_process_role_requires_client(
|
||||
CBM_DAEMON_PROCESS_LOCAL_CLI));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_cli_help_is_stateless_but_tool_calls_are_local) {
|
||||
char *tool_help[] = {"codebase-memory-mcp", "cli", "search_graph", "--help", NULL};
|
||||
char *tool_call[] = {"codebase-memory-mcp", "cli", "search_graph", "{}", NULL};
|
||||
ASSERT_EQ(classify(4, tool_help), CBM_DAEMON_PROCESS_STATELESS);
|
||||
ASSERT_EQ(classify(4, tool_call), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
||||
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_LOCAL_CLI));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_cli_arguments_cannot_reclassify_the_process) {
|
||||
char *install_value[] = {"codebase-memory-mcp", "cli", "search_code",
|
||||
"--query", "install", NULL};
|
||||
char *version_value[] = {"codebase-memory-mcp", "cli", "search_code",
|
||||
"--query", "--version", NULL};
|
||||
ASSERT_EQ(classify(5, install_value), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
||||
ASSERT_EQ(classify(5, version_value), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_internal_roles_never_take_client_leases) {
|
||||
static char build[] =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
char *daemon[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, NULL};
|
||||
char *worker[] = {"codebase-memory-mcp", "cli", "--index-worker",
|
||||
"--index-worker-build", build, "index_repository",
|
||||
"{}", "--response-out", "/tmp/response", NULL};
|
||||
char *malformed_worker[] = {"codebase-memory-mcp", "cli", "--index-worker",
|
||||
"index_repository", "{}", NULL};
|
||||
char *reserved_user_value[] = {"codebase-memory-mcp", "cli", "search_code", "--query",
|
||||
"--index-worker", NULL};
|
||||
char *hook[] = {"codebase-memory-mcp", "hook-augment", NULL};
|
||||
ASSERT_EQ(classify(2, daemon), CBM_DAEMON_PROCESS_DAEMON);
|
||||
ASSERT_EQ(classify(9, worker), CBM_DAEMON_PROCESS_WORKER);
|
||||
ASSERT_EQ(classify(5, malformed_worker), CBM_DAEMON_PROCESS_INVALID);
|
||||
ASSERT_EQ(classify(5, reserved_user_value), CBM_DAEMON_PROCESS_INVALID);
|
||||
ASSERT_EQ(classify(2, hook), CBM_DAEMON_PROCESS_HOOK_CLIENT);
|
||||
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_DAEMON));
|
||||
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_WORKER));
|
||||
ASSERT_TRUE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_HOOK_CLIENT));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_rejects_ambiguous_internal_daemon_argv) {
|
||||
char *missing[] = {NULL};
|
||||
char *mixed[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, "cli", NULL};
|
||||
ASSERT_EQ(classify(0, missing), CBM_DAEMON_PROCESS_INVALID);
|
||||
ASSERT_EQ(classify(3, mixed), CBM_DAEMON_PROCESS_INVALID);
|
||||
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_INVALID));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_uses_one_stable_per_account_endpoint) {
|
||||
bootstrap_endpoint_fixture_t fixture;
|
||||
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "stable-endpoint"));
|
||||
cbm_daemon_ipc_endpoint_t *second = cbm_daemon_bootstrap_endpoint_new(fixture.parent);
|
||||
ASSERT_NOT_NULL(second);
|
||||
ASSERT_STR_EQ(cbm_daemon_ipc_endpoint_address(fixture.endpoint),
|
||||
cbm_daemon_ipc_endpoint_address(second));
|
||||
cbm_daemon_ipc_endpoint_free(second);
|
||||
bootstrap_endpoint_fixture_finish(&fixture);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_launches_only_exact_detached_hidden_role) {
|
||||
cbm_daemon_bootstrap_launch_spec_t spec;
|
||||
ASSERT_TRUE(cbm_daemon_bootstrap_launch_spec_init("/tmp/cbm exact", &spec));
|
||||
ASSERT_EQ(spec.argc, CBM_DAEMON_BOOTSTRAP_LAUNCH_ARGC);
|
||||
ASSERT_STR_EQ(spec.executable_path, "/tmp/cbm exact");
|
||||
ASSERT_STR_EQ(spec.argv[0], "/tmp/cbm exact");
|
||||
ASSERT_STR_EQ(spec.argv[1], CBM_DAEMON_INTERNAL_ARG);
|
||||
ASSERT_NULL(spec.argv[2]);
|
||||
ASSERT_TRUE(spec.detached);
|
||||
ASSERT_FALSE(spec.inherit_standard_handles);
|
||||
ASSERT_FALSE(spec.use_shell);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_stateless_roles_bypass_every_daemon_operation) {
|
||||
bootstrap_fake_ops_t fake = {0};
|
||||
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
||||
cbm_daemon_bootstrap_config_t config = {
|
||||
.role = CBM_DAEMON_PROCESS_STATELESS,
|
||||
};
|
||||
cbm_daemon_bootstrap_result_t result;
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
||||
CBM_DAEMON_BOOTSTRAP_BYPASSED);
|
||||
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_BYPASSED);
|
||||
ASSERT_EQ(atomic_load(&fake.cohort_acquire_count), 0);
|
||||
ASSERT_EQ(atomic_load(&fake.probe_count), 0);
|
||||
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
||||
ASSERT_EQ(atomic_load(&fake.diagnostic_count), 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_cohort_conflict_is_visible_before_probe_or_spawn) {
|
||||
bootstrap_endpoint_fixture_t fixture;
|
||||
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "cohort-conflict"));
|
||||
bootstrap_fake_ops_t fake = {0};
|
||||
fake.forced_cohort = CBM_VERSION_COHORT_CONFLICT;
|
||||
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
||||
cbm_daemon_build_identity_t identity =
|
||||
bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_B);
|
||||
cbm_daemon_bootstrap_config_t config = {
|
||||
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
||||
.endpoint = fixture.endpoint,
|
||||
.identity = &identity,
|
||||
.executable_path = "/tmp/cbm",
|
||||
.connect_timeout_ms = 50,
|
||||
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
||||
};
|
||||
cbm_daemon_bootstrap_result_t result;
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
||||
CBM_DAEMON_BOOTSTRAP_CONFLICT);
|
||||
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONFLICT);
|
||||
ASSERT_EQ(atomic_load(&fake.cohort_acquire_count), 1);
|
||||
ASSERT_EQ(atomic_load(&fake.cohort_release_count), 0);
|
||||
ASSERT_EQ(atomic_load(&fake.probe_count), 0);
|
||||
ASSERT_EQ(atomic_load(&fake.lock_attempt_count), 0);
|
||||
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
||||
ASSERT_EQ(atomic_load(&fake.diagnostic_count), 1);
|
||||
ASSERT_NOT_NULL(strstr(fake.diagnostic, "conflicting CBM process is active"));
|
||||
ASSERT_NOT_NULL(strstr(fake.diagnostic,
|
||||
"Close all CBM sessions and commands"));
|
||||
bootstrap_endpoint_fixture_finish(&fixture);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_existing_exact_daemon_connects_without_spawn) {
|
||||
bootstrap_endpoint_fixture_t fixture;
|
||||
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "existing"));
|
||||
bootstrap_fake_ops_t fake = {0};
|
||||
atomic_store(&fake.available, true);
|
||||
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
||||
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
||||
cbm_daemon_bootstrap_config_t config = {
|
||||
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
||||
.endpoint = fixture.endpoint,
|
||||
.identity = &identity,
|
||||
.executable_path = "/tmp/cbm",
|
||||
.connect_timeout_ms = 50,
|
||||
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
||||
};
|
||||
cbm_daemon_bootstrap_result_t result;
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
||||
CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
||||
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
||||
ASSERT_NOT_NULL(result.client);
|
||||
ASSERT_FALSE(result.daemon_spawned);
|
||||
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
||||
bootstrap_endpoint_fixture_finish(&fixture);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_conflict_is_visible_and_never_spawns) {
|
||||
bootstrap_endpoint_fixture_t fixture;
|
||||
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "conflict"));
|
||||
bootstrap_fake_ops_t fake = {0};
|
||||
fake.forced_probe = CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT;
|
||||
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
||||
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_B);
|
||||
cbm_daemon_bootstrap_config_t config = {
|
||||
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
||||
.endpoint = fixture.endpoint,
|
||||
.identity = &identity,
|
||||
.executable_path = "/tmp/cbm",
|
||||
.connect_timeout_ms = 50,
|
||||
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
||||
};
|
||||
cbm_daemon_bootstrap_result_t result;
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
||||
CBM_DAEMON_BOOTSTRAP_CONFLICT);
|
||||
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONFLICT);
|
||||
ASSERT_NULL(result.client);
|
||||
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
||||
ASSERT_EQ(atomic_load(&fake.diagnostic_count), 1);
|
||||
ASSERT_NOT_NULL(strstr(fake.diagnostic, "conflicting versions"));
|
||||
bootstrap_endpoint_fixture_finish(&fixture);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_terminal_generation_that_never_exits_is_not_replaced) {
|
||||
bootstrap_endpoint_fixture_t fixture;
|
||||
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "terminal"));
|
||||
bootstrap_fake_ops_t fake = {0};
|
||||
fake.forced_probe = CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL;
|
||||
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
||||
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
||||
cbm_daemon_bootstrap_config_t config = {
|
||||
.role = CBM_DAEMON_PROCESS_HOOK_CLIENT,
|
||||
.endpoint = fixture.endpoint,
|
||||
.identity = &identity,
|
||||
.executable_path = "/tmp/cbm",
|
||||
.connect_timeout_ms = 1,
|
||||
.startup_timeout_ms = BOOTSTRAP_TEST_SHORT_TIMEOUT_MS,
|
||||
};
|
||||
cbm_daemon_bootstrap_result_t result;
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
||||
CBM_DAEMON_BOOTSTRAP_FAILED);
|
||||
ASSERT_EQ(atomic_load(&fake.lock_held), 0);
|
||||
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
||||
bootstrap_endpoint_fixture_finish(&fixture);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* RED for final-session/new-session overlap: STOPPING is a temporary state of
|
||||
* the previous same-build generation. Once that generation disappears, the
|
||||
* already-running bootstrap attempt must serialize and become the new first
|
||||
* client instead of forcing the coding agent to restart its MCP process. */
|
||||
TEST(daemon_bootstrap_terminal_then_absent_spawns_replacement) {
|
||||
bootstrap_endpoint_fixture_t fixture;
|
||||
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "terminal-absent"));
|
||||
bootstrap_fake_ops_t fake = {0};
|
||||
atomic_store(&fake.terminal_probes_remaining, 1);
|
||||
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
||||
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
||||
cbm_daemon_bootstrap_config_t config = {
|
||||
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
||||
.endpoint = fixture.endpoint,
|
||||
.identity = &identity,
|
||||
.executable_path = "/tmp/cbm",
|
||||
.connect_timeout_ms = 1,
|
||||
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
||||
};
|
||||
cbm_daemon_bootstrap_result_t result;
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
||||
CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
||||
ASSERT_TRUE(result.daemon_spawned);
|
||||
ASSERT_EQ(atomic_load(&fake.spawn_count), 1);
|
||||
ASSERT(atomic_load(&fake.lock_attempt_count) >= 1);
|
||||
bootstrap_endpoint_fixture_finish(&fixture);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_reserved_generation_becomes_connectable_without_spawn) {
|
||||
bootstrap_endpoint_fixture_t fixture;
|
||||
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "reserved-connect"));
|
||||
bootstrap_fake_ops_t fake = {0};
|
||||
atomic_store(&fake.reserved_probes_remaining, 2);
|
||||
atomic_store(&fake.connect_after_reserved, true);
|
||||
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
||||
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
||||
cbm_daemon_bootstrap_config_t config = {
|
||||
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
||||
.endpoint = fixture.endpoint,
|
||||
.identity = &identity,
|
||||
.executable_path = "/tmp/cbm",
|
||||
.connect_timeout_ms = 1,
|
||||
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
||||
};
|
||||
cbm_daemon_bootstrap_result_t result;
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
||||
CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
||||
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
||||
ASSERT_FALSE(result.daemon_spawned);
|
||||
ASSERT_EQ(atomic_load(&fake.lock_attempt_count), 0);
|
||||
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
||||
ASSERT(atomic_load(&fake.probe_count) >= 3);
|
||||
bootstrap_endpoint_fixture_finish(&fixture);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* RED for the equivalent race when the old listener vanishes without first
|
||||
* returning the explicit STOPPING response. Startup serialization decides
|
||||
* whether absence is now safe; a historical RESERVED sample is not sticky. */
|
||||
TEST(daemon_bootstrap_reserved_then_absent_spawns_replacement) {
|
||||
bootstrap_endpoint_fixture_t fixture;
|
||||
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "reserved-absent"));
|
||||
bootstrap_fake_ops_t fake = {0};
|
||||
atomic_store(&fake.reserved_probes_remaining, 1);
|
||||
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
||||
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
||||
cbm_daemon_bootstrap_config_t config = {
|
||||
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
||||
.endpoint = fixture.endpoint,
|
||||
.identity = &identity,
|
||||
.executable_path = "/tmp/cbm",
|
||||
.connect_timeout_ms = 1,
|
||||
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
||||
};
|
||||
cbm_daemon_bootstrap_result_t result;
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
||||
CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
||||
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
||||
ASSERT_TRUE(result.daemon_spawned);
|
||||
ASSERT(atomic_load(&fake.lock_attempt_count) >= 1);
|
||||
ASSERT_EQ(atomic_load(&fake.spawn_count), 1);
|
||||
ASSERT(atomic_load(&fake.probe_count) > 1);
|
||||
bootstrap_endpoint_fixture_finish(&fixture);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable) {
|
||||
cbm_daemon_runtime_connect_result_t capacity = {0};
|
||||
capacity.status = CBM_DAEMON_RUNTIME_CONNECT_REJECTED;
|
||||
snprintf(capacity.message, sizeof(capacity.message),
|
||||
"CBM daemon connection capacity reached");
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, 1),
|
||||
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, 0),
|
||||
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, -1),
|
||||
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
||||
|
||||
cbm_daemon_runtime_connect_result_t stopping = capacity;
|
||||
snprintf(stopping.message, sizeof(stopping.message), "CBM daemon is stopping");
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&stopping, 1),
|
||||
CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL);
|
||||
|
||||
cbm_daemon_runtime_connect_result_t absent = {0};
|
||||
absent.status = CBM_DAEMON_RUNTIME_CONNECT_ERROR;
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, 1),
|
||||
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, 0),
|
||||
CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE);
|
||||
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, -1),
|
||||
CBM_DAEMON_BOOTSTRAP_PROBE_ERROR);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_bootstrap_concurrent_first_clients_spawn_one_daemon) {
|
||||
bootstrap_endpoint_fixture_t fixture;
|
||||
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "startup-race"));
|
||||
bootstrap_fake_ops_t fake = {0};
|
||||
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
||||
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
||||
cbm_daemon_bootstrap_config_t config = {
|
||||
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
||||
.endpoint = fixture.endpoint,
|
||||
.identity = &identity,
|
||||
.executable_path = "/tmp/cbm",
|
||||
.connect_timeout_ms = 10,
|
||||
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
||||
};
|
||||
atomic_int ready = 0;
|
||||
atomic_bool go = false;
|
||||
bootstrap_thread_call_t calls[2] = {
|
||||
{.config = &config, .ops = &ops, .ready = &ready, .go = &go},
|
||||
{.config = &config, .ops = &ops, .ready = &ready, .go = &go},
|
||||
};
|
||||
cbm_thread_t threads[2];
|
||||
ASSERT_EQ(cbm_thread_create(&threads[0], 0, bootstrap_thread_execute, &calls[0]), 0);
|
||||
ASSERT_EQ(cbm_thread_create(&threads[1], 0, bootstrap_thread_execute, &calls[1]), 0);
|
||||
uint64_t ready_deadline = cbm_now_ms() + BOOTSTRAP_TEST_TIMEOUT_MS;
|
||||
while (atomic_load(&ready) != 2 && cbm_now_ms() < ready_deadline) {
|
||||
const struct timespec pause = {0, 1000000L};
|
||||
(void)cbm_nanosleep(&pause, NULL);
|
||||
}
|
||||
atomic_store(&go, true);
|
||||
ASSERT_EQ(cbm_thread_join(&threads[0]), 0);
|
||||
ASSERT_EQ(cbm_thread_join(&threads[1]), 0);
|
||||
ASSERT_EQ(calls[0].status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
||||
ASSERT_EQ(calls[1].status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
||||
ASSERT_EQ(atomic_load(&fake.spawn_count), 1);
|
||||
ASSERT_TRUE(calls[0].result.daemon_spawned != calls[1].result.daemon_spawned);
|
||||
bootstrap_endpoint_fixture_finish(&fixture);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(daemon_bootstrap) {
|
||||
RUN_TEST(daemon_bootstrap_classifies_default_and_ui_as_mcp_clients);
|
||||
RUN_TEST(daemon_bootstrap_classifies_stateless_commands_without_client);
|
||||
RUN_TEST(daemon_bootstrap_classifies_config_as_coordinated_local_cli);
|
||||
RUN_TEST(daemon_bootstrap_cli_help_is_stateless_but_tool_calls_are_local);
|
||||
RUN_TEST(daemon_bootstrap_cli_arguments_cannot_reclassify_the_process);
|
||||
RUN_TEST(daemon_bootstrap_internal_roles_never_take_client_leases);
|
||||
RUN_TEST(daemon_bootstrap_rejects_ambiguous_internal_daemon_argv);
|
||||
RUN_TEST(daemon_bootstrap_uses_one_stable_per_account_endpoint);
|
||||
RUN_TEST(daemon_bootstrap_launches_only_exact_detached_hidden_role);
|
||||
RUN_TEST(daemon_bootstrap_stateless_roles_bypass_every_daemon_operation);
|
||||
RUN_TEST(daemon_bootstrap_cohort_conflict_is_visible_before_probe_or_spawn);
|
||||
RUN_TEST(daemon_bootstrap_existing_exact_daemon_connects_without_spawn);
|
||||
RUN_TEST(daemon_bootstrap_conflict_is_visible_and_never_spawns);
|
||||
RUN_TEST(daemon_bootstrap_terminal_generation_that_never_exits_is_not_replaced);
|
||||
RUN_TEST(daemon_bootstrap_terminal_then_absent_spawns_replacement);
|
||||
RUN_TEST(daemon_bootstrap_reserved_generation_becomes_connectable_without_spawn);
|
||||
RUN_TEST(daemon_bootstrap_reserved_then_absent_spawns_replacement);
|
||||
RUN_TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable);
|
||||
RUN_TEST(daemon_bootstrap_concurrent_first_clients_spawn_one_daemon);
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
/*
|
||||
* test_daemon_frontend.c — Exact JSON-RPC cancellation and maintenance.
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include "test_helpers.h"
|
||||
|
||||
#include "daemon/frontend.h"
|
||||
#include "daemon/ipc.h"
|
||||
#include "daemon/service.h"
|
||||
#include "daemon/version_cohort.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/platform.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
enum { FRONTEND_TEST_PATH_CAP = 1024 };
|
||||
|
||||
typedef struct {
|
||||
char parent[FRONTEND_TEST_PATH_CAP];
|
||||
cbm_daemon_ipc_endpoint_t *endpoint;
|
||||
cbm_version_cohort_manager_t *manager;
|
||||
} frontend_maintenance_fixture_t;
|
||||
|
||||
static bool frontend_maintenance_fixture_start(
|
||||
frontend_maintenance_fixture_t *fixture, const char *tag) {
|
||||
memset(fixture, 0, sizeof(*fixture));
|
||||
int written = snprintf(fixture->parent, sizeof(fixture->parent),
|
||||
"%s/cbm-frontend-%s-XXXXXX", cbm_tmpdir(), tag);
|
||||
if (written <= 0 || written >= (int)sizeof(fixture->parent) ||
|
||||
!cbm_mkdtemp(fixture->parent)) {
|
||||
return false;
|
||||
}
|
||||
fixture->endpoint =
|
||||
cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture->parent);
|
||||
fixture->manager = fixture->endpoint
|
||||
? cbm_version_cohort_manager_new(fixture->endpoint)
|
||||
: NULL;
|
||||
return fixture->endpoint && fixture->manager;
|
||||
}
|
||||
|
||||
static void frontend_maintenance_fixture_finish(
|
||||
frontend_maintenance_fixture_t *fixture) {
|
||||
while (fixture->manager &&
|
||||
cbm_version_cohort_manager_free(&fixture->manager) !=
|
||||
CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
cbm_daemon_ipc_endpoint_free(fixture->endpoint);
|
||||
if (fixture->parent[0]) {
|
||||
(void)th_rmtree(fixture->parent);
|
||||
}
|
||||
memset(fixture, 0, sizeof(*fixture));
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
static const char FRONTEND_TEST_BUILD[] =
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
static cbm_daemon_build_identity_t frontend_test_identity(void) {
|
||||
cbm_daemon_build_identity_t identity = {
|
||||
.semantic_version = "2.4.0",
|
||||
.build_fingerprint = FRONTEND_TEST_BUILD,
|
||||
.protocol_abi = 3,
|
||||
.store_abi = 11,
|
||||
.feature_abi = 7,
|
||||
};
|
||||
return identity;
|
||||
}
|
||||
|
||||
static void frontend_test_release_lease(
|
||||
cbm_version_cohort_lease_t **lease) {
|
||||
while (lease && *lease &&
|
||||
cbm_version_cohort_lease_release(lease) !=
|
||||
CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
static bool frontend_test_wait_byte(int fd, char expected,
|
||||
uint64_t deadline_ms) {
|
||||
int flags = fcntl(fd, F_GETFL, 0);
|
||||
if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) {
|
||||
return false;
|
||||
}
|
||||
for (;;) {
|
||||
char value = '\0';
|
||||
ssize_t count = read(fd, &value, 1);
|
||||
if (count == 1) {
|
||||
return value == expected;
|
||||
}
|
||||
if (count == 0 ||
|
||||
(count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) ||
|
||||
cbm_now_ms() >= deadline_ms) {
|
||||
return false;
|
||||
}
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
static cbm_version_cohort_quiesce_result_t
|
||||
frontend_test_quiesce_requested(void *context) {
|
||||
(void)context;
|
||||
return CBM_VERSION_COHORT_QUIESCE_REQUESTED;
|
||||
}
|
||||
|
||||
static bool frontend_test_cancel_active(void *context) {
|
||||
int fd = *(int *)context;
|
||||
const char marker = 'C';
|
||||
return write(fd, &marker, 1) == 1;
|
||||
}
|
||||
|
||||
static bool frontend_test_cancel_and_mark(void *context) {
|
||||
atomic_bool *cancelled = context;
|
||||
atomic_store_explicit(cancelled, true, memory_order_release);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST(daemon_frontend_recognizes_exact_cancellation_notification) {
|
||||
ASSERT_TRUE(cbm_daemon_frontend_is_cancellation_notification(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\","
|
||||
"\"params\":{\"requestId\":7}}"));
|
||||
ASSERT_TRUE(cbm_daemon_frontend_is_cancellation_notification(
|
||||
"{ \"params\": {}, \"method\": \"notifications/cancelled\", "
|
||||
"\"jsonrpc\": \"2.0\" }"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* RED on the whole-session cancellation shortcut: merely recognizing the
|
||||
* notification method is not authority to close a session. The requestId
|
||||
* must match the exact numeric/string identity currently being executed. */
|
||||
TEST(daemon_frontend_correlates_cancellation_to_exact_request) {
|
||||
ASSERT_TRUE(cbm_daemon_frontend_cancellation_matches_request(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\","
|
||||
"\"params\":{\"requestId\":7}}",
|
||||
7, NULL));
|
||||
ASSERT_FALSE(cbm_daemon_frontend_cancellation_matches_request(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\","
|
||||
"\"params\":{\"requestId\":8}}",
|
||||
7, NULL));
|
||||
ASSERT_TRUE(cbm_daemon_frontend_cancellation_matches_request(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\","
|
||||
"\"params\":{\"requestId\":\"request-7\"}}",
|
||||
-1, "request-7"));
|
||||
ASSERT_FALSE(cbm_daemon_frontend_cancellation_matches_request(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\","
|
||||
"\"params\":{\"requestId\":7}}",
|
||||
-1, "7"));
|
||||
ASSERT_FALSE(cbm_daemon_frontend_cancellation_matches_request(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\","
|
||||
"\"params\":{}}",
|
||||
7, NULL));
|
||||
ASSERT_FALSE(cbm_daemon_frontend_cancellation_matches_request(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":9,"
|
||||
"\"method\":\"notifications/cancelled\","
|
||||
"\"params\":{\"requestId\":7}}",
|
||||
7, NULL));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_frontend_ignores_cancellation_text_in_string_content) {
|
||||
ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
|
||||
"\"params\":{\"name\":\"search_graph\",\"arguments\":{\"query\":"
|
||||
"\"notifications/cancelled\"}}}"));
|
||||
ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","
|
||||
"\"params\":{\"method\":\"notifications/cancelled\"}}"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_frontend_rejects_non_notification_cancellation_shapes) {
|
||||
ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":3,"
|
||||
"\"method\":\"notifications/cancelled\"}"));
|
||||
ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled-extra\"}"));
|
||||
ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"prefix/notifications/cancelled\"}"));
|
||||
ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification(
|
||||
"{\"jsonrpc\":\"2.0\",\"params\":{\"method\":"
|
||||
"\"notifications/cancelled\"}}"));
|
||||
ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification("not-json"));
|
||||
ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification(NULL));
|
||||
PASS();
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
/* RED: an MCP frontend's main thread can remain blocked forever in stdio while
|
||||
* install/update/uninstall owns maintenance intent. The already-present
|
||||
* frontend worker must observe that native intent and terminate this stateless
|
||||
* child, releasing its cohort lease and kernel IPC ownership. A deliberately
|
||||
* invalid client pointer is safe only if the maintenance path exits before
|
||||
* ordinary EOF/session-close handling, which also pins that exact path. */
|
||||
TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked) {
|
||||
frontend_maintenance_fixture_t fixture;
|
||||
ASSERT_TRUE(frontend_maintenance_fixture_start(&fixture, "blocked-stdio"));
|
||||
int input_pipe[2] = {-1, -1};
|
||||
int ready_pipe[2] = {-1, -1};
|
||||
bool pipes_ready = pipe(input_pipe) == 0 && pipe(ready_pipe) == 0;
|
||||
pid_t child = pipes_ready ? fork() : -1;
|
||||
if (child == 0) {
|
||||
(void)close(input_pipe[1]);
|
||||
(void)close(ready_pipe[0]);
|
||||
cbm_daemon_ipc_endpoint_t *endpoint =
|
||||
cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture.parent);
|
||||
cbm_version_cohort_manager_t *manager =
|
||||
endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL;
|
||||
cbm_version_cohort_lease_t *participant = NULL;
|
||||
cbm_daemon_conflict_t conflict;
|
||||
cbm_daemon_build_identity_t identity = frontend_test_identity();
|
||||
cbm_version_cohort_status_t admitted =
|
||||
manager ? cbm_version_cohort_acquire(
|
||||
manager, &identity, cbm_now_ms() + 2000U,
|
||||
&participant, &conflict)
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
FILE *input = admitted == CBM_VERSION_COHORT_OK
|
||||
? fdopen(input_pipe[0], "rb")
|
||||
: NULL;
|
||||
FILE *output = input ? tmpfile() : NULL;
|
||||
const char ready = 'R';
|
||||
bool announced = output && write(ready_pipe[1], &ready, 1) == 1;
|
||||
(void)close(ready_pipe[1]);
|
||||
if (!announced) {
|
||||
_exit(70);
|
||||
}
|
||||
int result = cbm_daemon_frontend_mcp_run(
|
||||
(cbm_daemon_runtime_client_t *)(uintptr_t)1, manager, input,
|
||||
output);
|
||||
(void)result;
|
||||
_exit(71);
|
||||
}
|
||||
|
||||
if (pipes_ready) {
|
||||
(void)close(input_pipe[0]);
|
||||
(void)close(ready_pipe[1]);
|
||||
}
|
||||
bool announced = child > 0 && frontend_test_wait_byte(
|
||||
ready_pipe[0], 'R', cbm_now_ms() + 2000U);
|
||||
cbm_version_cohort_lease_t *mutation = NULL;
|
||||
cbm_version_cohort_quiesce_result_t quiesce =
|
||||
CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED;
|
||||
cbm_version_cohort_status_t status =
|
||||
announced ? cbm_version_cohort_reserve_for_mutation(
|
||||
fixture.manager, cbm_now_ms() + 3000U,
|
||||
frontend_test_quiesce_requested, NULL, &quiesce,
|
||||
&mutation)
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
if (status != CBM_VERSION_COHORT_OK && child > 0) {
|
||||
(void)kill(child, SIGKILL);
|
||||
}
|
||||
int child_status = 0;
|
||||
bool waited = child > 0 && waitpid(child, &child_status, 0) == child;
|
||||
if (pipes_ready) {
|
||||
(void)close(input_pipe[1]);
|
||||
(void)close(ready_pipe[0]);
|
||||
}
|
||||
frontend_test_release_lease(&mutation);
|
||||
frontend_maintenance_fixture_finish(&fixture);
|
||||
|
||||
ASSERT_TRUE(pipes_ready);
|
||||
ASSERT_TRUE(child > 0);
|
||||
ASSERT_TRUE(announced);
|
||||
ASSERT_EQ(status, CBM_VERSION_COHORT_OK);
|
||||
ASSERT_EQ(quiesce, CBM_VERSION_COHORT_QUIESCE_REQUESTED);
|
||||
ASSERT_TRUE(waited);
|
||||
ASSERT_TRUE(WIFEXITED(child_status));
|
||||
ASSERT_EQ(WEXITSTATUS(child_status), 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* RED: local CLI/worker work has no standing daemon session to interrupt. Its
|
||||
* command-lifetime monitor must request cooperative cancellation once, allow a
|
||||
* bounded grace, then hard-exit the isolated process so SQLite/native locks
|
||||
* roll back and the mutation barrier can prove that every lifetime participant
|
||||
* is gone. */
|
||||
TEST(daemon_local_participant_monitor_cancels_then_bounds_active_operation) {
|
||||
frontend_maintenance_fixture_t fixture;
|
||||
ASSERT_TRUE(frontend_maintenance_fixture_start(&fixture, "local-monitor"));
|
||||
int ready_pipe[2] = {-1, -1};
|
||||
int cancel_pipe[2] = {-1, -1};
|
||||
bool pipes_ready = pipe(ready_pipe) == 0 && pipe(cancel_pipe) == 0;
|
||||
pid_t child = pipes_ready ? fork() : -1;
|
||||
if (child == 0) {
|
||||
(void)close(ready_pipe[0]);
|
||||
(void)close(cancel_pipe[0]);
|
||||
cbm_daemon_ipc_endpoint_t *endpoint =
|
||||
cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture.parent);
|
||||
cbm_version_cohort_manager_t *manager =
|
||||
endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL;
|
||||
cbm_version_cohort_lease_t *participant = NULL;
|
||||
cbm_daemon_conflict_t conflict;
|
||||
cbm_daemon_build_identity_t identity = frontend_test_identity();
|
||||
cbm_version_cohort_status_t admitted =
|
||||
manager ? cbm_version_cohort_acquire(
|
||||
manager, &identity, cbm_now_ms() + 2000U,
|
||||
&participant, &conflict)
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
cbm_daemon_maintenance_monitor_t *monitor =
|
||||
admitted == CBM_VERSION_COHORT_OK
|
||||
? cbm_daemon_maintenance_monitor_start(
|
||||
manager, frontend_test_cancel_active, &cancel_pipe[1],
|
||||
37, "test-local-operation")
|
||||
: NULL;
|
||||
const char ready = 'R';
|
||||
bool announced = monitor && write(ready_pipe[1], &ready, 1) == 1;
|
||||
(void)close(ready_pipe[1]);
|
||||
if (!announced) {
|
||||
_exit(72);
|
||||
}
|
||||
for (;;) {
|
||||
cbm_usleep(100000);
|
||||
}
|
||||
}
|
||||
|
||||
if (pipes_ready) {
|
||||
(void)close(ready_pipe[1]);
|
||||
(void)close(cancel_pipe[1]);
|
||||
}
|
||||
bool announced = child > 0 && frontend_test_wait_byte(
|
||||
ready_pipe[0], 'R', cbm_now_ms() + 2000U);
|
||||
cbm_version_cohort_lease_t *mutation = NULL;
|
||||
cbm_version_cohort_quiesce_result_t quiesce =
|
||||
CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED;
|
||||
cbm_version_cohort_status_t status =
|
||||
announced ? cbm_version_cohort_reserve_for_mutation(
|
||||
fixture.manager, cbm_now_ms() + 5000U,
|
||||
frontend_test_quiesce_requested, NULL, &quiesce,
|
||||
&mutation)
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
bool cancelled = status == CBM_VERSION_COHORT_OK &&
|
||||
frontend_test_wait_byte(cancel_pipe[0], 'C',
|
||||
cbm_now_ms() + 1000U);
|
||||
if (status != CBM_VERSION_COHORT_OK && child > 0) {
|
||||
(void)kill(child, SIGKILL);
|
||||
}
|
||||
int child_status = 0;
|
||||
bool waited = child > 0 && waitpid(child, &child_status, 0) == child;
|
||||
if (pipes_ready) {
|
||||
(void)close(ready_pipe[0]);
|
||||
(void)close(cancel_pipe[0]);
|
||||
}
|
||||
frontend_test_release_lease(&mutation);
|
||||
frontend_maintenance_fixture_finish(&fixture);
|
||||
|
||||
ASSERT_TRUE(pipes_ready);
|
||||
ASSERT_TRUE(child > 0);
|
||||
ASSERT_TRUE(announced);
|
||||
ASSERT_EQ(status, CBM_VERSION_COHORT_OK);
|
||||
ASSERT_EQ(quiesce, CBM_VERSION_COHORT_QUIESCE_REQUESTED);
|
||||
ASSERT_TRUE(cancelled);
|
||||
ASSERT_TRUE(waited);
|
||||
ASSERT_TRUE(WIFEXITED(child_status));
|
||||
ASSERT_EQ(WEXITSTATUS(child_status), 37);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* RED: supervised process-tree cancellation permits one second of graceful
|
||||
* shutdown followed by one second of forced containment. The maintenance
|
||||
* observer must not _Exit the owning process before that bounded supervisor
|
||||
* window can finish and join the observer normally. */
|
||||
TEST(daemon_local_participant_monitor_allows_supervisor_containment_window) {
|
||||
frontend_maintenance_fixture_t fixture;
|
||||
ASSERT_TRUE(frontend_maintenance_fixture_start(&fixture, "monitor-window"));
|
||||
int ready_pipe[2] = {-1, -1};
|
||||
bool pipe_ready = pipe(ready_pipe) == 0;
|
||||
pid_t child = pipe_ready ? fork() : -1;
|
||||
if (child == 0) {
|
||||
(void)close(ready_pipe[0]);
|
||||
cbm_daemon_ipc_endpoint_t *endpoint =
|
||||
cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture.parent);
|
||||
cbm_version_cohort_manager_t *manager =
|
||||
endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL;
|
||||
cbm_version_cohort_lease_t *participant = NULL;
|
||||
cbm_daemon_conflict_t conflict;
|
||||
cbm_daemon_build_identity_t identity = frontend_test_identity();
|
||||
cbm_version_cohort_status_t admitted =
|
||||
manager ? cbm_version_cohort_acquire(
|
||||
manager, &identity, cbm_now_ms() + 2000U,
|
||||
&participant, &conflict)
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
atomic_bool cancelled;
|
||||
atomic_init(&cancelled, false);
|
||||
cbm_daemon_maintenance_monitor_t *monitor =
|
||||
admitted == CBM_VERSION_COHORT_OK
|
||||
? cbm_daemon_maintenance_monitor_start(
|
||||
manager, frontend_test_cancel_and_mark, &cancelled,
|
||||
38, "test-supervisor-window")
|
||||
: NULL;
|
||||
const char ready = 'R';
|
||||
bool announced = monitor && write(ready_pipe[1], &ready, 1) == 1;
|
||||
(void)close(ready_pipe[1]);
|
||||
if (!announced) {
|
||||
_exit(72);
|
||||
}
|
||||
while (!atomic_load_explicit(&cancelled, memory_order_acquire)) {
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
|
||||
/* Model the maximum graceful + forced-settle supervisor bounds. */
|
||||
cbm_usleep(2100U * 1000U);
|
||||
bool stopped = cbm_daemon_maintenance_monitor_stop(&monitor);
|
||||
frontend_test_release_lease(&participant);
|
||||
while (manager &&
|
||||
cbm_version_cohort_manager_free(&manager) !=
|
||||
CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
cbm_daemon_ipc_endpoint_free(endpoint);
|
||||
_exit(stopped ? 0 : 73);
|
||||
}
|
||||
|
||||
if (pipe_ready) {
|
||||
(void)close(ready_pipe[1]);
|
||||
}
|
||||
bool announced = child > 0 && frontend_test_wait_byte(
|
||||
ready_pipe[0], 'R', cbm_now_ms() + 2000U);
|
||||
cbm_version_cohort_lease_t *mutation = NULL;
|
||||
cbm_version_cohort_quiesce_result_t quiesce =
|
||||
CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED;
|
||||
cbm_version_cohort_status_t status =
|
||||
announced ? cbm_version_cohort_reserve_for_mutation(
|
||||
fixture.manager, cbm_now_ms() + 5000U,
|
||||
frontend_test_quiesce_requested, NULL, &quiesce,
|
||||
&mutation)
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
if (status != CBM_VERSION_COHORT_OK && child > 0) {
|
||||
(void)kill(child, SIGKILL);
|
||||
}
|
||||
int child_status = 0;
|
||||
bool waited = child > 0 && waitpid(child, &child_status, 0) == child;
|
||||
if (pipe_ready) {
|
||||
(void)close(ready_pipe[0]);
|
||||
}
|
||||
frontend_test_release_lease(&mutation);
|
||||
frontend_maintenance_fixture_finish(&fixture);
|
||||
|
||||
ASSERT_TRUE(pipe_ready);
|
||||
ASSERT_TRUE(child > 0);
|
||||
ASSERT_TRUE(announced);
|
||||
ASSERT_EQ(status, CBM_VERSION_COHORT_OK);
|
||||
ASSERT_EQ(quiesce, CBM_VERSION_COHORT_QUIESCE_REQUESTED);
|
||||
ASSERT_TRUE(waited);
|
||||
ASSERT_TRUE(WIFEXITED(child_status));
|
||||
ASSERT_EQ(WEXITSTATUS(child_status), 0);
|
||||
PASS();
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST(daemon_local_participant_monitor_joins_before_manager_teardown) {
|
||||
frontend_maintenance_fixture_t fixture;
|
||||
ASSERT_TRUE(frontend_maintenance_fixture_start(&fixture, "monitor-join"));
|
||||
cbm_daemon_maintenance_monitor_t *monitor =
|
||||
cbm_daemon_maintenance_monitor_start(
|
||||
fixture.manager, NULL, NULL, EXIT_FAILURE, "test-idle-command");
|
||||
bool started = monitor != NULL;
|
||||
bool stopped = started && cbm_daemon_maintenance_monitor_stop(&monitor);
|
||||
bool consumed = monitor == NULL;
|
||||
frontend_maintenance_fixture_finish(&fixture);
|
||||
|
||||
ASSERT_TRUE(started);
|
||||
ASSERT_TRUE(stopped);
|
||||
ASSERT_TRUE(consumed);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(daemon_frontend) {
|
||||
RUN_TEST(daemon_frontend_recognizes_exact_cancellation_notification);
|
||||
RUN_TEST(daemon_frontend_correlates_cancellation_to_exact_request);
|
||||
RUN_TEST(daemon_frontend_ignores_cancellation_text_in_string_content);
|
||||
RUN_TEST(daemon_frontend_rejects_non_notification_cancellation_shapes);
|
||||
#ifndef _WIN32
|
||||
RUN_TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked);
|
||||
RUN_TEST(daemon_local_participant_monitor_cancels_then_bounds_active_operation);
|
||||
RUN_TEST(daemon_local_participant_monitor_allows_supervisor_containment_window);
|
||||
#endif
|
||||
RUN_TEST(daemon_local_participant_monitor_joins_before_manager_teardown);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,641 @@
|
||||
/*
|
||||
* test_daemon_version.c — RED contract for one-version daemon rendezvous.
|
||||
*
|
||||
* These tests intentionally target the service boundary that will be exposed
|
||||
* by daemon/service.h. They remain a separate suite so implementation can be
|
||||
* developed without mixing version policy into transport or job-lifecycle
|
||||
* tests.
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
|
||||
#include "daemon/daemon.h"
|
||||
#include "daemon/service.h"
|
||||
#include "daemon/service_internal.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/compat_thread.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#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
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <watcher/watcher.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#ifndef _WIN32
|
||||
@@ -431,6 +432,79 @@ static int th_server_start_with_watcher(th_server_t *ts, cbm_watcher_t *watcher)
|
||||
return 0;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
atomic_int calls;
|
||||
char root_path[512];
|
||||
char project_name[256];
|
||||
} th_ui_index_executor_t;
|
||||
|
||||
static int th_ui_index_executor(void *opaque, const char *root_path,
|
||||
const char *project_name) {
|
||||
th_ui_index_executor_t *executor = opaque;
|
||||
snprintf(executor->root_path, sizeof(executor->root_path), "%s", root_path);
|
||||
snprintf(executor->project_name, sizeof(executor->project_name), "%s",
|
||||
project_name ? project_name : "");
|
||||
atomic_fetch_add(&executor->calls, 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool th_wait_atomic_int(atomic_int *value, int expected, uint32_t timeout_ms) {
|
||||
uint64_t deadline = cbm_now_ms() + timeout_ms;
|
||||
while (cbm_now_ms() < deadline) {
|
||||
if (atomic_load(value) == expected) {
|
||||
return true;
|
||||
}
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
return atomic_load(value) == expected;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
atomic_int begin_calls;
|
||||
atomic_int end_calls;
|
||||
bool allow;
|
||||
char begin_project[256];
|
||||
char end_project[256];
|
||||
} th_ui_mutation_guard_t;
|
||||
|
||||
static void th_ui_mutation_guard_init(th_ui_mutation_guard_t *guard, bool allow) {
|
||||
memset(guard, 0, sizeof(*guard));
|
||||
atomic_init(&guard->begin_calls, 0);
|
||||
atomic_init(&guard->end_calls, 0);
|
||||
guard->allow = allow;
|
||||
}
|
||||
|
||||
static bool th_ui_mutation_begin(void *opaque, const char *project) {
|
||||
th_ui_mutation_guard_t *guard = opaque;
|
||||
snprintf(guard->begin_project, sizeof(guard->begin_project), "%s",
|
||||
project ? project : "");
|
||||
atomic_fetch_add(&guard->begin_calls, 1);
|
||||
return guard->allow;
|
||||
}
|
||||
|
||||
static void th_ui_mutation_end(void *opaque, const char *project) {
|
||||
th_ui_mutation_guard_t *guard = opaque;
|
||||
snprintf(guard->end_project, sizeof(guard->end_project), "%s",
|
||||
project ? project : "");
|
||||
atomic_fetch_add(&guard->end_calls, 1);
|
||||
}
|
||||
|
||||
static int th_server_start_with_mutation_guard(th_server_t *ts, cbm_watcher_t *watcher,
|
||||
th_ui_mutation_guard_t *guard) {
|
||||
ts->srv = cbm_http_server_new(0);
|
||||
if (!ts->srv)
|
||||
return -1;
|
||||
if (watcher)
|
||||
cbm_http_server_set_watcher(ts->srv, watcher);
|
||||
cbm_http_server_set_project_mutation_guard(ts->srv, th_ui_mutation_begin,
|
||||
th_ui_mutation_end, guard);
|
||||
if (cbm_thread_create(&ts->tid, 0, th_server_thread, ts->srv) != 0) {
|
||||
cbm_http_server_free(ts->srv);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void th_server_stop(th_server_t *ts) {
|
||||
cbm_http_server_stop(ts->srv);
|
||||
cbm_thread_join(&ts->tid);
|
||||
@@ -507,6 +581,65 @@ static int ui_delete_request(th_server_t *ts, const char *target, char *resp, si
|
||||
return th_http(cbm_http_server_port(ts->srv), req, resp, respsz);
|
||||
}
|
||||
|
||||
static int ui_adr_post_request(th_server_t *ts, const char *project, const char *content,
|
||||
char *resp, size_t respsz) {
|
||||
char body[2048];
|
||||
int body_len = snprintf(body, sizeof(body),
|
||||
"{\"project\":\"%s\",\"content\":\"%s\"}", project,
|
||||
content);
|
||||
if (body_len < 0 || (size_t)body_len >= sizeof(body))
|
||||
return 0;
|
||||
|
||||
char req[2304];
|
||||
int req_len = snprintf(req, sizeof(req),
|
||||
"POST /api/adr HTTP/1.1\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
"Content-Length: %d\r\n\r\n%s",
|
||||
body_len, body);
|
||||
if (req_len < 0 || (size_t)req_len >= sizeof(req))
|
||||
return 0;
|
||||
return th_http(cbm_http_server_port(ts->srv), req, resp, respsz);
|
||||
}
|
||||
|
||||
static int ui_adr_get_request(th_server_t *ts, const char *project, char *resp,
|
||||
size_t respsz) {
|
||||
char req[512];
|
||||
int req_len = snprintf(req, sizeof(req),
|
||||
"GET /api/adr?project=%s HTTP/1.1\r\n\r\n", project);
|
||||
if (req_len < 0 || (size_t)req_len >= sizeof(req))
|
||||
return 0;
|
||||
return th_http(cbm_http_server_port(ts->srv), req, resp, respsz);
|
||||
}
|
||||
|
||||
static int ui_adr_seed(const ui_delete_fixture_t *fx, const char *project,
|
||||
const char *content) {
|
||||
char db_path[1024];
|
||||
ui_delete_db_path(fx, project, db_path, sizeof(db_path));
|
||||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||||
if (!store)
|
||||
return CBM_STORE_ERR;
|
||||
int rc = cbm_store_adr_store(store, project, content);
|
||||
cbm_store_close(store);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static bool ui_adr_equals(const ui_delete_fixture_t *fx, const char *project,
|
||||
const char *expected) {
|
||||
char db_path[1024];
|
||||
ui_delete_db_path(fx, project, db_path, sizeof(db_path));
|
||||
cbm_store_t *store = cbm_store_open_path_query(db_path);
|
||||
if (!store)
|
||||
return false;
|
||||
|
||||
cbm_adr_t adr = {0};
|
||||
int rc = cbm_store_adr_get(store, project, &adr);
|
||||
bool equal = rc == CBM_STORE_OK && adr.content && strcmp(adr.content, expected) == 0;
|
||||
if (rc == CBM_STORE_OK)
|
||||
cbm_store_adr_free(&adr);
|
||||
cbm_store_close(store);
|
||||
return equal;
|
||||
}
|
||||
|
||||
TEST(ui_server_unknown_path_404) {
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start(&ts), 0);
|
||||
@@ -524,6 +657,67 @@ TEST(ui_server_unknown_path_404) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Security regression: process IDs are not cancellation capabilities. The UI
|
||||
* must never expose an endpoint that accepts an arbitrary PID and reaches an
|
||||
* OS process-termination API (the former Windows path accepted every PID but
|
||||
* self). Daemon-owned jobs are cancelled by opaque, owner-bound subscription
|
||||
* handles instead, so this legacy route must be completely absent. */
|
||||
TEST(ui_server_process_kill_route_is_unavailable) {
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start(&ts), 0);
|
||||
int port = cbm_http_server_port(ts.srv);
|
||||
|
||||
static const char body[] = "{\"pid\":2147483646}";
|
||||
char request[512];
|
||||
snprintf(request, sizeof(request),
|
||||
"POST /api/process-kill HTTP/1.1\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
"Content-Length: %zu\r\n\r\n%s",
|
||||
strlen(body), body);
|
||||
char resp[4096];
|
||||
int n = th_http(port, request, resp, sizeof(resp));
|
||||
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 404);
|
||||
ASSERT_NULL(strstr(resp, "\"killed\""));
|
||||
th_server_stop(&ts);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_routes_indexing_through_joinable_daemon_executor) {
|
||||
char *root = th_mktempdir("cbm_httpd_daemon_index");
|
||||
ASSERT_NOT_NULL(root);
|
||||
th_ui_index_executor_t executor = {0};
|
||||
atomic_init(&executor.calls, 0);
|
||||
th_server_t ts;
|
||||
ts.srv = cbm_http_server_new(0);
|
||||
ASSERT_NOT_NULL(ts.srv);
|
||||
cbm_http_server_set_index_executor(ts.srv, th_ui_index_executor, &executor);
|
||||
ASSERT_EQ(cbm_thread_create(&ts.tid, 0, th_server_thread, ts.srv), 0);
|
||||
|
||||
char body[1024];
|
||||
snprintf(body, sizeof(body),
|
||||
"{\"root_path\":\"%s\",\"project_name\":\"ui-project\"}", root);
|
||||
char request[1400];
|
||||
snprintf(request, sizeof(request),
|
||||
"POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n"
|
||||
"Content-Length: %zu\r\n\r\n%s",
|
||||
strlen(body), body);
|
||||
char response[4096];
|
||||
int response_length = th_http(cbm_http_server_port(ts.srv), request,
|
||||
response, sizeof(response));
|
||||
bool called = th_wait_atomic_int(&executor.calls, 1, 2000);
|
||||
|
||||
th_server_stop(&ts);
|
||||
ASSERT_GT(response_length, 0);
|
||||
ASSERT_EQ(th_status(response), 202);
|
||||
ASSERT_TRUE(called);
|
||||
ASSERT_STR_EQ(executor.root_path, root);
|
||||
ASSERT_STR_EQ(executor.project_name, "ui-project");
|
||||
th_cleanup(root);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_root_serves_stub_404) {
|
||||
/* Test binary links embedded_stub.c → no frontend → 404 with marker */
|
||||
th_server_t ts;
|
||||
@@ -650,6 +844,143 @@ TEST(ui_server_browse_traversal_probe) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_adr_mutation_guard_busy_preserves_existing_adr) {
|
||||
static const char *project = "ui-guard-adr-busy";
|
||||
static const char *original = "original architecture";
|
||||
ui_delete_fixture_t fx;
|
||||
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
|
||||
ASSERT_EQ(ui_adr_seed(&fx, project, original), CBM_STORE_OK);
|
||||
|
||||
th_ui_mutation_guard_t guard;
|
||||
th_ui_mutation_guard_init(&guard, false);
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start_with_mutation_guard(&ts, NULL, &guard), 0);
|
||||
|
||||
char resp[4096];
|
||||
int n = ui_adr_post_request(&ts, project, "replacement architecture", resp,
|
||||
sizeof(resp));
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 423);
|
||||
ASSERT_NOT_NULL(strstr(resp, "project is busy; retry after indexing"));
|
||||
ASSERT_EQ(atomic_load(&guard.begin_calls), 1);
|
||||
ASSERT_EQ(atomic_load(&guard.end_calls), 0);
|
||||
ASSERT_STR_EQ(guard.begin_project, project);
|
||||
|
||||
/* A rejected mutation must leave the published DB queryable and unchanged.
|
||||
* GET is a query operation and therefore must not enter the mutation guard. */
|
||||
n = ui_adr_get_request(&ts, project, resp, sizeof(resp));
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 200);
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"has_adr\":true"));
|
||||
ASSERT_NOT_NULL(strstr(resp, original));
|
||||
ASSERT_EQ(atomic_load(&guard.begin_calls), 1);
|
||||
ASSERT_EQ(atomic_load(&guard.end_calls), 0);
|
||||
ASSERT_TRUE(ui_adr_equals(&fx, project, original));
|
||||
|
||||
th_server_stop(&ts);
|
||||
ui_delete_fixture_cleanup(&fx);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_adr_mutation_guard_balances_success) {
|
||||
static const char *project = "ui-guard-adr-success";
|
||||
static const char *content = "coordinated architecture";
|
||||
ui_delete_fixture_t fx;
|
||||
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
|
||||
|
||||
th_ui_mutation_guard_t guard;
|
||||
th_ui_mutation_guard_init(&guard, true);
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start_with_mutation_guard(&ts, NULL, &guard), 0);
|
||||
|
||||
char resp[4096];
|
||||
int n = ui_adr_post_request(&ts, project, content, resp, sizeof(resp));
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 200);
|
||||
ASSERT_NOT_NULL(strstr(resp, "{\"saved\":true}"));
|
||||
ASSERT_EQ(atomic_load(&guard.begin_calls), 1);
|
||||
ASSERT_EQ(atomic_load(&guard.end_calls), 1);
|
||||
ASSERT_STR_EQ(guard.begin_project, project);
|
||||
ASSERT_STR_EQ(guard.end_project, project);
|
||||
ASSERT_TRUE(ui_adr_equals(&fx, project, content));
|
||||
|
||||
th_server_stop(&ts);
|
||||
ui_delete_fixture_cleanup(&fx);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_delete_mutation_guard_busy_preserves_project) {
|
||||
static const char *project = "ui-guard-delete-busy";
|
||||
ui_delete_fixture_t fx;
|
||||
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
|
||||
ASSERT_EQ(ui_delete_make_db_file(&fx, project), 0);
|
||||
ASSERT_EQ(ui_delete_make_sidecars(&fx, project), 0);
|
||||
cbm_watcher_watch(fx.watcher, project, fx.root_dir);
|
||||
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
|
||||
|
||||
th_ui_mutation_guard_t guard;
|
||||
th_ui_mutation_guard_init(&guard, false);
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start_with_mutation_guard(&ts, fx.watcher, &guard), 0);
|
||||
|
||||
char resp[4096];
|
||||
int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-busy", resp,
|
||||
sizeof(resp));
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 423);
|
||||
ASSERT_NOT_NULL(strstr(resp, "project is busy; retry after indexing"));
|
||||
ASSERT_EQ(atomic_load(&guard.begin_calls), 1);
|
||||
ASSERT_EQ(atomic_load(&guard.end_calls), 0);
|
||||
ASSERT_STR_EQ(guard.begin_project, project);
|
||||
|
||||
char db_path[1024], wal_path[1040], shm_path[1040];
|
||||
ui_delete_db_path(&fx, project, db_path, sizeof(db_path));
|
||||
snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path);
|
||||
snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path);
|
||||
ASSERT_TRUE(cbm_file_exists(db_path));
|
||||
ASSERT_TRUE(cbm_file_exists(wal_path));
|
||||
ASSERT_TRUE(cbm_file_exists(shm_path));
|
||||
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
|
||||
|
||||
th_server_stop(&ts);
|
||||
ui_delete_fixture_cleanup(&fx);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_delete_mutation_guard_balances_success) {
|
||||
static const char *project = "ui-guard-delete-success";
|
||||
ui_delete_fixture_t fx;
|
||||
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
|
||||
ASSERT_EQ(ui_delete_make_db_file(&fx, project), 0);
|
||||
cbm_watcher_watch(fx.watcher, project, fx.root_dir);
|
||||
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1);
|
||||
|
||||
th_ui_mutation_guard_t guard;
|
||||
th_ui_mutation_guard_init(&guard, true);
|
||||
th_server_t ts;
|
||||
ASSERT_EQ(th_server_start_with_mutation_guard(&ts, fx.watcher, &guard), 0);
|
||||
|
||||
char resp[4096];
|
||||
int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-success", resp,
|
||||
sizeof(resp));
|
||||
ASSERT_GT(n, 0);
|
||||
ASSERT_EQ(th_status(resp), 200);
|
||||
ASSERT_NOT_NULL(strstr(resp, "{\"deleted\":true}"));
|
||||
ASSERT_EQ(atomic_load(&guard.begin_calls), 1);
|
||||
ASSERT_EQ(atomic_load(&guard.end_calls), 1);
|
||||
ASSERT_STR_EQ(guard.begin_project, project);
|
||||
ASSERT_STR_EQ(guard.end_project, project);
|
||||
|
||||
char db_path[1024];
|
||||
ui_delete_db_path(&fx, project, db_path, sizeof(db_path));
|
||||
ASSERT_FALSE(cbm_file_exists(db_path));
|
||||
ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 0);
|
||||
|
||||
th_server_stop(&ts);
|
||||
ui_delete_fixture_cleanup(&fx);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(ui_server_delete_project_unwatches_after_delete) {
|
||||
ui_delete_fixture_t fx;
|
||||
ASSERT_EQ(ui_delete_fixture_init(&fx), 0);
|
||||
@@ -1199,6 +1530,8 @@ SUITE(httpd) {
|
||||
/* Full UI server */
|
||||
RUN_TEST(ui_server_rejects_non_loopback_host);
|
||||
RUN_TEST(ui_server_unknown_path_404);
|
||||
RUN_TEST(ui_server_process_kill_route_is_unavailable);
|
||||
RUN_TEST(ui_server_routes_indexing_through_joinable_daemon_executor);
|
||||
RUN_TEST(ui_server_root_serves_stub_404);
|
||||
RUN_TEST(ui_server_cors_localhost_reflected);
|
||||
RUN_TEST(ui_server_cors_evil_origin_not_reflected);
|
||||
@@ -1207,6 +1540,10 @@ SUITE(httpd) {
|
||||
RUN_TEST(ui_server_encoded_slash_not_routed);
|
||||
RUN_TEST(ui_server_nul_in_target_rejected);
|
||||
RUN_TEST(ui_server_browse_traversal_probe);
|
||||
RUN_TEST(ui_server_adr_mutation_guard_busy_preserves_existing_adr);
|
||||
RUN_TEST(ui_server_adr_mutation_guard_balances_success);
|
||||
RUN_TEST(ui_server_delete_mutation_guard_busy_preserves_project);
|
||||
RUN_TEST(ui_server_delete_mutation_guard_balances_success);
|
||||
RUN_TEST(ui_server_delete_project_unwatches_after_delete);
|
||||
RUN_TEST(ui_server_delete_project_unwatches_missing_db);
|
||||
RUN_TEST(ui_server_delete_project_no_watcher_still_deletes);
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
/* RED contract for daemon-owned asynchronous index workers. */
|
||||
#include "test_framework.h"
|
||||
#include "test_helpers.h"
|
||||
|
||||
#include "cli/progress_sink.h"
|
||||
#include "daemon/bootstrap.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/profile.h"
|
||||
#include "mcp/index_supervisor.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
enum {
|
||||
INDEX_SUPERVISOR_TEST_PATH_CAP = 1024,
|
||||
INDEX_SUPERVISOR_TEST_TERMINAL_MS = 5000,
|
||||
INDEX_SUPERVISOR_TEST_BACKLOG_LINES = 1024,
|
||||
};
|
||||
|
||||
static void index_supervisor_test_pause(void) {
|
||||
const struct timespec pause = {0, 10000000L};
|
||||
(void)cbm_nanosleep(&pause, NULL);
|
||||
}
|
||||
|
||||
static bool index_supervisor_test_poll_terminal(cbm_index_worker_handle_t *handle,
|
||||
uint32_t timeout_ms,
|
||||
const cbm_index_worker_result_t **result_out) {
|
||||
uint64_t deadline = cbm_now_ms() + timeout_ms;
|
||||
do {
|
||||
cbm_index_worker_poll_t state = cbm_index_worker_poll(handle, result_out);
|
||||
if (state == CBM_INDEX_WORKER_POLL_TERMINAL) {
|
||||
return result_out && *result_out;
|
||||
}
|
||||
if (state == CBM_INDEX_WORKER_POLL_ERROR) {
|
||||
return false;
|
||||
}
|
||||
index_supervisor_test_pause();
|
||||
} while (cbm_now_ms() < deadline);
|
||||
return cbm_index_worker_poll(handle, result_out) == CBM_INDEX_WORKER_POLL_TERMINAL;
|
||||
}
|
||||
|
||||
static bool index_supervisor_test_wait_file(cbm_index_worker_handle_t *handle, const char *path,
|
||||
char *out, size_t out_size) {
|
||||
uint64_t deadline = cbm_now_ms() + 3000;
|
||||
do {
|
||||
FILE *file = cbm_fopen(path, "rb");
|
||||
if (file) {
|
||||
size_t used = fread(out, 1, out_size - 1, file);
|
||||
out[used] = '\0';
|
||||
(void)fclose(file);
|
||||
if (used > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const cbm_index_worker_result_t *unexpected = NULL;
|
||||
if (cbm_index_worker_poll(handle, &unexpected) != CBM_INDEX_WORKER_POLL_RUNNING) {
|
||||
return false;
|
||||
}
|
||||
index_supervisor_test_pause();
|
||||
} while (cbm_now_ms() < deadline);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool index_supervisor_test_wait_file_text(const char *path, const char *needle,
|
||||
uint32_t timeout_ms) {
|
||||
uint64_t deadline = cbm_now_ms() + timeout_ms;
|
||||
do {
|
||||
FILE *file = cbm_fopen(path, "rb");
|
||||
if (file) {
|
||||
char content[4096] = {0};
|
||||
size_t used = fread(content, 1, sizeof(content) - 1, file);
|
||||
content[used] = '\0';
|
||||
(void)fclose(file);
|
||||
if (strstr(content, needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
index_supervisor_test_pause();
|
||||
} while (cbm_now_ms() < deadline);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool index_supervisor_test_append_log(const char *path, const char *line) {
|
||||
FILE *file = cbm_fopen(path, "ab");
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
bool written = fputs(line, file) >= 0 && fflush(file) == 0;
|
||||
return fclose(file) == 0 && written;
|
||||
}
|
||||
|
||||
static bool index_supervisor_test_append_terminal_backlog(const char *path) {
|
||||
FILE *file = cbm_fopen(path, "ab");
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
bool written =
|
||||
fputs("level=info msg=pipeline.discover files=7\n", file) >= 0 &&
|
||||
fputs("{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}\n",
|
||||
file) >= 0;
|
||||
for (int i = 0; written && i < INDEX_SUPERVISOR_TEST_BACKLOG_LINES; i++) {
|
||||
written = fprintf(file, "terminal-backlog-%04d\n", i) > 0;
|
||||
}
|
||||
written = written && fputs("terminal-backlog-sentinel\n", file) >= 0 &&
|
||||
fflush(file) == 0;
|
||||
return fclose(file) == 0 && written;
|
||||
}
|
||||
|
||||
static void index_supervisor_test_cleanup_handle(cbm_index_worker_handle_t *handle) {
|
||||
if (!handle) {
|
||||
return;
|
||||
}
|
||||
(void)cbm_index_worker_request_cancel(handle);
|
||||
const cbm_index_worker_result_t *result = NULL;
|
||||
if (index_supervisor_test_poll_terminal(handle, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &result)) {
|
||||
cbm_index_worker_destroy(handle);
|
||||
}
|
||||
}
|
||||
|
||||
static void index_supervisor_test_restore_env(const char *name, char *saved) {
|
||||
if (saved) {
|
||||
(void)cbm_setenv(name, saved, 1);
|
||||
} else {
|
||||
(void)cbm_unsetenv(name);
|
||||
}
|
||||
free(saved);
|
||||
}
|
||||
|
||||
static bool index_supervisor_test_owner_file(const char *path) {
|
||||
#ifdef _WIN32
|
||||
return cbm_file_size(path) >= 0;
|
||||
#else
|
||||
struct stat status;
|
||||
return path && stat(path, &status) == 0 && S_ISREG(status.st_mode) &&
|
||||
(status.st_mode & 0777) == 0600;
|
||||
#endif
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
int delivered;
|
||||
bool saw_clean_probe;
|
||||
bool saw_request_a;
|
||||
bool saw_request_b;
|
||||
bool saw_structured_text;
|
||||
bool saw_structured_json;
|
||||
bool saw_backlog_sentinel;
|
||||
int backlog_lines;
|
||||
bool render_progress;
|
||||
} index_supervisor_log_capture_t;
|
||||
|
||||
static void index_supervisor_test_capture_log(const char *line, void *context) {
|
||||
index_supervisor_log_capture_t *capture = context;
|
||||
if (!capture || !line) {
|
||||
return;
|
||||
}
|
||||
capture->delivered++;
|
||||
if (strstr(line, "async worker clean probe")) {
|
||||
capture->saw_clean_probe = true;
|
||||
}
|
||||
if (strcmp(line, "request-a-only") == 0) {
|
||||
capture->saw_request_a = true;
|
||||
}
|
||||
if (strcmp(line, "request-b-only") == 0) {
|
||||
capture->saw_request_b = true;
|
||||
}
|
||||
if (strcmp(line, "level=info msg=pipeline.discover files=7") == 0) {
|
||||
capture->saw_structured_text = true;
|
||||
}
|
||||
if (strcmp(line,
|
||||
"{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}") == 0) {
|
||||
capture->saw_structured_json = true;
|
||||
}
|
||||
if (strncmp(line, "terminal-backlog-", strlen("terminal-backlog-")) == 0) {
|
||||
capture->backlog_lines++;
|
||||
}
|
||||
if (strcmp(line, "terminal-backlog-sentinel") == 0) {
|
||||
capture->saw_backlog_sentinel = true;
|
||||
}
|
||||
if (capture->render_progress) {
|
||||
cbm_progress_sink_fn(line);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(index_supervisor_worker_argv_requires_exact_build_bound_grammar) {
|
||||
const char *captured = cbm_index_supervisor_build_fingerprint();
|
||||
ASSERT_NOT_NULL(captured);
|
||||
ASSERT_EQ(strlen(captured), CBM_INDEX_WORKER_BUILD_FINGERPRINT_LENGTH);
|
||||
|
||||
char mismatched[CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE];
|
||||
(void)snprintf(mismatched, sizeof(mismatched), "%s", captured);
|
||||
mismatched[0] = mismatched[0] == '0' ? '1' : '0';
|
||||
|
||||
char *valid[] = {
|
||||
"test-runner",
|
||||
"cli",
|
||||
"--index-worker",
|
||||
CBM_INDEX_WORKER_BUILD_ARG,
|
||||
(char *)captured,
|
||||
"index_repository",
|
||||
"{\"__cbm_test_worker\":\"hang-tree\"}",
|
||||
"--response-out",
|
||||
"/tmp/r",
|
||||
CBM_INDEX_WORKER_MEMORY_BUDGET_ARG,
|
||||
"1024",
|
||||
CBM_INDEX_WORKER_SINGLE_THREAD_ARG,
|
||||
CBM_INDEX_WORKER_MARKER_ARG,
|
||||
"/tmp/m",
|
||||
CBM_INDEX_WORKER_QUARANTINE_ARG,
|
||||
"/tmp/q",
|
||||
NULL,
|
||||
};
|
||||
cbm_index_worker_invocation_t invocation;
|
||||
ASSERT_EQ(cbm_index_worker_parse_process_argv(16, valid, &invocation),
|
||||
CBM_INDEX_WORKER_ARGV_VALID);
|
||||
ASSERT_EQ(cbm_daemon_process_role(16, valid), CBM_DAEMON_PROCESS_WORKER);
|
||||
ASSERT_STR_EQ(invocation.args_json, valid[6]);
|
||||
ASSERT_STR_EQ(invocation.response_out, valid[8]);
|
||||
ASSERT_EQ(invocation.memory_budget_bytes, 1024);
|
||||
ASSERT_TRUE(invocation.single_thread);
|
||||
ASSERT_STR_EQ(invocation.marker_file, valid[13]);
|
||||
ASSERT_STR_EQ(invocation.quarantine_file, valid[15]);
|
||||
|
||||
char *missing_build[] = {"test-runner", "cli", "--index-worker", "index_repository",
|
||||
"{}", "--response-out", "/tmp/r", NULL};
|
||||
char *wrong_build[] = {"test-runner", "cli", "--index-worker",
|
||||
CBM_INDEX_WORKER_BUILD_ARG, mismatched, "index_repository",
|
||||
"{}", "--response-out", "/tmp/r", NULL};
|
||||
char *reordered[] = {"test-runner", "cli", "--index-worker",
|
||||
CBM_INDEX_WORKER_BUILD_ARG, (char *)captured, "index_repository",
|
||||
"{}", "--response-out", "/tmp/r",
|
||||
CBM_INDEX_WORKER_QUARANTINE_ARG, "/tmp/q",
|
||||
CBM_INDEX_WORKER_MARKER_ARG, "/tmp/m", NULL};
|
||||
char *trailing[] = {"test-runner", "cli", "--index-worker",
|
||||
CBM_INDEX_WORKER_BUILD_ARG, (char *)captured, "index_repository",
|
||||
"{}", "--response-out", "/tmp/r", "unexpected", NULL};
|
||||
char *zero_budget[] = {"test-runner", "cli", "--index-worker",
|
||||
CBM_INDEX_WORKER_BUILD_ARG, (char *)captured, "index_repository",
|
||||
"{}", "--response-out", "/tmp/r",
|
||||
CBM_INDEX_WORKER_MEMORY_BUDGET_ARG, "0", NULL};
|
||||
char *overflow_budget[] = {
|
||||
"test-runner", "cli", "--index-worker", CBM_INDEX_WORKER_BUILD_ARG,
|
||||
(char *)captured, "index_repository", "{}", "--response-out", "/tmp/r",
|
||||
CBM_INDEX_WORKER_MEMORY_BUDGET_ARG, "184467440737095516160", NULL};
|
||||
char *user_value[] = {"test-runner", "cli", "search_code", "--query",
|
||||
"--index-worker", NULL};
|
||||
ASSERT_EQ(cbm_index_worker_parse_process_argv(7, missing_build, &invocation),
|
||||
CBM_INDEX_WORKER_ARGV_INVALID);
|
||||
ASSERT_EQ(cbm_daemon_process_role(7, missing_build), CBM_DAEMON_PROCESS_INVALID);
|
||||
ASSERT_EQ(cbm_index_worker_parse_process_argv(9, wrong_build, &invocation),
|
||||
CBM_INDEX_WORKER_ARGV_BUILD_MISMATCH);
|
||||
ASSERT_EQ(cbm_index_worker_parse_process_argv(13, reordered, &invocation),
|
||||
CBM_INDEX_WORKER_ARGV_INVALID);
|
||||
ASSERT_EQ(cbm_index_worker_parse_process_argv(10, trailing, &invocation),
|
||||
CBM_INDEX_WORKER_ARGV_INVALID);
|
||||
ASSERT_EQ(cbm_index_worker_parse_process_argv(11, zero_budget, &invocation),
|
||||
CBM_INDEX_WORKER_ARGV_INVALID);
|
||||
ASSERT_EQ(cbm_daemon_process_role(11, zero_budget), CBM_DAEMON_PROCESS_INVALID);
|
||||
ASSERT_EQ(cbm_index_worker_parse_process_argv(11, overflow_budget, &invocation),
|
||||
CBM_INDEX_WORKER_ARGV_INVALID);
|
||||
ASSERT_EQ(cbm_daemon_process_role(11, overflow_budget), CBM_DAEMON_PROCESS_INVALID);
|
||||
ASSERT_EQ(cbm_index_worker_parse_process_argv(5, user_value, &invocation),
|
||||
CBM_INDEX_WORKER_ARGV_INVALID);
|
||||
ASSERT_EQ(cbm_daemon_process_role(5, user_value), CBM_DAEMON_PROCESS_INVALID);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(index_supervisor_async_jobs_are_isolated_cancellable_and_terminal_cached) {
|
||||
const char *captured = cbm_index_supervisor_build_fingerprint();
|
||||
ASSERT_NOT_NULL(captured);
|
||||
char *worker_argv[] = {
|
||||
"test-runner",
|
||||
"cli",
|
||||
"--index-worker",
|
||||
CBM_INDEX_WORKER_BUILD_ARG,
|
||||
(char *)captured,
|
||||
"index_repository",
|
||||
"{\"__cbm_test_worker\":\"hang-tree\"}",
|
||||
"--response-out",
|
||||
"/tmp/r",
|
||||
CBM_INDEX_WORKER_SINGLE_THREAD_ARG,
|
||||
CBM_INDEX_WORKER_MARKER_ARG,
|
||||
"/tmp/m",
|
||||
CBM_INDEX_WORKER_QUARANTINE_ARG,
|
||||
"/tmp/q",
|
||||
NULL,
|
||||
};
|
||||
ASSERT_EQ(cbm_daemon_process_role(14, worker_argv), CBM_DAEMON_PROCESS_WORKER);
|
||||
|
||||
char cache[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
(void)snprintf(cache, sizeof(cache), "%s/cbm-index-async-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
char marker_a[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
char marker_b[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
char quarantine_a[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
char quarantine_b[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
(void)snprintf(marker_a, sizeof(marker_a), "%s/marker-a", cache);
|
||||
(void)snprintf(marker_b, sizeof(marker_b), "%s/marker-b", cache);
|
||||
(void)snprintf(quarantine_a, sizeof(quarantine_a), "%s/quarantine-a", cache);
|
||||
(void)snprintf(quarantine_b, sizeof(quarantine_b), "%s/quarantine-b", cache);
|
||||
|
||||
const char *old_cache = getenv("CBM_CACHE_DIR");
|
||||
const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
|
||||
const char *old_marker = getenv("CBM_INDEX_MARKER_FILE");
|
||||
const char *old_quarantine = getenv("CBM_INDEX_QUARANTINE_FILE");
|
||||
char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL;
|
||||
char *saved_single = old_single ? cbm_strdup(old_single) : NULL;
|
||||
char *saved_marker = old_marker ? cbm_strdup(old_marker) : NULL;
|
||||
char *saved_quarantine = old_quarantine ? cbm_strdup(old_quarantine) : NULL;
|
||||
(void)cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
(void)cbm_setenv("CBM_INDEX_SINGLE_THREAD", "parent-single", 1);
|
||||
(void)cbm_setenv("CBM_INDEX_MARKER_FILE", "parent-marker", 1);
|
||||
(void)cbm_setenv("CBM_INDEX_QUARANTINE_FILE", "parent-quarantine", 1);
|
||||
|
||||
const char args[] = "{\"__cbm_test_worker\":\"hang-tree\"}";
|
||||
cbm_index_worker_handle_t *first = NULL;
|
||||
cbm_index_worker_handle_t *second = NULL;
|
||||
index_supervisor_log_capture_t first_capture = {0};
|
||||
index_supervisor_log_capture_t second_capture = {0};
|
||||
uint64_t start_before = cbm_now_ms();
|
||||
int first_rc = cbm_index_worker_start_with_log(
|
||||
args, 4096, true, marker_a, quarantine_a, index_supervisor_test_capture_log,
|
||||
&first_capture, &first);
|
||||
int second_rc = cbm_index_worker_start_with_log(
|
||||
args, 8192, true, marker_b, quarantine_b, index_supervisor_test_capture_log,
|
||||
&second_capture, &second);
|
||||
uint64_t start_elapsed = cbm_now_ms() - start_before;
|
||||
|
||||
char response_a[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0};
|
||||
char response_b[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0};
|
||||
char log_a[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0};
|
||||
char log_b[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0};
|
||||
if (first) {
|
||||
(void)snprintf(response_a, sizeof(response_a), "%s", cbm_index_worker_response_path(first));
|
||||
(void)snprintf(log_a, sizeof(log_a), "%s", cbm_index_worker_log_path(first));
|
||||
}
|
||||
if (second) {
|
||||
(void)snprintf(response_b, sizeof(response_b), "%s",
|
||||
cbm_index_worker_response_path(second));
|
||||
(void)snprintf(log_b, sizeof(log_b), "%s", cbm_index_worker_log_path(second));
|
||||
}
|
||||
bool unique = response_a[0] && response_b[0] && log_a[0] && log_b[0] &&
|
||||
strcmp(response_a, response_b) != 0 && strcmp(log_a, log_b) != 0 &&
|
||||
strcmp(response_a, log_a) != 0 && strcmp(response_b, log_b) != 0;
|
||||
bool owner_only = index_supervisor_test_owner_file(response_a) &&
|
||||
index_supervisor_test_owner_file(response_b) &&
|
||||
index_supervisor_test_owner_file(log_a) &&
|
||||
index_supervisor_test_owner_file(log_b);
|
||||
|
||||
char ready_a[2048] = {0};
|
||||
char ready_b[2048] = {0};
|
||||
bool first_ready =
|
||||
first && index_supervisor_test_wait_file(first, marker_a, ready_a, sizeof(ready_a));
|
||||
bool second_ready =
|
||||
second && index_supervisor_test_wait_file(second, marker_b, ready_b, sizeof(ready_b));
|
||||
bool child_options = strstr(ready_a, "single=1") && strstr(ready_a, marker_a) &&
|
||||
strstr(ready_a, quarantine_a) && strstr(ready_a, "budget=4096") &&
|
||||
strstr(ready_b, "single=1") && strstr(ready_b, marker_b) &&
|
||||
strstr(ready_b, quarantine_b) && strstr(ready_b, "budget=8192");
|
||||
bool parent_unchanged = strcmp(getenv("CBM_INDEX_SINGLE_THREAD"), "parent-single") == 0 &&
|
||||
strcmp(getenv("CBM_INDEX_MARKER_FILE"), "parent-marker") == 0 &&
|
||||
strcmp(getenv("CBM_INDEX_QUARANTINE_FILE"), "parent-quarantine") == 0;
|
||||
|
||||
bool worker_logs_ready = index_supervisor_test_wait_file_text(
|
||||
log_a, "async worker hang-tree probe", 3000) &&
|
||||
index_supervisor_test_wait_file_text(
|
||||
log_b, "async worker hang-tree probe", 3000);
|
||||
bool callback_lines_injected = worker_logs_ready &&
|
||||
index_supervisor_test_append_log(log_a, "request-a-only\n") &&
|
||||
index_supervisor_test_append_log(log_b, "request-b-only\n");
|
||||
|
||||
const cbm_index_worker_result_t *premature = (const cbm_index_worker_result_t *)1;
|
||||
uint64_t poll_before = cbm_now_ms();
|
||||
cbm_index_worker_poll_t running =
|
||||
first ? cbm_index_worker_poll(first, &premature) : CBM_INDEX_WORKER_POLL_ERROR;
|
||||
uint64_t poll_elapsed = cbm_now_ms() - poll_before;
|
||||
uint64_t cancel_before = cbm_now_ms();
|
||||
bool first_cancel = first && cbm_index_worker_request_cancel(first);
|
||||
bool second_cancel = second && cbm_index_worker_request_cancel(second);
|
||||
uint64_t cancel_elapsed = cbm_now_ms() - cancel_before;
|
||||
|
||||
const cbm_index_worker_result_t *first_result = NULL;
|
||||
const cbm_index_worker_result_t *second_result = NULL;
|
||||
bool first_terminal = first && index_supervisor_test_poll_terminal(
|
||||
first, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &first_result);
|
||||
bool second_terminal = second && index_supervisor_test_poll_terminal(
|
||||
second, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &second_result);
|
||||
const cbm_index_worker_result_t *cached = NULL;
|
||||
int first_callbacks_at_terminal = first_capture.delivered;
|
||||
bool cached_result = first_terminal &&
|
||||
cbm_index_worker_poll(first, &cached) == CBM_INDEX_WORKER_POLL_TERMINAL &&
|
||||
cached == first_result;
|
||||
bool callback_terminal_stable = first_capture.delivered == first_callbacks_at_terminal;
|
||||
bool callback_isolation = callback_lines_injected && first_capture.saw_request_a &&
|
||||
!first_capture.saw_request_b && second_capture.saw_request_b &&
|
||||
!second_capture.saw_request_a;
|
||||
bool forced_as_expected = true;
|
||||
#ifndef _WIN32
|
||||
forced_as_expected =
|
||||
first_result && second_result && first_result->forced && second_result->forced;
|
||||
#endif
|
||||
bool contained = first_result && second_result && first_result->cancellation_requested &&
|
||||
second_result->cancellation_requested && forced_as_expected &&
|
||||
first_result->tree_quiesced && second_result->tree_quiesced &&
|
||||
!first_result->supervision_failed && !second_result->supervision_failed;
|
||||
bool terminal_cancel_rejected = first && !cbm_index_worker_request_cancel(first);
|
||||
bool response_cleaned = cbm_file_size(response_a) < 0 && cbm_file_size(response_b) < 0;
|
||||
bool failure_logs_kept = cbm_file_size(log_a) >= 0 && cbm_file_size(log_b) >= 0;
|
||||
|
||||
if (first_terminal) {
|
||||
cbm_index_worker_destroy(first);
|
||||
} else {
|
||||
index_supervisor_test_cleanup_handle(first);
|
||||
}
|
||||
if (second_terminal) {
|
||||
cbm_index_worker_destroy(second);
|
||||
} else {
|
||||
index_supervisor_test_cleanup_handle(second);
|
||||
}
|
||||
(void)cbm_unlink(log_a);
|
||||
(void)cbm_unlink(log_b);
|
||||
(void)cbm_unlink(marker_a);
|
||||
(void)cbm_unlink(marker_b);
|
||||
index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache);
|
||||
index_supervisor_test_restore_env("CBM_INDEX_SINGLE_THREAD", saved_single);
|
||||
index_supervisor_test_restore_env("CBM_INDEX_MARKER_FILE", saved_marker);
|
||||
index_supervisor_test_restore_env("CBM_INDEX_QUARANTINE_FILE", saved_quarantine);
|
||||
(void)th_rmtree(cache);
|
||||
|
||||
ASSERT_EQ(first_rc, 0);
|
||||
ASSERT_EQ(second_rc, 0);
|
||||
ASSERT_TRUE(start_elapsed < 2000);
|
||||
ASSERT_TRUE(unique);
|
||||
ASSERT_TRUE(owner_only);
|
||||
ASSERT_TRUE(first_ready);
|
||||
ASSERT_TRUE(second_ready);
|
||||
ASSERT_TRUE(child_options);
|
||||
ASSERT_TRUE(parent_unchanged);
|
||||
ASSERT_EQ(running, CBM_INDEX_WORKER_POLL_RUNNING);
|
||||
ASSERT_NULL(premature);
|
||||
ASSERT_TRUE(poll_elapsed < 100);
|
||||
ASSERT_TRUE(first_cancel);
|
||||
ASSERT_TRUE(second_cancel);
|
||||
ASSERT_TRUE(cancel_elapsed < 100);
|
||||
ASSERT_TRUE(first_terminal);
|
||||
ASSERT_TRUE(second_terminal);
|
||||
ASSERT_TRUE(cached_result);
|
||||
ASSERT_TRUE(callback_terminal_stable);
|
||||
ASSERT_TRUE(callback_isolation);
|
||||
ASSERT_TRUE(contained);
|
||||
ASSERT_TRUE(terminal_cancel_rejected);
|
||||
ASSERT_TRUE(response_cleaned);
|
||||
ASSERT_TRUE(failure_logs_kept);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(index_supervisor_sync_wrapper_forwards_cancel_and_drains_tree) {
|
||||
char cache[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
(void)snprintf(cache, sizeof(cache),
|
||||
"%s/cbm-index-sync-cancel-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
const char *old_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL;
|
||||
(void)cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
|
||||
atomic_int cancel_requested;
|
||||
atomic_init(&cancel_requested, 1);
|
||||
cbm_index_worker_result_t result = {0};
|
||||
int run_status = cbm_index_spawn_worker_with_log_cancel(
|
||||
"{\"__cbm_test_worker\":\"hang-tree\"}", false, NULL, NULL,
|
||||
NULL, NULL, &cancel_requested, &result);
|
||||
bool contained = run_status == 0 && result.cancellation_requested &&
|
||||
result.tree_quiesced && !result.supervision_failed &&
|
||||
result.response == NULL;
|
||||
cbm_index_worker_result_free(&result);
|
||||
index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache);
|
||||
(void)th_rmtree(cache);
|
||||
|
||||
ASSERT_TRUE(contained);
|
||||
PASS();
|
||||
}
|
||||
|
||||
static bool index_supervisor_test_run_probe(const char *mode, bool profiling,
|
||||
cbm_proc_outcome_t *outcome_out, bool *has_response_out,
|
||||
bool *log_exists_out, bool *response_path_exists_out) {
|
||||
char args[128];
|
||||
(void)snprintf(args, sizeof(args), "{\"__cbm_test_worker\":\"%s\"}", mode);
|
||||
cbm_profile_active = profiling;
|
||||
cbm_index_worker_handle_t *handle = NULL;
|
||||
if (cbm_index_worker_start(args, 0, false, NULL, NULL, &handle) != 0 || !handle) {
|
||||
return false;
|
||||
}
|
||||
char log_path[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
char response_path[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
(void)snprintf(log_path, sizeof(log_path), "%s", cbm_index_worker_log_path(handle));
|
||||
(void)snprintf(response_path, sizeof(response_path), "%s",
|
||||
cbm_index_worker_response_path(handle));
|
||||
const cbm_index_worker_result_t *result = NULL;
|
||||
bool terminal =
|
||||
index_supervisor_test_poll_terminal(handle, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &result);
|
||||
if (terminal && result) {
|
||||
*outcome_out = result->outcome;
|
||||
*has_response_out = result->response != NULL;
|
||||
*log_exists_out = cbm_file_size(log_path) >= 0;
|
||||
*response_path_exists_out = cbm_file_size(response_path) >= 0;
|
||||
cbm_index_worker_destroy(handle);
|
||||
} else {
|
||||
index_supervisor_test_cleanup_handle(handle);
|
||||
}
|
||||
if (cbm_file_size(log_path) >= 0) {
|
||||
(void)cbm_unlink(log_path);
|
||||
}
|
||||
return terminal;
|
||||
}
|
||||
|
||||
TEST(index_supervisor_terminal_log_lifecycle_matches_outcome_and_profiling) {
|
||||
char cache[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
(void)snprintf(cache, sizeof(cache), "%s/cbm-index-logs-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
const char *old_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL;
|
||||
bool saved_profile = cbm_profile_active;
|
||||
(void)cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
|
||||
cbm_proc_outcome_t clean_outcome = CBM_PROC_SPAWN_FAILED;
|
||||
cbm_proc_outcome_t profile_outcome = CBM_PROC_SPAWN_FAILED;
|
||||
cbm_proc_outcome_t crash_outcome = CBM_PROC_SPAWN_FAILED;
|
||||
bool clean_response = false;
|
||||
bool clean_log = true;
|
||||
bool clean_response_file = true;
|
||||
bool profile_response = false;
|
||||
bool profile_log = false;
|
||||
bool profile_response_file = true;
|
||||
bool crash_response = true;
|
||||
bool crash_log = false;
|
||||
bool crash_response_file = true;
|
||||
bool clean_terminal = index_supervisor_test_run_probe(
|
||||
"clean", false, &clean_outcome, &clean_response, &clean_log, &clean_response_file);
|
||||
bool profile_terminal = index_supervisor_test_run_probe(
|
||||
"clean", true, &profile_outcome, &profile_response, &profile_log, &profile_response_file);
|
||||
bool crash_terminal = index_supervisor_test_run_probe(
|
||||
"crash", false, &crash_outcome, &crash_response, &crash_log, &crash_response_file);
|
||||
#ifdef _WIN32
|
||||
bool crash_classified_failure = crash_outcome != CBM_PROC_CLEAN;
|
||||
#else
|
||||
bool crash_classified_failure = crash_outcome == CBM_PROC_CRASH;
|
||||
#endif
|
||||
|
||||
cbm_profile_active = saved_profile;
|
||||
index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache);
|
||||
(void)th_rmtree(cache);
|
||||
|
||||
ASSERT_TRUE(clean_terminal);
|
||||
ASSERT_EQ(clean_outcome, CBM_PROC_CLEAN);
|
||||
ASSERT_TRUE(clean_response);
|
||||
ASSERT_FALSE(clean_log);
|
||||
ASSERT_FALSE(clean_response_file);
|
||||
ASSERT_TRUE(profile_terminal);
|
||||
ASSERT_EQ(profile_outcome, CBM_PROC_CLEAN);
|
||||
ASSERT_TRUE(profile_response);
|
||||
ASSERT_TRUE(profile_log);
|
||||
ASSERT_FALSE(profile_response_file);
|
||||
ASSERT_TRUE(crash_terminal);
|
||||
ASSERT_TRUE(crash_classified_failure);
|
||||
ASSERT_FALSE(crash_response);
|
||||
ASSERT_TRUE(crash_log);
|
||||
ASSERT_FALSE(crash_response_file);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(index_supervisor_drains_terminal_backlog_into_request_progress_callback) {
|
||||
char cache[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
(void)snprintf(cache, sizeof(cache), "%s/cbm-index-relay-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
const char *old_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL;
|
||||
(void)cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
|
||||
FILE *progress = tmpfile();
|
||||
ASSERT_NOT_NULL(progress);
|
||||
index_supervisor_log_capture_t capture = {.render_progress = true};
|
||||
cbm_progress_sink_init(progress);
|
||||
cbm_index_worker_handle_t *handle = NULL;
|
||||
int start_rc = cbm_index_worker_start_with_log(
|
||||
"{\"__cbm_test_worker\":\"clean\"}", 0, false, NULL, NULL,
|
||||
index_supervisor_test_capture_log, &capture, &handle);
|
||||
char log_path[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0};
|
||||
if (handle) {
|
||||
(void)snprintf(log_path, sizeof(log_path), "%s", cbm_index_worker_log_path(handle));
|
||||
}
|
||||
bool worker_logged = log_path[0] && index_supervisor_test_wait_file_text(
|
||||
log_path, "async worker clean probe", 3000);
|
||||
if (worker_logged) {
|
||||
/* Seeing the flushed probe places the child immediately before _Exit.
|
||||
* Give it time to become waitable without polling: the regression is a
|
||||
* backlog already present when the first terminal poll occurs. */
|
||||
cbm_usleep(250000);
|
||||
}
|
||||
bool backlog_written = worker_logged &&
|
||||
index_supervisor_test_append_terminal_backlog(log_path);
|
||||
const cbm_index_worker_result_t *result = NULL;
|
||||
bool terminal = handle && index_supervisor_test_poll_terminal(
|
||||
handle, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &result);
|
||||
cbm_proc_outcome_t outcome = result ? result->outcome : CBM_PROC_SPAWN_FAILED;
|
||||
int callbacks_at_terminal = capture.delivered;
|
||||
const cbm_index_worker_result_t *cached = NULL;
|
||||
bool terminal_cached = terminal &&
|
||||
cbm_index_worker_poll(handle, &cached) ==
|
||||
CBM_INDEX_WORKER_POLL_TERMINAL &&
|
||||
cached == result && capture.delivered == callbacks_at_terminal;
|
||||
cbm_progress_sink_fini();
|
||||
bool progress_rewound = fseek(progress, 0, SEEK_SET) == 0;
|
||||
char rendered[1024] = {0};
|
||||
size_t rendered_size = progress_rewound
|
||||
? fread(rendered, 1, sizeof(rendered) - 1, progress)
|
||||
: 0;
|
||||
(void)fclose(progress);
|
||||
if (terminal) {
|
||||
cbm_index_worker_destroy(handle);
|
||||
} else {
|
||||
index_supervisor_test_cleanup_handle(handle);
|
||||
}
|
||||
|
||||
index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache);
|
||||
(void)th_rmtree(cache);
|
||||
|
||||
ASSERT_EQ(start_rc, 0);
|
||||
ASSERT_TRUE(worker_logged);
|
||||
ASSERT_TRUE(backlog_written);
|
||||
ASSERT_TRUE(terminal);
|
||||
ASSERT_EQ(outcome, CBM_PROC_CLEAN);
|
||||
ASSERT_TRUE(terminal_cached);
|
||||
ASSERT_TRUE(capture.saw_clean_probe);
|
||||
ASSERT_TRUE(capture.saw_structured_text);
|
||||
ASSERT_TRUE(capture.saw_structured_json);
|
||||
ASSERT_EQ(capture.backlog_lines, INDEX_SUPERVISOR_TEST_BACKLOG_LINES + 1);
|
||||
ASSERT_TRUE(capture.saw_backlog_sentinel);
|
||||
ASSERT_TRUE(progress_rewound);
|
||||
ASSERT_TRUE(rendered_size > 0);
|
||||
ASSERT_NOT_NULL(strstr(rendered, "Discovering files (7 found)"));
|
||||
ASSERT_NOT_NULL(strstr(rendered, "[1/9] Building file structure"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(index_supervisor_oversized_response_is_contained_and_log_is_retained) {
|
||||
char cache[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||
(void)snprintf(cache, sizeof(cache), "%s/cbm-index-oversize-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
const char *old_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL;
|
||||
(void)cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
|
||||
cbm_index_worker_handle_t *handle = NULL;
|
||||
int start_rc = cbm_index_worker_start("{\"__cbm_test_worker\":\"oversize\"}", 0, false,
|
||||
NULL, NULL, &handle);
|
||||
char log_path[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0};
|
||||
char response_path[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0};
|
||||
if (handle) {
|
||||
(void)snprintf(log_path, sizeof(log_path), "%s", cbm_index_worker_log_path(handle));
|
||||
(void)snprintf(response_path, sizeof(response_path), "%s",
|
||||
cbm_index_worker_response_path(handle));
|
||||
}
|
||||
const cbm_index_worker_result_t *result = NULL;
|
||||
bool terminal = handle && index_supervisor_test_poll_terminal(
|
||||
handle, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &result);
|
||||
bool rejected = terminal && result && result->response_rejected && !result->response &&
|
||||
result->outcome == CBM_PROC_EXIT_NONZERO && result->tree_quiesced &&
|
||||
!result->supervision_failed;
|
||||
bool log_retained = log_path[0] && cbm_file_size(log_path) >= 0;
|
||||
bool response_removed = response_path[0] && cbm_file_size(response_path) < 0;
|
||||
if (terminal) {
|
||||
cbm_index_worker_destroy(handle);
|
||||
} else {
|
||||
index_supervisor_test_cleanup_handle(handle);
|
||||
}
|
||||
(void)cbm_unlink(log_path);
|
||||
index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache);
|
||||
(void)th_rmtree(cache);
|
||||
|
||||
ASSERT_EQ(start_rc, 0);
|
||||
ASSERT_TRUE(terminal);
|
||||
ASSERT_TRUE(rejected);
|
||||
ASSERT_TRUE(log_retained);
|
||||
ASSERT_TRUE(response_removed);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(index_supervisor) {
|
||||
RUN_TEST(index_supervisor_worker_argv_requires_exact_build_bound_grammar);
|
||||
RUN_TEST(index_supervisor_async_jobs_are_isolated_cancellable_and_terminal_cached);
|
||||
RUN_TEST(index_supervisor_sync_wrapper_forwards_cancel_and_drains_tree);
|
||||
RUN_TEST(index_supervisor_terminal_log_lifecycle_matches_outcome_and_profiling);
|
||||
RUN_TEST(index_supervisor_drains_terminal_backlog_into_request_progress_callback);
|
||||
RUN_TEST(index_supervisor_oversized_response_is_contained_and_log_is_retained);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+403
-30
@@ -13,6 +13,9 @@ int tf_skip_count = 0;
|
||||
#include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */
|
||||
#include "foundation/compat_fs.h" /* cbm_fopen — worker response file */
|
||||
#include "foundation/mem.h" /* cbm_mem_init — worker budget */
|
||||
#include "daemon/runtime.h" /* bounded worker response probe */
|
||||
#include "daemon/ipc.h" /* Windows private-lock re-exec probe */
|
||||
#include "daemon/version_cohort.h" /* Windows crash-turnover re-exec probe */
|
||||
#include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */
|
||||
#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */
|
||||
#include <sqlite3.h>
|
||||
@@ -21,8 +24,15 @@ int tf_skip_count = 0;
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <signal.h>
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h> /* #798 follow-up: socket-isolation re-exec probe */
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#ifdef __APPLE__
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/* Test handlers that exercise the production index_repository flow must never
|
||||
@@ -51,48 +61,102 @@ static bool tf_setup_cache_sentinel(void) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* #832 guard support: when the index supervisor spawns THIS binary as
|
||||
* `<self> cli --index-worker index_repository <args_json> --response-out <file>`
|
||||
* (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
|
||||
/* 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* #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) {
|
||||
if (argc < 2 || strcmp(argv[1], "cli") != 0) {
|
||||
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;
|
||||
}
|
||||
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];
|
||||
}
|
||||
} else if (argv[i][0] == '{') {
|
||||
args_json = argv[i];
|
||||
} else if (argv[i][0] != '-' && !tool) {
|
||||
tool = argv[i];
|
||||
}
|
||||
}
|
||||
if (!is_worker) {
|
||||
return -1;
|
||||
}
|
||||
if (!tool) {
|
||||
tool = "index_repository";
|
||||
if (status != CBM_INDEX_WORKER_ARGV_VALID) {
|
||||
(void)fprintf(stderr, "CBM test index worker could not start: %s\n",
|
||||
cbm_index_worker_argv_status_message(status));
|
||||
return 1;
|
||||
}
|
||||
|
||||
cbm_mem_init(0.5);
|
||||
cbm_index_set_worker_role(true, response_out); /* worker role → index in-process */
|
||||
cbm_index_set_worker_role_options(true, invocation.response_out, invocation.single_thread,
|
||||
invocation.marker_file, invocation.quarantine_file,
|
||||
invocation.memory_budget_bytes);
|
||||
cbm_mem_init_with_cap(0.5, invocation.memory_budget_bytes);
|
||||
tf_index_worker_probe(invocation.args_json, invocation.response_out);
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
if (!srv) {
|
||||
return 1;
|
||||
}
|
||||
char *result = cbm_mcp_handle_tool(srv, tool, args_json);
|
||||
char *result = cbm_mcp_handle_tool(srv, "index_repository", invocation.args_json);
|
||||
if (result) {
|
||||
const char *ro = cbm_index_worker_response_out();
|
||||
if (ro) {
|
||||
@@ -148,6 +212,236 @@ static int tf_maybe_run_socket_probe(int argc, char **argv) {
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Windows cannot use fork to prove process-to-process daemon lock ownership.
|
||||
* The daemon IPC suite re-execs this runner in this narrow mode and observes
|
||||
* the tri-state through a stable exit-code mapping: 0 acquired, 20 busy,
|
||||
* 21 validation/OS error. */
|
||||
static int tf_maybe_run_daemon_ipc_lock_probe(int argc, char **argv) {
|
||||
#ifdef _WIN32
|
||||
if (argc != 5 || strcmp(argv[1], "__cbm_daemon_ipc_lock_probe") != 0) {
|
||||
return -1;
|
||||
}
|
||||
cbm_daemon_ipc_endpoint_t *endpoint =
|
||||
cbm_daemon_ipc_endpoint_new(argv[3], argv[4]);
|
||||
if (!endpoint) {
|
||||
return 21;
|
||||
}
|
||||
int result = -1;
|
||||
if (strcmp(argv[2], "startup") == 0) {
|
||||
cbm_daemon_ipc_startup_lock_t *lock = NULL;
|
||||
result = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &lock);
|
||||
if (!cbm_daemon_ipc_startup_lock_release(&lock)) {
|
||||
result = -1;
|
||||
}
|
||||
} else if (strcmp(argv[2], "lifetime") == 0) {
|
||||
cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL;
|
||||
result = cbm_daemon_ipc_lifetime_reservation_try_acquire(
|
||||
endpoint, &reservation);
|
||||
cbm_daemon_ipc_lifetime_reservation_release(reservation);
|
||||
}
|
||||
cbm_daemon_ipc_endpoint_free(endpoint);
|
||||
return result == 1 ? 0 : (result == 0 ? 20 : 21);
|
||||
#else
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
static int tf_maybe_run_version_cohort_crash_holder(int argc, char **argv) {
|
||||
#ifdef _WIN32
|
||||
if (argc != 5 ||
|
||||
strcmp(argv[1], "__cbm_version_cohort_crash_holder") != 0) {
|
||||
return -1;
|
||||
}
|
||||
cbm_daemon_ipc_endpoint_t *endpoint =
|
||||
cbm_daemon_ipc_endpoint_new(argv[2], argv[3]);
|
||||
cbm_version_cohort_manager_t *manager =
|
||||
endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL;
|
||||
cbm_daemon_build_identity_t identity = {
|
||||
.semantic_version = "2.4.0",
|
||||
.build_fingerprint =
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
.protocol_abi = 3,
|
||||
.store_abi = 11,
|
||||
.feature_abi = 7,
|
||||
};
|
||||
cbm_version_cohort_lease_t *lease = NULL;
|
||||
cbm_daemon_conflict_t conflict;
|
||||
cbm_version_cohort_status_t status =
|
||||
manager ? cbm_version_cohort_acquire(manager, &identity, UINT64_MAX,
|
||||
&lease, &conflict)
|
||||
: CBM_VERSION_COHORT_IO;
|
||||
FILE *ready = status == CBM_VERSION_COHORT_OK
|
||||
? cbm_fopen(argv[4], "wb")
|
||||
: NULL;
|
||||
bool announced = false;
|
||||
if (ready) {
|
||||
bool written = fputc('R', ready) != EOF;
|
||||
announced = fclose(ready) == 0 && written;
|
||||
}
|
||||
if (announced) {
|
||||
Sleep(INFINITE);
|
||||
return 23;
|
||||
}
|
||||
while (lease && cbm_version_cohort_lease_release(&lease) !=
|
||||
CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
while (manager && cbm_version_cohort_manager_free(&manager) !=
|
||||
CBM_PRIVATE_FILE_LOCK_OK) {
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
cbm_daemon_ipc_endpoint_free(endpoint);
|
||||
return 22;
|
||||
#else
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
static int tf_maybe_run_runtime_image_holder(int argc, char **argv) {
|
||||
#ifdef _WIN32
|
||||
if (argc != 3 ||
|
||||
strcmp(argv[1], "__cbm_runtime_image_holder") != 0) {
|
||||
return -1;
|
||||
}
|
||||
HANDLE ready = OpenEventA(EVENT_MODIFY_STATE, FALSE, argv[2]);
|
||||
bool announced = ready && SetEvent(ready) != 0;
|
||||
if (ready) {
|
||||
(void)CloseHandle(ready);
|
||||
}
|
||||
if (!announced) {
|
||||
return 24;
|
||||
}
|
||||
Sleep(INFINITE);
|
||||
return 25;
|
||||
#else
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Real copied-image HELLO probe used by daemon_runtime. Keeping this in the
|
||||
* test runner avoids any production-only test hook: the daemon authenticates
|
||||
* and fingerprints an ordinary, separately executed process image. */
|
||||
static int tf_maybe_run_runtime_hello_client(int argc, char **argv) {
|
||||
if (argc != 6 || strcmp(argv[1], "__cbm_runtime_hello_client") != 0) {
|
||||
return -1;
|
||||
}
|
||||
cbm_daemon_ipc_endpoint_t *endpoint =
|
||||
cbm_daemon_ipc_endpoint_new(argv[3], argv[2]);
|
||||
cbm_daemon_build_identity_t identity = {
|
||||
.semantic_version = argv[4],
|
||||
.build_fingerprint = argv[5],
|
||||
};
|
||||
cbm_daemon_runtime_connect_result_t result;
|
||||
memset(&result, 0, sizeof(result));
|
||||
cbm_daemon_runtime_client_t *client =
|
||||
endpoint ? cbm_daemon_runtime_client_connect(endpoint, &identity, 5000,
|
||||
&result)
|
||||
: NULL;
|
||||
bool accepted = client &&
|
||||
result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED &&
|
||||
result.hello_status == CBM_DAEMON_HELLO_COMPATIBLE;
|
||||
bool closed = !client || cbm_daemon_runtime_client_close(client, 5000);
|
||||
cbm_daemon_ipc_endpoint_free(endpoint);
|
||||
if (!accepted) {
|
||||
return 26;
|
||||
}
|
||||
return closed ? 0 : 27;
|
||||
}
|
||||
|
||||
/* Real copied-image activation probe. The request identity is supplied by the
|
||||
* executing copy so the daemon must authenticate that peer image rather than
|
||||
* require the active generation's build fingerprint. */
|
||||
static int tf_maybe_run_runtime_activation_client(int argc, char **argv) {
|
||||
if (argc != 7 ||
|
||||
strcmp(argv[1], "__cbm_runtime_activation_client") != 0) {
|
||||
return -1;
|
||||
}
|
||||
char *action_end = NULL;
|
||||
unsigned long action_value = strtoul(argv[6], &action_end, 10);
|
||||
bool action_valid = action_end != argv[6] && *action_end == '\0' &&
|
||||
action_value >= (unsigned long)
|
||||
CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL &&
|
||||
action_value <= (unsigned long)
|
||||
CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL;
|
||||
cbm_daemon_ipc_endpoint_t *endpoint =
|
||||
action_valid ? cbm_daemon_ipc_endpoint_new(argv[3], argv[2]) : NULL;
|
||||
cbm_daemon_build_identity_t identity = {
|
||||
.semantic_version = argv[4],
|
||||
.build_fingerprint = argv[5],
|
||||
};
|
||||
cbm_daemon_runtime_activation_result_t result;
|
||||
memset(&result, 0, sizeof(result));
|
||||
bool exchanged = endpoint &&
|
||||
cbm_daemon_runtime_request_activation_shutdown(
|
||||
endpoint, &identity,
|
||||
(cbm_daemon_runtime_activation_action_t)action_value,
|
||||
5000, &result);
|
||||
cbm_daemon_ipc_endpoint_free(endpoint);
|
||||
return exchanged && result.accepted ? 0 : 29;
|
||||
}
|
||||
|
||||
/* macOS adversarial HELLO probe: execute this mode from a foreign process
|
||||
* image while mapping the genuine runner RX. The daemon must authenticate the
|
||||
* main image, not merely find its own vnode in an arbitrary executable map. */
|
||||
static int tf_maybe_run_runtime_mapped_hello_client(int argc, char **argv) {
|
||||
#ifdef __APPLE__
|
||||
if (argc != 7 ||
|
||||
strcmp(argv[1], "__cbm_runtime_mapped_hello_client") != 0) {
|
||||
return -1;
|
||||
}
|
||||
int image_fd = open(argv[2], O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
|
||||
void *mapping = image_fd >= 0
|
||||
? mmap(NULL, 1, PROT_READ | PROT_EXEC, MAP_PRIVATE,
|
||||
image_fd, 0)
|
||||
: MAP_FAILED;
|
||||
bool mapped = mapping != MAP_FAILED;
|
||||
if (image_fd >= 0 && close(image_fd) != 0) {
|
||||
mapped = false;
|
||||
}
|
||||
if (!mapped) {
|
||||
if (mapping != MAP_FAILED) {
|
||||
(void)munmap(mapping, 1);
|
||||
}
|
||||
return 28;
|
||||
}
|
||||
|
||||
char *hello_argv[] = {argv[0], "__cbm_runtime_hello_client", argv[3],
|
||||
argv[4], argv[5], argv[6]};
|
||||
int result = tf_maybe_run_runtime_hello_client(6, hello_argv);
|
||||
if (munmap(mapping, 1) != 0) {
|
||||
return 28;
|
||||
}
|
||||
return result >= 0 ? result : 28;
|
||||
#else
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
static int tf_maybe_run_mcp_idxfailclosed_probe(int argc, char **argv) {
|
||||
#ifndef _WIN32
|
||||
if (argc != 4 ||
|
||||
strcmp(argv[1], "__cbm_mcp_idxfailclosed_probe") != 0) {
|
||||
return -1;
|
||||
}
|
||||
extern int mcp_test_idxfailclosed_supervisor_start_check(
|
||||
const char *repo_dir, const char *cache_dir);
|
||||
alarm(20);
|
||||
return mcp_test_idxfailclosed_supervisor_start_check(argv[2], argv[3]);
|
||||
#else
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
static int g_suite_argc = 0;
|
||||
static char **g_suite_argv = NULL;
|
||||
static bool *g_suite_arg_matched = NULL;
|
||||
@@ -182,6 +476,8 @@ extern void suite_log(void);
|
||||
extern void suite_str_util(void);
|
||||
extern void suite_platform(void);
|
||||
extern void suite_subprocess(void);
|
||||
extern void suite_private_file_lock(void);
|
||||
extern void suite_lock_registry(void);
|
||||
extern void suite_extraction(void);
|
||||
extern void suite_extraction_inheritance(void);
|
||||
extern void suite_extraction_imports(void);
|
||||
@@ -195,6 +491,17 @@ extern void suite_store_edges(void);
|
||||
extern void suite_store_search(void);
|
||||
extern void suite_cypher(void);
|
||||
extern void suite_mcp(void);
|
||||
extern void suite_mcp_mutation_guard(void);
|
||||
extern void suite_index_supervisor(void);
|
||||
extern void suite_daemon(void);
|
||||
extern void suite_project_lock(void);
|
||||
extern void suite_version_cohort(void);
|
||||
extern void suite_daemon_version(void);
|
||||
extern void suite_daemon_runtime(void);
|
||||
extern void suite_daemon_application(void);
|
||||
extern void suite_daemon_frontend(void);
|
||||
extern void suite_daemon_bootstrap(void);
|
||||
extern void suite_daemon_ipc(void);
|
||||
extern void suite_language(void);
|
||||
extern void suite_userconfig(void);
|
||||
extern void suite_gitignore(void);
|
||||
@@ -203,6 +510,7 @@ extern void suite_discover(void);
|
||||
extern void suite_graph_buffer(void);
|
||||
extern void suite_registry(void);
|
||||
extern void suite_pipeline(void);
|
||||
extern void suite_cross_repo(void);
|
||||
extern void suite_index_resilience(void);
|
||||
extern void suite_fqn(void);
|
||||
extern void suite_route_canon(void);
|
||||
@@ -243,6 +551,7 @@ extern void suite_config_json_like(void);
|
||||
extern void suite_config_toml_edit(void);
|
||||
extern void suite_config_yaml_edit(void);
|
||||
extern void suite_config_text_edit(void);
|
||||
extern void suite_activation_transaction(void);
|
||||
extern void suite_system_info(void);
|
||||
extern void suite_worker_pool(void);
|
||||
extern void suite_parallel(void);
|
||||
@@ -283,6 +592,48 @@ extern void suite_dump_verify_io(void);
|
||||
extern void cbm_kind_in_set_free_cache(void);
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
/* Installation tests use this executable as a structurally real candidate.
|
||||
* Mirror the production binary's minimal verification contract. */
|
||||
if (argc == 2 && strcmp(argv[1], "--version") == 0) {
|
||||
(void)puts("codebase-memory-mcp test-runner");
|
||||
return 0;
|
||||
}
|
||||
int mcp_idxfailclosed_rc =
|
||||
tf_maybe_run_mcp_idxfailclosed_probe(argc, argv);
|
||||
if (mcp_idxfailclosed_rc >= 0) {
|
||||
return mcp_idxfailclosed_rc;
|
||||
}
|
||||
int runtime_image_rc =
|
||||
tf_maybe_run_runtime_image_holder(argc, argv);
|
||||
if (runtime_image_rc >= 0) {
|
||||
return runtime_image_rc;
|
||||
}
|
||||
int runtime_hello_rc =
|
||||
tf_maybe_run_runtime_hello_client(argc, argv);
|
||||
if (runtime_hello_rc >= 0) {
|
||||
return runtime_hello_rc;
|
||||
}
|
||||
int runtime_activation_rc =
|
||||
tf_maybe_run_runtime_activation_client(argc, argv);
|
||||
if (runtime_activation_rc >= 0) {
|
||||
return runtime_activation_rc;
|
||||
}
|
||||
int runtime_mapped_hello_rc =
|
||||
tf_maybe_run_runtime_mapped_hello_client(argc, argv);
|
||||
if (runtime_mapped_hello_rc >= 0) {
|
||||
return runtime_mapped_hello_rc;
|
||||
}
|
||||
int cohort_crash_rc =
|
||||
tf_maybe_run_version_cohort_crash_holder(argc, argv);
|
||||
if (cohort_crash_rc >= 0) {
|
||||
return cohort_crash_rc;
|
||||
}
|
||||
int daemon_ipc_probe_rc =
|
||||
tf_maybe_run_daemon_ipc_lock_probe(argc, argv);
|
||||
if (daemon_ipc_probe_rc >= 0) {
|
||||
return daemon_ipc_probe_rc;
|
||||
}
|
||||
|
||||
/* #798 follow-up: if spawned as the socket-isolation probe, report whether an
|
||||
* inheritable socket handle crossed into this child and exit before any suite. */
|
||||
int probe_rc = tf_maybe_run_socket_probe(argc, argv);
|
||||
@@ -290,6 +641,11 @@ int main(int argc, char **argv) {
|
||||
return probe_rc;
|
||||
}
|
||||
|
||||
/* Capture once so the parent tests and any re-exec'd worker bind to this
|
||||
* executable image. A worker with an unavailable/mismatched identity is
|
||||
* rejected by the exact parser below before any suite or MCP state exists. */
|
||||
(void)cbm_index_supervisor_capture_build_fingerprint();
|
||||
|
||||
/* #832: if spawned as a supervised index worker, do the real work and exit
|
||||
* before any suite runs (see tf_maybe_run_index_worker). */
|
||||
int worker_rc = tf_maybe_run_index_worker(argc, argv);
|
||||
@@ -328,6 +684,8 @@ int main(int argc, char **argv) {
|
||||
RUN_SELECTED_SUITE(str_util);
|
||||
RUN_SELECTED_SUITE(platform);
|
||||
RUN_SELECTED_SUITE(subprocess);
|
||||
RUN_SELECTED_SUITE(private_file_lock);
|
||||
RUN_SELECTED_SUITE(lock_registry);
|
||||
RUN_SELECTED_SUITE(dump_verify);
|
||||
|
||||
/* Existing C code regression tests */
|
||||
@@ -354,6 +712,19 @@ int main(int argc, char **argv) {
|
||||
|
||||
/* MCP Server (M9) */
|
||||
RUN_SELECTED_SUITE(mcp);
|
||||
RUN_SELECTED_SUITE(mcp_mutation_guard);
|
||||
RUN_SELECTED_SUITE(index_supervisor);
|
||||
|
||||
/* Shared MCP daemon coordination + private framing */
|
||||
RUN_SELECTED_SUITE(daemon);
|
||||
RUN_SELECTED_SUITE(project_lock);
|
||||
RUN_SELECTED_SUITE(version_cohort);
|
||||
RUN_SELECTED_SUITE(daemon_version);
|
||||
RUN_SELECTED_SUITE(daemon_runtime);
|
||||
RUN_SELECTED_SUITE(daemon_application);
|
||||
RUN_SELECTED_SUITE(daemon_frontend);
|
||||
RUN_SELECTED_SUITE(daemon_bootstrap);
|
||||
RUN_SELECTED_SUITE(daemon_ipc);
|
||||
|
||||
/* Discover (M2) */
|
||||
RUN_SELECTED_SUITE(language);
|
||||
@@ -368,6 +739,7 @@ int main(int argc, char **argv) {
|
||||
/* Pipeline (M8) */
|
||||
RUN_SELECTED_SUITE(registry);
|
||||
RUN_SELECTED_SUITE(pipeline);
|
||||
RUN_SELECTED_SUITE(cross_repo);
|
||||
RUN_SELECTED_SUITE(index_resilience);
|
||||
RUN_SELECTED_SUITE(fqn);
|
||||
RUN_SELECTED_SUITE(route_canon);
|
||||
@@ -425,6 +797,7 @@ int main(int argc, char **argv) {
|
||||
RUN_SELECTED_SUITE(config_toml_edit);
|
||||
RUN_SELECTED_SUITE(config_yaml_edit);
|
||||
RUN_SELECTED_SUITE(config_text_edit);
|
||||
RUN_SELECTED_SUITE(activation_transaction);
|
||||
|
||||
/* System info + worker pool (parallelism) */
|
||||
RUN_SELECTED_SUITE(system_info);
|
||||
|
||||
+2051
-15
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user