diff --git a/Makefile.cbm b/Makefile.cbm index b815bce3..be5c4d01 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -2,7 +2,6 @@ # # Usage: # make -f Makefile.cbm test # Build + run all tests (ASan + UBSan) -# make -f Makefile.cbm test-foundation # Foundation tests only (fast) # make -f Makefile.cbm test-tsan # Thread sanitizer build # make -f Makefile.cbm cbm # Production binary # make -f Makefile.cbm clean-c # Remove build artifacts @@ -93,29 +92,11 @@ LDFLAGS_TSAN = -lm -lstdc++ -lpthread -lz -fsanitize=thread $(WIN32_LIBS) # ── Source files ───────────────────────────────────────────────── +# The rest of src/foundation is Zig (CBM_ZIG_SRCS). hash_table.c wraps the +# vendored Verstable macro template; c_shim.c holds the variadic functions. FOUNDATION_SRCS = \ - src/foundation/arena.c \ src/foundation/hash_table.c \ - src/foundation/str_intern.c \ - src/foundation/log.c \ - src/foundation/str_util.c \ - src/foundation/platform.c \ - src/foundation/system_info.c \ - src/foundation/slab_alloc.c \ - src/foundation/yaml.c \ - src/foundation/compat.c \ - src/foundation/compat_thread.c \ - src/foundation/compat_fs.c \ - src/foundation/file_lock.c \ - src/foundation/compat_regex.c \ - src/foundation/mem.c \ - src/foundation/diagnostics.c \ - src/foundation/profile.c \ - src/foundation/dump_verify.c \ - src/foundation/limits.c \ - src/foundation/subprocess.c \ - src/foundation/git_env.c \ - src/foundation/sha256.c + src/foundation/c_shim.c # Existing extraction C code (compiled from current location) EXTRACTION_SRCS = \ @@ -492,12 +473,20 @@ CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cl src/cli/archive.zig src/cli/cli.zig src/cli/commands.zig src/cli/store.zig \ src/cli/tool_args.zig \ src/cli/hook_augment.zig src/cli/progress_sink.zig src/cli/progress_sink.h \ - src/foundation/sha256.h \ src/cli/cli.h src/mcp/mcp.h src/pipeline/pipeline.h \ src/mcp/mcp.zig src/mcp/server.zig src/mcp/code_tools.zig \ src/mcp/graph_tools.zig src/mcp/index_tools.zig src/mcp/project_tools.zig \ src/mcp/search_tools.zig src/mcp/index_supervisor.zig src/mcp/index_supervisor.h \ src/foundation/interop.zig \ + src/foundation/arena.zig src/foundation/compat.zig src/foundation/compat_fs.zig \ + src/foundation/compat_regex.zig src/foundation/compat_thread.zig \ + src/foundation/diagnostics.zig src/foundation/dump_verify.zig \ + src/foundation/file_lock.zig src/foundation/git_env.zig src/foundation/limits.zig \ + src/foundation/log.zig src/foundation/mem.zig src/foundation/platform.zig \ + src/foundation/profile.zig src/foundation/sha256.zig src/foundation/slab_alloc.zig \ + src/foundation/str_intern.zig src/foundation/str_util.zig \ + src/foundation/subprocess.zig src/foundation/system_info.zig src/foundation/yaml.zig \ + $(wildcard src/foundation/*.h) \ src/cypher/parser.zig src/cypher/cypher.zig src/cypher/cypher.h \ src/discover/discover.zig src/discover/gitignore.zig src/discover/language.zig \ src/discover/userconfig.zig \ @@ -539,7 +528,7 @@ PP_OBJ_TEST = $(BUILD_DIR)/preprocessor.o # ── Targets ────────────────────────────────────────────────────── -.PHONY: test test-cli test-mcp test-repro test-foundation test-tsan cbm check-zova test-zova test-zova-c-sql-functions test-zova-bridge zova-test-runner clean-c print-build-sources lint lint-tidy lint-cppcheck lint-format security +.PHONY: test test-cli test-mcp test-repro test-tsan cbm check-zova test-zova test-zova-c-sql-functions test-zova-bridge zova-test-runner clean-c print-build-sources lint lint-tidy lint-cppcheck lint-format security $(BUILD_DIR): mkdir -p $(BUILD_DIR) @@ -625,14 +614,6 @@ $(BUILD_DIR)/cbm_zova_bridge.o: $(CBM_ZOVA_BRIDGE_ZIG_SRC) src/zova/cbm_zova_bri -Mzova_build_options=$(BUILD_DIR)/zova_build_options.zig endif -# ── Foundation-only test (fast, no extraction) ─────────────────── - -$(BUILD_DIR)/test-foundation: $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) | $(BUILD_DIR) - $(CC) $(CFLAGS_TEST) -o $@ $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(LDFLAGS_TEST) - -test-foundation: $(BUILD_DIR)/test-foundation - cd $(CURDIR) && $(BUILD_DIR)/test-foundation - # ── Grammar/TS/LSP object files (compiled with relaxed warnings) ─ $(BUILD_DIR)/%.o: $(CBM_DIR)/%.c | $(BUILD_DIR) diff --git a/build.zig b/build.zig index f7908ff8..636c93cd 100644 --- a/build.zig +++ b/build.zig @@ -530,28 +530,8 @@ fn grammarSources(b: *std.Build) []const []const u8 { } const native_sources = [_][]const u8{ - "src/foundation/arena.c", "src/foundation/hash_table.c", - "src/foundation/str_intern.c", - "src/foundation/log.c", - "src/foundation/str_util.c", - "src/foundation/platform.c", - "src/foundation/system_info.c", - "src/foundation/slab_alloc.c", - "src/foundation/yaml.c", - "src/foundation/compat.c", - "src/foundation/compat_thread.c", - "src/foundation/compat_fs.c", - "src/foundation/file_lock.c", - "src/foundation/compat_regex.c", - "src/foundation/mem.c", - "src/foundation/diagnostics.c", - "src/foundation/profile.c", - "src/foundation/dump_verify.c", - "src/foundation/limits.c", - "src/foundation/subprocess.c", - "src/foundation/git_env.c", - "src/foundation/sha256.c", + "src/foundation/c_shim.c", "src/store/store.c", "src/zova/cbm_zova.c", "src/zova/cbm_zova_publish_model.c", diff --git a/scripts/security-allowlist.txt b/scripts/security-allowlist.txt index fe336dec..1b65db06 100644 --- a/scripts/security-allowlist.txt +++ b/scripts/security-allowlist.txt @@ -5,12 +5,12 @@ # list causes the security audit (scripts/security-audit.sh) to fail. # ── Foundation: platform abstraction (defines cbm_popen wrapper + shell-free exec) ── -src/foundation/compat_fs.c:popen:cbm_popen wrapper definition (POSIX) -src/foundation/compat_fs.c:cbm_popen:cbm_popen function definition -src/foundation/compat_fs.c:cbm_popen_git:cbm_popen_git function definition (git children with the repository-local git environment removed) -src/foundation/compat_fs.c:fork:cbm_exec_no_shell — fork+execvp for shell-free subprocess execution -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 +src/foundation/compat_fs.zig:popen:cbm_popen wrapper definition (POSIX) +src/foundation/compat_fs.zig:cbm_popen:cbm_popen function definition +src/foundation/compat_fs.zig:cbm_popen_git:cbm_popen_git function definition (git children with the repository-local git environment removed) +src/foundation/compat_fs.zig:fork:cbm_exec_no_shell — fork+execvp for shell-free subprocess execution +src/foundation/subprocess.zig:fork:runPosix — 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.zig:execvp:cbm_exec_no_shell — direct exec without shell interpretation # ── CLI: update command (user-initiated, interactive) ────────────────────── src/cli/commands.zig:cbm_popen:latest-release lookup via curl redirect header (hardcoded URL) diff --git a/scripts/security-audit.sh b/scripts/security-audit.sh index ecdb8d24..2d27d35b 100755 --- a/scripts/security-audit.sh +++ b/scripts/security-audit.sh @@ -171,8 +171,8 @@ while IFS= read -r match; do file=$(echo "$match" | cut -d: -f1) relfile="${file#"$ROOT/"}" case "$relfile" in - src/cli/cli.zig|src/store/store.c|src/pipeline/*.c|src/foundation/log.c|src/foundation/diagnostics.c|src/mcp/search_tools.zig|src/mcp/index_tools.zig) - ;; # Known safe (diagnostics.c: atomic .tmp+rename metrics dump to configured path; + src/cli/cli.zig|src/store/store.c|src/pipeline/*.c|src/foundation/diagnostics.zig|src/mcp/search_tools.zig|src/mcp/index_tools.zig) + ;; # Known safe (diagnostics.zig: atomic .tmp+rename metrics dump to configured path; # search_tools.zig: search pattern/file-list temp files; index_tools.zig: # index supervisor response/quarantine files) *) diff --git a/src/cbm_zig.zig b/src/cbm_zig.zig index c52fa982..9dbbe776 100644 --- a/src/cbm_zig.zig +++ b/src/cbm_zig.zig @@ -25,6 +25,32 @@ comptime { checkExports(@import("discover/gitignore.zig")); checkExports(@import("discover/language.zig")); checkExports(@import("discover/userconfig.zig")); + checkExports(@import("foundation/arena.zig")); + checkExports(@import("foundation/compat.zig")); + checkExports(@import("foundation/compat.zig").windows); + checkExports(@import("foundation/compat_fs.zig")); + checkExports(@import("foundation/compat_fs.zig").impl); + checkExports(@import("foundation/compat_regex.zig")); + checkExports(@import("foundation/compat_thread.zig")); + checkExports(@import("foundation/diagnostics.zig")); + checkExports(@import("foundation/dump_verify.zig")); + checkExports(@import("foundation/file_lock.zig")); + checkExports(@import("foundation/git_env.zig")); + checkExports(@import("foundation/git_env.zig").windows); + checkExports(@import("foundation/git_env.zig").posix); + checkExports(@import("foundation/limits.zig")); + checkExports(@import("foundation/log.zig")); + checkExports(@import("foundation/mem.zig")); + checkExports(@import("foundation/platform.zig")); + checkExports(@import("foundation/profile.zig")); + checkExports(@import("foundation/sha256.zig")); + checkExports(@import("foundation/slab_alloc.zig")); + checkExports(@import("foundation/str_intern.zig")); + checkExports(@import("foundation/str_util.zig")); + checkExports(@import("foundation/subprocess.zig")); + checkExports(@import("foundation/system_info.zig")); + checkExports(@import("foundation/system_info.zig").linux); + checkExports(@import("foundation/yaml.zig")); checkExports(@import("git/git_context.zig")); checkExports(@import("mcp/code_tools.zig")); checkExports(@import("mcp/graph_tools.zig")); diff --git a/src/cbm_zig_c.h b/src/cbm_zig_c.h index 7f626324..e4b2dd70 100644 --- a/src/cbm_zig_c.h +++ b/src/cbm_zig_c.h @@ -27,8 +27,43 @@ #include #endif #include +#include +#include #include +/* Platform headers used by the Zig foundation modules. */ +#ifdef _WIN32 +#include +#include +#include +#include +#include +#include "foundation/win_utf8.h" +#include +#else +#include +#include +#include +#include +#include +#include +#include +#include +#endif +#ifdef __APPLE__ +#include +#include +#include +#include +#include +#include +#endif +#ifdef __linux__ +#include +#endif +#include + +#include "foundation/arena.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/compat_regex.h" @@ -37,14 +72,23 @@ #include "foundation/hash_table.h" #include "foundation/limits.h" #include "foundation/log.h" +#include "foundation/log_internal.h" #include "foundation/mem.h" #include "foundation/diagnostics.h" #include "foundation/dump_verify.h" +#include "foundation/file_lock.h" +#include "foundation/git_env.h" #include "foundation/profile.h" #include "foundation/subprocess.h" #include "foundation/platform.h" #include "foundation/sha256.h" +#include "foundation/sha256_backend.h" +#include "foundation/slab_alloc.h" +#include "foundation/str_intern.h" #include "foundation/str_util.h" +#include "foundation/system_info_internal.h" +#include "foundation/compat_fs_internal.h" +#include "foundation/yaml.h" #include "mcp/mcp.h" #include "pipeline/pipeline.h" #include "store/store.h" diff --git a/src/foundation/arena.c b/src/foundation/arena.c deleted file mode 100644 index 489a736e..00000000 --- a/src/foundation/arena.c +++ /dev/null @@ -1,202 +0,0 @@ -/* - * arena.c — Bump allocator implementation. - * - * Restructured from internal/cbm/arena.c with additions: - * - cbm_arena_init_sized() for custom block sizes - * - cbm_arena_calloc() for zero-initialized allocations - * - cbm_arena_reset() for reuse without full destroy - * - cbm_arena_total() for allocation tracking - * - * Arena blocks use malloc/free (= mimalloc in production builds). - */ -#include "arena.h" -#include "foundation/constants.h" - -enum { ARENA_ALIGN = 7, ARENA_GROW_OK = 1 }; -#include -#include -#include -#if defined(__APPLE__) -#include -#include -#endif -#include - -void cbm_arena_init(CBMArena *a) { - cbm_arena_init_sized(a, CBM_ARENA_DEFAULT_BLOCK_SIZE); -} - -void cbm_arena_init_sized(CBMArena *a, size_t block_size) { - memset(a, 0, sizeof(*a)); - if (block_size < CBM_SZ_64) { - block_size = CBM_SZ_64; /* minimum sanity */ - } - a->block_size = block_size; - a->blocks[0] = (char *)malloc(block_size); - if (a->blocks[0]) { - a->block_sizes[0] = block_size; - a->nblocks = SKIP_ONE; - } -} - -static int arena_grow(CBMArena *a, size_t min_size) { - if (a->nblocks >= CBM_ARENA_MAX_BLOCKS) { - return 0; - } - size_t new_size = a->block_size * PAIR_LEN; - if (new_size < min_size) { - new_size = min_size; - } - char *block = (char *)malloc(new_size); - if (!block) { - return 0; - } - a->blocks[a->nblocks] = block; - a->block_sizes[a->nblocks] = new_size; - a->nblocks++; - a->block_size = new_size; - a->used = 0; - return ARENA_GROW_OK; -} - -void *cbm_arena_alloc(CBMArena *a, size_t n) { - if (!a || n == 0) { - return NULL; - } - /* 8-byte alignment */ - n = (n + ARENA_ALIGN) & ~(size_t)ARENA_ALIGN; - if (a->nblocks == 0) { - return NULL; - } - if (a->used + n > a->block_size) { - if (!arena_grow(a, n)) { - return NULL; - } - } - char *ptr = a->blocks[a->nblocks - SKIP_ONE] + a->used; - a->used += n; - a->total_alloc += n; - return ptr; -} - -void *cbm_arena_calloc(CBMArena *a, size_t n) { - void *p = cbm_arena_alloc(a, n); - if (p) { - memset(p, 0, n); - } - return p; -} - -char *cbm_arena_strdup(CBMArena *a, const char *s) { - if (!s) { - return NULL; - } - size_t len = strlen(s); - char *dst = (char *)cbm_arena_alloc(a, len + SKIP_ONE); - if (dst) { - memcpy(dst, s, len + SKIP_ONE); - } - return dst; -} - -char *cbm_arena_strndup(CBMArena *a, const char *s, size_t len) { - if (!s) { - return NULL; - } - char *dst = (char *)cbm_arena_alloc(a, len + SKIP_ONE); - if (dst) { - memcpy(dst, s, len); - dst[len] = '\0'; - } - return dst; -} - -/* On macOS every vsnprintf consults the process locale under an unfair lock - * (localeconv_l inside __vfprintf), so parallel workers formatting type names - * serialize on it (upstream 7b77fd48: 10 MB C# files took 68 s instead of under - * a second). Each thread formats with a C locale object of its own, freed by a - * pthread key destructor at thread exit, and the common short string is - * formatted once into a stack buffer. */ -#if defined(__APPLE__) -static _Thread_local locale_t tl_c_locale; -static pthread_key_t tl_c_locale_key; -static pthread_once_t tl_c_locale_once = PTHREAD_ONCE_INIT; -static void arena_c_locale_free(void *loc) { - if (loc) { - freelocale((locale_t)loc); - } -} -static void arena_c_locale_key_init(void) { - (void)pthread_key_create(&tl_c_locale_key, arena_c_locale_free); -} -static locale_t arena_c_locale(void) { - if (!tl_c_locale) { - tl_c_locale = newlocale(LC_ALL_MASK, "C", NULL); - if (tl_c_locale) { - pthread_once(&tl_c_locale_once, arena_c_locale_key_init); - (void)pthread_setspecific(tl_c_locale_key, tl_c_locale); - } - } - return tl_c_locale; /* NULL = the global locale */ -} -#define ARENA_VSNPRINTF(buf, n, fmt, ap) vsnprintf_l((buf), (n), arena_c_locale(), (fmt), (ap)) -#else -#define ARENA_VSNPRINTF(buf, n, fmt, ap) vsnprintf((buf), (n), (fmt), (ap)) -#endif - -enum { ARENA_SPRINTF_LOCAL = 512 }; - -char *cbm_arena_sprintf(CBMArena *a, const char *fmt, ...) { - char local[ARENA_SPRINTF_LOCAL]; - va_list args; - va_start(args, fmt); - int needed = ARENA_VSNPRINTF(local, sizeof(local), fmt, args); - va_end(args); - if (needed < 0) { - return NULL; - } - - char *dst = (char *)cbm_arena_alloc(a, (size_t)needed + SKIP_ONE); - if (!dst) { - return NULL; - } - if ((size_t)needed < sizeof(local)) { - memcpy(dst, local, (size_t)needed + SKIP_ONE); - return dst; - } - - va_start(args, fmt); - ARENA_VSNPRINTF(dst, (size_t)needed + SKIP_ONE, fmt, args); - va_end(args); - return dst; -} - -void cbm_arena_reset(CBMArena *a) { - /* Keep first block, free the rest */ - for (int i = SKIP_ONE; i < a->nblocks; i++) { - free(a->blocks[i]); - a->blocks[i] = NULL; - a->block_sizes[i] = 0; - } - if (a->nblocks > SKIP_ONE) { - a->nblocks = SKIP_ONE; - } - a->used = 0; - a->total_alloc = 0; - /* Reset block_size to match surviving block — prevents overflow if - * block_size grew during previous allocations (e.g., CBM_SZ_128 → CBM_SZ_256). */ - if (a->nblocks == SKIP_ONE) { - a->block_size = a->block_sizes[0]; - } -} - -void cbm_arena_destroy(CBMArena *a) { - for (int i = 0; i < a->nblocks; i++) { - free(a->blocks[i]); - } - memset(a, 0, sizeof(*a)); -} - -size_t cbm_arena_total(const CBMArena *a) { - return a->total_alloc; -} diff --git a/src/foundation/arena.zig b/src/foundation/arena.zig new file mode 100644 index 00000000..c0288a3b --- /dev/null +++ b/src/foundation/arena.zig @@ -0,0 +1,112 @@ +//! arena.zig — Bump allocator implementation. +//! +//! Implements src/foundation/arena.h. Arena blocks use malloc/free +//! (= mimalloc in production builds). The variadic cbm_arena_sprintf stays in +//! C (c_shim.c): Zig cannot forward a va_list to vsnprintf portably. + +const std = @import("std"); +const c = @import("c"); + +const ARENA_ALIGN: usize = 7; +const ARENA_MIN_BLOCK: usize = 64; + +pub export fn cbm_arena_init(a: *c.CBMArena) void { + cbm_arena_init_sized(a, c.CBM_ARENA_DEFAULT_BLOCK_SIZE); +} + +pub export fn cbm_arena_init_sized(a: *c.CBMArena, block_size_in: usize) void { + a.* = std.mem.zeroes(c.CBMArena); + // minimum sanity + const block_size = @max(block_size_in, ARENA_MIN_BLOCK); + a.block_size = block_size; + a.blocks[0] = @ptrCast(c.malloc(block_size)); + if (a.blocks[0] != null) { + a.block_sizes[0] = block_size; + a.nblocks = 1; + } +} + +fn arenaGrow(a: *c.CBMArena, min_size: usize) bool { + if (a.nblocks >= c.CBM_ARENA_MAX_BLOCKS) return false; + var new_size = a.block_size *% 2; + if (new_size < min_size) new_size = min_size; + const block: [*c]u8 = @ptrCast(c.malloc(new_size)); + if (block == null) return false; + const i: usize = @intCast(a.nblocks); + a.blocks[i] = block; + a.block_sizes[i] = new_size; + a.nblocks += 1; + a.block_size = new_size; + a.used = 0; + return true; +} + +pub export fn cbm_arena_alloc(a: ?*c.CBMArena, n_in: usize) ?*anyopaque { + const arena = a orelse return null; + if (n_in == 0) return null; + + // 8-byte alignment + const n = (n_in +% ARENA_ALIGN) & ~ARENA_ALIGN; + + if (arena.nblocks == 0) return null; + + if (arena.used +% n > arena.block_size) { + if (!arenaGrow(arena, n)) return null; + } + + const last: usize = @intCast(arena.nblocks - 1); + const ptr = arena.blocks[last] + arena.used; + arena.used +%= n; + arena.total_alloc +%= n; + return @ptrCast(ptr); +} + +pub export fn cbm_arena_calloc(a: ?*c.CBMArena, n: usize) ?*anyopaque { + const p = cbm_arena_alloc(a, n) orelse return null; + @memset(@as([*]u8, @ptrCast(p))[0..n], 0); + return p; +} + +pub export fn cbm_arena_strdup(a: ?*c.CBMArena, s: ?[*:0]const u8) ?[*:0]u8 { + const src = s orelse return null; + const len = std.mem.len(src); + const dst: [*]u8 = @ptrCast(cbm_arena_alloc(a, len + 1) orelse return null); + @memcpy(dst[0 .. len + 1], src[0 .. len + 1]); + return @ptrCast(dst); +} + +pub export fn cbm_arena_strndup(a: ?*c.CBMArena, s: ?[*]const u8, len: usize) ?[*:0]u8 { + const src = s orelse return null; + const dst: [*]u8 = @ptrCast(cbm_arena_alloc(a, len +% 1) orelse return null); + @memcpy(dst[0..len], src[0..len]); + dst[len] = 0; + return @ptrCast(dst); +} + +pub export fn cbm_arena_reset(a: *c.CBMArena) void { + // Keep first block, free the rest + var i: usize = 1; + while (i < a.nblocks) : (i += 1) { + c.free(a.blocks[i]); + a.blocks[i] = null; + a.block_sizes[i] = 0; + } + if (a.nblocks > 1) a.nblocks = 1; + a.used = 0; + a.total_alloc = 0; + // Reset block_size to match surviving block — prevents overflow if + // block_size grew during previous allocations (e.g., 128 → 256). + if (a.nblocks == 1) a.block_size = a.block_sizes[0]; +} + +pub export fn cbm_arena_destroy(a: *c.CBMArena) void { + var i: usize = 0; + while (i < a.nblocks) : (i += 1) { + c.free(a.blocks[i]); + } + a.* = std.mem.zeroes(c.CBMArena); +} + +pub export fn cbm_arena_total(a: *const c.CBMArena) usize { + return a.total_alloc; +} diff --git a/src/foundation/c_shim.c b/src/foundation/c_shim.c new file mode 100644 index 00000000..83dfe402 --- /dev/null +++ b/src/foundation/c_shim.c @@ -0,0 +1,127 @@ +/* + * c_shim.c — The parts of the foundation library that have to stay C. + * + * Everything else in src/foundation is Zig (hash_table.c wraps a C macro + * template and is separate). What is here: + * - cbm_arena_sprintf and cbm_log consume a va_list: Zig can define a + * C-variadic function only on some targets and cannot forward a va_list + * to vsnprintf. Each one does the va_list work and hands the rest to Zig + * (arena.zig, log.zig). + * - a size check on regex_t, which translate-c cannot see on glibc. + */ +#include "foundation/arena.h" +#include "foundation/compat_regex.h" +#include "foundation/constants.h" +#include "foundation/log.h" +#include "foundation/log_internal.h" + +#include +#include +#include +#if defined(__APPLE__) +#include +#include +#endif +#ifdef _WIN32 +#include "../../vendored/tre/regex.h" +#else +#include +#endif + +/* ── compat_regex.zig ─────────────────────────────────────────── */ + +/* compat_regex.zig stores the platform regex_t inside cbm_regex_t. glibc's + * regex_t has bitfields, so the Zig side cannot take its size; this check + * covers every platform. If it fires, increase cbm_regex_t.opaque size. */ +_Static_assert(sizeof(regex_t) <= sizeof(cbm_regex_t), + "cbm_regex_t opaque buffer too small for regex_t"); + +/* ── cbm_arena_sprintf ────────────────────────────────────────── */ + +/* On macOS every vsnprintf consults the process locale under an unfair lock + * (localeconv_l inside __vfprintf), so parallel workers formatting type names + * serialize on it (upstream 7b77fd48: 10 MB C# files took 68 s instead of under + * a second). Each thread formats with a C locale object of its own, freed by a + * pthread key destructor at thread exit, and the common short string is + * formatted once into a stack buffer. */ +#if defined(__APPLE__) +static _Thread_local locale_t tl_c_locale; +static pthread_key_t tl_c_locale_key; +static pthread_once_t tl_c_locale_once = PTHREAD_ONCE_INIT; + +static void arena_c_locale_free(void *loc) { + if (loc) { + freelocale((locale_t)loc); + } +} + +static void arena_c_locale_key_init(void) { + (void)pthread_key_create(&tl_c_locale_key, arena_c_locale_free); +} + +static locale_t arena_c_locale(void) { + if (!tl_c_locale) { + tl_c_locale = newlocale(LC_ALL_MASK, "C", NULL); + if (tl_c_locale) { + pthread_once(&tl_c_locale_once, arena_c_locale_key_init); + (void)pthread_setspecific(tl_c_locale_key, tl_c_locale); + } + } + return tl_c_locale; /* NULL = the global locale */ +} + +#define ARENA_VSNPRINTF(buf, n, fmt, ap) vsnprintf_l((buf), (n), arena_c_locale(), (fmt), (ap)) +#else +#define ARENA_VSNPRINTF(buf, n, fmt, ap) vsnprintf((buf), (n), (fmt), (ap)) +#endif + +enum { ARENA_SPRINTF_LOCAL = 512 }; + +char *cbm_arena_sprintf(CBMArena *a, const char *fmt, ...) { + char local[ARENA_SPRINTF_LOCAL]; + va_list args; + va_start(args, fmt); + int needed = ARENA_VSNPRINTF(local, sizeof(local), fmt, args); + va_end(args); + + if (needed < 0) { + return NULL; + } + + char *dst = (char *)cbm_arena_alloc(a, (size_t)needed + SKIP_ONE); + if (!dst) { + return NULL; + } + if ((size_t)needed < sizeof(local)) { + memcpy(dst, local, (size_t)needed + SKIP_ONE); + return dst; + } + + va_start(args, fmt); + ARENA_VSNPRINTF(dst, (size_t)needed + SKIP_ONE, fmt, args); + va_end(args); + return dst; +} + +/* ── cbm_log ──────────────────────────────────────────────────── */ + +void cbm_log(CBMLogLevel level, const char *msg, ...) { + cbm_log_line_t line; + if (!cbm_log_line_begin(&line, level, msg)) { + return; + } + + va_list args; + va_start(args, msg); + for (;;) { + const char *key = va_arg(args, const char *); + if (!key) { + break; + } + const char *val = va_arg(args, const char *); + cbm_log_line_pair(&line, key, val); + } + va_end(args); + + cbm_log_line_end(&line); +} diff --git a/src/foundation/compat.c b/src/foundation/compat.c deleted file mode 100644 index e4e2c2b7..00000000 --- a/src/foundation/compat.c +++ /dev/null @@ -1,206 +0,0 @@ -/* - * compat.c — Implementations for Windows-only shims. - * - * On POSIX, these functions are provided by the standard library via - * macros in compat.h. On Windows, we implement them here. - */ -#include "foundation/compat.h" -#include "foundation/constants.h" - -#include -#include -#ifdef _WIN32 -#include -#include -#include -#endif - -/* ── strndup (Windows lacks it) ───────────────────────────────── */ - -#ifdef _WIN32 -char *cbm_strndup(const char *s, size_t n) { - if (!s) { - return NULL; - } - size_t len = 0; - while (len < n && s[len]) { - len++; - } - char *d = (char *)malloc(len + SKIP_ONE); - if (d) { - memcpy(d, s, len); - d[len] = '\0'; - } - return d; -} -#endif - -/* ── strcasestr (Windows lacks it) ────────────────────────────── */ - -#ifdef _WIN32 -char *cbm_strcasestr(const char *haystack, const char *needle) { - if (!needle[0]) - return (char *)haystack; - size_t nlen = strlen(needle); - for (; *haystack; haystack++) { - if (_strnicmp(haystack, needle, nlen) == 0) - return (char *)haystack; - } - return NULL; -} -#endif - -/* ── mkdtemp (Windows lacks it) ───────────────────────────────── */ - -#ifdef _WIN32 -#include -char *cbm_mkdtemp(char *tmpl) { - /* Build path in static buffer, then copy back to caller. - * Callers must provide buffers >= CBM_SZ_256 bytes (all test code does). */ - static char buf[CBM_SZ_512]; - 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); - } else { - snprintf(buf, sizeof(buf), "%s", tmpl); - } - if (!_mktemp(buf)) - return NULL; - if (_mkdir(buf) != 0) - return NULL; - /* Normalize to forward slashes. Callers embed this path in JSON repo_path - * (where "\t"/"\a" are invalid escapes → index fails) and pass it to git -C. - * Windows file APIs accept forward slashes, so the created dir is unaffected. */ - for (char *p = buf; *p; p++) { - if (*p == '\\') { - *p = '/'; - } - } - /* Copy result back — callers now use char[CBM_SZ_256]+ buffers */ - strcpy(tmpl, buf); - return tmpl; -} -#endif - -/* ── mkstemp (Windows lacks it) ───────────────────────────────── */ - -#ifdef _WIN32 -int cbm_mkstemp(char *tmpl) { - /* Rewrite /tmp/ to %TEMP%\ like cbm_mkdtemp */ - static char buf[CBM_SZ_512]; - 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); - } else { - snprintf(buf, sizeof(buf), "%s", tmpl); - } - if (!_mktemp(buf)) - return CBM_NOT_FOUND; - int fd = _open(buf, _O_CREAT | _O_RDWR | _O_BINARY, _S_IREAD | _S_IWRITE); - if (fd >= 0) - strcpy(tmpl, buf); - return fd; -} -#endif - -/* ── clock_gettime (Windows lacks it) ─────────────────────────── */ - -#ifdef _WIN32 -int cbm_clock_gettime(int clk_id, struct timespec *tp) { - (void)clk_id; - LARGE_INTEGER freq, count; - QueryPerformanceFrequency(&freq); - QueryPerformanceCounter(&count); - tp->tv_sec = (time_t)(count.QuadPart / freq.QuadPart); - tp->tv_nsec = (long)((count.QuadPart % freq.QuadPart) * 1000000000LL / freq.QuadPart); - return 0; -} -#endif - -char *cbm_heap_strdup(const char *s) { - if (!s) { - return NULL; - } - size_t len = strlen(s); - char *d = malloc(len + SKIP_ONE); - if (d) { - memcpy(d, s, len + SKIP_ONE); - } - return d; -} - -double cbm_elapsed_ms(struct timespec start) { - struct timespec now; - cbm_clock_gettime(CLOCK_MONOTONIC, &now); - double s = (double)(now.tv_sec - start.tv_sec); - double ns = (double)(now.tv_nsec - start.tv_nsec); - return (s * (double)CBM_MSEC_PER_SEC) + (ns / (double)CBM_NSEC_PER_MSEC); -} - -/* ── Per-thread CPU time ──────────────────────────────────────── */ - -uint64_t cbm_thread_cpu_time_ns(void) { -#ifdef _WIN32 - FILETIME creation, exit_time, kernel, user; - if (!GetThreadTimes(GetCurrentThread(), &creation, &exit_time, &kernel, &user)) { - return 0; - } - /* Each FILETIME is a 64-bit count of 100-ns ticks split across two DWORDs. */ - uint64_t kernel_ticks = ((uint64_t)kernel.dwHighDateTime << 32) | kernel.dwLowDateTime; - uint64_t user_ticks = ((uint64_t)user.dwHighDateTime << 32) | user.dwLowDateTime; - return (kernel_ticks + user_ticks) * 100ULL; -#else - struct timespec ts; - if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts) != 0) { - return 0; - } - return ((uint64_t)ts.tv_sec * 1000000000ULL) + (uint64_t)ts.tv_nsec; -#endif -} - -/* ── getline (Windows lacks it) ───────────────────────────────── */ - -#ifdef _WIN32 -ssize_t cbm_getline(char **lineptr, size_t *n, FILE *stream) { - if (!lineptr || !n || !stream) { - return CBM_NOT_FOUND; - } - if (!*lineptr || *n == 0) { - *n = CBM_SZ_128; - *lineptr = (char *)malloc(*n); - if (!*lineptr) { - return CBM_NOT_FOUND; - } - } - size_t pos = 0; - int c; - while ((c = fgetc(stream)) != EOF) { - if (pos + 1 >= *n) { - size_t new_n = *n * PAIR_LEN; - char *tmp = (char *)realloc(*lineptr, new_n); - if (!tmp) { - return CBM_NOT_FOUND; - } - *lineptr = tmp; - *n = new_n; - } - (*lineptr)[pos++] = (char)c; - if (c == '\n') { - break; - } - } - if (pos == 0 && c == EOF) { - return CBM_NOT_FOUND; - } - (*lineptr)[pos] = '\0'; - return (ssize_t)pos; -} -#endif diff --git a/src/foundation/compat.zig b/src/foundation/compat.zig new file mode 100644 index 00000000..5db255ee --- /dev/null +++ b/src/foundation/compat.zig @@ -0,0 +1,167 @@ +//! compat.zig — Implementations for the compat.h shims. +//! +//! On POSIX most of compat.h maps to the standard library through macros; the +//! `windows` namespace holds the functions Windows lacks. The static inline +//! helpers of compat.h stay in the header. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); + +const is_windows = builtin.os.tag == .windows; + +pub const windows = if (is_windows) struct { + // ── strndup (Windows lacks it) ─────────────────────────────── + + pub export fn cbm_strndup(s: ?[*]const u8, n: usize) ?[*:0]u8 { + const src = s orelse return null; + var len: usize = 0; + while (len < n and src[len] != 0) len += 1; + const d: [*]u8 = @ptrCast(c.malloc(len + 1) orelse return null); + @memcpy(d[0..len], src[0..len]); + d[len] = 0; + return @ptrCast(d); + } + + // ── strcasestr (Windows lacks it) ──────────────────────────── + + pub export fn cbm_strcasestr(haystack: [*:0]const u8, needle: [*:0]const u8) ?[*:0]u8 { + if (needle[0] == 0) return @constCast(haystack); + const nlen = std.mem.len(needle); + var h = haystack; + while (h[0] != 0) : (h += 1) { + if (c._strnicmp(h, needle, nlen) == 0) return @constCast(h); + } + return null; + } + + // ── mkdtemp / mkstemp (Windows lacks them) ─────────────────── + + /// Rewrites a "/tmp/" template to %TEMP%\ in `buf`. + fn tempTemplate(buf: *[512]u8, tmpl: [*:0]const u8) void { + if (c.strncmp(tmpl, "/tmp/", 5) == 0) { + var tmp: [*c]const u8 = c.getenv("TEMP"); + if (tmp == null) tmp = c.getenv("TMP"); + if (tmp == null) tmp = "."; + _ = c.snprintf(buf, buf.len, "%s\\%s", tmp, tmpl + 5); + } else { + _ = c.snprintf(buf, buf.len, "%s", tmpl); + } + } + + var mkdtemp_buf: [512]u8 = undefined; + + pub export fn cbm_mkdtemp(tmpl: [*:0]u8) ?[*:0]u8 { + // Build path in static buffer, then copy back to caller. + // Callers must provide buffers >= 256 bytes (all test code does). + const buf = &mkdtemp_buf; + tempTemplate(buf, tmpl); + if (c._mktemp(buf) == null) return null; + if (c._mkdir(buf) != 0) return null; + // Normalize to forward slashes. Callers embed this path in JSON repo_path + // (where "\t"/"\a" are invalid escapes → index fails) and pass it to git -C. + // Windows file APIs accept forward slashes, so the created dir is unaffected. + for (buf) |*ch| { + if (ch.* == 0) break; + if (ch.* == '\\') ch.* = '/'; + } + // Copy result back — callers now use char[256]+ buffers + _ = c.strcpy(tmpl, buf); + return tmpl; + } + + var mkstemp_buf: [512]u8 = undefined; + + pub export fn cbm_mkstemp(tmpl: [*:0]u8) c_int { + // Rewrite /tmp/ to %TEMP%\ like cbm_mkdtemp + const buf = &mkstemp_buf; + tempTemplate(buf, tmpl); + if (c._mktemp(buf) == null) return c.CBM_NOT_FOUND; + const fd = c._open(buf, c._O_CREAT | c._O_RDWR | c._O_BINARY, @as(c_int, c._S_IREAD | c._S_IWRITE)); + if (fd >= 0) _ = c.strcpy(tmpl, buf); + return fd; + } + + // ── clock_gettime (Windows lacks it) ───────────────────────── + + pub export fn cbm_clock_gettime(clk_id: c_int, tp: *c.struct_timespec) c_int { + _ = clk_id; + var freq: c.LARGE_INTEGER = undefined; + var count: c.LARGE_INTEGER = undefined; + _ = c.QueryPerformanceFrequency(&freq); + _ = c.QueryPerformanceCounter(&count); + tp.tv_sec = @intCast(@divTrunc(count.QuadPart, freq.QuadPart)); + tp.tv_nsec = @truncate(@divTrunc(@rem(count.QuadPart, freq.QuadPart) *% 1000000000, freq.QuadPart)); + return 0; + } + + // ── getline (Windows lacks it) ─────────────────────────────── + + pub export fn cbm_getline(lineptr: ?*[*c]u8, n: ?*usize, stream: ?*c.FILE) isize { + const line = lineptr orelse return c.CBM_NOT_FOUND; + const cap = n orelse return c.CBM_NOT_FOUND; + if (stream == null) return c.CBM_NOT_FOUND; + if (line.* == null or cap.* == 0) { + cap.* = 128; + line.* = @ptrCast(c.malloc(cap.*)); + if (line.* == null) return c.CBM_NOT_FOUND; + } + var pos: usize = 0; + var ch: c_int = undefined; + while (true) { + ch = c.fgetc(stream); + if (ch == c.EOF) break; + if (pos + 1 >= cap.*) { + const new_n = cap.* *% 2; + const tmp: [*c]u8 = @ptrCast(c.realloc(line.*, new_n)); + if (tmp == null) return c.CBM_NOT_FOUND; + line.* = tmp; + cap.* = new_n; + } + line.*[pos] = @truncate(@as(c_uint, @bitCast(ch))); + pos += 1; + if (ch == '\n') break; + } + if (pos == 0 and ch == c.EOF) return c.CBM_NOT_FOUND; + line.*[pos] = 0; + return @intCast(pos); + } +} else struct {}; + +pub export fn cbm_heap_strdup(s: ?[*:0]const u8) ?[*:0]u8 { + const src = s orelse return null; + const len = std.mem.len(src); + const d: [*]u8 = @ptrCast(c.malloc(len + 1) orelse return null); + @memcpy(d[0 .. len + 1], src[0 .. len + 1]); + return @ptrCast(d); +} + +pub export fn cbm_elapsed_ms(start: c.struct_timespec) f64 { + var now: c.struct_timespec = undefined; + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &now); + const s: f64 = @floatFromInt(now.tv_sec - start.tv_sec); + const ns: f64 = @floatFromInt(now.tv_nsec - start.tv_nsec); + return (s * 1000.0) + (ns / 1000000.0); +} + +// ── Per-thread CPU time ────────────────────────────────────────── + +pub export fn cbm_thread_cpu_time_ns() u64 { + if (is_windows) { + var creation: c.FILETIME = undefined; + var exit_time: c.FILETIME = undefined; + var kernel: c.FILETIME = undefined; + var user: c.FILETIME = undefined; + if (c.GetThreadTimes(c.GetCurrentThread(), &creation, &exit_time, &kernel, &user) == 0) return 0; + // Each FILETIME is a 64-bit count of 100-ns ticks split across two DWORDs. + const kernel_ticks = (@as(u64, kernel.dwHighDateTime) << 32) | kernel.dwLowDateTime; + const user_ticks = (@as(u64, user.dwHighDateTime) << 32) | user.dwLowDateTime; + return (kernel_ticks +% user_ticks) *% 100; + } else { + var ts: c.struct_timespec = undefined; + if (c.clock_gettime(c.CLOCK_THREAD_CPUTIME_ID, &ts) != 0) return 0; + const sec: u64 = @bitCast(@as(i64, ts.tv_sec)); + const nsec: u64 = @bitCast(@as(i64, ts.tv_nsec)); + return (sec *% 1000000000) +% nsec; + } +} diff --git a/src/foundation/compat_fs.c b/src/foundation/compat_fs.c deleted file mode 100644 index 7cfb58ad..00000000 --- a/src/foundation/compat_fs.c +++ /dev/null @@ -1,996 +0,0 @@ -/* - * compat_fs.c — Portable file system operations. - * - * POSIX: direct wrappers around opendir/readdir/closedir, popen/pclose, mkdir, unlink. - * Windows: FindFirstFile/FindNextFile, _popen/_pclose, _mkdir, _unlink. - */ -#include "foundation/constants.h" -#include "foundation/compat.h" -#include "foundation/compat_fs.h" -#include "foundation/compat_fs_internal.h" -#include "foundation/git_env.h" - -#include -#include -#include -#include - -#ifdef _WIN32 - -/* ── Windows implementation ────────────────────────────────── */ - -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#include /* _wmkdir */ -#include /* errno for spawn-failure logging */ -#include /* _O_RDONLY */ -#include /* _wunlink, _open_osfhandle, _close */ -#include /* intptr_t */ -#include "foundation/log.h" -#include "foundation/win_utf8.h" - -struct cbm_dir { - HANDLE find_handle; - WIN32_FIND_DATAW find_data; - wchar_t wide_pattern[CBM_PATH_MAX]; - cbm_dirent_t entry; - bool first; - bool done; -}; - -cbm_dir_t *cbm_opendir(const char *path) { - if (!path) { - return NULL; - } - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return NULL; - } - - size_t wlen = wcslen(wpath); - if (wlen == 0 || wlen + 2 >= CBM_PATH_MAX) { - free(wpath); - return NULL; - } - - cbm_dir_t *d = (cbm_dir_t *)calloc(CBM_ALLOC_ONE, sizeof(cbm_dir_t)); - if (!d) { - free(wpath); - return NULL; - } - - wmemcpy(d->wide_pattern, wpath, wlen + 1); - wchar_t *p = d->wide_pattern + wlen - SKIP_ONE; - if (*p != L'\\' && *p != L'/') { - ++p; - *p++ = L'\\'; - } else { - ++p; - } - *p++ = L'*'; - *p = L'\0'; - free(wpath); - - d->find_handle = FindFirstFileW(d->wide_pattern, &d->find_data); - if (d->find_handle == INVALID_HANDLE_VALUE) { - free(d); - return NULL; - } - d->first = true; - d->done = false; - return d; -} - -cbm_dirent_t *cbm_readdir(cbm_dir_t *d) { - if (!d || d->done) { - return NULL; - } - if (!d->first) { - if (!FindNextFileW(d->find_handle, &d->find_data)) { - d->done = true; - return NULL; - } - } - d->first = false; - - while (d->find_data.cFileName[0] == L'.' && - (d->find_data.cFileName[1] == L'\0' || - (d->find_data.cFileName[1] == L'.' && d->find_data.cFileName[2] == L'\0'))) { - if (!FindNextFileW(d->find_handle, &d->find_data)) { - d->done = true; - return NULL; - } - } - - char *u8 = cbm_wide_to_utf8(d->find_data.cFileName); - if (!u8) { - d->done = true; - return NULL; - } - size_t nlen = strlen(u8); - if (nlen >= CBM_DIRENT_NAME_MAX) { - nlen = CBM_DIRENT_NAME_MAX - SKIP_ONE; - } - memcpy(d->entry.name, u8, nlen); - d->entry.name[nlen] = '\0'; - free(u8); - d->entry.is_dir = (d->find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; - d->entry.d_type = 0; - return &d->entry; -} - -void cbm_closedir(cbm_dir_t *d) { - if (d) { - if (d->find_handle != INVALID_HANDLE_VALUE) { - FindClose(d->find_handle); - } - free(d); - } -} - -/* Windows _popen replacement that inherits ONLY the child's stdout pipe. - * - * The CRT's _popen uses CreateProcess(bInheritHandles=TRUE), which leaks EVERY - * inheritable handle we hold into the child — listening/client sockets, the - * Winsock/AFD helper handles created by WSAStartup, the MCP stdio pipe, etc. - * When the child is git-for-Windows (MSYS2/Cygwin runtime), its startup walks - * every inherited handle and calls NtQueryObject on each to classify it; on an - * inherited socket/AFD handle NtQueryObject deadlocks. Since our UI server runs - * requests on a single thread, that wedges the whole server (list_projects, - * which shells out to git per project, never returns → the web UI hangs). - * - * The fix: spawn via CreateProcessW with STARTUPINFOEXW + an explicit - * PROC_THREAD_ATTRIBUTE_HANDLE_LIST containing only the stdout write-end and a - * NUL handle for stdin/stderr. Nothing else crosses into git, so there is no - * foreign handle to deadlock on. POSIX popen() already sets O_CLOEXEC on its - * pipe, so the POSIX path is unchanged. - * - * There is deliberately NO fallback to _popen when the isolated spawn fails: - * falling back would silently re-arm the deadlock. cbm_popen logs a structured - * warning and returns NULL instead (every call site handles NULL). */ - -enum { CBM_POPEN_MAX = 16 }; -static struct { - FILE *fp; - HANDLE proc; -} g_popen_tab[CBM_POPEN_MAX]; -static CRITICAL_SECTION g_popen_lock; -static INIT_ONCE g_popen_once = INIT_ONCE_STATIC_INIT; - -/* Test hook (declared in compat_fs_internal.h): 1 when the most recent - * cbm_popen(..., "r") stream came from the isolated spawn. Test-only - * observable; not synchronized across threads. */ -static volatile LONG g_popen_last_isolated = 0; - -int cbm_popen_last_was_isolated(void) { - return (int)g_popen_last_isolated; -} - -static BOOL CALLBACK cbm_popen_init(PINIT_ONCE once, PVOID param, PVOID *ctx) { - (void)once; - (void)param; - (void)ctx; - InitializeCriticalSection(&g_popen_lock); - return TRUE; -} - -/* Resolve the shell explicitly — %COMSPEC%, else \cmd.exe — so it - * can be passed as lpApplicationName and CreateProcess never walks the search - * path (no cmd.exe planting from a hostile CWD). Heap string; caller frees. */ -static wchar_t *cbm_resolve_comspec(void) { - wchar_t buf[MAX_PATH]; - const wchar_t suffix[] = L"\\cmd.exe"; - DWORD n = GetEnvironmentVariableW(L"COMSPEC", buf, MAX_PATH); - if (n == 0 || n >= MAX_PATH) { - UINT sn = GetSystemDirectoryW(buf, MAX_PATH); - if (sn == 0 || (size_t)sn + wcslen(suffix) >= MAX_PATH) { - return NULL; - } - wmemcpy(buf + sn, suffix, wcslen(suffix) + 1); - } - return _wcsdup(buf); -} - -/* On failure returns NULL with *stage naming the failing step and *gle the - * GetLastError value captured at that step (0 when errno is the signal). - * env: a CREATE_UNICODE_ENVIRONMENT block for the child, or NULL to inherit. */ -static FILE *cbm_popen_isolated(const char *cmd, const wchar_t *env, const char **stage, - DWORD *gle) { - *stage = ""; - *gle = 0; - InitOnceExecuteOnce(&g_popen_once, cbm_popen_init, NULL, NULL); - - SECURITY_ATTRIBUTES sa; - sa.nLength = sizeof(sa); - sa.lpSecurityDescriptor = NULL; - sa.bInheritHandle = TRUE; - - HANDLE rd = NULL, wr = NULL; - if (!CreatePipe(&rd, &wr, &sa, 0)) { - *stage = "pipe"; - *gle = GetLastError(); - return NULL; - } - /* The parent read-end must never cross into the child. */ - SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0); - - /* NUL for the child's stdin/stderr so it never touches our real stdin - * pipe. If NUL cannot be opened, fail: STARTF_USESTDHANDLES slots must - * never carry INVALID_HANDLE_VALUE. */ - HANDLE nul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0, NULL); - if (nul == INVALID_HANDLE_VALUE) { - *stage = "nul"; - *gle = GetLastError(); - CloseHandle(rd); - CloseHandle(wr); - return NULL; - } - - HANDLE inherit[2]; - inherit[0] = wr; - inherit[1] = nul; - - SIZE_T attr_sz = 0; - InitializeProcThreadAttributeList(NULL, 1, 0, &attr_sz); - LPPROC_THREAD_ATTRIBUTE_LIST attr = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(attr_sz); - BOOL attr_init = attr && InitializeProcThreadAttributeList(attr, 1, 0, &attr_sz); - BOOL prepared = - attr_init && UpdateProcThreadAttribute(attr, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherit, - sizeof(inherit), NULL, NULL); - DWORD attr_gle = prepared ? 0 : GetLastError(); - - STARTUPINFOEXW si; - ZeroMemory(&si, sizeof(si)); - si.StartupInfo.cb = sizeof(si); - si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; - si.StartupInfo.hStdInput = nul; - si.StartupInfo.hStdOutput = wr; - si.StartupInfo.hStdError = nul; - si.lpAttributeList = attr; - - /* Run through cmd.exe /c so command quoting and `2>NUL` behave as under - * _popen. The command line is heap-composed (no fixed-size truncation) - * and widened via UTF-8 so non-ASCII repo paths survive intact. */ - wchar_t *app = cbm_resolve_comspec(); - wchar_t *wcmdline = NULL; - if (app) { - size_t u8len = strlen(cmd) + sizeof("cmd.exe /c "); - char *u8 = (char *)malloc(u8len); - if (u8) { - snprintf(u8, u8len, "cmd.exe /c %s", cmd); - wcmdline = cbm_utf8_to_wide(u8); - free(u8); - } - } - - PROCESS_INFORMATION pi; - ZeroMemory(&pi, sizeof(pi)); - BOOL created = FALSE; - if (!prepared) { - *stage = "attr"; - *gle = attr_gle; - } else if (!app || !wcmdline) { - *stage = "cmdline"; - *gle = ERROR_NOT_ENOUGH_MEMORY; - } else { - DWORD flags = EXTENDED_STARTUPINFO_PRESENT; - if (env) { - flags |= CREATE_UNICODE_ENVIRONMENT; - } - created = CreateProcessW(app, wcmdline, NULL, NULL, TRUE, flags, (LPVOID)env, NULL, - &si.StartupInfo, &pi); - if (!created) { - *stage = "spawn"; - *gle = GetLastError(); - } - } - - free(app); - free(wcmdline); - if (attr) { - if (attr_init) { - DeleteProcThreadAttributeList(attr); - } - free(attr); - } - CloseHandle(wr); /* the child owns the write-end now */ - CloseHandle(nul); - if (!created) { - CloseHandle(rd); - return NULL; - } - CloseHandle(pi.hThread); - - int fd = _open_osfhandle((intptr_t)rd, _O_RDONLY); - if (fd == -1) { - *stage = "osfhandle"; - CloseHandle(rd); - CloseHandle(pi.hProcess); - return NULL; - } - FILE *fp = _fdopen(fd, "r"); /* takes ownership of fd/rd */ - if (!fp) { - *stage = "fdopen"; - _close(fd); - CloseHandle(pi.hProcess); - return NULL; - } - - EnterCriticalSection(&g_popen_lock); - for (int i = 0; i < CBM_POPEN_MAX; i++) { - if (!g_popen_tab[i].fp) { - g_popen_tab[i].fp = fp; - g_popen_tab[i].proc = pi.hProcess; - LeaveCriticalSection(&g_popen_lock); - return fp; - } - } - LeaveCriticalSection(&g_popen_lock); - /* Table full (shouldn't happen): don't leak the process handle. */ - *stage = "table"; - CloseHandle(pi.hProcess); - fclose(fp); - return NULL; -} - -static FILE *cbm_popen_read_isolated(const char *cmd, const wchar_t *env) { - const char *stage = ""; - DWORD gle = 0; - FILE *fp = cbm_popen_isolated(cmd, env, &stage, &gle); - g_popen_last_isolated = (fp != NULL); - if (!fp) { - char glebuf[CBM_SZ_16]; - char errnobuf[CBM_SZ_16]; - snprintf(glebuf, sizeof(glebuf), "%lu", (unsigned long)gle); - snprintf(errnobuf, sizeof(errnobuf), "%d", errno); - cbm_log_warn("compat.popen_isolated_failed", "stage", stage, "gle", glebuf, "errno", - errnobuf); - } - return fp; -} - -FILE *cbm_popen(const char *cmd, const char *mode) { - /* Our git shell-outs are all read-mode; they MUST use the isolated - * spawn. On failure, log and fail the call — never fall back to - * _popen, whose full handle inheritance re-arms the UI hang (#798). */ - if (mode && mode[0] == 'r' && mode[1] == '\0') { - return cbm_popen_read_isolated(cmd, NULL); - } - g_popen_last_isolated = 0; - return _popen(cmd, mode); -} - -FILE *cbm_popen_git(const char *cmd) { - /* Fail closed: without the scrubbed block the child would inherit a - * caller's GIT_DIR and read the wrong repository (#56). */ - wchar_t *env = cbm_git_child_env_block(); - if (!env) { - cbm_log_warn("compat.popen_git_env_failed", "stage", "env_block"); - return NULL; - } - FILE *fp = cbm_popen_read_isolated(cmd, env); - cbm_git_child_env_free(env); /* CreateProcessW copied the block into the child */ - return fp; -} - -int cbm_pclose(FILE *f) { - InitOnceExecuteOnce(&g_popen_once, cbm_popen_init, NULL, NULL); - - HANDLE proc = NULL; - EnterCriticalSection(&g_popen_lock); - for (int i = 0; i < CBM_POPEN_MAX; i++) { - if (g_popen_tab[i].fp == f) { - proc = g_popen_tab[i].proc; - g_popen_tab[i].fp = NULL; - g_popen_tab[i].proc = NULL; - break; - } - } - LeaveCriticalSection(&g_popen_lock); - - if (!proc) { - return _pclose(f); /* opened via _popen (non-read mode) */ - } - fclose(f); - WaitForSingleObject(proc, INFINITE); - DWORD code = 0; - BOOL got = GetExitCodeProcess(proc, &code); - CloseHandle(proc); - return got ? (int)code : -1; -} - -FILE *cbm_fopen(const char *path, const char *mode) { - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return NULL; - } - wchar_t *wmode = cbm_utf8_to_wide(mode); - if (!wmode) { - free(wpath); - return NULL; - } - FILE *f = _wfopen(wpath, wmode); - free(wpath); - free(wmode); - return f; -} - -bool cbm_mkdir_p(const char *path, int mode) { - (void)mode; - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return false; - } - - if (_wmkdir(wpath) == 0) { - free(wpath); - return true; - } - size_t wlen = wcslen(wpath); - wchar_t *tmp = (wchar_t *)malloc((wlen + 1) * sizeof(wchar_t)); - if (!tmp) { - free(wpath); - return false; - } - wmemcpy(tmp, wpath, wlen + 1); - for (wchar_t *p = tmp + SKIP_ONE; *p; p++) { - if (*p == L'/' || *p == L'\\') { - *p = L'\0'; - _wmkdir(tmp); - *p = L'\\'; - } - } - bool ok = _wmkdir(tmp) == 0 || GetLastError() == ERROR_ALREADY_EXISTS; - free(tmp); - free(wpath); - return ok; -} - -int cbm_unlink(const char *path) { - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return CBM_NOT_FOUND; - } - int ret = _wunlink(wpath); - free(wpath); - return ret; -} - -int cbm_rmdir(const char *path) { - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return CBM_NOT_FOUND; - } - int ret = _wrmdir(wpath); - free(wpath); - return ret; -} - -int cbm_stat_utf8(const char *path, struct stat *st) { - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return CBM_NOT_FOUND; - } - struct _stat64 wst; - int ret = _wstat64(wpath, &wst); - free(wpath); - if (ret != 0) { - return CBM_NOT_FOUND; - } - st->st_mode = wst.st_mode; - st->st_size = wst.st_size; - st->st_mtime = wst.st_mtime; - return 0; -} - -int cbm_stat_no_follow(const char *path, struct stat *st) { - wchar_t *wpath = cbm_utf8_to_wide(path); - if (wpath) { - DWORD attr = GetFileAttributesW(wpath); - free(wpath); - if (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_REPARSE_POINT)) { - return CBM_NOT_FOUND; - } - } - return cbm_stat_utf8(path, st); -} - -bool cbm_canonical_path(const char *path, char *out, size_t out_sz) { - return _fullpath(out, path, out_sz) != NULL; -} - -/* Build a properly-quoted Windows command line from an argv array. - * Returns a heap-allocated wide string, or NULL on allocation failure. - * Quoting follows the MSVC CRT convention: arguments containing spaces, - * tabs, or double-quotes are wrapped in double-quotes, with backslashes - * before a closing quote doubled and the quote itself escaped. Argument - * bytes are treated as UTF-8 and converted to wide via cbm_utf8_to_wide, - * so non-ASCII arguments (e.g. a non-ASCII %USERPROFILE%) survive intact. - * Declared in compat_fs_internal.h so the test suite can drive it. */ -wchar_t *cbm_build_cmdline(const char *const *argv) { - /* First pass: compute required buffer size. */ - size_t total = 1; /* NUL terminator */ - for (int i = 0; argv[i]; i++) { - const char *arg = argv[i]; - bool needs_quote = (arg[0] == '\0'); - for (const char *p = arg; *p; p++) { - if (*p == ' ' || *p == '\t' || *p == '"') { - needs_quote = true; - } - } - if (i > 0) { - total++; /* space separator */ - } - if (needs_quote) { - total += 2; /* opening and closing quote */ - size_t backslashes = 0; - for (const char *p = arg; *p; p++) { - if (*p == '\\') { - backslashes++; - } else if (*p == '"') { - total += backslashes + 1; /* double backslashes + escape backslash */ - backslashes = 0; - } else { - backslashes = 0; - } - total++; - } - /* Trailing backslashes before closing quote must be doubled. */ - total += backslashes; - } else { - total += strlen(arg); - } - } - - /* Build the quoted command line in UTF-8 first, then widen it as a - * whole via cbm_utf8_to_wide. Every character the quoting logic acts - * on (space, tab, '"', '\\') is ASCII and, by UTF-8's design, never - * appears inside a multibyte sequence, so operating on raw bytes here - * is safe and keeps multibyte argument bytes intact for conversion. */ - char *buf = (char *)malloc(total); - if (!buf) { - return NULL; - } - - /* Second pass: write the command line bytes. */ - char *w = buf; - for (int i = 0; argv[i]; i++) { - const char *arg = argv[i]; - bool needs_quote = (arg[0] == '\0'); - for (const char *p = arg; *p; p++) { - if (*p == ' ' || *p == '\t' || *p == '"') { - needs_quote = true; - break; - } - } - if (i > 0) { - *w++ = ' '; - } - if (needs_quote) { - *w++ = '"'; - size_t backslashes = 0; - for (const char *p = arg; *p; p++) { - if (*p == '\\') { - backslashes++; - *w++ = '\\'; - } else if (*p == '"') { - /* Double the preceding backslashes, then escape the quote. */ - for (size_t b = 0; b < backslashes; b++) { - *w++ = '\\'; - } - *w++ = '\\'; - *w++ = '"'; - backslashes = 0; - } else { - backslashes = 0; - *w++ = *p; - } - } - /* Double trailing backslashes before the closing quote. */ - for (size_t b = 0; b < backslashes; b++) { - *w++ = '\\'; - } - *w++ = '"'; - } else { - for (const char *p = arg; *p; p++) { - *w++ = *p; - } - } - } - *w = '\0'; - - wchar_t *out = cbm_utf8_to_wide(buf); - free(buf); - return out; -} - -int cbm_exec_no_shell(const char *const *argv) { - if (!argv || !argv[0]) { - return CBM_NOT_FOUND; - } - - wchar_t *cmdline = cbm_build_cmdline(argv); - if (!cmdline) { - return CBM_NOT_FOUND; - } - - STARTUPINFOW si; - PROCESS_INFORMATION pi; - memset(&si, 0, sizeof(si)); - memset(&pi, 0, sizeof(pi)); - si.cb = sizeof(si); - - if (!CreateProcessW(NULL, cmdline, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) { - free(cmdline); - return CBM_NOT_FOUND; - } - free(cmdline); - - WaitForSingleObject(pi.hProcess, INFINITE); - DWORD exit_code = (DWORD)CBM_NOT_FOUND; - GetExitCodeProcess(pi.hProcess, &exit_code); - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - return (int)exit_code; -} - -#else /* POSIX */ - -/* ── POSIX implementation ────────────────────────────────── */ - -#include -#include -#include -#include /* PATH_MAX */ -#include -#include -#include -#include -#include "foundation/compat_thread.h" - -struct cbm_dir { - DIR *dir; - cbm_dirent_t entry; -}; - -cbm_dir_t *cbm_opendir(const char *path) { - if (!path) { - return NULL; - } - DIR *dir = opendir(path); - if (!dir) { - return NULL; - } - cbm_dir_t *d = (cbm_dir_t *)calloc(CBM_ALLOC_ONE, sizeof(cbm_dir_t)); - if (!d) { - closedir(dir); - return NULL; - } - d->dir = dir; - return d; -} - -cbm_dirent_t *cbm_readdir(cbm_dir_t *d) { - if (!d || !d->dir) { - return NULL; - } - struct dirent *de; - while ((de = readdir(d->dir)) != NULL) { - /* Skip "." and ".." */ - if (de->d_name[0] == '.' && - (de->d_name[SKIP_ONE] == '\0' || - (de->d_name[SKIP_ONE] == '.' && de->d_name[PAIR_LEN] == '\0'))) { - continue; - } - size_t nlen = strlen(de->d_name); - if (nlen >= CBM_DIRENT_NAME_MAX) { - nlen = CBM_DIRENT_NAME_MAX - SKIP_ONE; - } - memcpy(d->entry.name, de->d_name, nlen); - d->entry.name[nlen] = '\0'; - d->entry.is_dir = (de->d_type == DT_DIR); - d->entry.d_type = de->d_type; - return &d->entry; - } - return NULL; -} - -void cbm_closedir(cbm_dir_t *d) { - if (d) { - if (d->dir) { - closedir(d->dir); - } - free(d); - } -} - -FILE *cbm_popen(const char *cmd, const char *mode) { - return popen(cmd, mode); -} - -/* Streams opened by cbm_popen_git: popen() cannot take an environment, so - * those children are spawned here and reaped by cbm_pclose via this list. */ -typedef struct cbm_popen_git_entry { - FILE *fp; - pid_t pid; - struct cbm_popen_git_entry *next; -} cbm_popen_git_entry_t; - -static cbm_popen_git_entry_t *g_popen_git_list = NULL; -static cbm_mutex_t g_popen_git_lock; -static cbm_once_t g_popen_git_once = CBM_ONCE_INIT; - -static void cbm_popen_git_lock_init(void) { - cbm_mutex_init(&g_popen_git_lock); -} - -enum { POPEN_PIPE_READ = 0, POPEN_PIPE_WRITE = 1, POPEN_PIPE_ENDS = 2 }; - -/* `/bin/sh -c cmd` with stdout on a pipe, stdin on /dev/null (never the MCP - * transport), stderr inherited like popen(), and `envp` as the environment. */ -static pid_t cbm_popen_spawn_sh(const char *cmd, char **envp, int *read_fd) { - int fds[POPEN_PIPE_ENDS]; - if (pipe(fds) != 0) { - return CBM_NOT_FOUND; - } - int rd = fds[POPEN_PIPE_READ]; - int wr = fds[POPEN_PIPE_WRITE]; - posix_spawn_file_actions_t actions; - if (!cbm_fd_set_cloexec(rd) || !cbm_fd_set_cloexec(wr) || - posix_spawn_file_actions_init(&actions) != 0) { - (void)close(rd); - (void)close(wr); - return CBM_NOT_FOUND; - } - /* dup2 clears close-on-exec on the child's stdout; both pipe ends - * themselves stay close-on-exec, so no other child ever holds them. */ - bool configured = - posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0) == 0 && - posix_spawn_file_actions_adddup2(&actions, wr, STDOUT_FILENO) == 0; -#if defined(__APPLE__) - posix_spawnattr_t attr; - posix_spawnattr_t *attr_ptr = NULL; - if (posix_spawnattr_init(&attr) == 0) { -#ifndef POSIX_SPAWN_CLOEXEC_DEFAULT -#define POSIX_SPAWN_CLOEXEC_DEFAULT 0x2000 -#endif - (void)posix_spawnattr_setflags(&attr, POSIX_SPAWN_CLOEXEC_DEFAULT); - attr_ptr = &attr; - (void)posix_spawn_file_actions_addinherit_np(&actions, STDERR_FILENO); - } -#else - posix_spawnattr_t *attr_ptr = NULL; -#endif - char *const argv[] = {(char *)"sh", (char *)"-c", (char *)cmd, NULL}; - pid_t pid = CBM_NOT_FOUND; - int rc = configured ? posix_spawn(&pid, "/bin/sh", &actions, attr_ptr, argv, envp) : CBM_NOT_FOUND; -#if defined(__APPLE__) - if (attr_ptr) { - (void)posix_spawnattr_destroy(&attr); - } -#endif - (void)posix_spawn_file_actions_destroy(&actions); - (void)close(wr); - if (rc != 0 || pid <= 0) { - (void)close(rd); - return CBM_NOT_FOUND; - } - *read_fd = rd; - return pid; -} - -static int cbm_popen_git_wait(pid_t pid) { - int status = 0; - while (waitpid(pid, &status, 0) < 0) { - if (errno != EINTR) { - return CBM_NOT_FOUND; - } - } - return status; -} - -FILE *cbm_popen_git(const char *cmd) { - if (!cmd) { - return NULL; - } - /* Fail closed: without the scrubbed environment the child would inherit - * a caller's GIT_DIR and read the wrong repository (#56). */ - char **envp = cbm_git_child_envp(); - if (!envp) { - return NULL; - } - int read_fd = CBM_NOT_FOUND; - pid_t pid = cbm_popen_spawn_sh(cmd, envp, &read_fd); - cbm_git_child_env_free(envp); /* posix_spawn copied it into the child */ - if (pid < 0) { - return NULL; - } - cbm_popen_git_entry_t *entry = (cbm_popen_git_entry_t *)malloc(sizeof(*entry)); - FILE *fp = entry ? fdopen(read_fd, "r") : NULL; - if (!fp) { - free(entry); - (void)close(read_fd); - (void)cbm_popen_git_wait(pid); - return NULL; - } - entry->fp = fp; - entry->pid = pid; - cbm_once(&g_popen_git_once, cbm_popen_git_lock_init); - cbm_mutex_lock(&g_popen_git_lock); - entry->next = g_popen_git_list; - g_popen_git_list = entry; - cbm_mutex_unlock(&g_popen_git_lock); - return fp; -} - -int cbm_pclose(FILE *f) { - cbm_popen_git_entry_t *found = NULL; - cbm_once(&g_popen_git_once, cbm_popen_git_lock_init); - cbm_mutex_lock(&g_popen_git_lock); - for (cbm_popen_git_entry_t **link = &g_popen_git_list; *link; link = &(*link)->next) { - if ((*link)->fp == f) { - found = *link; - *link = found->next; - break; - } - } - cbm_mutex_unlock(&g_popen_git_lock); - if (!found) { - return pclose(f); /* opened by popen() */ - } - pid_t pid = found->pid; - free(found); - (void)fclose(f); - return cbm_popen_git_wait(pid); /* the raw wait status, as pclose() */ -} - -FILE *cbm_fopen(const char *path, const char *mode) { - FILE *f = fopen(path, mode); - if (f) { - int fd = fileno(f); - if (fd >= 0) { - (void)cbm_fd_set_cloexec(fd); - } - } - return f; -} - -bool cbm_mkdir_p(const char *path, int mode) { - /* Try direct mkdir first */ - if (mkdir(path, (mode_t)mode) == 0) { - return true; - } - /* Walk path and create each component */ - char *tmp = strdup(path); - if (!tmp) { - return false; - } - for (char *p = tmp + SKIP_ONE; *p; p++) { - if (*p == '/') { - *p = '\0'; - mkdir(tmp, (mode_t)mode); /* ignore intermediate errors */ - *p = '/'; - } - } - bool ok = (mkdir(tmp, (mode_t)mode) == 0 || errno == EEXIST) != 0; - free(tmp); - return ok; -} - -int cbm_unlink(const char *path) { - return unlink(path); -} - -int cbm_rmdir(const char *path) { - return rmdir(path); -} - -int cbm_stat_utf8(const char *path, struct stat *st) { - return stat(path, st); -} - -int cbm_stat_no_follow(const char *path, struct stat *st) { - if (lstat(path, st) != 0) { - return CBM_NOT_FOUND; - } - if (S_ISLNK(st->st_mode)) { - return CBM_NOT_FOUND; - } - return 0; -} - -bool cbm_canonical_path(const char *path, char *out, size_t out_sz) { - char resolved[PATH_MAX]; - if (!realpath(path, resolved)) { - return false; - } - size_t len = strlen(resolved); - if (len >= out_sz) { - return false; - } - memcpy(out, resolved, len + SKIP_ONE); - return true; -} - -int cbm_exec_no_shell(const char *const *argv) { - if (!argv || !argv[0]) { - return CBM_NOT_FOUND; - } -#if defined(__APPLE__) - posix_spawnattr_t attr; - if (posix_spawnattr_init(&attr) != 0) { - return CBM_NOT_FOUND; - } -#ifndef POSIX_SPAWN_CLOEXEC_DEFAULT -#define POSIX_SPAWN_CLOEXEC_DEFAULT 0x2000 -#endif - (void)posix_spawnattr_setflags(&attr, POSIX_SPAWN_CLOEXEC_DEFAULT); - - posix_spawn_file_actions_t actions; - if (posix_spawn_file_actions_init(&actions) != 0) { - (void)posix_spawnattr_destroy(&attr); - return CBM_NOT_FOUND; - } - (void)posix_spawn_file_actions_addinherit_np(&actions, STDIN_FILENO); - (void)posix_spawn_file_actions_addinherit_np(&actions, STDOUT_FILENO); - (void)posix_spawn_file_actions_addinherit_np(&actions, STDERR_FILENO); - - pid_t pid = CBM_NOT_FOUND; - extern char **environ; - int rc = posix_spawnp(&pid, argv[0], &actions, &attr, (char *const *)argv, environ); - (void)posix_spawn_file_actions_destroy(&actions); - (void)posix_spawnattr_destroy(&attr); - - if (rc != 0 || pid <= 0) { - return (rc == ENOENT || rc == EACCES) ? 127 : CBM_NOT_FOUND; - } - int status = 0; - while (waitpid(pid, &status, 0) < 0) { - if (errno != EINTR) { - return CBM_NOT_FOUND; - } - } - if (WIFEXITED(status)) { - return WEXITSTATUS(status); - } - return CBM_NOT_FOUND; -#else - pid_t pid = fork(); - if (pid < 0) { - return CBM_NOT_FOUND; - } - if (pid == 0) { - /* Child: exec directly — no shell interpretation */ - cbm_fd_close_above_stderr(); - /* 127 = standard "command not found" exit code (POSIX convention) */ - enum { EXEC_NOT_FOUND = 127 }; - execvp(argv[0], (char *const *)argv); - _exit(EXEC_NOT_FOUND); - } - /* Parent: wait for child */ - int status = 0; - if (waitpid(pid, &status, 0) < 0) { - return CBM_NOT_FOUND; - } - if (WIFEXITED(status)) { - return WEXITSTATUS(status); - } - return CBM_NOT_FOUND; /* killed by signal */ -#endif -} - -#endif /* _WIN32 */ - -int64_t cbm_stat_mtime_ns(const struct stat *st) { - const int64_t ns_per_sec = (int64_t)CBM_NSEC_PER_SEC; -#ifdef __APPLE__ - return ((int64_t)st->st_mtimespec.tv_sec * ns_per_sec) + (int64_t)st->st_mtimespec.tv_nsec; -#elif defined(_WIN32) - return (int64_t)st->st_mtime * ns_per_sec; -#else - return ((int64_t)st->st_mtim.tv_sec * ns_per_sec) + (int64_t)st->st_mtim.tv_nsec; -#endif -} diff --git a/src/foundation/compat_fs.zig b/src/foundation/compat_fs.zig new file mode 100644 index 00000000..663c484f --- /dev/null +++ b/src/foundation/compat_fs.zig @@ -0,0 +1,833 @@ +//! compat_fs.zig — Portable file system operations. +//! +//! Implements src/foundation/compat_fs.h (and compat_fs_internal.h). +//! POSIX: direct wrappers around opendir/readdir/closedir, popen/pclose, mkdir, unlink. +//! Windows: FindFirstFile/FindNextFile, isolated CreateProcessW, _wmkdir, _wunlink. +//! +//! The two implementations share every exported name, so each lives in its +//! own namespace and `impl` selects the one for the target. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("interop.zig"); +const compat_thread = @import("compat_thread.zig"); + +const is_windows = builtin.os.tag == .windows; +const is_apple = builtin.os.tag.isDarwin(); + +pub const impl = if (is_windows) windows_impl else posix_impl; + +const Argv = ?[*:null]const ?[*:0]const u8; + +// ── Windows implementation ─────────────────────────────────────── + +const windows_impl = struct { + const L = std.unicode.utf8ToUtf16LeStringLiteral; + + // Win32 constants whose macros translate-c cannot evaluate. + const GENERIC_READ: c.DWORD = 0x80000000; + const GENERIC_WRITE: c.DWORD = 0x40000000; + const INVALID_FILE_ATTRIBUTES: c.DWORD = 0xFFFFFFFF; + const PROC_THREAD_ATTRIBUTE_HANDLE_LIST: usize = 0x00020002; + const ERROR_NOT_ENOUGH_MEMORY: c.DWORD = 8; + const ERROR_ALREADY_EXISTS: c.DWORD = 183; + + const Dir = extern struct { + find_handle: c.HANDLE, + find_data: c.WIN32_FIND_DATAW, + wide_pattern: [c.CBM_PATH_MAX]c.wchar_t, + entry: c.cbm_dirent_t, + first: bool, + done: bool, + }; + + fn toWide(s: ?[*:0]const u8) ?[*:0]c.wchar_t { + return @ptrCast(c.cbm_utf8_to_wide(s)); + } + + pub export fn cbm_opendir(path: ?[*:0]const u8) ?*Dir { + const p = path orelse return null; + const wpath = toWide(p) orelse return null; + defer c.free(wpath); + const wlen = std.mem.len(wpath); + if (wlen == 0 or wlen + 2 >= c.CBM_PATH_MAX) return null; + const d: *Dir = @ptrCast(@alignCast(c.calloc(1, @sizeOf(Dir)) orelse return null)); + @memcpy(d.wide_pattern[0 .. wlen + 1], wpath[0 .. wlen + 1]); + var end = wlen; + const last = d.wide_pattern[wlen - 1]; + if (last != '\\' and last != '/') { + d.wide_pattern[end] = '\\'; + end += 1; + } + d.wide_pattern[end] = '*'; + d.wide_pattern[end + 1] = 0; + + d.find_handle = c.FindFirstFileW(&d.wide_pattern, &d.find_data); + if (d.find_handle == interop.INVALID_HANDLE_VALUE) { + c.free(d); + return null; + } + d.first = true; + d.done = false; + return d; + } + + pub export fn cbm_readdir(dir: ?*Dir) ?*c.cbm_dirent_t { + const d = dir orelse return null; + if (d.done) return null; + + if (!d.first) { + if (c.FindNextFileW(d.find_handle, &d.find_data) == 0) { + d.done = true; + return null; + } + } + d.first = false; + + const name = &d.find_data.cFileName; + while (name[0] == '.' and (name[1] == 0 or (name[1] == '.' and name[2] == 0))) { + if (c.FindNextFileW(d.find_handle, &d.find_data) == 0) { + d.done = true; + return null; + } + } + + const utf8: [*:0]u8 = @ptrCast(c.cbm_wide_to_utf8(name) orelse { + d.done = true; + return null; + }); + const nlen = @min(std.mem.len(utf8), c.CBM_DIRENT_NAME_MAX - 1); + @memcpy(d.entry.name[0..nlen], utf8[0..nlen]); + d.entry.name[nlen] = 0; + c.free(utf8); + d.entry.is_dir = (d.find_data.dwFileAttributes & c.FILE_ATTRIBUTE_DIRECTORY) != 0; + d.entry.d_type = 0; + return &d.entry; + } + + pub export fn cbm_closedir(dir: ?*Dir) void { + const d = dir orelse return; + if (d.find_handle != interop.INVALID_HANDLE_VALUE) _ = c.FindClose(d.find_handle); + c.free(d); + } + + // Windows _popen replacement that inherits ONLY the child's stdout pipe. + // + // The CRT's _popen uses CreateProcess(bInheritHandles=TRUE), which leaks EVERY + // inheritable handle we hold into the child — listening/client sockets, the + // Winsock/AFD helper handles created by WSAStartup, the MCP stdio pipe, etc. + // When the child is git-for-Windows (MSYS2/Cygwin runtime), its startup walks + // every inherited handle and calls NtQueryObject on each to classify it; on an + // inherited socket/AFD handle NtQueryObject deadlocks. Since our UI server runs + // requests on a single thread, that wedges the whole server (list_projects, + // which shells out to git per project, never returns → the web UI hangs). + // + // The fix: spawn via CreateProcessW with STARTUPINFOEXW + an explicit + // PROC_THREAD_ATTRIBUTE_HANDLE_LIST containing only the stdout write-end and a + // NUL handle for stdin/stderr. Nothing else crosses into git, so there is no + // foreign handle to deadlock on. POSIX popen() already sets O_CLOEXEC on its + // pipe, so the POSIX path is unchanged. + // + // There is deliberately NO fallback to _popen when the isolated spawn fails: + // falling back would silently re-arm the deadlock. cbm_popen logs a structured + // warning and returns NULL instead (every call site handles NULL). + const CBM_POPEN_MAX = 16; + const PopenSlot = struct { + fp: ?*c.FILE = null, + proc: c.HANDLE = null, + }; + var g_popen_tab: [CBM_POPEN_MAX]PopenSlot = @splat(.{}); + var g_popen_lock: c.CRITICAL_SECTION = undefined; + var g_popen_once: c.INIT_ONCE = std.mem.zeroes(c.INIT_ONCE); + + // Test hook (declared in compat_fs_internal.h): 1 when the most recent + // cbm_popen(..., "r") stream came from the isolated spawn. Test-only + // observable; not synchronized across threads. + var g_popen_last_isolated: c_int = 0; + + pub export fn cbm_popen_last_was_isolated() c_int { + return g_popen_last_isolated; + } + + fn popenInit(_: c.PINIT_ONCE, _: c.PVOID, _: [*c]c.PVOID) callconv(.c) c.WINBOOL { + c.InitializeCriticalSection(&g_popen_lock); + return 1; + } + + /// Resolve the shell explicitly — %COMSPEC%, else \cmd.exe — so it + /// can be passed as lpApplicationName and CreateProcess never walks the search + /// path (no cmd.exe planting from a hostile CWD). Heap string; caller frees. + fn resolveComspec() ?[*:0]c.wchar_t { + var buf: [c.MAX_PATH]c.wchar_t = undefined; + const suffix = L("\\cmd.exe"); + const n = c.GetEnvironmentVariableW(L("COMSPEC"), &buf, c.MAX_PATH); + if (n == 0 or n >= c.MAX_PATH) { + const sn: usize = c.GetSystemDirectoryW(&buf, c.MAX_PATH); + if (sn == 0 or sn + suffix.len >= c.MAX_PATH) return null; + @memcpy(buf[sn..][0 .. suffix.len + 1], suffix[0 .. suffix.len + 1]); + } + return @ptrCast(c._wcsdup(&buf)); + } + + /// On failure returns null with stage.* naming the failing step and gle.* the + /// GetLastError value captured at that step (0 when errno is the signal). + /// env: a CREATE_UNICODE_ENVIRONMENT block for the child, or null to inherit. + fn popenIsolated(cmd: [*:0]const u8, env: ?[*]const c.wchar_t, stage: *[*:0]const u8, gle: *c.DWORD) ?*c.FILE { + stage.* = ""; + gle.* = 0; + _ = c.InitOnceExecuteOnce(&g_popen_once, popenInit, null, null); + + var sa: c.SECURITY_ATTRIBUTES = undefined; + sa.nLength = @sizeOf(c.SECURITY_ATTRIBUTES); + sa.lpSecurityDescriptor = null; + sa.bInheritHandle = 1; + + var rd: c.HANDLE = null; + var wr: c.HANDLE = null; + if (c.CreatePipe(&rd, &wr, &sa, 0) == 0) { + stage.* = "pipe"; + gle.* = c.GetLastError(); + return null; + } + // The parent read-end must never cross into the child. + _ = c.SetHandleInformation(rd, c.HANDLE_FLAG_INHERIT, 0); + + // NUL for the child's stdin/stderr so it never touches our real stdin + // pipe. If NUL cannot be opened, fail: STARTF_USESTDHANDLES slots must + // never carry INVALID_HANDLE_VALUE. + const nul = c.CreateFileW(L("NUL"), GENERIC_READ | GENERIC_WRITE, c.FILE_SHARE_READ | c.FILE_SHARE_WRITE, &sa, c.OPEN_EXISTING, 0, null); + if (nul == interop.INVALID_HANDLE_VALUE) { + stage.* = "nul"; + gle.* = c.GetLastError(); + _ = c.CloseHandle(rd); + _ = c.CloseHandle(wr); + return null; + } + + var inherit = [2]c.HANDLE{ wr, nul }; + + var attr_sz: usize = 0; + _ = c.InitializeProcThreadAttributeList(null, 1, 0, &attr_sz); + const attr: c.LPPROC_THREAD_ATTRIBUTE_LIST = @ptrCast(c.malloc(attr_sz)); + const attr_init = attr != null and c.InitializeProcThreadAttributeList(attr, 1, 0, &attr_sz) != 0; + const prepared = attr_init and + c.UpdateProcThreadAttribute(attr, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &inherit, @sizeOf(@TypeOf(inherit)), null, null) != 0; + const attr_gle: c.DWORD = if (prepared) 0 else c.GetLastError(); + + var si = std.mem.zeroes(c.STARTUPINFOEXW); + si.StartupInfo.cb = @sizeOf(c.STARTUPINFOEXW); + si.StartupInfo.dwFlags = c.STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = nul; + si.StartupInfo.hStdOutput = wr; + si.StartupInfo.hStdError = nul; + si.lpAttributeList = attr; + + // Run through cmd.exe /c so command quoting and `2>NUL` behave as under + // _popen. The command line is heap-composed (no fixed-size truncation) + // and widened via UTF-8 so non-ASCII repo paths survive intact. + const app = resolveComspec(); + var wcmdline: ?[*:0]c.wchar_t = null; + if (app != null) { + const prefix = "cmd.exe /c "; + const cmd_len = std.mem.len(cmd); + if (@as(?[*]u8, @ptrCast(c.malloc(prefix.len + cmd_len + 1)))) |utf8| { + @memcpy(utf8[0..prefix.len], prefix); + @memcpy(utf8[prefix.len..][0 .. cmd_len + 1], cmd[0 .. cmd_len + 1]); + wcmdline = toWide(@ptrCast(utf8)); + c.free(utf8); + } + } + + var pi = std.mem.zeroes(c.PROCESS_INFORMATION); + var created = false; + if (!prepared) { + stage.* = "attr"; + gle.* = attr_gle; + } else if (app == null or wcmdline == null) { + stage.* = "cmdline"; + gle.* = ERROR_NOT_ENOUGH_MEMORY; + } else { + var flags: c.DWORD = c.EXTENDED_STARTUPINFO_PRESENT; + if (env != null) flags |= c.CREATE_UNICODE_ENVIRONMENT; + created = c.CreateProcessW(app, wcmdline, null, null, 1, flags, @ptrCast(@constCast(env)), null, &si.StartupInfo, &pi) != 0; + if (!created) { + stage.* = "spawn"; + gle.* = c.GetLastError(); + } + } + + c.free(app); + c.free(wcmdline); + if (attr != null) { + if (attr_init) c.DeleteProcThreadAttributeList(attr); + c.free(attr); + } + _ = c.CloseHandle(wr); // the child owns the write-end now + _ = c.CloseHandle(nul); + if (!created) { + _ = c.CloseHandle(rd); + return null; + } + _ = c.CloseHandle(pi.hThread); + + const fd = c._open_osfhandle(@bitCast(@intFromPtr(rd)), c._O_RDONLY); + if (fd == -1) { + stage.* = "osfhandle"; + _ = c.CloseHandle(rd); + _ = c.CloseHandle(pi.hProcess); + return null; + } + const fp: *c.FILE = c._fdopen(fd, "r") orelse { // takes ownership of fd/rd + stage.* = "fdopen"; + _ = c._close(fd); + _ = c.CloseHandle(pi.hProcess); + return null; + }; + + c.EnterCriticalSection(&g_popen_lock); + for (&g_popen_tab) |*slot| { + if (slot.fp == null) { + slot.fp = fp; + slot.proc = pi.hProcess; + c.LeaveCriticalSection(&g_popen_lock); + return fp; + } + } + c.LeaveCriticalSection(&g_popen_lock); + // Table full (shouldn't happen): don't leak the process handle. + stage.* = "table"; + _ = c.CloseHandle(pi.hProcess); + _ = c.fclose(fp); + return null; + } + + fn popenReadIsolated(cmd: [*:0]const u8, env: ?[*]const c.wchar_t) ?*c.FILE { + var stage: [*:0]const u8 = ""; + var gle: c.DWORD = 0; + const fp = popenIsolated(cmd, env, &stage, &gle); + g_popen_last_isolated = @intFromBool(fp != null); + if (fp == null) { + var glebuf: [16]u8 = undefined; + var errnobuf: [16]u8 = undefined; + _ = interop.bprint(&glebuf, "{d}", .{gle}); + _ = interop.bprint(&errnobuf, "{d}", .{interop.errno()}); + interop.logWarn("compat.popen_isolated_failed", .{ + "stage", stage, + "gle", @as(interop.Str, @ptrCast(&glebuf)), + "errno", @as(interop.Str, @ptrCast(&errnobuf)), + }); + } + return fp; + } + + pub export fn cbm_popen(cmd: [*:0]const u8, mode: ?[*:0]const u8) ?*c.FILE { + // Our git shell-outs are all read-mode; they MUST use the isolated + // spawn. On failure, log and fail the call — never fall back to + // _popen, whose full handle inheritance re-arms the UI hang (#798). + if (mode) |m| { + if (m[0] == 'r' and m[1] == 0) return popenReadIsolated(cmd, null); + } + g_popen_last_isolated = 0; + return c._popen(cmd, mode); + } + + pub export fn cbm_popen_git(cmd: [*:0]const u8) ?*c.FILE { + // Fail closed: without the scrubbed block the child would inherit a + // caller's GIT_DIR and read the wrong repository (#56). + const env = c.cbm_git_child_env_block() orelse { + interop.logWarn("compat.popen_git_env_failed", .{ "stage", "env_block" }); + return null; + }; + const fp = popenReadIsolated(cmd, env); + c.cbm_git_child_env_free(env); // CreateProcessW copied the block into the child + return fp; + } + + pub export fn cbm_pclose(f: ?*c.FILE) c_int { + _ = c.InitOnceExecuteOnce(&g_popen_once, popenInit, null, null); + var proc: c.HANDLE = null; + c.EnterCriticalSection(&g_popen_lock); + for (&g_popen_tab) |*slot| { + if (slot.fp == f) { + proc = slot.proc; + slot.* = .{}; + break; + } + } + c.LeaveCriticalSection(&g_popen_lock); + if (proc == null) return c._pclose(f); // opened via _popen (non-read mode) + _ = c.fclose(f); + _ = c.WaitForSingleObject(proc, c.INFINITE); + var code: c.DWORD = 0; + const got = c.GetExitCodeProcess(proc, &code) != 0; + _ = c.CloseHandle(proc); + return if (got) @bitCast(code) else -1; + } + + pub export fn cbm_fopen(path: ?[*:0]const u8, mode: ?[*:0]const u8) ?*c.FILE { + const wpath = toWide(path) orelse return null; + defer c.free(wpath); + const wmode = toWide(mode) orelse return null; + defer c.free(wmode); + return c._wfopen(wpath, wmode); + } + + pub export fn cbm_mkdir_p(path: ?[*:0]const u8, mode: c_int) bool { + _ = mode; + const wpath = toWide(path) orelse return false; + defer c.free(wpath); + if (c._wmkdir(wpath) == 0) return true; + const wlen = std.mem.len(wpath); + const tmp: [*]c.wchar_t = @ptrCast(@alignCast(c.malloc((wlen + 1) * @sizeOf(c.wchar_t)) orelse return false)); + defer c.free(tmp); + @memcpy(tmp[0 .. wlen + 1], wpath[0 .. wlen + 1]); + var i: usize = 1; + while (i < wlen) : (i += 1) { + if (tmp[i] == '/' or tmp[i] == '\\') { + tmp[i] = 0; + _ = c._wmkdir(tmp); + tmp[i] = '\\'; + } + } + return c._wmkdir(tmp) == 0 or c.GetLastError() == ERROR_ALREADY_EXISTS; + } + + pub export fn cbm_unlink(path: ?[*:0]const u8) c_int { + const wpath = toWide(path) orelse return c.CBM_NOT_FOUND; + defer c.free(wpath); + return c._wunlink(wpath); + } + + pub export fn cbm_rmdir(path: ?[*:0]const u8) c_int { + const wpath = toWide(path) orelse return c.CBM_NOT_FOUND; + defer c.free(wpath); + return c._wrmdir(wpath); + } + + pub export fn cbm_stat_utf8(path: ?[*:0]const u8, st: *c.struct_stat) c_int { + const wpath = toWide(path) orelse return c.CBM_NOT_FOUND; + var wst: c.struct__stat64 = undefined; + const ret = c._wstat64(wpath, &wst); + c.free(wpath); + if (ret != 0) return c.CBM_NOT_FOUND; + st.st_mode = wst.st_mode; + st.st_size = @truncate(wst.st_size); + st.st_mtime = wst.st_mtime; + return 0; + } + + pub export fn cbm_stat_no_follow(path: ?[*:0]const u8, st: *c.struct_stat) c_int { + if (toWide(path)) |wpath| { + const attr = c.GetFileAttributesW(wpath); + c.free(wpath); + if (attr != INVALID_FILE_ATTRIBUTES and (attr & c.FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return c.CBM_NOT_FOUND; + } + } + return cbm_stat_utf8(path, st); + } + + pub export fn cbm_canonical_path(path: ?[*:0]const u8, out: [*]u8, out_sz: usize) bool { + return c._fullpath(out, path, out_sz) != null; + } + + fn needsQuote(arg: []const u8) bool { + if (arg.len == 0) return true; + return std.mem.indexOfAny(u8, arg, " \t\"") != null; + } + + /// Build a properly-quoted Windows command line from an argv array. + /// Returns a heap-allocated wide string, or NULL on allocation failure. + /// Quoting follows the MSVC CRT convention: arguments containing spaces, + /// tabs, or double-quotes are wrapped in double-quotes, with backslashes + /// before a closing quote doubled and the quote itself escaped. Argument + /// bytes are treated as UTF-8 and converted to wide via cbm_utf8_to_wide, + /// so non-ASCII arguments (e.g. a non-ASCII %USERPROFILE%) survive intact. + /// Declared in compat_fs_internal.h so the test suite can drive it. + pub export fn cbm_build_cmdline(argv: [*:null]const ?[*:0]const u8) ?[*:0]c.wchar_t { + // First pass: compute required buffer size. + var total: usize = 1; // NUL terminator + var i: usize = 0; + while (argv[i]) |arg_ptr| : (i += 1) { + const arg = std.mem.span(arg_ptr); + if (i > 0) total += 1; // space separator + if (needsQuote(arg)) { + total += 2; // opening and closing quote + var backslashes: usize = 0; + for (arg) |ch| { + if (ch == '\\') { + backslashes += 1; + } else if (ch == '"') { + total += backslashes + 1; // double backslashes + escape backslash + backslashes = 0; + } else { + backslashes = 0; + } + total += 1; + } + // Trailing backslashes before closing quote must be doubled. + total += backslashes; + } else { + total += arg.len; + } + } + + // Build the quoted command line in UTF-8 first, then widen it as a + // whole via cbm_utf8_to_wide. Every character the quoting logic acts + // on (space, tab, '"', '\\') is ASCII and, by UTF-8's design, never + // appears inside a multibyte sequence, so operating on raw bytes here + // is safe and keeps multibyte argument bytes intact for conversion. + const buf: [*]u8 = @ptrCast(c.malloc(total) orelse return null); + defer c.free(buf); + + // Second pass: write the command line bytes. + var w: usize = 0; + i = 0; + while (argv[i]) |arg_ptr| : (i += 1) { + const arg = std.mem.span(arg_ptr); + if (i > 0) { + buf[w] = ' '; + w += 1; + } + if (needsQuote(arg)) { + buf[w] = '"'; + w += 1; + var backslashes: usize = 0; + for (arg) |ch| { + if (ch == '\\') { + backslashes += 1; + buf[w] = '\\'; + w += 1; + } else if (ch == '"') { + // Double the preceding backslashes, then escape the quote. + @memset(buf[w..][0..backslashes], '\\'); + w += backslashes; + buf[w] = '\\'; + buf[w + 1] = '"'; + w += 2; + backslashes = 0; + } else { + backslashes = 0; + buf[w] = ch; + w += 1; + } + } + // Double trailing backslashes before the closing quote. + @memset(buf[w..][0..backslashes], '\\'); + w += backslashes; + buf[w] = '"'; + w += 1; + } else { + @memcpy(buf[w..][0..arg.len], arg); + w += arg.len; + } + } + buf[w] = 0; + + return toWide(@ptrCast(buf)); + } + + pub export fn cbm_exec_no_shell(argv: Argv) c_int { + const args = argv orelse return c.CBM_NOT_FOUND; + if (args[0] == null) return c.CBM_NOT_FOUND; + + const cmdline = cbm_build_cmdline(args) orelse return c.CBM_NOT_FOUND; + + var si = std.mem.zeroes(c.STARTUPINFOW); + var pi = std.mem.zeroes(c.PROCESS_INFORMATION); + si.cb = @sizeOf(c.STARTUPINFOW); + + if (c.CreateProcessW(null, cmdline, null, null, 0, 0, null, null, &si, &pi) == 0) { + c.free(cmdline); + return c.CBM_NOT_FOUND; + } + c.free(cmdline); + + _ = c.WaitForSingleObject(pi.hProcess, c.INFINITE); + var exit_code: c.DWORD = std.math.maxInt(c.DWORD); // (DWORD)CBM_NOT_FOUND + _ = c.GetExitCodeProcess(pi.hProcess, &exit_code); + _ = c.CloseHandle(pi.hProcess); + _ = c.CloseHandle(pi.hThread); + return @bitCast(exit_code); + } +}; + +// ── POSIX implementation ───────────────────────────────────────── + +const posix_impl = struct { + const Dir = extern struct { + dir: ?*c.DIR, + entry: c.cbm_dirent_t, + }; + + pub export fn cbm_opendir(path: ?[*:0]const u8) ?*Dir { + const p = path orelse return null; + const dir = c.opendir(p) orelse return null; + const d: *Dir = @ptrCast(@alignCast(c.calloc(1, @sizeOf(Dir)) orelse { + _ = c.closedir(dir); + return null; + })); + d.dir = dir; + return d; + } + + pub export fn cbm_readdir(dir: ?*Dir) ?*c.cbm_dirent_t { + const d = dir orelse return null; + const handle = d.dir orelse return null; + while (@as(?*c.struct_dirent, c.readdir(handle))) |de| { + const name: [*:0]const u8 = @ptrCast(&de.d_name); + // Skip "." and ".." + if (name[0] == '.' and (name[1] == 0 or (name[1] == '.' and name[2] == 0))) continue; + const nlen = @min(std.mem.len(name), c.CBM_DIRENT_NAME_MAX - 1); + @memcpy(d.entry.name[0..nlen], name[0..nlen]); + d.entry.name[nlen] = 0; + d.entry.is_dir = de.d_type == c.DT_DIR; + d.entry.d_type = de.d_type; + return &d.entry; + } + return null; + } + + pub export fn cbm_closedir(dir: ?*Dir) void { + const d = dir orelse return; + if (d.dir) |handle| _ = c.closedir(handle); + c.free(d); + } + + pub export fn cbm_popen(cmd: ?[*:0]const u8, mode: ?[*:0]const u8) ?*c.FILE { + return c.popen(cmd, mode); + } + + /// Streams opened by cbm_popen_git: popen() cannot take an environment, so + /// those children are spawned here and reaped by cbm_pclose via this list. + const PopenGitEntry = struct { + fp: *c.FILE, + pid: c.pid_t, + next: ?*PopenGitEntry, + }; + + var g_popen_git_list: ?*PopenGitEntry = null; + var g_popen_git_lock: c.cbm_mutex_t = undefined; + var g_popen_git_once: c.cbm_once_t = compat_thread.once_init; + + fn popenGitLockInit() callconv(.c) void { + c.cbm_mutex_init(&g_popen_git_lock); + } + + /// `/bin/sh -c cmd` with stdout on a pipe, stdin on /dev/null (never the MCP + /// transport), stderr inherited like popen(), and `envp` as the environment. + fn popenSpawnSh(cmd: [*:0]const u8, envp: [*c][*c]u8, read_fd: *c_int) c.pid_t { + var fds: [2]c_int = undefined; + if (c.pipe(&fds) != 0) return c.CBM_NOT_FOUND; + const rd = fds[0]; + const wr = fds[1]; + var actions: c.posix_spawn_file_actions_t = undefined; + if (!c.cbm_fd_set_cloexec(rd) or !c.cbm_fd_set_cloexec(wr) or + c.posix_spawn_file_actions_init(&actions) != 0) + { + _ = c.close(rd); + _ = c.close(wr); + return c.CBM_NOT_FOUND; + } + // dup2 clears close-on-exec on the child's stdout; both pipe ends + // themselves stay close-on-exec, so no other child ever holds them. + const configured = + c.posix_spawn_file_actions_addopen(&actions, c.STDIN_FILENO, "/dev/null", c.O_RDONLY, 0) == 0 and + c.posix_spawn_file_actions_adddup2(&actions, wr, c.STDOUT_FILENO) == 0; + var attr: c.posix_spawnattr_t = undefined; + var attr_ptr: ?*c.posix_spawnattr_t = null; + if (is_apple) { + if (c.posix_spawnattr_init(&attr) == 0) { + _ = c.posix_spawnattr_setflags(&attr, c.POSIX_SPAWN_CLOEXEC_DEFAULT); + attr_ptr = &attr; + _ = c.posix_spawn_file_actions_addinherit_np(&actions, c.STDERR_FILENO); + } + } + const argv = [_:null]?[*:0]const u8{ "sh", "-c", cmd }; + var pid: c.pid_t = c.CBM_NOT_FOUND; + const rc = if (configured) + c.posix_spawn(&pid, "/bin/sh", &actions, attr_ptr, @ptrCast(&argv), envp) + else + c.CBM_NOT_FOUND; + if (attr_ptr != null) _ = c.posix_spawnattr_destroy(&attr); + _ = c.posix_spawn_file_actions_destroy(&actions); + _ = c.close(wr); + if (rc != 0 or pid <= 0) { + _ = c.close(rd); + return c.CBM_NOT_FOUND; + } + read_fd.* = rd; + return pid; + } + + fn popenGitWait(pid: c.pid_t) c_int { + var status: c_int = 0; + while (c.waitpid(pid, &status, 0) < 0) { + if (interop.errno() != c.EINTR) return c.CBM_NOT_FOUND; + } + return status; + } + + pub export fn cbm_popen_git(cmd: ?[*:0]const u8) ?*c.FILE { + const command = cmd orelse return null; + // Fail closed: without the scrubbed environment the child would inherit + // a caller's GIT_DIR and read the wrong repository (#56). + const envp = c.cbm_git_child_envp() orelse return null; + var read_fd: c_int = c.CBM_NOT_FOUND; + const pid = popenSpawnSh(command, envp, &read_fd); + c.cbm_git_child_env_free(@ptrCast(envp)); // posix_spawn copied it into the child + if (pid < 0) return null; + + const entry: ?*PopenGitEntry = @ptrCast(@alignCast(c.malloc(@sizeOf(PopenGitEntry)))); + const fp: ?*c.FILE = if (entry != null) c.fdopen(read_fd, "r") else null; + if (fp == null) { + c.free(entry); + _ = c.close(read_fd); + _ = popenGitWait(pid); + return null; + } + entry.?.fp = fp.?; + entry.?.pid = pid; + c.cbm_once(&g_popen_git_once, popenGitLockInit); + c.cbm_mutex_lock(&g_popen_git_lock); + entry.?.next = g_popen_git_list; + g_popen_git_list = entry; + c.cbm_mutex_unlock(&g_popen_git_lock); + return fp; + } + + pub export fn cbm_pclose(f: ?*c.FILE) c_int { + var found: ?*PopenGitEntry = null; + c.cbm_once(&g_popen_git_once, popenGitLockInit); + c.cbm_mutex_lock(&g_popen_git_lock); + var link = &g_popen_git_list; + while (link.*) |entry| : (link = &entry.next) { + if (entry.fp == f) { + found = entry; + link.* = entry.next; + break; + } + } + c.cbm_mutex_unlock(&g_popen_git_lock); + const entry = found orelse return c.pclose(f); // opened by popen() + const pid = entry.pid; + c.free(entry); + _ = c.fclose(f); + return popenGitWait(pid); // the raw wait status, as pclose() + } + + pub export fn cbm_fopen(path: ?[*:0]const u8, mode: ?[*:0]const u8) ?*c.FILE { + const f = c.fopen(path, mode); + if (f != null) { + const fd = c.fileno(f); + if (fd >= 0) _ = c.cbm_fd_set_cloexec(fd); + } + return f; + } + + pub export fn cbm_mkdir_p(path: [*:0]const u8, mode: c_int) bool { + const m: c.mode_t = @truncate(@as(c_uint, @bitCast(mode))); + // Try direct mkdir first + if (c.mkdir(path, m) == 0) return true; + // Walk path and create each component + const tmp: [*:0]u8 = @ptrCast(c.strdup(path) orelse return false); + defer c.free(tmp); + var i: usize = 1; + while (tmp[0] != 0 and tmp[i] != 0) : (i += 1) { + if (tmp[i] == '/') { + tmp[i] = 0; + _ = c.mkdir(tmp, m); // ignore intermediate errors + tmp[i] = '/'; + } + } + return c.mkdir(tmp, m) == 0 or interop.errno() == c.EEXIST; + } + + pub export fn cbm_unlink(path: ?[*:0]const u8) c_int { + return c.unlink(path); + } + + pub export fn cbm_rmdir(path: ?[*:0]const u8) c_int { + return c.rmdir(path); + } + + pub export fn cbm_stat_utf8(path: ?[*:0]const u8, st: ?*c.struct_stat) c_int { + return c.stat(path, st); + } + + pub export fn cbm_stat_no_follow(path: ?[*:0]const u8, st: *c.struct_stat) c_int { + if (c.lstat(path, st) != 0) return c.CBM_NOT_FOUND; + if ((st.st_mode & c.S_IFMT) == c.S_IFLNK) return c.CBM_NOT_FOUND; + return 0; + } + + pub export fn cbm_canonical_path(path: ?[*:0]const u8, out: [*]u8, out_sz: usize) bool { + var resolved: [c.PATH_MAX]u8 = undefined; + if (c.realpath(path, &resolved) == null) return false; + const len = std.mem.len(@as([*:0]const u8, @ptrCast(&resolved))); + if (len >= out_sz) return false; + @memcpy(out[0 .. len + 1], resolved[0 .. len + 1]); + return true; + } + + pub export fn cbm_exec_no_shell(argv: Argv) c_int { + const args = argv orelse return c.CBM_NOT_FOUND; + const program = args[0] orelse return c.CBM_NOT_FOUND; + + if (is_apple) { + var attr: c.posix_spawnattr_t = undefined; + if (c.posix_spawnattr_init(&attr) != 0) return c.CBM_NOT_FOUND; + _ = c.posix_spawnattr_setflags(&attr, c.POSIX_SPAWN_CLOEXEC_DEFAULT); + var actions: c.posix_spawn_file_actions_t = undefined; + if (c.posix_spawn_file_actions_init(&actions) != 0) { + _ = c.posix_spawnattr_destroy(&attr); + return c.CBM_NOT_FOUND; + } + _ = c.posix_spawn_file_actions_addinherit_np(&actions, c.STDIN_FILENO); + _ = c.posix_spawn_file_actions_addinherit_np(&actions, c.STDOUT_FILENO); + _ = c.posix_spawn_file_actions_addinherit_np(&actions, c.STDERR_FILENO); + var pid: c.pid_t = c.CBM_NOT_FOUND; + const rc = c.posix_spawnp(&pid, program, &actions, &attr, @ptrCast(args), @ptrCast(interop.environ())); + _ = c.posix_spawn_file_actions_destroy(&actions); + _ = c.posix_spawnattr_destroy(&attr); + if (rc != 0 or pid <= 0) { + return if (rc == c.ENOENT or rc == c.EACCES) 127 else c.CBM_NOT_FOUND; + } + var status: c_int = 0; + while (c.waitpid(pid, &status, 0) < 0) { + if (interop.errno() != c.EINTR) return c.CBM_NOT_FOUND; + } + if (interop.wifexited(status)) return interop.wexitstatus(status); + return c.CBM_NOT_FOUND; + } else { + const pid = c.fork(); + if (pid < 0) return c.CBM_NOT_FOUND; + if (pid == 0) { + // Child: exec directly — no shell interpretation + c.cbm_fd_close_above_stderr(); + _ = c.execvp(program, @ptrCast(args)); + // 127 = standard "command not found" exit code (POSIX convention) + c._exit(127); + } + // Parent: wait for child + var status: c_int = 0; + if (c.waitpid(pid, &status, 0) < 0) return c.CBM_NOT_FOUND; + if (interop.wifexited(status)) return interop.wexitstatus(status); + return c.CBM_NOT_FOUND; // killed by signal + } + } +}; + +// ── Shared ─────────────────────────────────────────────────────── + +pub export fn cbm_stat_mtime_ns(st: *const c.struct_stat) i64 { + const ns_per_sec: i64 = 1000000000; + if (is_apple) { + return (@as(i64, st.st_mtimespec.tv_sec) *% ns_per_sec) +% @as(i64, st.st_mtimespec.tv_nsec); + } else if (is_windows) { + return @as(i64, st.st_mtime) *% ns_per_sec; + } else { + return (@as(i64, st.st_mtim.tv_sec) *% ns_per_sec) +% @as(i64, st.st_mtim.tv_nsec); + } +} diff --git a/src/foundation/compat_regex.c b/src/foundation/compat_regex.c deleted file mode 100644 index 441a9ff0..00000000 --- a/src/foundation/compat_regex.c +++ /dev/null @@ -1,130 +0,0 @@ -/* - * compat_regex.c — Portable regular expression implementation. - * - * POSIX: direct wrappers around . - * Windows: vendored TRE regex library (BSD-licensed). - */ -#include "foundation/constants.h" -#include "foundation/compat_regex.h" - -#include - -#ifdef _WIN32 - -/* ── Windows: use vendored TRE regex ─────────────────────────── */ -#include "../../vendored/tre/regex.h" - -_Static_assert(sizeof(regex_t) <= CBM_SZ_256, - "cbm_regex_t opaque buffer too small for TRE regex_t"); - -static int translate_flags_tre(int flags) { - int tre_flags = 0; - if (flags & CBM_REG_EXTENDED) - tre_flags |= REG_EXTENDED; - if (flags & CBM_REG_ICASE) - tre_flags |= REG_ICASE; - if (flags & CBM_REG_NOSUB) - tre_flags |= REG_NOSUB; - if (flags & CBM_REG_NEWLINE) - tre_flags |= REG_NEWLINE; - return tre_flags; -} - -int cbm_regcomp(cbm_regex_t *r, const char *pattern, int flags) { - regex_t *re = (regex_t *)r->opaque; - int rc = tre_regcomp(re, pattern, translate_flags_tre(flags)); - return rc == 0 ? CBM_REG_OK : rc; -} - -int cbm_regexec(const cbm_regex_t *r, const char *str, int nmatch, cbm_regmatch_t *matches, - int eflags) { - const regex_t *re = (const regex_t *)r->opaque; - if (nmatch <= 0 || !matches) { - int rc = tre_regexec(re, str, 0, NULL, eflags); - return rc == 0 ? CBM_REG_OK : CBM_REG_NOMATCH; - } - regmatch_t pmatch[CBM_SZ_32]; - int n = nmatch > CBM_SZ_32 ? CBM_SZ_32 : nmatch; - int rc = tre_regexec(re, str, (size_t)n, pmatch, eflags); - if (rc != 0) - return CBM_REG_NOMATCH; - for (int i = 0; i < n; i++) { - matches[i].rm_so = (int)pmatch[i].rm_so; - matches[i].rm_eo = (int)pmatch[i].rm_eo; - } - return CBM_REG_OK; -} - -void cbm_regfree(cbm_regex_t *r) { - regex_t *re = (regex_t *)r->opaque; - tre_regfree(re); -} - -#else /* POSIX */ - -/* ── POSIX implementation ─────────────────────────────────────── */ - -#include - -/* Static assert: our opaque buffer is large enough for regex_t. - * If this fires, increase cbm_regex_t.opaque size. */ -_Static_assert(sizeof(regex_t) <= CBM_SZ_256, "cbm_regex_t opaque buffer too small for regex_t"); - -static int translate_flags(int flags) { - int posix_flags = 0; - if (flags & CBM_REG_EXTENDED) { - posix_flags |= REG_EXTENDED; - } - if (flags & CBM_REG_ICASE) { - posix_flags |= REG_ICASE; - } - if (flags & CBM_REG_NOSUB) { - posix_flags |= REG_NOSUB; - } - if (flags & CBM_REG_NEWLINE) { - posix_flags |= REG_NEWLINE; - } -#ifdef REG_ENHANCED - if (flags & CBM_REG_ENHANCED) { - posix_flags |= REG_ENHANCED; - } -#endif - return posix_flags; -} - -int cbm_regcomp(cbm_regex_t *r, const char *pattern, int flags) { - regex_t *re = (regex_t *)r->opaque; - int rc = regcomp(re, pattern, translate_flags(flags)); - return rc == 0 ? CBM_REG_OK : rc; -} - -int cbm_regexec(const cbm_regex_t *r, const char *str, int nmatch, cbm_regmatch_t *matches, - int eflags) { - const regex_t *re = (const regex_t *)r->opaque; - - if (nmatch <= 0 || !matches) { - int rc = regexec(re, str, 0, NULL, eflags); - return rc == 0 ? CBM_REG_OK : CBM_REG_NOMATCH; - } - - /* Map through POSIX regmatch_t */ - regmatch_t pmatch[CBM_SZ_32]; - int n = nmatch > CBM_SZ_32 ? CBM_SZ_32 : nmatch; - int rc = regexec(re, str, (size_t)n, pmatch, eflags); - if (rc != 0) { - return CBM_REG_NOMATCH; - } - - for (int i = 0; i < n; i++) { - matches[i].rm_so = (int)pmatch[i].rm_so; - matches[i].rm_eo = (int)pmatch[i].rm_eo; - } - return CBM_REG_OK; -} - -void cbm_regfree(cbm_regex_t *r) { - regex_t *re = (regex_t *)r->opaque; - regfree(re); -} - -#endif /* _WIN32 */ diff --git a/src/foundation/compat_regex.zig b/src/foundation/compat_regex.zig new file mode 100644 index 00000000..ebbd5a55 --- /dev/null +++ b/src/foundation/compat_regex.zig @@ -0,0 +1,77 @@ +//! compat_regex.zig — Portable regular expression implementation. +//! +//! Implements src/foundation/compat_regex.h. +//! POSIX: direct wrappers around . +//! Windows: vendored TRE regex library (BSD-licensed). + +const builtin = @import("builtin"); +const c = @import("c"); + +const is_windows = builtin.os.tag == .windows; + +const regcomp = if (is_windows) c.tre_regcomp else c.regcomp; +const regexec = if (is_windows) c.tre_regexec else c.regexec; +const regfree = if (is_windows) c.tre_regfree else c.regfree; + +const MAX_MATCHES = 32; + +comptime { + // Our opaque buffer must be large enough for the platform's regex_t. + // If this fires, increase cbm_regex_t.opaque size. glibc's regex_t has + // bitfields, so translate-c gives an opaque type with no size here; the + // same check in c_shim.c covers every platform. + if (@typeInfo(c.regex_t) != .@"opaque" and @sizeOf(c.regex_t) > @sizeOf(c.cbm_regex_t)) { + @compileError("cbm_regex_t opaque buffer too small for regex_t"); + } +} + +/// The regex_t stored in the opaque buffer. cbm_regex_t is a char array, so +/// its alignment is 1; like the C cast, this does not check alignment. +fn regexOf(r: *const c.cbm_regex_t) *c.regex_t { + @setRuntimeSafety(false); + return @ptrCast(@alignCast(@constCast(&r.@"opaque"))); +} + +fn translateFlags(flags: c_int) c_int { + var out: c_int = 0; + if (flags & c.CBM_REG_EXTENDED != 0) out |= c.REG_EXTENDED; + if (flags & c.CBM_REG_ICASE != 0) out |= c.REG_ICASE; + if (flags & c.CBM_REG_NOSUB != 0) out |= c.REG_NOSUB; + if (flags & c.CBM_REG_NEWLINE != 0) out |= c.REG_NEWLINE; + if (!is_windows and @hasDecl(c, "REG_ENHANCED")) { + if (flags & c.CBM_REG_ENHANCED != 0) out |= c.REG_ENHANCED; + } + return out; +} + +pub export fn cbm_regcomp(r: *c.cbm_regex_t, pattern: ?[*:0]const u8, flags: c_int) c_int { + const rc = regcomp(regexOf(r), pattern, translateFlags(flags)); + return if (rc == 0) c.CBM_REG_OK else rc; +} + +pub export fn cbm_regexec(r: *const c.cbm_regex_t, str: ?[*:0]const u8, nmatch: c_int, matches: ?[*]c.cbm_regmatch_t, eflags: c_int) c_int { + const re = regexOf(r); + const out = matches orelse { + const rc = regexec(re, str, 0, null, eflags); + return if (rc == 0) c.CBM_REG_OK else c.CBM_REG_NOMATCH; + }; + if (nmatch <= 0) { + const rc = regexec(re, str, 0, null, eflags); + return if (rc == 0) c.CBM_REG_OK else c.CBM_REG_NOMATCH; + } + + // Map through the platform regmatch_t + var pmatch: [MAX_MATCHES]c.regmatch_t = undefined; + const n: usize = @intCast(@min(nmatch, MAX_MATCHES)); + const rc = regexec(re, str, n, &pmatch, eflags); + if (rc != 0) return c.CBM_REG_NOMATCH; + for (out[0..n], pmatch[0..n]) |*dst, src| { + dst.rm_so = @truncate(src.rm_so); + dst.rm_eo = @truncate(src.rm_eo); + } + return c.CBM_REG_OK; +} + +pub export fn cbm_regfree(r: *c.cbm_regex_t) void { + regfree(regexOf(r)); +} diff --git a/src/foundation/compat_thread.c b/src/foundation/compat_thread.c deleted file mode 100644 index 3428af3c..00000000 --- a/src/foundation/compat_thread.c +++ /dev/null @@ -1,186 +0,0 @@ -/* - * compat_thread.c — Portable thread, mutex, and aligned allocation. - * - * POSIX: thin wrappers around pthreads and posix_memalign. - * Windows: CreateThread, CRITICAL_SECTION, _aligned_malloc. - */ -#include "foundation/constants.h" -#include "foundation/compat_thread.h" - -#include -#include - -/* Default 8MB stack for all threads. macOS ARM64 default is only 512KB, - * which is too small for deep pipeline passes (configlink, etc.). */ -#define CBM_DEFAULT_STACK_SIZE ((size_t)8 * CBM_SZ_1K * CBM_SZ_1K) -#include - -/* ── Thread ───────────────────────────────────────────────────── */ - -#ifdef _WIN32 - -typedef struct { - void *(*fn)(void *); - void *arg; -} win_thread_arg_t; - -static DWORD WINAPI win_thread_wrapper(LPVOID lpParam) { - win_thread_arg_t *a = (win_thread_arg_t *)lpParam; - void *(*fn)(void *) = a->fn; - void *arg = a->arg; - free(a); - fn(arg); - return 0; -} - -int cbm_thread_create(cbm_thread_t *t, size_t stack_size, void *(*fn)(void *), void *arg) { - if (stack_size == 0) { - stack_size = CBM_DEFAULT_STACK_SIZE; - } - win_thread_arg_t *a = (win_thread_arg_t *)malloc(sizeof(win_thread_arg_t)); - if (!a) { - return CBM_NOT_FOUND; - } - a->fn = fn; - a->arg = arg; - t->handle = CreateThread(NULL, stack_size, win_thread_wrapper, a, 0, NULL); - if (!t->handle) { - free(a); - return CBM_NOT_FOUND; - } - return 0; -} - -int cbm_thread_join(cbm_thread_t *t) { - if (WaitForSingleObject(t->handle, INFINITE) != WAIT_OBJECT_0) { - return CBM_NOT_FOUND; - } - CloseHandle(t->handle); - t->handle = NULL; - return 0; -} - -int cbm_thread_detach(cbm_thread_t *t) { - if (t->handle) { - CloseHandle(t->handle); - t->handle = NULL; - } - return 0; -} - -#else /* POSIX */ - -int cbm_thread_create(cbm_thread_t *t, size_t stack_size, void *(*fn)(void *), void *arg) { - if (stack_size == 0) { - stack_size = CBM_DEFAULT_STACK_SIZE; - } - pthread_attr_t attr; - pthread_attr_init(&attr); - pthread_attr_setstacksize(&attr, stack_size); - int rc = pthread_create(&t->handle, &attr, fn, arg); - pthread_attr_destroy(&attr); - return rc; -} - -int cbm_thread_join(cbm_thread_t *t) { - int rc = pthread_join(t->handle, NULL); - if (rc == 0) { - memset(&t->handle, 0, sizeof(t->handle)); - } - return rc; -} - -int cbm_thread_detach(cbm_thread_t *t) { - int rc = pthread_detach(t->handle); - if (rc == 0) { - memset(&t->handle, 0, sizeof(t->handle)); - } - return rc; -} - -#endif - -/* ── Mutex ────────────────────────────────────────────────────── */ - -#ifdef _WIN32 - -void cbm_mutex_init(cbm_mutex_t *m) { - InitializeCriticalSection(&m->cs); -} - -void cbm_mutex_lock(cbm_mutex_t *m) { - EnterCriticalSection(&m->cs); -} - -void cbm_mutex_unlock(cbm_mutex_t *m) { - LeaveCriticalSection(&m->cs); -} - -void cbm_mutex_destroy(cbm_mutex_t *m) { - DeleteCriticalSection(&m->cs); -} - -typedef struct { - void (*fn)(void); -} cbm_once_call_t; - -static BOOL CALLBACK cbm_once_trampoline(PINIT_ONCE once, PVOID parameter, PVOID *context) { - (void)once; - (void)context; - ((cbm_once_call_t *)parameter)->fn(); - return TRUE; -} - -void cbm_once(cbm_once_t *once, void (*fn)(void)) { - cbm_once_call_t call = {fn}; - InitOnceExecuteOnce(once, cbm_once_trampoline, &call, NULL); -} - -#else /* POSIX */ - -void cbm_mutex_init(cbm_mutex_t *m) { - pthread_mutex_init(&m->mtx, NULL); -} - -void cbm_mutex_lock(cbm_mutex_t *m) { - pthread_mutex_lock(&m->mtx); -} - -void cbm_mutex_unlock(cbm_mutex_t *m) { - pthread_mutex_unlock(&m->mtx); -} - -void cbm_mutex_destroy(cbm_mutex_t *m) { - pthread_mutex_destroy(&m->mtx); -} - -void cbm_once(cbm_once_t *once, void (*fn)(void)) { - (void)pthread_once(once, fn); -} - -#endif - -/* ── Aligned allocation ───────────────────────────────────────── */ - -#ifdef _WIN32 - -int cbm_aligned_alloc(void **ptr, size_t alignment, size_t size) { - *ptr = _aligned_malloc(size, alignment); - return *ptr ? 0 : -1; -} - -void cbm_aligned_free(void *ptr) { - _aligned_free(ptr); -} - -#else /* POSIX */ - -int cbm_aligned_alloc(void **ptr, size_t alignment, size_t size) { - return posix_memalign(ptr, alignment, size); -} - -void cbm_aligned_free(void *ptr) { - free(ptr); -} - -#endif diff --git a/src/foundation/compat_thread.zig b/src/foundation/compat_thread.zig new file mode 100644 index 00000000..82a8d273 --- /dev/null +++ b/src/foundation/compat_thread.zig @@ -0,0 +1,147 @@ +//! compat_thread.zig — Portable thread, mutex, and aligned allocation. +//! +//! Implements src/foundation/compat_thread.h. +//! POSIX: thin wrappers around pthreads and posix_memalign. +//! Windows: CreateThread, CRITICAL_SECTION, _aligned_malloc. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); + +const is_windows = builtin.os.tag == .windows; + +/// Default 8MB stack for all threads. macOS ARM64 default is only 512KB, +/// which is too small for deep pipeline passes (configlink, etc.). +const CBM_DEFAULT_STACK_SIZE: usize = 8 * 1024 * 1024; + +const ThreadFn = *const fn (?*anyopaque) callconv(.c) ?*anyopaque; + +// ── Thread ─────────────────────────────────────────────────────── + +const WinThreadArg = extern struct { + func: ThreadFn, + arg: ?*anyopaque, +}; + +fn winThreadWrapper(param: ?*anyopaque) callconv(.winapi) c.DWORD { + const a: *WinThreadArg = @ptrCast(@alignCast(param)); + const func = a.func; + const arg = a.arg; + c.free(a); + _ = func(arg); + return 0; +} + +pub export fn cbm_thread_create(t: *c.cbm_thread_t, stack_size_in: usize, func: ?ThreadFn, arg: ?*anyopaque) c_int { + const stack_size = if (stack_size_in == 0) CBM_DEFAULT_STACK_SIZE else stack_size_in; + if (is_windows) { + const a: *WinThreadArg = @ptrCast(@alignCast(c.malloc(@sizeOf(WinThreadArg)) orelse return c.CBM_NOT_FOUND)); + a.func = func.?; + a.arg = arg; + t.handle = c.CreateThread(null, stack_size, winThreadWrapper, a, 0, null); + if (t.handle == null) { + c.free(a); + return c.CBM_NOT_FOUND; + } + return 0; + } else { + var attr: c.pthread_attr_t = undefined; + _ = c.pthread_attr_init(&attr); + _ = c.pthread_attr_setstacksize(&attr, stack_size); + const rc = c.pthread_create(&t.handle, &attr, func, arg); + _ = c.pthread_attr_destroy(&attr); + return rc; + } +} + +pub export fn cbm_thread_join(t: *c.cbm_thread_t) c_int { + if (is_windows) { + if (c.WaitForSingleObject(t.handle, c.INFINITE) != c.WAIT_OBJECT_0) return c.CBM_NOT_FOUND; + _ = c.CloseHandle(t.handle); + t.handle = null; + return 0; + } else { + const rc = c.pthread_join(t.handle, null); + if (rc == 0) t.handle = std.mem.zeroes(c.pthread_t); + return rc; + } +} + +pub export fn cbm_thread_detach(t: *c.cbm_thread_t) c_int { + if (is_windows) { + if (t.handle != null) { + _ = c.CloseHandle(t.handle); + t.handle = null; + } + return 0; + } else { + const rc = c.pthread_detach(t.handle); + if (rc == 0) t.handle = std.mem.zeroes(c.pthread_t); + return rc; + } +} + +// ── Mutex ──────────────────────────────────────────────────────── + +pub export fn cbm_mutex_init(m: *c.cbm_mutex_t) void { + if (is_windows) c.InitializeCriticalSection(&m.cs) else _ = c.pthread_mutex_init(&m.mtx, null); +} + +pub export fn cbm_mutex_lock(m: *c.cbm_mutex_t) void { + if (is_windows) c.EnterCriticalSection(&m.cs) else _ = c.pthread_mutex_lock(&m.mtx); +} + +pub export fn cbm_mutex_unlock(m: *c.cbm_mutex_t) void { + if (is_windows) c.LeaveCriticalSection(&m.cs) else _ = c.pthread_mutex_unlock(&m.mtx); +} + +pub export fn cbm_mutex_destroy(m: *c.cbm_mutex_t) void { + if (is_windows) c.DeleteCriticalSection(&m.cs) else _ = c.pthread_mutex_destroy(&m.mtx); +} + +// ── One-time initialization ────────────────────────────────────── + +/// CBM_ONCE_INIT. PTHREAD_ONCE_INIT is a brace initializer on macOS, which +/// translate-c cannot turn into a value. +pub const once_init: c.cbm_once_t = if (builtin.os.tag.isDarwin()) + .{ .__sig = c._PTHREAD_ONCE_SIG_init, .__opaque = @splat(0) } +else + std.mem.zeroes(c.cbm_once_t); + +const OnceFn = *const fn () callconv(.c) void; + +const OnceCall = extern struct { + func: OnceFn, +}; + +fn onceTrampoline(once: ?*anyopaque, parameter: ?*anyopaque, context: ?*?*anyopaque) callconv(.winapi) c_int { + _ = once; + _ = context; + const call: *OnceCall = @ptrCast(@alignCast(parameter)); + call.func(); + return 1; +} + +pub export fn cbm_once(once: *c.cbm_once_t, func: ?OnceFn) void { + if (is_windows) { + var call = OnceCall{ .func = func.? }; + _ = c.InitOnceExecuteOnce(once, @ptrCast(&onceTrampoline), &call, null); + } else { + _ = c.pthread_once(once, func); + } +} + +// ── Aligned allocation ─────────────────────────────────────────── + +pub export fn cbm_aligned_alloc(ptr: *?*anyopaque, alignment: usize, size: usize) c_int { + if (is_windows) { + ptr.* = c._aligned_malloc(size, alignment); + return if (ptr.* != null) 0 else -1; + } else { + return c.posix_memalign(ptr, alignment, size); + } +} + +pub export fn cbm_aligned_free(ptr: ?*anyopaque) void { + if (is_windows) c._aligned_free(ptr) else c.free(ptr); +} diff --git a/src/foundation/diagnostics.c b/src/foundation/diagnostics.c deleted file mode 100644 index 01bf7c75..00000000 --- a/src/foundation/diagnostics.c +++ /dev/null @@ -1,254 +0,0 @@ -/* - * diagnostics.c — Periodic diagnostics file writer. - * - * Writes JSON to /tmp/cbm-diagnostics-.json every 5 seconds. - * Atomic: writes .tmp then renames to avoid partial reads. - */ -#include "foundation/constants.h" -#include "foundation/diagnostics.h" -#include -#include "foundation/mem.h" -#include "foundation/compat.h" -#include "foundation/compat_thread.h" -#include "foundation/compat_fs.h" -#include "foundation/platform.h" - -#include -#include -#include -#include -#include - -#ifdef _WIN32 -#include -#define getpid _getpid -#else -#include -#include -#endif - -/* ── Globals ─────────────────────────────────────────────────────── */ - -cbm_query_stats_t g_query_stats = {0}; -static atomic_int g_diag_stop = 0; -static cbm_thread_t g_diag_thread; -static bool g_diag_started = false; -static time_t g_start_time = 0; -static char g_diag_path[CBM_SZ_256] = ""; -/* Persistent NDJSON time-series (the memory TRAJECTORY users send us to diagnose - * slow leaks like #581). Unlike g_diag_path (a latest-snapshot file overwritten - * every interval and deleted on stop), this is appended-to and KEPT on exit. */ -static char g_diag_ndjson_path[CBM_SZ_256] = ""; -static size_t g_diag_ndjson_size = 0; -#define DIAG_NDJSON_CAP_BYTES (8u * 1024u * 1024u) /* rotate to .1 past this */ - -/* ── Query stats ─────────────────────────────────────────────────── */ - -void cbm_diag_record_query(long long duration_us, bool is_error) { - atomic_fetch_add(&g_query_stats.count, 1); - atomic_fetch_add(&g_query_stats.time_us, duration_us); - if (is_error) { - atomic_fetch_add(&g_query_stats.errors, 1); - } - /* Update max (lock-free CAS loop) */ - long long old_max = atomic_load(&g_query_stats.max_us); - while (duration_us > old_max) { - if (atomic_compare_exchange_weak(&g_query_stats.max_us, &old_max, duration_us)) { - break; - } - } -} - -/* ── FD count (platform-specific) ────────────────────────────────── */ - -static int count_open_fds(void) { -#ifdef __linux__ - struct dirent **entries = NULL; - int n = scandir("/proc/self/fd", &entries, NULL, NULL); - if (n < 0) { - return CBM_NOT_FOUND; - } - for (int i = 0; i < n; i++) { - free(entries[i]); - } - free(entries); - return n - PAIR_LEN; /* . and .. */ -#elif defined(__APPLE__) - /* Count via /dev/fd using scandir (MT-safe) */ - struct dirent **entries = NULL; - int n = scandir("/dev/fd", &entries, NULL, NULL); - if (n < 0) { - return CBM_NOT_FOUND; - } - for (int i = 0; i < n; i++) { - free(entries[i]); - } - free(entries); - return n - PAIR_LEN; /* . and .. */ -#else - return CBM_NOT_FOUND; /* Not available on Windows */ -#endif -} - -/* ── Writer ──────────────────────────────────────────────────────── */ - -#define DIAG_INTERVAL_S 5 -#define DIAG_PATH_EXTRA 24 /* ".tmp" + safety margin */ - -/* Append one compact JSON line to the persistent NDJSON trajectory, rotating to - * .1 once it passes the cap. Best-effort: a failed append never disrupts - * the server. The trajectory (a monotonic rss/committed climb over hours) is - * what reveals a slow leak like #581 — the single latest-snapshot file cannot. */ -static void append_trajectory(long uptime, size_t rss, size_t peak_rss, size_t commit, - size_t peak_commit, size_t page_faults, int fds, int qcount) { - if (g_diag_ndjson_path[0] == '\0') { - return; - } - if (g_diag_ndjson_size > DIAG_NDJSON_CAP_BYTES) { - char rot[sizeof(g_diag_ndjson_path) + DIAG_PATH_EXTRA]; - snprintf(rot, sizeof(rot), "%s.1", g_diag_ndjson_path); - (void)rename(g_diag_ndjson_path, rot); /* keep one previous generation */ - g_diag_ndjson_size = 0; - } - FILE *f = cbm_fopen(g_diag_ndjson_path, "a"); - if (!f) { - return; - } - int n = fprintf(f, - "{\"uptime_s\":%ld,\"rss\":%zu,\"peak_rss\":%zu,\"committed\":%zu," - "\"peak_committed\":%zu,\"page_faults\":%zu,\"fd\":%d,\"queries\":%d}\n", - uptime, rss, peak_rss, commit, peak_commit, page_faults, fds, qcount); - if (n > 0) { - g_diag_ndjson_size += (size_t)n; - } - (void)fclose(f); -} - -static void write_diagnostics(void) { - /* Collect mimalloc stats */ - size_t elapsed_ms = 0; - size_t user_ms = 0; - size_t sys_ms = 0; - size_t current_rss = 0; - size_t peak_rss = 0; - size_t current_commit = 0; - size_t peak_commit = 0; - size_t page_faults = 0; - mi_process_info(&elapsed_ms, &user_ms, &sys_ms, ¤t_rss, &peak_rss, ¤t_commit, - &peak_commit, &page_faults); - - /* Fallback RSS for ASan builds */ - if (current_rss == 0) { - current_rss = cbm_mem_rss(); - } - - int fds = count_open_fds(); - time_t now = time(NULL); - long uptime = (long)(now - g_start_time); - - int qcount = atomic_load(&g_query_stats.count); - int qerrors = atomic_load(&g_query_stats.errors); - long long qtime = atomic_load(&g_query_stats.time_us); - long long qmax = atomic_load(&g_query_stats.max_us); - long long qavg = qcount > 0 ? qtime / qcount : 0; - - /* Write to .tmp then rename (atomic) */ - char tmp_path[sizeof(g_diag_path) + DIAG_PATH_EXTRA]; - snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", g_diag_path); - - FILE *f = cbm_fopen(tmp_path, "w"); - if (!f) { - return; - } - - if (fprintf(f, - "{\n" - " \"uptime_s\": %ld,\n" - " \"rss_bytes\": %zu,\n" - " \"peak_rss_bytes\": %zu,\n" - " \"heap_committed_bytes\": %zu,\n" - " \"peak_committed_bytes\": %zu,\n" - " \"page_faults\": %zu,\n" - " \"fd_count\": %d,\n" - " \"query_count\": %d,\n" - " \"query_errors\": %d,\n" - " \"query_total_us\": %lld,\n" - " \"query_avg_us\": %lld,\n" - " \"query_max_us\": %lld,\n" - " \"pid\": %d\n" - "}\n", - uptime, current_rss, peak_rss, current_commit, peak_commit, page_faults, fds, - qcount, qerrors, qtime, qavg, qmax, (int)getpid()) < 0) { - (void)fclose(f); - return; - } - if (fclose(f) != 0) { - return; - } - (void)rename(tmp_path, g_diag_path); - - /* Also append to the persistent trajectory (kept on exit for users to send). */ - append_trajectory(uptime, current_rss, peak_rss, current_commit, peak_commit, page_faults, fds, - qcount); -} - -static void *diag_thread_fn(void *arg) { - (void)arg; - while (!atomic_load(&g_diag_stop)) { - write_diagnostics(); - struct timespec ts = {DIAG_INTERVAL_S, 0}; - cbm_nanosleep(&ts, NULL); - } - /* Final write before exit */ - write_diagnostics(); - return NULL; -} - -/* ── Public API ──────────────────────────────────────────────────── */ - -bool cbm_diag_start(void) { - char env_buf[CBM_SZ_32] = ""; - cbm_safe_getenv("CBM_DIAGNOSTICS", env_buf, sizeof(env_buf), NULL); - if (env_buf[0] == '\0' || (strcmp(env_buf, "1") != 0 && strcmp(env_buf, "true") != 0)) { - return false; - } - - g_start_time = time(NULL); - atomic_store(&g_diag_stop, 0); - - snprintf(g_diag_path, sizeof(g_diag_path), "%s/cbm-diagnostics-%d.json", cbm_tmpdir(), - (int)getpid()); - snprintf(g_diag_ndjson_path, sizeof(g_diag_ndjson_path), "%s/cbm-diagnostics-%d.ndjson", - cbm_tmpdir(), (int)getpid()); - g_diag_ndjson_size = 0; - - if (cbm_thread_create(&g_diag_thread, 0, diag_thread_fn, NULL) != 0) { - return false; - } - - g_diag_started = true; - (void)fprintf(stderr, - "level=info msg=diagnostics.start snapshot=%s trajectory=%s interval=%ds\n", - g_diag_path, g_diag_ndjson_path, DIAG_INTERVAL_S); - return true; -} - -void cbm_diag_stop(void) { - if (!g_diag_started) { - return; - } - atomic_store(&g_diag_stop, 1); - cbm_thread_join(&g_diag_thread); - g_diag_started = false; - - /* Remove the live latest-snapshot file (and its .tmp), but KEEP the - * trajectory NDJSON so the user can send it after the server exits. */ - cbm_unlink(g_diag_path); - char tmp_path[sizeof(g_diag_path) + DIAG_PATH_EXTRA]; - snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", g_diag_path); - cbm_unlink(tmp_path); - if (g_diag_ndjson_path[0] != '\0') { - (void)fprintf(stderr, "level=info msg=diagnostics.trajectory_kept path=%s\n", - g_diag_ndjson_path); - } -} diff --git a/src/foundation/diagnostics.zig b/src/foundation/diagnostics.zig new file mode 100644 index 00000000..ed3a8ee0 --- /dev/null +++ b/src/foundation/diagnostics.zig @@ -0,0 +1,234 @@ +//! diagnostics.zig — Periodic diagnostics file writer. +//! +//! Implements src/foundation/diagnostics.h. Writes JSON to +//! /cbm-diagnostics-.json every 5 seconds. Atomic: writes .tmp +//! then renames to avoid partial reads. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("interop.zig"); + +const is_windows = builtin.os.tag == .windows; +const has_fd_dir = builtin.os.tag == .linux or builtin.os.tag.isDarwin(); + +// ── Globals ────────────────────────────────────────────────────── + +/// cbm_query_stats_t. translate-c cannot resolve the C11 atomic typedefs, so +/// the layout is restated here: an _Atomic int/long long has the size and +/// alignment of the plain type on every supported target. +const QueryStats = extern struct { + count: c_int = 0, // total tool calls + errors: c_int = 0, // tool calls that returned isError=true + time_us: c_longlong = 0, // cumulative wall-clock time (microseconds) + max_us: c_longlong = 0, // max single call time (microseconds) +}; + +pub export var g_query_stats: QueryStats = .{}; + +var g_diag_stop = std.atomic.Value(c_int).init(0); +var g_diag_thread: c.cbm_thread_t = undefined; +var g_diag_started: bool = false; +var g_start_time: c.time_t = 0; +var g_diag_path: [256]u8 = @splat(0); + +// Persistent NDJSON time-series (the memory TRAJECTORY users send us to diagnose +// slow leaks like #581). Unlike g_diag_path (a latest-snapshot file overwritten +// every interval and deleted on stop), this is appended-to and KEPT on exit. +var g_diag_ndjson_path: [256]u8 = @splat(0); +var g_diag_ndjson_size: usize = 0; +const DIAG_NDJSON_CAP_BYTES: usize = 8 * 1024 * 1024; // rotate to .1 past this + +fn getpid() c_int { + return if (is_windows) c._getpid() else c.getpid(); +} + +// ── Query stats ────────────────────────────────────────────────── + +pub export fn cbm_diag_record_query(duration_us: c_longlong, is_error: bool) void { + _ = @atomicRmw(c_int, &g_query_stats.count, .Add, 1, .seq_cst); + _ = @atomicRmw(c_longlong, &g_query_stats.time_us, .Add, duration_us, .seq_cst); + if (is_error) { + _ = @atomicRmw(c_int, &g_query_stats.errors, .Add, 1, .seq_cst); + } + // Update max (lock-free CAS loop) + var old_max = @atomicLoad(c_longlong, &g_query_stats.max_us, .seq_cst); + while (duration_us > old_max) { + old_max = @cmpxchgWeak(c_longlong, &g_query_stats.max_us, old_max, duration_us, .seq_cst, .seq_cst) orelse break; + } +} + +// ── FD count (platform-specific) ───────────────────────────────── + +fn countOpenFds() c_int { + if (!has_fd_dir) return c.CBM_NOT_FOUND; // Not available on Windows + + // Linux: /proc/self/fd. macOS: /dev/fd. scandir is MT-safe. + const dir = if (builtin.os.tag == .linux) "/proc/self/fd" else "/dev/fd"; + var entries: [*c][*c]c.struct_dirent = null; + const n = c.scandir(dir, &entries, null, null); + if (n < 0) return c.CBM_NOT_FOUND; + for (0..@intCast(n)) |i| c.free(entries[i]); + c.free(@ptrCast(entries)); + return n - 2; // . and .. +} + +// ── Writer ─────────────────────────────────────────────────────── + +const DIAG_INTERVAL_S = 5; +const DIAG_PATH_EXTRA = 24; // ".tmp" + safety margin + +/// Append one compact JSON line to the persistent NDJSON trajectory, rotating to +/// .1 once it passes the cap. Best-effort: a failed append never disrupts +/// the server. The trajectory (a monotonic rss/committed climb over hours) is +/// what reveals a slow leak like #581 — the single latest-snapshot file cannot. +fn appendTrajectory(uptime: c_long, rss: usize, peak_rss: usize, commit: usize, peak_commit: usize, page_faults: usize, fds: c_int, qcount: c_int) void { + if (g_diag_ndjson_path[0] == 0) return; + if (g_diag_ndjson_size > DIAG_NDJSON_CAP_BYTES) { + var rot: [g_diag_ndjson_path.len + DIAG_PATH_EXTRA]u8 = undefined; + _ = c.snprintf(&rot, rot.len, "%s.1", &g_diag_ndjson_path); + _ = c.rename(&g_diag_ndjson_path, &rot); // keep one previous generation + g_diag_ndjson_size = 0; + } + const f = c.cbm_fopen(&g_diag_ndjson_path, "a") orelse return; + const n = c.fprintf( + f, + "{\"uptime_s\":%ld,\"rss\":%zu,\"peak_rss\":%zu,\"committed\":%zu," ++ + "\"peak_committed\":%zu,\"page_faults\":%zu,\"fd\":%d,\"queries\":%d}\n", + uptime, + rss, + peak_rss, + commit, + peak_commit, + page_faults, + fds, + qcount, + ); + if (n > 0) g_diag_ndjson_size += @intCast(n); + _ = c.fclose(f); +} + +fn writeDiagnostics() void { + // Collect mimalloc stats + var elapsed_ms: usize = 0; + var user_ms: usize = 0; + var sys_ms: usize = 0; + var current_rss: usize = 0; + var peak_rss: usize = 0; + var current_commit: usize = 0; + var peak_commit: usize = 0; + var page_faults: usize = 0; + c.mi_process_info(&elapsed_ms, &user_ms, &sys_ms, ¤t_rss, &peak_rss, ¤t_commit, &peak_commit, &page_faults); + + // Fallback RSS for ASan builds + if (current_rss == 0) current_rss = c.cbm_mem_rss(); + + const fds = countOpenFds(); + const now = c.time(null); + const uptime: c_long = @truncate(now - g_start_time); + + const qcount = @atomicLoad(c_int, &g_query_stats.count, .seq_cst); + const qerrors = @atomicLoad(c_int, &g_query_stats.errors, .seq_cst); + const qtime = @atomicLoad(c_longlong, &g_query_stats.time_us, .seq_cst); + const qmax = @atomicLoad(c_longlong, &g_query_stats.max_us, .seq_cst); + const qavg: c_longlong = if (qcount > 0) @divTrunc(qtime, qcount) else 0; + + // Write to .tmp then rename (atomic) + var tmp_path: [g_diag_path.len + DIAG_PATH_EXTRA]u8 = undefined; + _ = c.snprintf(&tmp_path, tmp_path.len, "%s.tmp", &g_diag_path); + + const f = c.cbm_fopen(&tmp_path, "w") orelse return; + + if (c.fprintf( + f, + "{\n" ++ + " \"uptime_s\": %ld,\n" ++ + " \"rss_bytes\": %zu,\n" ++ + " \"peak_rss_bytes\": %zu,\n" ++ + " \"heap_committed_bytes\": %zu,\n" ++ + " \"peak_committed_bytes\": %zu,\n" ++ + " \"page_faults\": %zu,\n" ++ + " \"fd_count\": %d,\n" ++ + " \"query_count\": %d,\n" ++ + " \"query_errors\": %d,\n" ++ + " \"query_total_us\": %lld,\n" ++ + " \"query_avg_us\": %lld,\n" ++ + " \"query_max_us\": %lld,\n" ++ + " \"pid\": %d\n" ++ + "}\n", + uptime, + current_rss, + peak_rss, + current_commit, + peak_commit, + page_faults, + fds, + qcount, + qerrors, + qtime, + qavg, + qmax, + getpid(), + ) < 0) { + _ = c.fclose(f); + return; + } + if (c.fclose(f) != 0) return; + _ = c.rename(&tmp_path, &g_diag_path); + + // Also append to the persistent trajectory (kept on exit for users to send). + appendTrajectory(uptime, current_rss, peak_rss, current_commit, peak_commit, page_faults, fds, qcount); +} + +fn diagThreadFn(_: ?*anyopaque) callconv(.c) ?*anyopaque { + while (g_diag_stop.load(.seq_cst) == 0) { + writeDiagnostics(); + var ts: c.struct_timespec = .{ .tv_sec = DIAG_INTERVAL_S, .tv_nsec = 0 }; + _ = c.cbm_nanosleep(&ts, null); + } + // Final write before exit + writeDiagnostics(); + return null; +} + +// ── Public API ─────────────────────────────────────────────────── + +pub export fn cbm_diag_start() bool { + var env_buf: [32]u8 = @splat(0); + _ = c.cbm_safe_getenv("CBM_DIAGNOSTICS", &env_buf, env_buf.len, null); + const env = std.mem.sliceTo(&env_buf, 0); + if (!std.mem.eql(u8, env, "1") and !std.mem.eql(u8, env, "true")) return false; + + g_start_time = c.time(null); + g_diag_stop.store(0, .seq_cst); + _ = c.snprintf(&g_diag_path, g_diag_path.len, "%s/cbm-diagnostics-%d.json", c.cbm_tmpdir(), getpid()); + _ = c.snprintf(&g_diag_ndjson_path, g_diag_ndjson_path.len, "%s/cbm-diagnostics-%d.ndjson", c.cbm_tmpdir(), getpid()); + g_diag_ndjson_size = 0; + + if (c.cbm_thread_create(&g_diag_thread, 0, diagThreadFn, null) != 0) return false; + g_diag_started = true; + _ = c.fprintf( + interop.stderr(), + "level=info msg=diagnostics.start snapshot=%s trajectory=%s interval=%ds\n", + @as([*c]const u8, &g_diag_path), + @as([*c]const u8, &g_diag_ndjson_path), + @as(c_int, DIAG_INTERVAL_S), + ); + return true; +} + +pub export fn cbm_diag_stop() void { + if (!g_diag_started) return; + g_diag_stop.store(1, .seq_cst); + _ = c.cbm_thread_join(&g_diag_thread); + g_diag_started = false; + // Remove the live latest-snapshot file (and its .tmp), but KEEP the + // trajectory NDJSON so the user can send it after the server exits. + _ = c.cbm_unlink(&g_diag_path); + var tmp_path: [g_diag_path.len + DIAG_PATH_EXTRA]u8 = undefined; + _ = c.snprintf(&tmp_path, tmp_path.len, "%s.tmp", &g_diag_path); + _ = c.cbm_unlink(&tmp_path); + if (g_diag_ndjson_path[0] != 0) { + _ = c.fprintf(interop.stderr(), "level=info msg=diagnostics.trajectory_kept path=%s\n", @as([*c]const u8, &g_diag_ndjson_path)); + } +} diff --git a/src/foundation/dump_verify.c b/src/foundation/dump_verify.c deleted file mode 100644 index c4275592..00000000 --- a/src/foundation/dump_verify.c +++ /dev/null @@ -1,40 +0,0 @@ -/* - * dump_verify.c — Post-dump plausibility gate (#334). - */ -#include "foundation/dump_verify.h" -#include "foundation/constants.h" -#include "foundation/log.h" -#include "foundation/platform.h" - -#include -#include - -bool cbm_dump_verify_is_degraded(int committed_nodes, int persisted_nodes, double ratio, - int min_floor) { - if (ratio <= 0.0) { - return false; - } - if (committed_nodes < 0) { - return false; - } - if (committed_nodes <= min_floor) { - return false; - } - if (persisted_nodes < 0) { - return true; - } - return (double)persisted_nodes < (double)committed_nodes * ratio; -} - -double cbm_dump_verify_min_ratio(void) { - char buf[CBM_SZ_32]; - if (cbm_safe_getenv("CBM_DUMP_VERIFY_MIN_RATIO", buf, sizeof(buf), NULL) != NULL) { - char *end = NULL; - double r = strtod(buf, &end); - if (end != buf && r >= 0.0 && r <= 1.0) { - return r; - } - cbm_log_warn("dump_verify.env.invalid", "value", buf, "fallback", "0.5"); - } - return CBM_DUMP_VERIFY_DEFAULT_RATIO; -} diff --git a/src/foundation/dump_verify.zig b/src/foundation/dump_verify.zig new file mode 100644 index 00000000..83e8e76c --- /dev/null +++ b/src/foundation/dump_verify.zig @@ -0,0 +1,25 @@ +//! dump_verify.zig — Post-dump plausibility gate (#334). +//! +//! Implements src/foundation/dump_verify.h. + +const c = @import("c"); +const interop = @import("interop.zig"); + +pub export fn cbm_dump_verify_is_degraded(committed_nodes: c_int, persisted_nodes: c_int, ratio: f64, min_floor: c_int) bool { + if (ratio <= 0.0) return false; + if (committed_nodes < 0) return false; + if (committed_nodes <= min_floor) return false; + if (persisted_nodes < 0) return true; + return @as(f64, @floatFromInt(persisted_nodes)) < @as(f64, @floatFromInt(committed_nodes)) * ratio; +} + +pub export fn cbm_dump_verify_min_ratio() f64 { + var buf: [32]u8 = undefined; + if (c.cbm_safe_getenv("CBM_DUMP_VERIFY_MIN_RATIO", &buf, buf.len, null) != null) { + var end: [*c]u8 = null; + const r = c.strtod(&buf, &end); + if (end != @as([*c]u8, &buf) and r >= 0.0 and r <= 1.0) return r; + interop.logWarn("dump_verify.env.invalid", .{ "value", @as(interop.Str, @ptrCast(&buf)), "fallback", "0.5" }); + } + return c.CBM_DUMP_VERIFY_DEFAULT_RATIO; +} diff --git a/src/foundation/file_lock.c b/src/foundation/file_lock.c deleted file mode 100644 index 5d09dfae..00000000 --- a/src/foundation/file_lock.c +++ /dev/null @@ -1,75 +0,0 @@ -#include "foundation/file_lock.h" -#include "foundation/compat.h" - -#include -#include - -#ifdef _WIN32 -#include -#include "foundation/win_utf8.h" -#else -#include -#include -#include -#endif - -int cbm_file_lock_acquire(const char *path, cbm_file_lock_mode_t mode, bool wait, - cbm_file_lock_t *out) { - if (!out) return -1; - out->handle = -1; - if (!path || !path[0]) return -1; -#ifdef _WIN32 - wchar_t *wide = cbm_utf8_to_wide(path); - if (!wide) return -1; - HANDLE handle = CreateFileW(wide, GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_ALWAYS, - FILE_ATTRIBUTE_NORMAL, NULL); - free(wide); - if (handle == INVALID_HANDLE_VALUE) return -1; - OVERLAPPED overlap; - memset(&overlap, 0, sizeof(overlap)); - DWORD flags = mode == CBM_FILE_LOCK_EXCLUSIVE ? LOCKFILE_EXCLUSIVE_LOCK : 0; - if (!wait) flags |= LOCKFILE_FAIL_IMMEDIATELY; - if (!LockFileEx(handle, flags, 0, 1, 0, &overlap)) { - DWORD error = GetLastError(); - CloseHandle(handle); - return !wait && error == ERROR_LOCK_VIOLATION ? CBM_FILE_LOCK_BUSY : -1; - } - out->handle = (intptr_t)handle; - return 0; -#else -#ifndef O_CLOEXEC -#define O_CLOEXEC 0 -#endif - int fd = open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0600); - if (fd < 0) return -1; - (void)cbm_fd_set_cloexec(fd); - struct flock lock = {.l_type = mode == CBM_FILE_LOCK_EXCLUSIVE ? F_WRLCK : F_RDLCK, - .l_whence = SEEK_SET}; - int command = wait ? F_SETLKW : F_SETLK; - while (fcntl(fd, command, &lock) != 0) { - if (errno == EINTR) continue; - int error = errno; - close(fd); - return !wait && (error == EAGAIN || error == EACCES) ? CBM_FILE_LOCK_BUSY : -1; - } - out->handle = (intptr_t)fd; - return 0; -#endif -} - -void cbm_file_lock_release(cbm_file_lock_t *lock) { - if (!lock || lock->handle == -1) return; -#ifdef _WIN32 - OVERLAPPED overlap; - memset(&overlap, 0, sizeof(overlap)); - (void)UnlockFileEx((HANDLE)lock->handle, 0, 1, 0, &overlap); - CloseHandle((HANDLE)lock->handle); -#else - int fd = (int)lock->handle; - struct flock unlock = {.l_type = F_UNLCK, .l_whence = SEEK_SET}; - (void)fcntl(fd, F_SETLK, &unlock); - close(fd); -#endif - lock->handle = -1; -} diff --git a/src/foundation/file_lock.zig b/src/foundation/file_lock.zig new file mode 100644 index 00000000..c331f909 --- /dev/null +++ b/src/foundation/file_lock.zig @@ -0,0 +1,78 @@ +//! file_lock.zig — Cross-process advisory lock on a lock file. +//! +//! Implements src/foundation/file_lock.h: fcntl record locks on POSIX, +//! LockFileEx on Windows. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("interop.zig"); + +const is_windows = builtin.os.tag == .windows; + +fn handleToInt(handle: anytype) isize { + return @bitCast(@intFromPtr(handle)); +} + +pub export fn cbm_file_lock_acquire(path: ?[*:0]const u8, mode: c.cbm_file_lock_mode_t, wait: bool, out: ?*c.cbm_file_lock_t) c_int { + const o = out orelse return -1; + o.handle = -1; + const p = path orelse return -1; + if (p[0] == 0) return -1; + + if (is_windows) { + const wide = c.cbm_utf8_to_wide(p); + if (wide == null) return -1; + const handle = c.CreateFileW(wide, c.GENERIC_READ | c.GENERIC_WRITE, c.FILE_SHARE_READ | c.FILE_SHARE_WRITE, null, c.OPEN_ALWAYS, c.FILE_ATTRIBUTE_NORMAL, null); + c.free(wide); + if (handle == interop.INVALID_HANDLE_VALUE) return -1; + + var overlap = std.mem.zeroes(c.OVERLAPPED); + var flags: c.DWORD = if (mode == c.CBM_FILE_LOCK_EXCLUSIVE) c.LOCKFILE_EXCLUSIVE_LOCK else 0; + if (!wait) flags |= c.LOCKFILE_FAIL_IMMEDIATELY; + if (c.LockFileEx(handle, flags, 0, 1, 0, &overlap) == 0) { + const err = c.GetLastError(); + _ = c.CloseHandle(handle); + return if (!wait and err == c.ERROR_LOCK_VIOLATION) c.CBM_FILE_LOCK_BUSY else -1; + } + o.handle = handleToInt(handle); + return 0; + } else { + const cloexec = if (@hasDecl(c, "O_CLOEXEC")) c.O_CLOEXEC else 0; + const fd = c.open(p, c.O_CREAT | c.O_RDWR | cloexec, @as(c_uint, 0o600)); + if (fd < 0) return -1; + _ = c.cbm_fd_set_cloexec(fd); + + var lock = std.mem.zeroes(c.struct_flock); + lock.l_type = if (mode == c.CBM_FILE_LOCK_EXCLUSIVE) c.F_WRLCK else c.F_RDLCK; + lock.l_whence = c.SEEK_SET; + const command: c_int = if (wait) c.F_SETLKW else c.F_SETLK; + while (c.fcntl(fd, command, &lock) != 0) { + if (interop.errno() == c.EINTR) continue; + const err = interop.errno(); + _ = c.close(fd); + return if (!wait and (err == c.EAGAIN or err == c.EACCES)) c.CBM_FILE_LOCK_BUSY else -1; + } + o.handle = fd; + return 0; + } +} + +pub export fn cbm_file_lock_release(lock: ?*c.cbm_file_lock_t) void { + const l = lock orelse return; + if (l.handle == -1) return; + if (is_windows) { + const handle: c.HANDLE = @ptrFromInt(@as(usize, @bitCast(l.handle))); + var overlap = std.mem.zeroes(c.OVERLAPPED); + _ = c.UnlockFileEx(handle, 0, 1, 0, &overlap); + _ = c.CloseHandle(handle); + } else { + const fd: c_int = @truncate(l.handle); + var unlock = std.mem.zeroes(c.struct_flock); + unlock.l_type = c.F_UNLCK; + unlock.l_whence = c.SEEK_SET; + _ = c.fcntl(fd, c.F_SETLK, &unlock); + _ = c.close(fd); + } + l.handle = -1; +} diff --git a/src/foundation/git_env.c b/src/foundation/git_env.c deleted file mode 100644 index 429dd76f..00000000 --- a/src/foundation/git_env.c +++ /dev/null @@ -1,164 +0,0 @@ -/* - * git_env.c — child environment for cbm's git spawns. See git_env.h. - */ -#include "foundation/constants.h" -#include "foundation/git_env.h" - -#include -#include - -#ifdef _WIN32 -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#else -extern char **environ; -#endif - -const char *const cbm_git_repo_env_vars[CBM_GIT_REPO_ENV_VAR_COUNT] = { - "GIT_ALTERNATE_OBJECT_DIRECTORIES", - "GIT_CONFIG", - "GIT_CONFIG_PARAMETERS", - "GIT_CONFIG_COUNT", - "GIT_OBJECT_DIRECTORY", - "GIT_DIR", - "GIT_WORK_TREE", - "GIT_IMPLICIT_WORK_TREE", - "GIT_GRAFT_FILE", - "GIT_INDEX_FILE", - "GIT_NO_REPLACE_OBJECTS", - "GIT_REPLACE_REF_BASE", - "GIT_PREFIX", - "GIT_SHALLOW_FILE", - "GIT_COMMON_DIR", -}; - -static char git_env_fold(char c) { -#ifdef _WIN32 - return (c >= 'a' && c <= 'z') ? (char)(c - 'a' + 'A') : c; -#else - return c; -#endif -} - -/* Do the first `name_len` chars of `name` equal `var`? */ -static bool git_env_name_equals(const char *name, size_t name_len, const char *var) { - size_t var_len = strlen(var); - if (name_len != var_len) { - return false; - } - for (size_t i = 0; i < var_len; i++) { - if (git_env_fold(name[i]) != var[i]) { - return false; - } - } - return true; -} - -static bool git_env_name_is_repo_local(const char *name, size_t name_len) { - for (size_t i = 0; i < CBM_GIT_REPO_ENV_VAR_COUNT; i++) { - if (git_env_name_equals(name, name_len, cbm_git_repo_env_vars[i])) { - return true; - } - } - return false; -} - -bool cbm_git_env_entry_is_repo_local(const char *entry) { - if (!entry) { - return false; - } - const char *eq = strchr(entry, '='); - size_t name_len = eq ? (size_t)(eq - entry) : strlen(entry); - return git_env_name_is_repo_local(entry, name_len); -} - -#ifdef _WIN32 - -enum { GIT_ENV_ASCII_MAX = 0x7F }; - -/* Is a wide "NAME=value" entry repository-local? The names we match are - * ASCII, so any non-ASCII code unit is a mismatch. Windows keeps per-drive - * working directories as "=C:=C:\\dir"; the separator search starts at index 1 - * so those entries are kept. */ -static bool git_env_wide_entry_is_repo_local(const wchar_t *entry) { - char name[CBM_SZ_64] = {0}; - size_t n = 0; - for (size_t i = 0; entry[i] && (i == 0 || entry[i] != L'='); i++) { - if (entry[i] > GIT_ENV_ASCII_MAX || n + SKIP_ONE >= sizeof(name)) { - return false; - } - name[n++] = (char)entry[i]; - } - return git_env_name_is_repo_local(name, n); -} - -wchar_t *cbm_git_child_env_block(void) { - wchar_t *current = GetEnvironmentStringsW(); - if (!current) { - return NULL; - } - size_t kept = 0; - for (const wchar_t *e = current; *e; e += wcslen(e) + 1) { - if (!git_env_wide_entry_is_repo_local(e)) { - kept += wcslen(e) + SKIP_ONE; - } - } - /* PAIR_LEN: a block that keeps nothing still needs its double terminator. */ - wchar_t *block = (wchar_t *)malloc((kept + PAIR_LEN) * sizeof(wchar_t)); - if (!block) { - FreeEnvironmentStringsW(current); - return NULL; - } - size_t pos = 0; - for (const wchar_t *e = current; *e; e += wcslen(e) + 1) { - if (!git_env_wide_entry_is_repo_local(e)) { - size_t len = wcslen(e) + SKIP_ONE; - memcpy(block + pos, e, len * sizeof(wchar_t)); - pos += len; - } - } - block[pos] = L'\0'; - if (pos == 0) { - block[SKIP_ONE] = L'\0'; - } - FreeEnvironmentStringsW(current); - return block; -} - -#else - -char **cbm_git_child_envp(void) { - size_t count = 0; - size_t bytes = 0; - for (char **e = environ; e && *e; e++) { - if (!cbm_git_env_entry_is_repo_local(*e)) { - count++; - bytes += strlen(*e) + SKIP_ONE; - } - } - size_t table = (count + SKIP_ONE) * sizeof(char *); - char **envp = (char **)malloc(table + bytes); - if (!envp) { - return NULL; - } - char *strings = (char *)envp + table; - size_t slot = 0; - for (char **e = environ; e && *e && slot < count; e++) { - if (!cbm_git_env_entry_is_repo_local(*e)) { - size_t len = strlen(*e) + SKIP_ONE; - memcpy(strings, *e, len); - envp[slot++] = strings; - strings += len; - } - } - envp[slot] = NULL; - return envp; -} - -#endif - -void cbm_git_child_env_free(void *env) { - free(env); -} diff --git a/src/foundation/git_env.zig b/src/foundation/git_env.zig new file mode 100644 index 00000000..4eea5774 --- /dev/null +++ b/src/foundation/git_env.zig @@ -0,0 +1,149 @@ +//! git_env.zig — child environment for cbm's git spawns. +//! +//! Implements src/foundation/git_env.h. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); + +const is_windows = builtin.os.tag == .windows; + +/// `git rev-parse --local-env-vars` (git 2.50). +pub export const cbm_git_repo_env_vars = [c.CBM_GIT_REPO_ENV_VAR_COUNT][*:0]const u8{ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CONFIG", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_OBJECT_DIRECTORY", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_GRAFT_FILE", + "GIT_INDEX_FILE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_REPLACE_REF_BASE", + "GIT_PREFIX", + "GIT_SHALLOW_FILE", + "GIT_COMMON_DIR", +}; + +fn gitEnvFold(ch: u8) u8 { + if (is_windows) return if (ch >= 'a' and ch <= 'z') ch - 'a' + 'A' else ch; + return ch; +} + +/// Does `name` equal `variable`? +fn gitEnvNameEquals(name: []const u8, variable: []const u8) bool { + if (name.len != variable.len) return false; + for (name, variable) |n, v| { + if (gitEnvFold(n) != v) return false; + } + return true; +} + +fn gitEnvNameIsRepoLocal(name: []const u8) bool { + for (cbm_git_repo_env_vars) |variable| { + if (gitEnvNameEquals(name, std.mem.span(variable))) return true; + } + return false; +} + +pub export fn cbm_git_env_entry_is_repo_local(entry: ?[*:0]const u8) bool { + const e = std.mem.span(entry orelse return false); + const name_len = std.mem.indexOfScalar(u8, e, '=') orelse e.len; + return gitEnvNameIsRepoLocal(e[0..name_len]); +} + +pub const windows = if (is_windows) struct { + const GIT_ENV_ASCII_MAX = 0x7F; + + /// Is a wide "NAME=value" entry repository-local? The names we match are + /// ASCII, so any non-ASCII code unit is a mismatch. Windows keeps per-drive + /// working directories as "=C:=C:\\dir"; the separator search starts at index 1 + /// so those entries are kept. + fn gitEnvWideEntryIsRepoLocal(entry: []const c.wchar_t) bool { + var name: [64]u8 = undefined; + var n: usize = 0; + for (entry, 0..) |unit, i| { + if (i != 0 and unit == '=') break; + if (unit > GIT_ENV_ASCII_MAX or n + 1 >= name.len) return false; + name[n] = @truncate(unit); + n += 1; + } + return gitEnvNameIsRepoLocal(name[0..n]); + } + + pub export fn cbm_git_child_env_block() ?[*:0]c.wchar_t { + const current: [*:0]const c.wchar_t = @ptrCast(c.GetEnvironmentStringsW() orelse return null); + + var kept: usize = 0; + var e = current; + while (e[0] != 0) : (e += std.mem.len(e) + 1) { + if (!gitEnvWideEntryIsRepoLocal(std.mem.span(e))) kept += std.mem.len(e) + 1; + } + + // 2: a block that keeps nothing still needs its double terminator. + const block: [*]c.wchar_t = @ptrCast(@alignCast(c.malloc((kept + 2) * @sizeOf(c.wchar_t)) orelse { + _ = c.FreeEnvironmentStringsW(@constCast(current)); + return null; + })); + + var pos: usize = 0; + e = current; + while (e[0] != 0) : (e += std.mem.len(e) + 1) { + if (!gitEnvWideEntryIsRepoLocal(std.mem.span(e))) { + const len = std.mem.len(e) + 1; + @memcpy(block[pos .. pos + len], e[0..len]); + pos += len; + } + } + block[pos] = 0; + if (pos == 0) block[1] = 0; + + _ = c.FreeEnvironmentStringsW(@constCast(current)); + return @ptrCast(block); + } +} else struct {}; + +pub const posix = if (!is_windows) struct { + extern var environ: ?[*:null]?[*:0]u8; + + pub export fn cbm_git_child_envp() ?[*:null]?[*:0]u8 { + var count: usize = 0; + var bytes: usize = 0; + if (environ) |env| { + var i: usize = 0; + while (env[i]) |entry| : (i += 1) { + if (!cbm_git_env_entry_is_repo_local(entry)) { + count += 1; + bytes += std.mem.len(entry) + 1; + } + } + } + + const table = (count + 1) * @sizeOf(?[*:0]u8); + const raw: [*]u8 = @ptrCast(c.malloc(table + bytes) orelse return null); + const envp: [*]?[*:0]u8 = @ptrCast(@alignCast(raw)); + var strings = raw + table; + var slot: usize = 0; + if (environ) |env| { + var i: usize = 0; + while (env[i]) |entry| : (i += 1) { + if (slot >= count) break; + if (!cbm_git_env_entry_is_repo_local(entry)) { + const len = std.mem.len(entry) + 1; + @memcpy(strings[0..len], entry[0..len]); + envp[slot] = @ptrCast(strings); + slot += 1; + strings += len; + } + } + } + envp[slot] = null; + return @ptrCast(envp); + } +} else struct {}; + +pub export fn cbm_git_child_env_free(env: ?*anyopaque) void { + c.free(env); +} diff --git a/src/foundation/interop.zig b/src/foundation/interop.zig index 223595ff..57867f24 100644 --- a/src/foundation/interop.zig +++ b/src/foundation/interop.zig @@ -2,6 +2,7 @@ //! cbm_log, snprintf-style formatting, malloc'd allocations and C strings. const std = @import("std"); +const builtin = @import("builtin"); const c = @import("c"); pub const Str = [*:0]const u8; @@ -82,6 +83,58 @@ pub fn ciEq(a: CStr, b: CStr) bool { return std.ascii.eqlIgnoreCase(std.mem.span(a), std.mem.span(b)); } +// ── Windows ──────────────────────────────────────────────────────── + +/// INVALID_HANDLE_VALUE: translate-c cannot evaluate the ((HANDLE)(LONG_PTR)-1) +/// macro. Only referenced from Windows branches. +pub const INVALID_HANDLE_VALUE: ?*anyopaque = @ptrFromInt(std.math.maxInt(usize)); + +// ── Environment ──────────────────────────────────────────────────── + +const posix_environ = struct { + extern var environ: ?[*:null]?[*:0]u8; +}; + +/// The process environment block (`environ`), however this libc spells it. +pub fn environ() ?[*:null]?[*:0]u8 { + return switch (builtin.os.tag) { + .windows => @ptrCast(c.__p__environ().*), + .macos => @ptrCast(c._NSGetEnviron().*), + else => posix_environ.environ, + }; +} + +// ── errno ────────────────────────────────────────────────────────── + +pub fn errno() c_int { + return std.c._errno().*; +} + +pub fn setErrno(value: c_int) void { + std.c._errno().* = value; +} + +// ── wait status ──────────────────────────────────────────────────── + +// The macros, which translate-c cannot express. The encoding is +// the same on Linux and macOS. + +pub fn wifexited(status: c_int) bool { + return (status & 0x7f) == 0; +} + +pub fn wexitstatus(status: c_int) c_int { + return (status >> 8) & 0xff; +} + +pub fn wifsignaled(status: c_int) bool { + return (status & 0x7f) != 0x7f and (status & 0x7f) != 0; +} + +pub fn wtermsig(status: c_int) c_int { + return status & 0x7f; +} + // ── stdio ────────────────────────────────────────────────────────── pub fn stdin() ?*c.FILE { diff --git a/src/foundation/limits.c b/src/foundation/limits.c deleted file mode 100644 index 3e6325b3..00000000 --- a/src/foundation/limits.c +++ /dev/null @@ -1,56 +0,0 @@ -/* - * limits.c — Env-configurable safety limits (Stage 2 / Track B4). - */ -#include "foundation/limits.h" - -#include -#include -#include - -long cbm_max_file_bytes(void) { - /* 512 MiB — generous: real source files never approach it, but a - * pathological / vendored blob degrades to a reported "oversized" skip - * instead of a silent drop or an unbounded read. */ - const long default_cap = 512L * 1024 * 1024; - - const char *raw = getenv("CBM_MAX_FILE_BYTES"); - if (raw && raw[0]) { - errno = 0; - char *end = NULL; - long v = strtol(raw, &end, 10); - if (errno == 0 && end != raw && *end == '\0' && v > 0) { - return v; - } - /* Unparseable / non-positive → fall through to the safe default. */ - } - return default_cap; -} - -/* Shared env-int parser: a positive integer in [1, INT_MAX], else the fallback. - * Read fresh each call (see cbm_max_file_bytes rationale — cheap, test-friendly, - * no stale memoized copy across runs). */ -static int env_positive_int(const char *name, int fallback) { - const char *raw = getenv(name); - if (raw && raw[0]) { - errno = 0; - char *end = NULL; - long v = strtol(raw, &end, 10); - if (errno == 0 && end != raw && *end == '\0' && v > 0 && v <= INT_MAX) { - return (int)v; - } - /* Unparseable / non-positive / out-of-range → safe default. */ - } - return fallback; -} - -int cbm_cypher_max_depth(void) { - /* 10 — generous for a code call/def graph; an explicit `*1..N` above this is - * WARN-capped, never an unbounded (cyclic-graph DoS) traversal. */ - return env_positive_int("CBM_CYPHER_MAX_DEPTH", 10); -} - -int cbm_mcp_max_depth(void) { - /* 15 — ceiling for client-driven MCP graph traversals (trace_call_path, - * detect_changes); the caller's `depth` is WARN-clamped to this. */ - return env_positive_int("CBM_MCP_MAX_DEPTH", 15); -} diff --git a/src/foundation/limits.zig b/src/foundation/limits.zig new file mode 100644 index 00000000..fc784ee3 --- /dev/null +++ b/src/foundation/limits.zig @@ -0,0 +1,52 @@ +//! limits.zig — Env-configurable safety limits (Stage 2 / Track B4). +//! +//! Implements src/foundation/limits.h. + +const std = @import("std"); +const c = @import("c"); +const interop = @import("interop.zig"); + +pub export fn cbm_max_file_bytes() c_long { + // 512 MiB — generous: real source files never approach it, but a + // pathological / vendored blob degrades to a reported "oversized" skip + // instead of a silent drop or an unbounded read. + const default_cap: c_long = 512 * 1024 * 1024; + const raw: [*c]const u8 = c.getenv("CBM_MAX_FILE_BYTES"); + if (raw != null and raw[0] != 0) { + interop.setErrno(0); + var end: [*c]u8 = null; + const v = c.strtol(raw, &end, 10); + if (interop.errno() == 0 and end != raw and end[0] == 0 and v > 0) return v; + // Unparseable / non-positive → fall through to the safe default. + } + return default_cap; +} + +/// Shared env-int parser: a positive integer in [1, INT_MAX], else the fallback. +/// Read fresh each call (see cbm_max_file_bytes rationale — cheap, test-friendly, +/// no stale memoized copy across runs). +fn envPositiveInt(name: [*:0]const u8, fallback: c_int) c_int { + const raw: [*c]const u8 = c.getenv(name); + if (raw != null and raw[0] != 0) { + interop.setErrno(0); + var end: [*c]u8 = null; + const v = c.strtol(raw, &end, 10); + if (interop.errno() == 0 and end != raw and end[0] == 0 and v > 0 and v <= std.math.maxInt(c_int)) { + return @intCast(v); + } + // Unparseable / non-positive / out-of-range → safe default. + } + return fallback; +} + +pub export fn cbm_cypher_max_depth() c_int { + // 10 — generous for a code call/def graph; an explicit `*1..N` above this is + // WARN-capped, never an unbounded (cyclic-graph DoS) traversal. + return envPositiveInt("CBM_CYPHER_MAX_DEPTH", 10); +} + +pub export fn cbm_mcp_max_depth() c_int { + // 15 — ceiling for client-driven MCP graph traversals (trace_call_path, + // detect_changes); the caller's `depth` is WARN-clamped to this. + return envPositiveInt("CBM_MCP_MAX_DEPTH", 15); +} diff --git a/src/foundation/log.c b/src/foundation/log.c deleted file mode 100644 index 2f921cad..00000000 --- a/src/foundation/log.c +++ /dev/null @@ -1,332 +0,0 @@ -/* - * log.c — Structured key-value logging to stderr. - */ -#include "log.h" -#include "foundation/compat.h" -#include "foundation/constants.h" -#include -#include -#include -#include -#include -#include -#include - -static CBMLogLevel g_log_level = CBM_LOG_INFO; -static CBMLogFormat g_log_format = CBM_LOG_FORMAT_TEXT; -static cbm_log_sink_fn g_log_sink = NULL; -static CBMLogSinkMode g_log_sink_mode = CBM_LOG_SINK_REPLACE; - -/* CBM_LOG_LEVEL support — distilled from #414 (closes #413, thanks @santanusinha). */ -void cbm_log_init_from_env(void) { - /* getenv() is safe here: this runs at startup before any thread is created, - * so there is no concurrent setenv() to race against. */ - const char *raw = getenv("CBM_LOG_LEVEL"); - if (raw && raw[0] != '\0') { - /* Textual form, case-insensitive. Index of each name == its enum value. */ - static const char *const names[] = {"debug", "info", "warn", "error", "none"}; - char lower[8]; - size_t i = 0; - for (; i < sizeof(lower) - 1 && raw[i] != '\0'; i++) { - lower[i] = (char)tolower((unsigned char)raw[i]); - } - lower[i] = '\0'; - if (raw[i] == '\0') { /* fully consumed — candidate textual match */ - for (size_t lvl = 0; lvl < sizeof(names) / sizeof(names[0]); lvl++) { - if (strcmp(lower, names[lvl]) == 0) { - cbm_log_set_level((CBMLogLevel)lvl); - goto parse_format; - } - } - } - - /* Numeric form: 0=debug .. 4=none, matching CBMLogLevel. */ - char *end = NULL; - long n = strtol(raw, &end, CBM_DECIMAL_BASE); - if (end != raw && *end == '\0' && n >= CBM_LOG_DEBUG && n <= CBM_LOG_NONE) { - cbm_log_set_level((CBMLogLevel)n); - } - } - - /* Unrecognised value: leave the level unchanged (fail-open). */ - -parse_format:; - const char *fmt = getenv("CBM_LOG_FORMAT"); - if (fmt && fmt[0] != '\0') { - char lower_fmt[8]; - size_t i = 0; - for (; i < sizeof(lower_fmt) - 1 && fmt[i] != '\0'; i++) { - lower_fmt[i] = (char)tolower((unsigned char)fmt[i]); - } - lower_fmt[i] = '\0'; - if (fmt[i] == '\0' && strcmp(lower_fmt, "json") == 0) { - cbm_log_set_format(CBM_LOG_FORMAT_JSON); - } else if (fmt[i] == '\0' && strcmp(lower_fmt, "text") == 0) { - cbm_log_set_format(CBM_LOG_FORMAT_TEXT); - } - return; - } - - /* Format is intentionally explicit-only. Logs stay local to stderr and the - * optional in-process sink; deployment environment variables must not - * silently change the operator-selected output shape. */ -} - -void cbm_log_set_sink(cbm_log_sink_fn fn) { - cbm_log_set_sink_ex(fn, CBM_LOG_SINK_REPLACE); -} - -void cbm_log_set_sink_ex(cbm_log_sink_fn fn, CBMLogSinkMode mode) { - g_log_sink = fn; - g_log_sink_mode = mode; -} - -void cbm_log_set_level(CBMLogLevel level) { - g_log_level = level; -} - -CBMLogLevel cbm_log_get_level(void) { - return g_log_level; -} - -void cbm_log_set_format(CBMLogFormat format) { - g_log_format = format; -} - -CBMLogFormat cbm_log_get_format(void) { - return g_log_format; -} - -static const char *level_str(CBMLogLevel level) { - switch (level) { - case CBM_LOG_DEBUG: - return "debug"; - case CBM_LOG_INFO: - return "info"; - case CBM_LOG_WARN: - return "warn"; - case CBM_LOG_ERROR: - return "error"; - default: - return "unknown"; - } -} - -static void append_char(char *buf, size_t bufsz, size_t *pos, char ch) { - if (*pos < bufsz - 1) { - buf[*pos] = ch; - } - (*pos)++; -} - -static void append_raw(char *buf, size_t bufsz, size_t *pos, const char *s) { - if (!s) { - return; - } - while (*s) { - append_char(buf, bufsz, pos, *s++); - } -} - -static void append_text_atom(char *buf, size_t bufsz, size_t *pos, const char *s) { - if (!s) { - return; - } - while (*s) { - unsigned char ch = (unsigned char)*s++; - if (ch <= ' ' || ch == 0x7f) { - append_char(buf, bufsz, pos, '_'); - } else { - append_char(buf, bufsz, pos, (char)ch); - } - } -} - -static void append_json_string(char *buf, size_t bufsz, size_t *pos, const char *s) { - append_char(buf, bufsz, pos, '"'); - if (s) { - while (*s) { - unsigned char ch = (unsigned char)*s++; - switch (ch) { - case '"': - append_raw(buf, bufsz, pos, "\\\""); - break; - case '\\': - append_raw(buf, bufsz, pos, "\\\\"); - break; - case '\b': - append_raw(buf, bufsz, pos, "\\b"); - break; - case '\f': - append_raw(buf, bufsz, pos, "\\f"); - break; - case '\n': - append_raw(buf, bufsz, pos, "\\n"); - break; - case '\r': - append_raw(buf, bufsz, pos, "\\r"); - break; - case '\t': - append_raw(buf, bufsz, pos, "\\t"); - break; - default: - if (ch < 0x20) { - static const char hex[] = "0123456789abcdef"; - append_raw(buf, bufsz, pos, "\\u00"); - append_char(buf, bufsz, pos, hex[ch >> 4]); - append_char(buf, bufsz, pos, hex[ch & 0xf]); - } else { - append_char(buf, bufsz, pos, (char)ch); - } - break; - } - } - } - append_char(buf, bufsz, pos, '"'); -} - -static void finish_line(char *buf, size_t bufsz, size_t pos) { - if (bufsz == 0) { - return; - } - if (pos >= bufsz) { - buf[bufsz - 1] = '\0'; - } else { - buf[pos] = '\0'; - } -} - -static void emit_line(const char *line) { - if (g_log_sink) { - g_log_sink(line); - if (g_log_sink_mode == CBM_LOG_SINK_REPLACE) { - return; - } - } - (void)fprintf(stderr, "%s\n", line); - /* The Windows CRT fully buffers a redirected stderr; a crashed index worker - * would otherwise leave an empty log. */ - (void)fflush(stderr); -} - -void cbm_log(CBMLogLevel level, const char *msg, ...) { - if (level < g_log_level) { - return; - } - - char line_buf[CBM_SZ_4K]; - size_t pos = 0; - va_list args; - va_start(args, msg); - - if (g_log_format == CBM_LOG_FORMAT_JSON) { - append_raw(line_buf, sizeof(line_buf), &pos, "{\"level\":"); - append_json_string(line_buf, sizeof(line_buf), &pos, level_str(level)); - append_raw(line_buf, sizeof(line_buf), &pos, ",\"event\":"); - append_json_string(line_buf, sizeof(line_buf), &pos, msg ? msg : ""); - for (;;) { - const char *key = va_arg(args, const char *); - if (!key) { - break; - } - const char *val = va_arg(args, const char *); - append_char(line_buf, sizeof(line_buf), &pos, ','); - append_json_string(line_buf, sizeof(line_buf), &pos, key); - append_char(line_buf, sizeof(line_buf), &pos, ':'); - append_json_string(line_buf, sizeof(line_buf), &pos, val ? val : ""); - } - append_char(line_buf, sizeof(line_buf), &pos, '}'); - } else { - append_raw(line_buf, sizeof(line_buf), &pos, "level="); - append_text_atom(line_buf, sizeof(line_buf), &pos, level_str(level)); - append_raw(line_buf, sizeof(line_buf), &pos, " msg="); - append_text_atom(line_buf, sizeof(line_buf), &pos, msg ? msg : ""); - for (;;) { - const char *key = va_arg(args, const char *); - if (!key) { - break; - } - const char *val = va_arg(args, const char *); - append_char(line_buf, sizeof(line_buf), &pos, ' '); - append_text_atom(line_buf, sizeof(line_buf), &pos, key); - append_char(line_buf, sizeof(line_buf), &pos, '='); - append_text_atom(line_buf, sizeof(line_buf), &pos, val ? val : ""); - } - } - va_end(args); - - finish_line(line_buf, sizeof(line_buf), pos); - emit_line(line_buf); -} - -void cbm_log_int(CBMLogLevel level, const char *msg, const char *key, int64_t value) { - char value_buf[CBM_SZ_32]; - snprintf(value_buf, sizeof(value_buf), "%" PRId64, value); - cbm_log(level, msg, key ? key : "?", value_buf, NULL); -} - -const char *cbm_log_itoa(int value) { - enum { RING = 16 }; - static CBM_TLS char bufs[RING][CBM_SZ_32]; - static CBM_TLS int idx = 0; - char *out = bufs[idx]; - idx = (idx + SKIP_ONE) % RING; - snprintf(out, CBM_SZ_32, "%d", value); - return out; -} - -static void copy_path_without_query(const char *path, char *out, size_t outsz) { - if (!out || outsz == 0) { - return; - } - out[0] = '\0'; - if (!path) { - return; - } - size_t n = 0; - while (path[n] && path[n] != '?' && path[n] != '#' && n < outsz - 1) { - out[n] = path[n]; - n++; - } - out[n] = '\0'; -} - -void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error, - int64_t duration_us) { - char duration_ms[CBM_SZ_32]; - snprintf(duration_ms, sizeof(duration_ms), "%" PRId64, duration_us / 1000); - if (tool_name && tool_name[0] != '\0') { - cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc", - "method", method ? method : "", "tool", tool_name, "status", - is_error ? "error" : "ok", "duration_ms", duration_ms, NULL); - } else { - cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc", - "method", method ? method : "", "status", is_error ? "error" : "ok", "duration_ms", - duration_ms, NULL); - } -} - -void cbm_log_http_request(const char *component, const char *method, const char *path, int status, - int64_t duration_ms, size_t request_bytes, size_t response_bytes) { - char safe_path[CBM_SZ_1K]; - char status_buf[CBM_SZ_16]; - char duration_buf[CBM_SZ_32]; - char request_buf[CBM_SZ_32]; - char response_buf[CBM_SZ_32]; - copy_path_without_query(path, safe_path, sizeof(safe_path)); - snprintf(status_buf, sizeof(status_buf), "%d", status); - snprintf(duration_buf, sizeof(duration_buf), "%" PRId64, duration_ms); - snprintf(request_buf, sizeof(request_buf), "%zu", request_bytes); - snprintf(response_buf, sizeof(response_buf), "%zu", response_bytes); - - CBMLogLevel level = CBM_LOG_INFO; - if (status >= 500) { - level = CBM_LOG_ERROR; - } else if (status >= 400) { - level = CBM_LOG_WARN; - } - - cbm_log(level, "http.request", "component", component ? component : "", "method", - method ? method : "", "path", safe_path, "status", status_buf, "duration_ms", - duration_buf, "request_bytes", request_buf, "response_bytes", response_buf, NULL); -} diff --git a/src/foundation/log.zig b/src/foundation/log.zig new file mode 100644 index 00000000..71cbfeb7 --- /dev/null +++ b/src/foundation/log.zig @@ -0,0 +1,313 @@ +//! log.zig — Structured key-value logging to stderr. +//! +//! Implements src/foundation/log.h. The variadic cbm_log() itself lives in +//! c_shim.c: it walks the va_list and feeds each pair to the line +//! builder below (log_internal.h). + +const std = @import("std"); +const c = @import("c"); +const interop = @import("interop.zig"); + +var g_log_level: c.CBMLogLevel = c.CBM_LOG_INFO; +var g_log_format: c.CBMLogFormat = c.CBM_LOG_FORMAT_TEXT; +var g_log_sink: c.cbm_log_sink_fn = null; +var g_log_sink_mode: c.CBMLogSinkMode = c.CBM_LOG_SINK_REPLACE; + +/// Lower-cases `raw` into `lower` (NUL-terminated). Returns the text when it +/// fit completely, else null. +fn lowerSmall(lower: *[8]u8, raw: [*:0]const u8) ?[]const u8 { + var i: usize = 0; + while (i < lower.len - 1 and raw[i] != 0) : (i += 1) { + lower[i] = std.ascii.toLower(raw[i]); + } + lower[i] = 0; + return if (raw[i] == 0) lower[0..i] else null; +} + +/// Applies CBM_LOG_LEVEL. Unrecognised values leave the level unchanged +/// (fail-open). +fn initLevelFromEnv() void { + // getenv() is safe here: this runs at startup before any thread is created, + // so there is no concurrent setenv() to race against. + const raw: [*:0]const u8 = @ptrCast(c.getenv("CBM_LOG_LEVEL") orelse return); + if (raw[0] == 0) return; + + // Textual form, case-insensitive. Index of each name == its enum value. + const names = [_][]const u8{ "debug", "info", "warn", "error", "none" }; + var lower: [8]u8 = undefined; + if (lowerSmall(&lower, raw)) |text| { // fully consumed — candidate textual match + for (names, 0..) |name, lvl| { + if (std.mem.eql(u8, text, name)) { + cbm_log_set_level(@intCast(lvl)); + return; + } + } + } + + // Numeric form: 0=debug .. 4=none, matching CBMLogLevel. + var end: [*c]u8 = null; + const n = c.strtol(raw, &end, 10); + if (end != @as([*c]const u8, raw) and end[0] == 0 and n >= c.CBM_LOG_DEBUG and n <= c.CBM_LOG_NONE) { + cbm_log_set_level(@intCast(n)); + } +} + +/// CBM_LOG_LEVEL support — distilled from #414 (closes #413, thanks @santanusinha). +pub export fn cbm_log_init_from_env() void { + initLevelFromEnv(); + + const fmt: [*:0]const u8 = @ptrCast(c.getenv("CBM_LOG_FORMAT") orelse return); + if (fmt[0] == 0) return; + var lower_fmt: [8]u8 = undefined; + if (lowerSmall(&lower_fmt, fmt)) |text| { + if (std.mem.eql(u8, text, "json")) { + cbm_log_set_format(c.CBM_LOG_FORMAT_JSON); + } else if (std.mem.eql(u8, text, "text")) { + cbm_log_set_format(c.CBM_LOG_FORMAT_TEXT); + } + } + + // Format is intentionally explicit-only. Logs stay local to stderr and the + // optional in-process sink; deployment environment variables must not + // silently change the operator-selected output shape. +} + +pub export fn cbm_log_set_sink(sink: c.cbm_log_sink_fn) void { + cbm_log_set_sink_ex(sink, c.CBM_LOG_SINK_REPLACE); +} + +pub export fn cbm_log_set_sink_ex(sink: c.cbm_log_sink_fn, mode: c.CBMLogSinkMode) void { + g_log_sink = sink; + g_log_sink_mode = mode; +} + +pub export fn cbm_log_set_level(level: c.CBMLogLevel) void { + g_log_level = level; +} + +pub export fn cbm_log_get_level() c.CBMLogLevel { + return g_log_level; +} + +pub export fn cbm_log_set_format(format: c.CBMLogFormat) void { + g_log_format = format; +} + +pub export fn cbm_log_get_format() c.CBMLogFormat { + return g_log_format; +} + +fn levelStr(level: c.CBMLogLevel) []const u8 { + return switch (level) { + c.CBM_LOG_DEBUG => "debug", + c.CBM_LOG_INFO => "info", + c.CBM_LOG_WARN => "warn", + c.CBM_LOG_ERROR => "error", + else => "unknown", + }; +} + +const Line = c.cbm_log_line_t; + +fn appendChar(line: *Line, ch: u8) void { + if (line.pos < line.buf.len - 1) line.buf[line.pos] = ch; + line.pos += 1; +} + +fn appendRaw(line: *Line, s: []const u8) void { + for (s) |ch| appendChar(line, ch); +} + +fn appendTextAtom(line: *Line, s: []const u8) void { + for (s) |ch| { + appendChar(line, if (ch <= ' ' or ch == 0x7f) '_' else ch); + } +} + +fn appendJsonString(line: *Line, s: []const u8) void { + appendChar(line, '"'); + for (s) |ch| { + switch (ch) { + '"' => appendRaw(line, "\\\""), + '\\' => appendRaw(line, "\\\\"), + 0x08 => appendRaw(line, "\\b"), + 0x0c => appendRaw(line, "\\f"), + '\n' => appendRaw(line, "\\n"), + '\r' => appendRaw(line, "\\r"), + '\t' => appendRaw(line, "\\t"), + else => { + if (ch < 0x20) { + const hex = "0123456789abcdef"; + appendRaw(line, "\\u00"); + appendChar(line, hex[ch >> 4]); + appendChar(line, hex[ch & 0xf]); + } else { + appendChar(line, ch); + } + }, + } + } + appendChar(line, '"'); +} + +fn finishLine(line: *Line) void { + if (line.pos >= line.buf.len) { + line.buf[line.buf.len - 1] = 0; + } else { + line.buf[line.pos] = 0; + } +} + +fn emitLine(line: [*:0]const u8) void { + if (g_log_sink) |sink| { + sink(line); + if (g_log_sink_mode == c.CBM_LOG_SINK_REPLACE) return; + } + _ = c.fprintf(interop.stderr(), "%s\n", line); + // The Windows CRT fully buffers a redirected stderr; a crashed index worker + // would otherwise leave an empty log. + _ = c.fflush(interop.stderr()); +} + +fn spanOrEmpty(s: ?[*:0]const u8) []const u8 { + return if (s) |p| std.mem.span(p) else ""; +} + +// ── Line builder (log_internal.h) ──────────────────────────────── + +pub export fn cbm_log_line_begin(line: *Line, level: c.CBMLogLevel, msg: ?[*:0]const u8) bool { + if (level < g_log_level) return false; + + line.pos = 0; + line.json = g_log_format == c.CBM_LOG_FORMAT_JSON; + if (line.json) { + appendRaw(line, "{\"level\":"); + appendJsonString(line, levelStr(level)); + appendRaw(line, ",\"event\":"); + appendJsonString(line, spanOrEmpty(msg)); + } else { + appendRaw(line, "level="); + appendTextAtom(line, levelStr(level)); + appendRaw(line, " msg="); + appendTextAtom(line, spanOrEmpty(msg)); + } + return true; +} + +pub export fn cbm_log_line_pair(line: *Line, key: ?[*:0]const u8, val: ?[*:0]const u8) void { + if (line.json) { + appendChar(line, ','); + appendJsonString(line, spanOrEmpty(key)); + appendChar(line, ':'); + appendJsonString(line, spanOrEmpty(val)); + } else { + appendChar(line, ' '); + appendTextAtom(line, spanOrEmpty(key)); + appendChar(line, '='); + appendTextAtom(line, spanOrEmpty(val)); + } +} + +pub export fn cbm_log_line_end(line: *Line) void { + if (line.json) appendChar(line, '}'); + finishLine(line); + emitLine(@ptrCast(&line.buf)); +} + +/// cbm_log() for the fixed key/value lists of this file. +fn logPairs(level: c.CBMLogLevel, msg: [*:0]const u8, pairs: []const [2]?[*:0]const u8) void { + var line: Line = undefined; + if (!cbm_log_line_begin(&line, level, msg)) return; + for (pairs) |pair| cbm_log_line_pair(&line, pair[0], pair[1]); + cbm_log_line_end(&line); +} + +fn fmtInt(buf: []u8, value: anytype) [*:0]const u8 { + _ = interop.bprint(buf, "{d}", .{value}); + return @ptrCast(buf.ptr); +} + +pub export fn cbm_log_int(level: c.CBMLogLevel, msg: ?[*:0]const u8, key: ?[*:0]const u8, value: i64) void { + var value_buf: [32]u8 = undefined; + var line: Line = undefined; + if (!cbm_log_line_begin(&line, level, msg)) return; + cbm_log_line_pair(&line, key orelse "?", fmtInt(&value_buf, value)); + cbm_log_line_end(&line); +} + +const ITOA_RING = 16; +threadlocal var itoa_bufs: [ITOA_RING][32]u8 = undefined; +threadlocal var itoa_idx: usize = 0; + +pub export fn cbm_log_itoa(value: c_int) [*:0]const u8 { + const out = &itoa_bufs[itoa_idx]; + itoa_idx = (itoa_idx + 1) % ITOA_RING; + return fmtInt(out, value); +} + +fn copyPathWithoutQuery(path: ?[*:0]const u8, out: []u8) void { + out[0] = 0; + const p = path orelse return; + var n: usize = 0; + while (p[n] != 0 and p[n] != '?' and p[n] != '#' and n < out.len - 1) : (n += 1) { + out[n] = p[n]; + } + out[n] = 0; +} + +pub export fn cbm_log_mcp_request(method: ?[*:0]const u8, tool_name: ?[*:0]const u8, is_error: bool, duration_us: i64) void { + var duration_buf: [32]u8 = undefined; + const duration_ms = fmtInt(&duration_buf, @divTrunc(duration_us, 1000)); + const level: c.CBMLogLevel = if (is_error) c.CBM_LOG_WARN else c.CBM_LOG_INFO; + const status: [*:0]const u8 = if (is_error) "error" else "ok"; + if (tool_name != null and tool_name.?[0] != 0) { + logPairs(level, "mcp.request", &.{ + .{ "protocol", "jsonrpc" }, + .{ "method", method orelse "" }, + .{ "tool", tool_name }, + .{ "status", status }, + .{ "duration_ms", duration_ms }, + }); + } else { + logPairs(level, "mcp.request", &.{ + .{ "protocol", "jsonrpc" }, + .{ "method", method orelse "" }, + .{ "status", status }, + .{ "duration_ms", duration_ms }, + }); + } +} + +pub export fn cbm_log_http_request( + component: ?[*:0]const u8, + method: ?[*:0]const u8, + path: ?[*:0]const u8, + status: c_int, + duration_ms: i64, + request_bytes: usize, + response_bytes: usize, +) void { + var safe_path: [1024]u8 = undefined; + var status_buf: [16]u8 = undefined; + var duration_buf: [32]u8 = undefined; + var request_buf: [32]u8 = undefined; + var response_buf: [32]u8 = undefined; + copyPathWithoutQuery(path, &safe_path); + + var level: c.CBMLogLevel = c.CBM_LOG_INFO; + if (status >= 500) { + level = c.CBM_LOG_ERROR; + } else if (status >= 400) { + level = c.CBM_LOG_WARN; + } + + logPairs(level, "http.request", &.{ + .{ "component", component orelse "" }, + .{ "method", method orelse "" }, + .{ "path", @ptrCast(&safe_path) }, + .{ "status", fmtInt(&status_buf, status) }, + .{ "duration_ms", fmtInt(&duration_buf, duration_ms) }, + .{ "request_bytes", fmtInt(&request_buf, request_bytes) }, + .{ "response_bytes", fmtInt(&response_buf, response_bytes) }, + }); +} diff --git a/src/foundation/log_internal.h b/src/foundation/log_internal.h new file mode 100644 index 00000000..6311ff33 --- /dev/null +++ b/src/foundation/log_internal.h @@ -0,0 +1,34 @@ +/* + * log_internal.h — Line builder behind the variadic cbm_log(). + * + * cbm_log() takes a NULL-terminated key/value list. Zig cannot walk a C + * va_list portably, so the variadic entry point stays in c_shim.c and + * feeds each pair to these functions, which log.zig implements. Nothing else + * should call them. + */ +#ifndef CBM_FOUNDATION_LOG_INTERNAL_H +#define CBM_FOUNDATION_LOG_INTERNAL_H + +#include "foundation/constants.h" +#include "foundation/log.h" + +#include +#include + +typedef struct { + char buf[CBM_SZ_4K]; + size_t pos; /* bytes the full line needs so far; may exceed sizeof(buf) */ + bool json; +} cbm_log_line_t; + +/* Start a line with the level and message. Returns false when `level` is + * below the runtime log level: the caller must then skip the line. */ +bool cbm_log_line_begin(cbm_log_line_t *line, CBMLogLevel level, const char *msg); + +/* Append one key/value pair. A NULL value is logged as "". */ +void cbm_log_line_pair(cbm_log_line_t *line, const char *key, const char *val); + +/* Terminate the line and write it to the sink and/or stderr. */ +void cbm_log_line_end(cbm_log_line_t *line); + +#endif /* CBM_FOUNDATION_LOG_INTERNAL_H */ diff --git a/src/foundation/mem.c b/src/foundation/mem.c deleted file mode 100644 index 69bc8fd1..00000000 --- a/src/foundation/mem.c +++ /dev/null @@ -1,270 +0,0 @@ -/* - * mem.c — Unified memory management via mimalloc. - * - * Budget tracking based on actual RSS via mi_process_info(). - * When MI_OVERRIDE=0 (ASan builds), falls back to OS-specific - * RSS queries (task_info on macOS, /proc/self/statm on Linux, - * GetProcessMemoryInfo on Windows). - */ -#include "mem.h" -#include "platform.h" -#include "log.h" - -#include "foundation/constants.h" - -#define MAX_RAM_FRACTION 1.0 -#define DEFAULT_RAM_FRACTION 0.5 -#include -#include -#include - -#ifdef _WIN32 -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#include -#elif defined(__APPLE__) -#include -#else -#include -#endif - -/* ── Static state ─────────────────────────────────────────────── */ - -static size_t g_budget; /* budget in bytes */ -static atomic_int g_initialized; /* init guard */ -static atomic_int g_was_over; /* pressure hysteresis */ -/* 1 = the user set CBM_MEM_BUDGET_MB, 0 = RAM-fraction detection chose it. - * Post-extraction phases only hard-fail on an explicit budget: the detected - * default is a soft target that extraction already handles with spill. */ -static atomic_int g_budget_explicit; - -#define MB_DIVISOR ((size_t)(CBM_SZ_1K * CBM_SZ_1K)) - -/* ── OS fallback for RSS (ASan builds where MI_OVERRIDE=0) ──── */ - -static size_t os_rss(void) { -#ifdef _WIN32 - PROCESS_MEMORY_COUNTERS pmc; - if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) { - return (size_t)pmc.WorkingSetSize; - } - return 0; -#elif defined(__APPLE__) - struct mach_task_basic_info info = {0}; - mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; - if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &count) == - KERN_SUCCESS) { - return (size_t)info.resident_size; - } - return 0; -#else - FILE *f = fopen("/proc/self/statm", "r"); - if (!f) { - return 0; - } - unsigned long pages = 0; - unsigned long rss_pages = 0; - if (fscanf(f, "%lu %lu", &pages, &rss_pages) != 2) { - rss_pages = 0; - } - (void)fclose(f); - long ps = sysconf(_SC_PAGESIZE); - return rss_pages * (ps > 0 ? (size_t)ps : CBM_SZ_4K); -#endif -} - -/* ── Pressure logging (hysteresis) ────────────────────────────── */ - -static void check_pressure(size_t rss) { - if (g_budget == 0) { - return; - } - - bool over = rss > g_budget; - int was = atomic_load(&g_was_over); - - if (over && !was) { - atomic_store(&g_was_over, 1); - char rss_mb[CBM_SZ_32]; - char budget_mb[CBM_SZ_32]; - char pct_str[CBM_SZ_16]; - snprintf(rss_mb, sizeof(rss_mb), "%zu", rss / MB_DIVISOR); - snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR); - snprintf(pct_str, sizeof(pct_str), "%zu", - g_budget > 0 ? (rss * CBM_PERCENT) / g_budget : 0); - cbm_log_warn("mem.pressure.warn", "rss_mb", rss_mb, "budget_mb", budget_mb, "pct", pct_str); - } else if (!over && was) { - atomic_store(&g_was_over, 0); - char rss_mb[CBM_SZ_32]; - char budget_mb[CBM_SZ_32]; - char pct_str[CBM_SZ_16]; - snprintf(rss_mb, sizeof(rss_mb), "%zu", rss / MB_DIVISOR); - snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR); - snprintf(pct_str, sizeof(pct_str), "%zu", - g_budget > 0 ? (rss * CBM_PERCENT) / g_budget : 0); - cbm_log_info("mem.pressure.ok", "rss_mb", rss_mb, "budget_mb", budget_mb, "pct", pct_str); - } -} - -/* ── Public API ────────────────────────────────────────────────── */ - -#define RAM_FRACTION_DEFAULT 0.5 -#define RAM_FRACTION_16GB 0.25 -#define RAM_FRACTION_32GB 0.35 -#define RAM_BYTES_PER_GB (1024ULL * 1024 * 1024) - -double cbm_mem_ram_fraction_for_total(size_t total_ram_bytes) { - if (total_ram_bytes <= 16ULL * RAM_BYTES_PER_GB) { - return RAM_FRACTION_16GB; - } - if (total_ram_bytes <= 32ULL * RAM_BYTES_PER_GB) { - return RAM_FRACTION_32GB; - } - return RAM_FRACTION_DEFAULT; -} - -void cbm_mem_init(double ram_fraction) { - int expected = 0; - if (!atomic_compare_exchange_strong(&g_initialized, &expected, 1)) { - return; - } - - if (ram_fraction <= 0.0 || ram_fraction > MAX_RAM_FRACTION) { - ram_fraction = DEFAULT_RAM_FRACTION; - } - - /* Reduce upfront memory: don't eagerly commit arenas. - * Force decommit on purge (MADV_FREE_REUSABLE on macOS) so RSS - * drops immediately instead of staying high until memory pressure. */ - mi_option_set(mi_option_arena_eager_commit, 0); - mi_option_set(mi_option_purge_decommits, SKIP_ONE); - mi_option_set(mi_option_purge_delay, 0); /* immediate purge, no 1s delay */ - /* v3 (#832): reclaim abandoned pages on ANY thread's free (=1), restoring the - * v2 behaviour. mimalloc v3 defaults page_reclaim_on_free=0, so pages a worker - * thread abandons at exit are NOT reclaimed when the main thread later frees - * their blocks (and mi_collect cannot touch abandoned pages) — RSS then - * ratchets across repeated in-process index cycles. The supervised subprocess - * is the primary cure (the child returns 100% RSS on exit); this is the - * in-process fallback for any path that stays in-process (kill switch, - * spawn-fail degrade, embedders). */ - mi_option_set(mi_option_page_reclaim_on_free, 1); - - /* CBM_MEM_BUDGET_MB env override (memory analogue of CBM_WORKERS). - * Lets users cap the budget directly without an enclosing cgroup — - * useful on bare-metal hosts where cgroup memory limits are absent - * (#363). Explicit override > implicit RAM/cgroup detection. */ - char env_buf[CBM_SZ_32]; - if (cbm_safe_getenv("CBM_MEM_BUDGET_MB", env_buf, sizeof(env_buf), NULL) != NULL) { - /* Whole-string read: "512MB" or " 512" is refused, not read as 512. */ - long mb = 0; - if (!cbm_env_long("CBM_MEM_BUDGET_MB", &mb)) { - mb = 0; - } - if (mb > 0) { - g_budget = (size_t)mb * MB_DIVISOR; - atomic_store(&g_budget_explicit, 1); - char ovr_mb[CBM_SZ_32]; - snprintf(ovr_mb, sizeof(ovr_mb), "%ld", mb); - cbm_log_info("mem.init", "budget_mb", ovr_mb, "source", "CBM_MEM_BUDGET_MB"); - return; - } - cbm_log_warn("mem.budget.env.invalid", "value", env_buf, "fallback", "ram_fraction"); - } - - cbm_system_info_t info = cbm_system_info(); - g_budget = (size_t)((double)info.total_ram * ram_fraction); - - char budget_mb[CBM_SZ_32]; - char ram_mb[CBM_SZ_32]; - snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR); - snprintf(ram_mb, sizeof(ram_mb), "%zu", info.total_ram / MB_DIVISOR); - cbm_log_info("mem.init", "budget_mb", budget_mb, "total_ram_mb", ram_mb); -} - -size_t cbm_mem_rss(void) { -#if defined(__linux__) - /* Linux: mimalloc's _mi_prim_process_info() (vendored/mimalloc/src/prim/ - * unix/prim.c) never sets pinfo->current_rss on Linux — it only sets - * peak_rss (from getrusage's ru_maxrss). current_rss therefore keeps - * mi_process_info()'s default of pinfo.current_commit: mimalloc's OWN - * committed-page counter, which this project deliberately tunes low via - * mi_option_arena_eager_commit=0 + purge_decommits=1 + purge_delay=0 - * (cbm_mem_init) to reduce upfront memory. So on Linux "current_rss" is a - * low-biased mimalloc-internal metric, not true RSS: under concurrent - * large-file parsing it can read a few MB while real RSS is multiple GB, - * silently blinding cbm_mem_over_budget()'s backpressure to real memory - * pressure (small-but-nonzero, so the `current_rss > 0` guard below never - * catches it). os_rss() reads /proc/self/statm — authoritative OS RSS, - * unaffected by mimalloc's accounting — so it is the PRIMARY source on - * Linux, not a last-resort fallback. macOS/Windows are unaffected: - * mimalloc sets current_rss correctly there via task_info / - * GetProcessMemoryInfo. */ - size_t proc_rss = os_rss(); - if (proc_rss > 0) { - return proc_rss; - } - /* Extremely unlikely (/proc unavailable) — fall through to mimalloc. */ -#endif - size_t current_rss = 0; - size_t peak_rss = 0; - mi_process_info(NULL, NULL, NULL, ¤t_rss, &peak_rss, NULL, NULL, NULL); - if (current_rss > 0) { - return current_rss; - } - /* Fallback for ASan builds (MI_OVERRIDE=0) and any platform where - * mimalloc's current_rss is unavailable/zero. */ - return os_rss(); -} - -size_t cbm_mem_peak_rss(void) { - size_t peak_rss = 0; - mi_process_info(NULL, NULL, NULL, NULL, &peak_rss, NULL, NULL, NULL); - /* Peak RSS is by definition >= current RSS. On Linux cbm_mem_rss() reads the - * live /proc/self/statm value (page-granular), while mimalloc's peak_rss - * comes from getrusage's ru_maxrss (KB-granular, and it can lag the live - * statm read by a few pages). Reading the two sources independently lets a - * fresh current read momentarily exceed the reported peak, breaking the - * peak >= current invariant. Reconcile them: the true peak is at least the - * current RSS. (Not observable on macOS, where both come from mimalloc.) */ - size_t current = cbm_mem_rss(); - if (current > peak_rss) { - peak_rss = current; - } - if (peak_rss > 0) { - return peak_rss; - } - /* No OS fallback for peak — return current as best approximation */ - return os_rss(); -} - -size_t cbm_mem_budget(void) { - return g_budget; -} - -void cbm_mem_set_budget_for_tests(size_t bytes, bool explicit_budget) { - g_budget = bytes; - atomic_store(&g_budget_explicit, explicit_budget ? 1 : 0); -} - -bool cbm_mem_budget_explicit(void) { - return atomic_load(&g_budget_explicit) != 0; -} - -bool cbm_mem_over_budget(void) { - size_t rss = cbm_mem_rss(); - check_pressure(rss); - return rss > g_budget; -} - -size_t cbm_mem_worker_budget(int num_workers) { - if (num_workers <= 0) { - num_workers = SKIP_ONE; - } - return g_budget / (size_t)num_workers; -} - -void cbm_mem_collect(void) { - mi_collect(true); -} diff --git a/src/foundation/mem.zig b/src/foundation/mem.zig new file mode 100644 index 00000000..5afd8837 --- /dev/null +++ b/src/foundation/mem.zig @@ -0,0 +1,228 @@ +//! mem.zig — Unified memory management via mimalloc. +//! +//! Implements src/foundation/mem.h. Budget tracking based on actual RSS via +//! mi_process_info(). When MI_OVERRIDE=0 (ASan builds), falls back to +//! OS-specific RSS queries (task_info on macOS, /proc/self/statm on Linux, +//! GetProcessMemoryInfo on Windows). + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("interop.zig"); + +const is_windows = builtin.os.tag == .windows; +const is_apple = builtin.os.tag.isDarwin(); +const is_linux = builtin.os.tag == .linux; + +const MAX_RAM_FRACTION = 1.0; +const DEFAULT_RAM_FRACTION = 0.5; + +// ── Static state ───────────────────────────────────────────────── + +var g_budget: usize = 0; // budget in bytes +var g_initialized = std.atomic.Value(c_int).init(0); // init guard +var g_was_over = std.atomic.Value(c_int).init(0); // pressure hysteresis +// 1 = the user set CBM_MEM_BUDGET_MB, 0 = RAM-fraction detection chose it. +// Post-extraction phases only hard-fail on an explicit budget: the detected +// default is a soft target that extraction already handles with spill. +var g_budget_explicit = std.atomic.Value(c_int).init(0); + +const MB_DIVISOR: usize = 1024 * 1024; + +// ── OS fallback for RSS (ASan builds where MI_OVERRIDE=0) ──────── + +fn osRss() usize { + if (is_windows) { + var pmc: c.PROCESS_MEMORY_COUNTERS = undefined; + if (c.K32GetProcessMemoryInfo(c.GetCurrentProcess(), &pmc, @sizeOf(c.PROCESS_MEMORY_COUNTERS)) != 0) { + return pmc.WorkingSetSize; + } + return 0; + } else if (is_apple) { + var info = std.mem.zeroes(c.struct_mach_task_basic_info); + var count: c.mach_msg_type_number_t = @intCast(c.MACH_TASK_BASIC_INFO_COUNT); + if (c.task_info(c.mach_task_self(), c.MACH_TASK_BASIC_INFO, @ptrCast(&info), &count) == c.KERN_SUCCESS) { + return @intCast(info.resident_size); + } + return 0; + } else { + const f = c.fopen("/proc/self/statm", "r") orelse return 0; + var pages: c_ulong = 0; + var rss_pages: c_ulong = 0; + if (c.fscanf(f, "%lu %lu", &pages, &rss_pages) != 2) rss_pages = 0; + _ = c.fclose(f); + const ps = c.sysconf(c._SC_PAGESIZE); + return @as(usize, rss_pages) *% (if (ps > 0) @as(usize, @intCast(ps)) else 4096); + } +} + +// ── Pressure logging (hysteresis) ──────────────────────────────── + +fn checkPressure(rss: usize) void { + if (g_budget == 0) return; + const over = rss > g_budget; + const was = g_was_over.load(.seq_cst) != 0; + if (over == was) return; + + g_was_over.store(@intFromBool(over), .seq_cst); + var rss_mb: [32]u8 = undefined; + var budget_mb: [32]u8 = undefined; + var pct_str: [16]u8 = undefined; + _ = interop.bprint(&rss_mb, "{d}", .{rss / MB_DIVISOR}); + _ = interop.bprint(&budget_mb, "{d}", .{g_budget / MB_DIVISOR}); + _ = interop.bprint(&pct_str, "{d}", .{(rss *% 100) / g_budget}); + interop.log( + if (over) c.CBM_LOG_WARN else c.CBM_LOG_INFO, + if (over) "mem.pressure.warn" else "mem.pressure.ok", + .{ "rss_mb", cstr(&rss_mb), "budget_mb", cstr(&budget_mb), "pct", cstr(&pct_str) }, + ); +} + +fn cstr(buf: []const u8) interop.Str { + return @ptrCast(buf.ptr); +} + +// ── Public API ─────────────────────────────────────────────────── + +const RAM_FRACTION_DEFAULT = 0.5; +const RAM_FRACTION_16GB = 0.25; +const RAM_FRACTION_32GB = 0.35; +const RAM_BYTES_PER_GB: u64 = 1024 * 1024 * 1024; + +pub export fn cbm_mem_ram_fraction_for_total(total_ram_bytes: usize) f64 { + if (total_ram_bytes <= 16 * RAM_BYTES_PER_GB) return RAM_FRACTION_16GB; + if (total_ram_bytes <= 32 * RAM_BYTES_PER_GB) return RAM_FRACTION_32GB; + return RAM_FRACTION_DEFAULT; +} + +pub export fn cbm_mem_init(ram_fraction_in: f64) void { + if (g_initialized.cmpxchgStrong(0, 1, .seq_cst, .seq_cst) != null) return; + + var ram_fraction = ram_fraction_in; + // Written so that NaN keeps flowing through, as in C. + if (ram_fraction <= 0.0 or ram_fraction > MAX_RAM_FRACTION) { + ram_fraction = DEFAULT_RAM_FRACTION; + } + + // Reduce upfront memory: don't eagerly commit arenas. + // Force decommit on purge (MADV_FREE_REUSABLE on macOS) so RSS + // drops immediately instead of staying high until memory pressure. + c.mi_option_set(c.mi_option_arena_eager_commit, 0); + c.mi_option_set(c.mi_option_purge_decommits, 1); + c.mi_option_set(c.mi_option_purge_delay, 0); // immediate purge, no 1s delay + + // v3 (#832): reclaim abandoned pages on ANY thread's free (=1), restoring the + // v2 behaviour. mimalloc v3 defaults page_reclaim_on_free=0, so pages a worker + // thread abandons at exit are NOT reclaimed when the main thread later frees + // their blocks (and mi_collect cannot touch abandoned pages) — RSS then + // ratchets across repeated in-process index cycles. The supervised subprocess + // is the primary cure (the child returns 100% RSS on exit); this is the + // in-process fallback for any path that stays in-process (kill switch, + // spawn-fail degrade, embedders). + c.mi_option_set(c.mi_option_page_reclaim_on_free, 1); + + // CBM_MEM_BUDGET_MB env override (memory analogue of CBM_WORKERS). + // Lets users cap the budget directly without an enclosing cgroup — + // useful on bare-metal hosts where cgroup memory limits are absent + // (#363). Explicit override > implicit RAM/cgroup detection. + var env_buf: [32]u8 = undefined; + if (c.cbm_safe_getenv("CBM_MEM_BUDGET_MB", &env_buf, env_buf.len, null) != null) { + // Whole-string read: "512MB" or " 512" is refused, not read as 512. + var mb: c_long = 0; + if (!c.cbm_env_long("CBM_MEM_BUDGET_MB", &mb)) mb = 0; + if (mb > 0) { + g_budget = @as(usize, @intCast(mb)) *% MB_DIVISOR; + g_budget_explicit.store(1, .seq_cst); + var ovr_mb: [32]u8 = undefined; + _ = interop.bprint(&ovr_mb, "{d}", .{mb}); + interop.logInfo("mem.init", .{ "budget_mb", cstr(&ovr_mb), "source", "CBM_MEM_BUDGET_MB" }); + return; + } + interop.logWarn("mem.budget.env.invalid", .{ "value", cstr(&env_buf), "fallback", "ram_fraction" }); + } + + const info = c.cbm_system_info(); + g_budget = @intFromFloat(@as(f64, @floatFromInt(info.total_ram)) * ram_fraction); + + var budget_mb: [32]u8 = undefined; + var ram_mb: [32]u8 = undefined; + _ = interop.bprint(&budget_mb, "{d}", .{g_budget / MB_DIVISOR}); + _ = interop.bprint(&ram_mb, "{d}", .{info.total_ram / MB_DIVISOR}); + interop.logInfo("mem.init", .{ "budget_mb", cstr(&budget_mb), "total_ram_mb", cstr(&ram_mb) }); +} + +pub export fn cbm_mem_rss() usize { + if (is_linux) { + // Linux: mimalloc's _mi_prim_process_info() (vendored/mimalloc/src/prim/ + // unix/prim.c) never sets pinfo->current_rss on Linux — it only sets + // peak_rss (from getrusage's ru_maxrss). current_rss therefore keeps + // mi_process_info()'s default of pinfo.current_commit: mimalloc's OWN + // committed-page counter, which this project deliberately tunes low via + // mi_option_arena_eager_commit=0 + purge_decommits=1 + purge_delay=0 + // (cbm_mem_init) to reduce upfront memory. So on Linux "current_rss" is a + // low-biased mimalloc-internal metric, not true RSS: under concurrent + // large-file parsing it can read a few MB while real RSS is multiple GB, + // silently blinding cbm_mem_over_budget()'s backpressure to real memory + // pressure (small-but-nonzero, so the `current_rss > 0` guard below never + // catches it). osRss() reads /proc/self/statm — authoritative OS RSS, + // unaffected by mimalloc's accounting — so it is the PRIMARY source on + // Linux, not a last-resort fallback. macOS/Windows are unaffected: + // mimalloc sets current_rss correctly there via task_info / + // GetProcessMemoryInfo. + const proc_rss = osRss(); + if (proc_rss > 0) return proc_rss; + // Extremely unlikely (/proc unavailable) — fall through to mimalloc. + } + var current_rss: usize = 0; + var peak_rss: usize = 0; + c.mi_process_info(null, null, null, ¤t_rss, &peak_rss, null, null, null); + if (current_rss > 0) return current_rss; + // Fallback for ASan builds (MI_OVERRIDE=0) and any platform where + // mimalloc's current_rss is unavailable/zero. + return osRss(); +} + +pub export fn cbm_mem_peak_rss() usize { + var peak_rss: usize = 0; + c.mi_process_info(null, null, null, null, &peak_rss, null, null, null); + // Peak RSS is by definition >= current RSS. On Linux cbm_mem_rss() reads the + // live /proc/self/statm value (page-granular), while mimalloc's peak_rss + // comes from getrusage's ru_maxrss (KB-granular, and it can lag the live + // statm read by a few pages). Reading the two sources independently lets a + // fresh current read momentarily exceed the reported peak, breaking the + // peak >= current invariant. Reconcile them: the true peak is at least the + // current RSS. (Not observable on macOS, where both come from mimalloc.) + const current = cbm_mem_rss(); + if (current > peak_rss) peak_rss = current; + if (peak_rss > 0) return peak_rss; + // No OS fallback for peak — return current as best approximation + return osRss(); +} + +pub export fn cbm_mem_budget() usize { + return g_budget; +} + +pub export fn cbm_mem_set_budget_for_tests(bytes: usize, explicit_budget: bool) void { + g_budget = bytes; + g_budget_explicit.store(@intFromBool(explicit_budget), .seq_cst); +} + +pub export fn cbm_mem_budget_explicit() bool { + return g_budget_explicit.load(.seq_cst) != 0; +} + +pub export fn cbm_mem_over_budget() bool { + const rss = cbm_mem_rss(); + checkPressure(rss); + return rss > g_budget; +} + +pub export fn cbm_mem_worker_budget(num_workers: c_int) usize { + const n: usize = if (num_workers <= 0) 1 else @intCast(num_workers); + return g_budget / n; +} + +pub export fn cbm_mem_collect() void { + c.mi_collect(true); +} diff --git a/src/foundation/platform.c b/src/foundation/platform.c deleted file mode 100644 index e1ed1fd4..00000000 --- a/src/foundation/platform.c +++ /dev/null @@ -1,447 +0,0 @@ -/* - * platform.c — OS abstraction implementations. - * - * macOS, Linux, and Windows. Platform-specific code behind #ifdef guards. - */ -#include "platform.h" - -#include "foundation/constants.h" -#include -#include -#include -#include -#include -#include - -/* 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 - * project key ("c-..." vs "C-...") and, on a case-insensitive FS, a colliding - * cache file that clobbers the good index (#227/#367/#394). Folding to a single - * canonical form here — at the one path-normalization choke point — keeps the - * project key, cache file and integrity check consistent regardless of case. - * Only the strict drive-root form `X:/` or bare `X:` is touched, so ordinary - * POSIX paths (which never start that way) are unaffected. */ -static void cbm_canonicalize_drive(char *path) { - if (path && path[0] >= 'a' && path[0] <= 'z' && path[1] == ':' && - (path[2] == '/' || path[2] == '\0')) { - path[0] = (char)(path[0] - 'a' + 'A'); - } -} - -#ifdef _WIN32 - -/* ── Windows implementation ────────────────────────────────── */ - -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#include -#include -#include "foundation/win_utf8.h" - -void *cbm_mmap_read(const char *path, size_t *out_size) { - if (!path || !out_size) { - return NULL; - } - *out_size = 0; - - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return NULL; - } - - HANDLE file = CreateFileW(wpath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, NULL); - if (file == INVALID_HANDLE_VALUE) { - free(wpath); - return NULL; - } - LARGE_INTEGER sz; - if (!GetFileSizeEx(file, &sz) || sz.QuadPart == 0) { - CloseHandle(file); - free(wpath); - return NULL; - } - HANDLE mapping = CreateFileMappingW(file, NULL, PAGE_READONLY, 0, 0, NULL); - if (!mapping) { - CloseHandle(file); - free(wpath); - return NULL; - } - void *addr = MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, 0); - CloseHandle(mapping); - CloseHandle(file); - free(wpath); - if (!addr) { - return NULL; - } - *out_size = (size_t)sz.QuadPart; - return addr; -} - -void cbm_munmap(void *addr, size_t size) { - (void)size; - if (addr) { - UnmapViewOfFile(addr); - } -} - -uint64_t cbm_now_ns(void) { - LARGE_INTEGER freq, count; - QueryPerformanceFrequency(&freq); - QueryPerformanceCounter(&count); - return (uint64_t)count.QuadPart * 1000000000ULL / (uint64_t)freq.QuadPart; -} - -#define CBM_USEC_PER_SEC 1000000ULL - -uint64_t cbm_now_ms(void) { - return cbm_now_ns() / CBM_USEC_PER_SEC; -} - -int cbm_nprocs(void) { - SYSTEM_INFO si; - GetSystemInfo(&si); - return (int)si.dwNumberOfProcessors > 0 ? (int)si.dwNumberOfProcessors : 1; -} - -bool cbm_file_exists(const char *path) { - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return false; - } - DWORD attr = GetFileAttributesW(wpath); - free(wpath); - return attr != INVALID_FILE_ATTRIBUTES; -} - -bool cbm_is_dir(const char *path) { - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return false; - } - DWORD attr = GetFileAttributesW(wpath); - free(wpath); - return attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_DIRECTORY); -} - -int64_t cbm_file_size(const char *path) { - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return CBM_NOT_FOUND; - } - WIN32_FILE_ATTRIBUTE_DATA fad; - BOOL ok = GetFileAttributesExW(wpath, GetFileExInfoStandard, &fad); - free(wpath); - if (!ok) { - return CBM_NOT_FOUND; - } - LARGE_INTEGER sz; - sz.HighPart = (LONG)fad.nFileSizeHigh; // cppcheck-suppress unreadVariable - sz.LowPart = fad.nFileSizeLow; // cppcheck-suppress unreadVariable - return (int64_t)sz.QuadPart; -} - -char *cbm_normalize_path_sep(char *path) { - if (path) { - for (char *p = path; *p; p++) { - if (*p == '\\') { - *p = '/'; - } - } - cbm_canonicalize_drive(path); - } - return path; -} - -#else /* POSIX (macOS + Linux) */ - -/* ── POSIX implementation ────────────────────────────────── */ - -#include -#include -#include -#include - -#ifdef __APPLE__ -#include -#include -#else -#include -#endif - -/* ── Memory mapping ──────────────────────────── */ - -void *cbm_mmap_read(const char *path, size_t *out_size) { - if (!path || !out_size) { - return NULL; - } - *out_size = 0; - -#ifndef O_CLOEXEC -#define O_CLOEXEC 0 -#endif - int fd = open(path, O_RDONLY | O_CLOEXEC); - if (fd < 0) { - return NULL; - } - - struct stat st; - if (fstat(fd, &st) != 0 || st.st_size == 0) { - close(fd); - return NULL; - } - - void *addr = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); - close(fd); - - if (addr == MAP_FAILED) { - return NULL; - } - *out_size = (size_t)st.st_size; - return addr; -} - -void cbm_munmap(void *addr, size_t size) { - if (addr && size > 0) { - munmap(addr, size); - } -} - -/* ── Timing ───────────────────────────── */ - -#ifdef __APPLE__ -static mach_timebase_info_data_t timebase_info; -static int timebase_init = 0; - -uint64_t cbm_now_ns(void) { - if (!timebase_init) { - mach_timebase_info(&timebase_info); - timebase_init = SKIP_ONE; - } - uint64_t ticks = mach_absolute_time(); - return ticks * timebase_info.numer / timebase_info.denom; -} -#else -uint64_t cbm_now_ns(void) { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec; -} -#endif - -#define CBM_USEC_PER_SEC 1000000ULL - -uint64_t cbm_now_ms(void) { - return cbm_now_ns() / CBM_USEC_PER_SEC; -} - -/* ── System info ───────────────────────────── */ - -int cbm_nprocs(void) { -#ifdef __APPLE__ - int ncpu = 0; - size_t len = sizeof(ncpu); - if (sysctlbyname("hw.ncpu", &ncpu, &len, NULL, 0) == 0 && ncpu > 0) { - return ncpu; - } - enum { FILE_EXISTS = 1 }; - return FILE_EXISTS; -#else - long n = sysconf(_SC_NPROCESSORS_ONLN); - return n > 0 ? (int)n : 1; -#endif -} - -/* ── File system ──────────────────────────── */ - -bool cbm_file_exists(const char *path) { - struct stat st; - return stat(path, &st) == 0; -} - -bool cbm_is_dir(const char *path) { - struct stat st; - return stat(path, &st) == 0 && S_ISDIR(st.st_mode); -} - -int64_t cbm_file_size(const char *path) { - struct stat st; - if (stat(path, &st) != 0) { - return CBM_NOT_FOUND; - } - return (int64_t)st.st_size; -} - -char *cbm_normalize_path_sep(char *path) { - /* Normalize on ALL platforms — backslash paths can arrive via stored - * data, cross-platform DB files, or Windows-style arguments. */ - if (path) { - for (char *p = path; *p; p++) { - if (*p == '\\') { - *p = '/'; - } - } - cbm_canonicalize_drive(path); - } - return path; -} - -#endif /* _WIN32 */ - -/* ── Environment variables ──────────────────────────── */ - -/* Thread-safe getenv: iterates environ directly instead of calling getenv(). - * getenv() is flagged by concurrency-mt-unsafe because the returned pointer - * can be invalidated by setenv/putenv in another thread. We copy to a - * caller-owned buffer immediately. */ -#ifdef _WIN32 -#include -#define CBM_ENVIRON _environ -#elif defined(__APPLE__) -#include -#define CBM_ENVIRON (*_NSGetEnviron()) -#else -extern char **environ; -#define CBM_ENVIRON environ -#endif - -const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const char *fallback) { - char **env = CBM_ENVIRON; - if (env) { - size_t nlen = strlen(name); - for (; *env; env++) { - if (strncmp(*env, name, nlen) == 0 && (*env)[nlen] == '=') { - snprintf(buf, buf_sz, "%s", *env + nlen + SKIP_ONE); - return buf; - } - } - } - if (fallback) { - snprintf(buf, buf_sz, "%s", fallback); - return buf; - } - buf[0] = '\0'; - return NULL; -} - -bool cbm_env_long(const char *name, long *out) { - if (!out) { - return false; - } - char raw[CBM_SZ_64] = {0}; - if (!cbm_safe_getenv(name, raw, sizeof(raw), NULL) || !raw[0]) { - return false; - } - /* strtol skips leading blanks, so " 5" would read as 5; a blank in front - * of a setting is a slip, not a number. */ - if (isspace((unsigned char)raw[0])) { - return false; - } - char *end = NULL; - errno = 0; - long value = strtol(raw, &end, CBM_DECIMAL_BASE); - if (errno != 0 || !end || end == raw || *end != '\0') { - return false; - } - *out = value; - return true; -} - -/* ── Home directory (cross-platform) ───────────────────── */ - -const char *cbm_get_home_dir(void) { - static char buf[CBM_SZ_1K]; - char tmp[CBM_SZ_256] = ""; - - cbm_safe_getenv("HOME", tmp, sizeof(tmp), NULL); - if (tmp[0]) { - snprintf(buf, sizeof(buf), "%s", tmp); - cbm_normalize_path_sep(buf); - return buf; - } - - cbm_safe_getenv("USERPROFILE", tmp, sizeof(tmp), NULL); - if (tmp[0]) { - snprintf(buf, sizeof(buf), "%s", tmp); - cbm_normalize_path_sep(buf); - return buf; - } - return NULL; -} - -/* ── App config directories (cross-platform) ────────── */ - -const char *cbm_app_config_dir(void) { - static char buf[CBM_SZ_1K]; - char tmp[CBM_SZ_256] = ""; -#ifdef _WIN32 - cbm_safe_getenv("APPDATA", tmp, sizeof(tmp), NULL); - if (tmp[0]) { - snprintf(buf, sizeof(buf), "%s", tmp); - cbm_normalize_path_sep(buf); - return buf; - } - const char *home = cbm_get_home_dir(); - if (home) { - snprintf(buf, sizeof(buf), "%s/AppData/Roaming", home); - return buf; - } - return NULL; -#else - /* Linux: XDG_CONFIG_HOME or ~/.config */ - cbm_safe_getenv("XDG_CONFIG_HOME", tmp, sizeof(tmp), NULL); - if (tmp[0]) { - snprintf(buf, sizeof(buf), "%s", tmp); - return buf; - } - const char *home = cbm_get_home_dir(); - if (home) { - snprintf(buf, sizeof(buf), "%s/.config", home); - return buf; - } - return NULL; -#endif /* _WIN32 */ -} - -const char *cbm_app_local_dir(void) { -#ifdef _WIN32 - static char buf[CBM_SZ_1K]; - char tmp[CBM_SZ_256] = ""; - cbm_safe_getenv("LOCALAPPDATA", tmp, sizeof(tmp), NULL); - if (tmp[0]) { - snprintf(buf, sizeof(buf), "%s", tmp); - cbm_normalize_path_sep(buf); - return buf; - } - const char *home = cbm_get_home_dir(); - if (home) { - snprintf(buf, sizeof(buf), "%s/AppData/Local", home); - return buf; - } - return NULL; -#else - return cbm_app_config_dir(); -#endif -} - -/* ── Cache directory ────────────────────────── */ - -const char *cbm_resolve_cache_dir(void) { - static char buf[CBM_SZ_1K]; - char tmp[CBM_SZ_256] = ""; - cbm_safe_getenv("CBM_CACHE_DIR", tmp, sizeof(tmp), NULL); - if (tmp[0]) { - snprintf(buf, sizeof(buf), "%s", tmp); - cbm_normalize_path_sep(buf); - return buf; - } - const char *home = cbm_get_home_dir(); - if (!home) { - return NULL; - } - snprintf(buf, sizeof(buf), "%s/.cache/codebase-memory-mcp", home); - return buf; -} diff --git a/src/foundation/platform.zig b/src/foundation/platform.zig new file mode 100644 index 00000000..e819d932 --- /dev/null +++ b/src/foundation/platform.zig @@ -0,0 +1,320 @@ +//! platform.zig — OS abstraction implementations. +//! +//! Implements src/foundation/platform.h (except cbm_system_info and +//! cbm_default_worker_count, which live in system_info.zig). macOS, Linux, +//! and Windows; platform-specific code sits behind comptime branches. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("interop.zig"); + +const is_windows = builtin.os.tag == .windows; +const is_apple = builtin.os.tag.isDarwin(); + +/// 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 +/// project key ("c-..." vs "C-...") and, on a case-insensitive FS, a colliding +/// cache file that clobbers the good index (#227/#367/#394). Folding to a single +/// canonical form here — at the one path-normalization choke point — keeps the +/// project key, cache file and integrity check consistent regardless of case. +/// Only the strict drive-root form `X:/` or bare `X:` is touched, so ordinary +/// POSIX paths (which never start that way) are unaffected. +fn canonicalizeDrive(path: [*:0]u8) void { + if (path[0] >= 'a' and path[0] <= 'z' and path[1] == ':' and (path[2] == '/' or path[2] == 0)) { + path[0] = path[0] - 'a' + 'A'; + } +} + +// Win32 constants whose macros translate-c cannot evaluate. +const GENERIC_READ: c_uint = 0x80000000; +const FILE_MAP_READ: c_uint = 0x0004; +const INVALID_FILE_ATTRIBUTES: c_uint = 0xFFFFFFFF; + +// ── Memory mapping ─────────────────────────────────────────────── + +pub export fn cbm_mmap_read(path: ?[*:0]const u8, out_size: ?*usize) ?*anyopaque { + const p = path orelse return null; + const size_out = out_size orelse return null; + size_out.* = 0; + + if (is_windows) { + const wpath = c.cbm_utf8_to_wide(p) orelse return null; + defer c.free(wpath); + const file = c.CreateFileW(wpath, GENERIC_READ, c.FILE_SHARE_READ, null, c.OPEN_EXISTING, c.FILE_ATTRIBUTE_NORMAL, null); + if (file == interop.INVALID_HANDLE_VALUE) return null; + defer _ = c.CloseHandle(file); + + var sz: c.LARGE_INTEGER = undefined; + if (c.GetFileSizeEx(file, &sz) == 0 or sz.QuadPart == 0) return null; + + const mapping = c.CreateFileMappingW(file, null, c.PAGE_READONLY, 0, 0, null) orelse return null; + const addr = c.MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, 0); + _ = c.CloseHandle(mapping); + if (addr == null) return null; + size_out.* = @intCast(sz.QuadPart); + return addr; + } else { + const fd = c.open(p, c.O_RDONLY | c.O_CLOEXEC); + if (fd < 0) return null; + + var st: c.struct_stat = undefined; + if (c.fstat(fd, &st) != 0 or st.st_size == 0) { + _ = c.close(fd); + return null; + } + + const len: usize = @intCast(st.st_size); + const addr = c.mmap(null, len, c.PROT_READ, c.MAP_PRIVATE, fd, 0); + _ = c.close(fd); + + if (@intFromPtr(addr) == std.math.maxInt(usize)) return null; // MAP_FAILED + size_out.* = len; + return addr; + } +} + +pub export fn cbm_munmap(addr: ?*anyopaque, size: usize) void { + if (is_windows) { + if (addr != null) _ = c.UnmapViewOfFile(addr); + } else { + if (addr != null and size > 0) _ = c.munmap(addr, size); + } +} + +// ── Timing ─────────────────────────────────────────────────────── + +const apple_clock = struct { + var timebase_info: c.mach_timebase_info_data_t = undefined; + var timebase_init: bool = false; +}; + +pub export fn cbm_now_ns() u64 { + if (is_windows) { + var freq: c.LARGE_INTEGER = undefined; + var count: c.LARGE_INTEGER = undefined; + _ = c.QueryPerformanceFrequency(&freq); + _ = c.QueryPerformanceCounter(&count); + return @as(u64, @bitCast(count.QuadPart)) *% 1000000000 / @as(u64, @bitCast(freq.QuadPart)); + } else if (is_apple) { + if (!apple_clock.timebase_init) { + _ = c.mach_timebase_info(&apple_clock.timebase_info); + apple_clock.timebase_init = true; + } + const ticks = c.mach_absolute_time(); + return ticks *% apple_clock.timebase_info.numer / apple_clock.timebase_info.denom; + } else { + var ts: c.struct_timespec = undefined; + _ = c.clock_gettime(c.CLOCK_MONOTONIC, &ts); + return @as(u64, @bitCast(@as(i64, ts.tv_sec))) *% 1000000000 +% @as(u64, @bitCast(@as(i64, ts.tv_nsec))); + } +} + +const CBM_USEC_PER_SEC: u64 = 1000000; + +pub export fn cbm_now_ms() u64 { + return cbm_now_ns() / CBM_USEC_PER_SEC; +} + +// ── System info ────────────────────────────────────────────────── + +pub export fn cbm_nprocs() c_int { + if (is_windows) { + var si: c.SYSTEM_INFO = undefined; + c.GetSystemInfo(&si); + const n: c_int = @bitCast(si.dwNumberOfProcessors); + return if (n > 0) n else 1; + } else if (is_apple) { + var ncpu: c_int = 0; + var len: usize = @sizeOf(c_int); + if (c.sysctlbyname("hw.ncpu", &ncpu, &len, null, 0) == 0 and ncpu > 0) return ncpu; + return 1; + } else { + const n = c.sysconf(c._SC_NPROCESSORS_ONLN); + return if (n > 0) @truncate(n) else 1; + } +} + +// ── File system ────────────────────────────────────────────────── + +/// GetFileAttributesW for a UTF-8 path; INVALID_FILE_ATTRIBUTES on failure. +fn winFileAttributes(path: ?[*:0]const u8) c_uint { + const wpath = c.cbm_utf8_to_wide(path) orelse return INVALID_FILE_ATTRIBUTES; + defer c.free(wpath); + return c.GetFileAttributesW(wpath); +} + +pub export fn cbm_file_exists(path: ?[*:0]const u8) bool { + if (is_windows) { + return winFileAttributes(path) != INVALID_FILE_ATTRIBUTES; + } else { + var st: c.struct_stat = undefined; + return c.stat(path, &st) == 0; + } +} + +pub export fn cbm_is_dir(path: ?[*:0]const u8) bool { + if (is_windows) { + const attr = winFileAttributes(path); + return attr != INVALID_FILE_ATTRIBUTES and (attr & c.FILE_ATTRIBUTE_DIRECTORY) != 0; + } else { + var st: c.struct_stat = undefined; + return c.stat(path, &st) == 0 and (st.st_mode & c.S_IFMT) == c.S_IFDIR; + } +} + +pub export fn cbm_file_size(path: ?[*:0]const u8) i64 { + if (is_windows) { + const wpath = c.cbm_utf8_to_wide(path) orelse return c.CBM_NOT_FOUND; + var fad: c.WIN32_FILE_ATTRIBUTE_DATA = undefined; + const ok = c.GetFileAttributesExW(wpath, c.GetFileExInfoStandard, &fad); + c.free(wpath); + if (ok == 0) return c.CBM_NOT_FOUND; + return @bitCast((@as(u64, fad.nFileSizeHigh) << 32) | fad.nFileSizeLow); + } else { + var st: c.struct_stat = undefined; + if (c.stat(path, &st) != 0) return c.CBM_NOT_FOUND; + return st.st_size; + } +} + +pub export fn cbm_normalize_path_sep(path: ?[*:0]u8) ?[*:0]u8 { + // Normalize on ALL platforms — backslash paths can arrive via stored + // data, cross-platform DB files, or Windows-style arguments. + if (path) |p| { + var i: usize = 0; + while (p[i] != 0) : (i += 1) { + if (p[i] == '\\') p[i] = '/'; + } + canonicalizeDrive(p); + } + return path; +} + +// ── Environment variables ──────────────────────────────────────── + +/// Thread-safe getenv: iterates environ directly instead of calling getenv(). +/// getenv() is flagged by concurrency-mt-unsafe because the returned pointer +/// can be invalidated by setenv/putenv in another thread. We copy to a +/// caller-owned buffer immediately. +pub export fn cbm_safe_getenv(name: [*:0]const u8, buf: [*]u8, buf_sz: usize, fallback: ?[*:0]const u8) ?[*:0]const u8 { + if (interop.environ()) |environ| { + const key = std.mem.span(name); + var env = environ; + while (env[0]) |entry| : (env += 1) { + if (c.strncmp(entry, name, key.len) == 0 and entry[key.len] == '=') { + _ = c.snprintf(buf, buf_sz, "%s", entry + key.len + 1); + return @ptrCast(buf); + } + } + } + if (fallback) |fb| { + _ = c.snprintf(buf, buf_sz, "%s", fb); + return @ptrCast(buf); + } + buf[0] = 0; + return null; +} + +pub export fn cbm_env_long(name: [*:0]const u8, out: ?*c_long) bool { + const result = out orelse return false; + var raw: [64]u8 = @splat(0); + if (cbm_safe_getenv(name, &raw, raw.len, null) == null or raw[0] == 0) return false; + // strtol skips leading blanks, so " 5" would read as 5; a blank in front + // of a setting is a slip, not a number. + if (c.isspace(raw[0]) != 0) return false; + var end: [*c]u8 = null; + interop.setErrno(0); + const value = c.strtol(&raw, &end, 10); + if (interop.errno() != 0 or end == null or end == @as([*c]u8, &raw) or end[0] != 0) return false; + result.* = value; + return true; +} + +/// Reads `name` into a 256-byte buffer the way the C callers do: the text when +/// the variable is set and non-empty, else null. +fn envSmall(tmp: *[256]u8, name: [*:0]const u8) ?[*:0]const u8 { + tmp[0] = 0; + _ = cbm_safe_getenv(name, tmp, tmp.len, null); + return if (tmp[0] != 0) @ptrCast(tmp) else null; +} + +// ── Home directory (cross-platform) ────────────────────────────── + +pub export fn cbm_get_home_dir() ?[*:0]const u8 { + const S = struct { + var buf: [1024]u8 = @splat(0); + }; + var tmp: [256]u8 = undefined; + for ([_][*:0]const u8{ "HOME", "USERPROFILE" }) |name| { + if (envSmall(&tmp, name)) |value| { + _ = c.snprintf(&S.buf, S.buf.len, "%s", value); + _ = cbm_normalize_path_sep(@ptrCast(&S.buf)); + return @ptrCast(&S.buf); + } + } + return null; +} + +// ── App config directories (cross-platform) ────────────────────── + +pub export fn cbm_app_config_dir() ?[*:0]const u8 { + const S = struct { + var buf: [1024]u8 = @splat(0); + }; + var tmp: [256]u8 = undefined; + if (is_windows) { + if (envSmall(&tmp, "APPDATA")) |value| { + _ = c.snprintf(&S.buf, S.buf.len, "%s", value); + _ = cbm_normalize_path_sep(@ptrCast(&S.buf)); + return @ptrCast(&S.buf); + } + const home = cbm_get_home_dir() orelse return null; + _ = c.snprintf(&S.buf, S.buf.len, "%s/AppData/Roaming", home); + return @ptrCast(&S.buf); + } else { + // Linux: XDG_CONFIG_HOME or ~/.config + if (envSmall(&tmp, "XDG_CONFIG_HOME")) |value| { + _ = c.snprintf(&S.buf, S.buf.len, "%s", value); + return @ptrCast(&S.buf); + } + const home = cbm_get_home_dir() orelse return null; + _ = c.snprintf(&S.buf, S.buf.len, "%s/.config", home); + return @ptrCast(&S.buf); + } +} + +pub export fn cbm_app_local_dir() ?[*:0]const u8 { + if (!is_windows) return cbm_app_config_dir(); + + const S = struct { + var buf: [1024]u8 = @splat(0); + }; + var tmp: [256]u8 = undefined; + if (envSmall(&tmp, "LOCALAPPDATA")) |value| { + _ = c.snprintf(&S.buf, S.buf.len, "%s", value); + _ = cbm_normalize_path_sep(@ptrCast(&S.buf)); + return @ptrCast(&S.buf); + } + const home = cbm_get_home_dir() orelse return null; + _ = c.snprintf(&S.buf, S.buf.len, "%s/AppData/Local", home); + return @ptrCast(&S.buf); +} + +// ── Cache directory ────────────────────────────────────────────── + +pub export fn cbm_resolve_cache_dir() ?[*:0]const u8 { + const S = struct { + var buf: [1024]u8 = @splat(0); + }; + var tmp: [256]u8 = undefined; + if (envSmall(&tmp, "CBM_CACHE_DIR")) |value| { + _ = c.snprintf(&S.buf, S.buf.len, "%s", value); + _ = cbm_normalize_path_sep(@ptrCast(&S.buf)); + return @ptrCast(&S.buf); + } + const home = cbm_get_home_dir() orelse return null; + _ = c.snprintf(&S.buf, S.buf.len, "%s/.cache/codebase-memory-mcp", home); + return @ptrCast(&S.buf); +} diff --git a/src/foundation/profile.c b/src/foundation/profile.c deleted file mode 100644 index 174a1bcc..00000000 --- a/src/foundation/profile.c +++ /dev/null @@ -1,66 +0,0 @@ -/* - * profile.c — Activatable profiling implementation. - */ -#include "foundation/profile.h" -#include "foundation/log.h" -#include "foundation/compat.h" - -#include -#include -#include - -enum { - PROF_BUF_LEN = 32, - PROF_NS_PER_US = 1000, - PROF_US_PER_MS = 1000, - PROF_US_PER_SEC = 1000000, -}; -#define PROF_US_PER_SEC_D 1000000.0 - -bool cbm_profile_active = false; - -void cbm_profile_init(void) { - const char *env = getenv("CBM_PROFILE"); - if (env && env[0] != '\0' && env[0] != '0') { - cbm_profile_active = true; - } -} - -void cbm_profile_enable(void) { - cbm_profile_active = true; -} - -void cbm_profile_now(struct timespec *ts) { - cbm_clock_gettime(CLOCK_MONOTONIC, ts); -} - -void cbm_profile_log_elapsed(const char *phase, const char *sub, const struct timespec *start, - long items) { - struct timespec now; - cbm_clock_gettime(CLOCK_MONOTONIC, &now); - - long us = ((long)(now.tv_sec - start->tv_sec) * PROF_US_PER_SEC) + - ((now.tv_nsec - start->tv_nsec) / PROF_NS_PER_US); - long ms = us / PROF_US_PER_MS; - - char ms_buf[PROF_BUF_LEN]; - char us_buf[PROF_BUF_LEN]; - char items_buf[PROF_BUF_LEN]; - snprintf(ms_buf, sizeof(ms_buf), "%ld", ms); - snprintf(us_buf, sizeof(us_buf), "%ld", us); - - if (items > 0 && us > 0) { - long rate = (long)((double)items * PROF_US_PER_SEC_D / (double)us); - char rate_buf[PROF_BUF_LEN]; - snprintf(items_buf, sizeof(items_buf), "%ld", items); - snprintf(rate_buf, sizeof(rate_buf), "%ld", rate); - cbm_log_info("prof", "phase", phase, "sub", sub, "ms", ms_buf, "us", us_buf, "items", - items_buf, "rate_per_s", rate_buf); - } else if (items > 0) { - snprintf(items_buf, sizeof(items_buf), "%ld", items); - cbm_log_info("prof", "phase", phase, "sub", sub, "ms", ms_buf, "us", us_buf, "items", - items_buf); - } else { - cbm_log_info("prof", "phase", phase, "sub", sub, "ms", ms_buf, "us", us_buf); - } -} diff --git a/src/foundation/profile.zig b/src/foundation/profile.zig new file mode 100644 index 00000000..846f6ed2 --- /dev/null +++ b/src/foundation/profile.zig @@ -0,0 +1,60 @@ +//! profile.zig — Activatable profiling implementation. +//! +//! Implements src/foundation/profile.h. + +const c = @import("c"); +const interop = @import("interop.zig"); + +const PROF_BUF_LEN = 32; +const PROF_NS_PER_US = 1000; +const PROF_US_PER_MS = 1000; +const PROF_US_PER_SEC = 1000000; + +/// Runtime-active flag, read from C through the CBM_PROF_* macros. +pub export var cbm_profile_active: bool = false; + +pub export fn cbm_profile_init() void { + const env: [*c]const u8 = c.getenv("CBM_PROFILE"); + if (env != null and env[0] != 0 and env[0] != '0') cbm_profile_active = true; +} + +pub export fn cbm_profile_enable() void { + cbm_profile_active = true; +} + +pub export fn cbm_profile_now(ts: *c.struct_timespec) void { + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, ts); +} + +fn fmtLong(buf: *[PROF_BUF_LEN]u8, v: c_long) interop.Str { + _ = interop.bprint(buf, "{d}", .{v}); + return @ptrCast(buf); +} + +pub export fn cbm_profile_log_elapsed(phase: ?[*:0]const u8, sub: ?[*:0]const u8, start: *const c.struct_timespec, items: c_long) void { + var now: c.struct_timespec = undefined; + _ = c.cbm_clock_gettime(c.CLOCK_MONOTONIC, &now); + const sec: c_long = @truncate(@as(i64, now.tv_sec - start.tv_sec)); + const nsec: c_long = @truncate(@as(i64, now.tv_nsec - start.tv_nsec)); + const us: c_long = sec *% PROF_US_PER_SEC +% @divTrunc(nsec, PROF_NS_PER_US); + const ms: c_long = @divTrunc(us, PROF_US_PER_MS); + + var ms_buf: [PROF_BUF_LEN]u8 = undefined; + var us_buf: [PROF_BUF_LEN]u8 = undefined; + var items_buf: [PROF_BUF_LEN]u8 = undefined; + const ms_s = fmtLong(&ms_buf, ms); + const us_s = fmtLong(&us_buf, us); + + if (items > 0 and us > 0) { + const rate: c_long = @intFromFloat(@as(f64, @floatFromInt(items)) * 1000000.0 / @as(f64, @floatFromInt(us))); + var rate_buf: [PROF_BUF_LEN]u8 = undefined; + interop.logInfo("prof", .{ + "phase", phase, "sub", sub, "ms", ms_s, "us", us_s, + "items", fmtLong(&items_buf, items), "rate_per_s", fmtLong(&rate_buf, rate), + }); + } else if (items > 0) { + interop.logInfo("prof", .{ "phase", phase, "sub", sub, "ms", ms_s, "us", us_s, "items", fmtLong(&items_buf, items) }); + } else { + interop.logInfo("prof", .{ "phase", phase, "sub", sub, "ms", ms_s, "us", us_s }); + } +} diff --git a/src/foundation/sha256.c b/src/foundation/sha256.c deleted file mode 100644 index 32df8ce2..00000000 --- a/src/foundation/sha256.c +++ /dev/null @@ -1,196 +0,0 @@ -/* SHA-256 per FIPS 180-4. Straightforward reference implementation; validated - * against the NIST test vectors in tests/test_cli.c. */ - -#include "foundation/sha256.h" -#include "foundation/sha256_backend.h" - -#if defined(__APPLE__) -#include -#endif - -#include -#include - -static const uint32_t K[64] = { - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2}; - -#define ROTR(x, n) (((x) >> (n)) | ((x) << (32 - (n)))) -#define CH(x, y, z) (((x) & (y)) ^ (~(x) & (z))) -#define MAJ(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z))) -#define EP0(x) (ROTR(x, 2) ^ ROTR(x, 13) ^ ROTR(x, 22)) -#define EP1(x) (ROTR(x, 6) ^ ROTR(x, 11) ^ ROTR(x, 25)) -#define SIG0(x) (ROTR(x, 7) ^ ROTR(x, 18) ^ ((x) >> 3)) -#define SIG1(x) (ROTR(x, 17) ^ ROTR(x, 19) ^ ((x) >> 10)) - -static void sha256_transform(cbm_sha256_ctx *c, const uint8_t *data) { - uint32_t m[64]; - for (int i = 0, j = 0; i < 16; i++, j += 4) { - m[i] = ((uint32_t)data[j] << 24) | ((uint32_t)data[j + 1] << 16) | - ((uint32_t)data[j + 2] << 8) | (uint32_t)data[j + 3]; - } - for (int i = 16; i < 64; i++) { - m[i] = SIG1(m[i - 2]) + m[i - 7] + SIG0(m[i - 15]) + m[i - 16]; - } - - uint32_t a = c->state[0], b = c->state[1], cc = c->state[2], d = c->state[3]; - uint32_t e = c->state[4], f = c->state[5], g = c->state[6], h = c->state[7]; - - for (int i = 0; i < 64; i++) { - uint32_t t1 = h + EP1(e) + CH(e, f, g) + K[i] + m[i]; - uint32_t t2 = EP0(a) + MAJ(a, b, cc); - h = g; - g = f; - f = e; - e = d + t1; - d = cc; - cc = b; - b = a; - a = t1 + t2; - } - - c->state[0] += a; - c->state[1] += b; - c->state[2] += cc; - c->state[3] += d; - c->state[4] += e; - c->state[5] += f; - c->state[6] += g; - c->state[7] += h; -} - -void cbm_sha256_init(cbm_sha256_ctx *c) { - c->bitlen = 0; - c->buflen = 0; - c->state[0] = 0x6a09e667; - c->state[1] = 0xbb67ae85; - c->state[2] = 0x3c6ef372; - c->state[3] = 0xa54ff53a; - c->state[4] = 0x510e527f; - c->state[5] = 0x9b05688c; - c->state[6] = 0x1f83d9ab; - c->state[7] = 0x5be0cd19; -} - -void cbm_sha256_update(cbm_sha256_ctx *c, const void *data, size_t len) { - if (len == 0) return; - const uint8_t *p = (const uint8_t *)data; - if (c->buflen != 0) { - size_t take = 64 - c->buflen; - if (take > len) take = len; - memcpy(c->buf + c->buflen, p, take); - c->buflen += take; - p += take; - len -= take; - if (c->buflen == 64) { - sha256_transform(c, c->buf); - c->bitlen += 512; - c->buflen = 0; - } - } - - while (len >= 64) { - sha256_transform(c, p); - c->bitlen += 512; - p += 64; - len -= 64; - } - - if (len != 0) { - memcpy(c->buf, p, len); - c->buflen = len; - } -} - -void cbm_sha256_final(cbm_sha256_ctx *c, uint8_t out[CBM_SHA256_DIGEST_LEN]) { - c->bitlen += (uint64_t)c->buflen * 8; - - size_t i = c->buflen; - c->buf[i++] = 0x80; /* append the '1' bit + zero padding */ - if (i > 56) { - while (i < 64) { - c->buf[i++] = 0; - } - sha256_transform(c, c->buf); - i = 0; - } - while (i < 56) { - c->buf[i++] = 0; - } - /* append the 64-bit big-endian message length */ - for (int j = 0; j < 8; j++) { - c->buf[56 + j] = (uint8_t)(c->bitlen >> (56 - 8 * j)); - } - sha256_transform(c, c->buf); - - for (int j = 0; j < 8; j++) { - out[j * 4] = (uint8_t)(c->state[j] >> 24); - out[j * 4 + 1] = (uint8_t)(c->state[j] >> 16); - out[j * 4 + 2] = (uint8_t)(c->state[j] >> 8); - out[j * 4 + 3] = (uint8_t)(c->state[j]); - } -} - -void cbm_sha256_hex(const void *data, size_t len, char out[CBM_SHA256_HEX_LEN + 1]) { - uint8_t digest[CBM_SHA256_DIGEST_LEN]; - cbm_sha256_ctx c; - cbm_sha256_init(&c); - cbm_sha256_update(&c, data, len); - cbm_sha256_final(&c, digest); - - static const char hex[] = "0123456789abcdef"; - for (int 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'; -} - -void cbm_sha256_backend_init(cbm_sha256_backend_ctx *c) { -#if defined(__APPLE__) - _Static_assert(sizeof(CC_SHA256_CTX) <= sizeof(c->state.bytes), - "SHA-256 backend storage is too small"); - (void)CC_SHA256_Init((CC_SHA256_CTX *)c->state.bytes); -#else - cbm_sha256_init((cbm_sha256_ctx *)c->state.bytes); -#endif -} - -void cbm_sha256_backend_update(cbm_sha256_backend_ctx *c, const void *data, size_t len) { -#if defined(__APPLE__) - const uint8_t *bytes = (const uint8_t *)data; - while (len > (size_t)UINT_MAX) { - (void)CC_SHA256_Update((CC_SHA256_CTX *)c->state.bytes, bytes, (CC_LONG)UINT_MAX); - bytes += UINT_MAX; - len -= UINT_MAX; - } - if (len != 0) { - (void)CC_SHA256_Update((CC_SHA256_CTX *)c->state.bytes, bytes, (CC_LONG)len); - } -#else - cbm_sha256_update((cbm_sha256_ctx *)c->state.bytes, data, len); -#endif -} - -void cbm_sha256_backend_final(cbm_sha256_backend_ctx *c, - uint8_t out[CBM_SHA256_DIGEST_LEN]) { -#if defined(__APPLE__) - (void)CC_SHA256_Final(out, (CC_SHA256_CTX *)c->state.bytes); -#else - cbm_sha256_final((cbm_sha256_ctx *)c->state.bytes, out); -#endif -} - -bool cbm_sha256_backend_is_accelerated(void) { -#if defined(__APPLE__) - return true; -#else - return false; -#endif -} diff --git a/src/foundation/sha256.zig b/src/foundation/sha256.zig new file mode 100644 index 00000000..515a800e --- /dev/null +++ b/src/foundation/sha256.zig @@ -0,0 +1,177 @@ +//! sha256.zig — SHA-256 per FIPS 180-4. +//! +//! Implements src/foundation/sha256.h and sha256_backend.h. Straightforward +//! reference implementation; validated against the NIST test vectors in +//! tests/test_cli.c. The backend uses CommonCrypto on Apple platforms. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); + +const is_apple = builtin.os.tag.isDarwin(); + +const K = [64]u32{ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +}; + +const rotr = std.math.rotr; + +fn ch(x: u32, y: u32, z: u32) u32 { + return (x & y) ^ (~x & z); +} +fn maj(x: u32, y: u32, z: u32) u32 { + return (x & y) ^ (x & z) ^ (y & z); +} +fn ep0(x: u32) u32 { + return rotr(u32, x, 2) ^ rotr(u32, x, 13) ^ rotr(u32, x, 22); +} +fn ep1(x: u32) u32 { + return rotr(u32, x, 6) ^ rotr(u32, x, 11) ^ rotr(u32, x, 25); +} +fn sig0(x: u32) u32 { + return rotr(u32, x, 7) ^ rotr(u32, x, 18) ^ (x >> 3); +} +fn sig1(x: u32) u32 { + return rotr(u32, x, 17) ^ rotr(u32, x, 19) ^ (x >> 10); +} + +fn sha256Transform(ctx: *c.cbm_sha256_ctx, data: *const [64]u8) void { + var m: [64]u32 = undefined; + for (0..16) |i| m[i] = std.mem.readInt(u32, data[i * 4 ..][0..4], .big); + for (16..64) |i| m[i] = sig1(m[i - 2]) +% m[i - 7] +% sig0(m[i - 15]) +% m[i - 16]; + + var s: [8]u32 = ctx.state; + for (0..64) |i| { + const t1 = s[7] +% ep1(s[4]) +% ch(s[4], s[5], s[6]) +% K[i] +% m[i]; + const t2 = ep0(s[0]) +% maj(s[0], s[1], s[2]); + s[7] = s[6]; + s[6] = s[5]; + s[5] = s[4]; + s[4] = s[3] +% t1; + s[3] = s[2]; + s[2] = s[1]; + s[1] = s[0]; + s[0] = t1 +% t2; + } + for (&ctx.state, s) |*state, v| state.* +%= v; +} + +pub export fn cbm_sha256_init(ctx: *c.cbm_sha256_ctx) void { + ctx.bitlen = 0; + ctx.buflen = 0; + ctx.state = .{ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + }; +} + +pub export fn cbm_sha256_update(ctx: *c.cbm_sha256_ctx, data: ?*const anyopaque, len: usize) void { + if (len == 0) return; + var p: []const u8 = @as([*]const u8, @ptrCast(data.?))[0..len]; + if (ctx.buflen != 0) { + const take = @min(64 - ctx.buflen, p.len); + @memcpy(ctx.buf[ctx.buflen..][0..take], p[0..take]); + ctx.buflen += take; + p = p[take..]; + if (ctx.buflen == 64) { + sha256Transform(ctx, &ctx.buf); + ctx.bitlen +%= 512; + ctx.buflen = 0; + } + } + while (p.len >= 64) : (p = p[64..]) { + sha256Transform(ctx, p[0..64]); + ctx.bitlen +%= 512; + } + if (p.len != 0) { + @memcpy(ctx.buf[0..p.len], p); + ctx.buflen = p.len; + } +} + +pub export fn cbm_sha256_final(ctx: *c.cbm_sha256_ctx, out: [*]u8) void { + ctx.bitlen +%= @as(u64, ctx.buflen) * 8; + var i = ctx.buflen; + ctx.buf[i] = 0x80; // append the '1' bit + zero padding + i += 1; + if (i > 56) { + @memset(ctx.buf[i..], 0); + sha256Transform(ctx, &ctx.buf); + i = 0; + } + @memset(ctx.buf[i..56], 0); + // append the 64-bit big-endian message length + std.mem.writeInt(u64, ctx.buf[56..64], ctx.bitlen, .big); + sha256Transform(ctx, &ctx.buf); + for (ctx.state, 0..) |word, j| std.mem.writeInt(u32, out[j * 4 ..][0..4], word, .big); +} + +pub export fn cbm_sha256_hex(data: ?*const anyopaque, len: usize, out: [*]u8) void { + var digest: [c.CBM_SHA256_DIGEST_LEN]u8 = undefined; + var ctx: c.cbm_sha256_ctx = undefined; + cbm_sha256_init(&ctx); + cbm_sha256_update(&ctx, data, len); + cbm_sha256_final(&ctx, &digest); + const hex = "0123456789abcdef"; + for (digest, 0..) |byte, i| { + out[i * 2] = hex[byte >> 4]; + out[i * 2 + 1] = hex[byte & 0x0f]; + } + out[c.CBM_SHA256_HEX_LEN] = 0; +} + +// ── Backend (sha256_backend.h) ─────────────────────────────────── + +const Backend = if (is_apple) c.CC_SHA256_CTX else c.cbm_sha256_ctx; + +comptime { + if (@sizeOf(Backend) > 256) @compileError("SHA-256 backend storage is too small"); +} + +fn backendState(ctx: *c.cbm_sha256_backend_ctx) *Backend { + return @ptrCast(@alignCast(&ctx.state.bytes)); +} + +pub export fn cbm_sha256_backend_init(ctx: *c.cbm_sha256_backend_ctx) void { + if (is_apple) { + _ = c.CC_SHA256_Init(backendState(ctx)); + } else { + cbm_sha256_init(backendState(ctx)); + } +} + +pub export fn cbm_sha256_backend_update(ctx: *c.cbm_sha256_backend_ctx, data: ?*const anyopaque, len_in: usize) void { + if (is_apple) { + if (len_in == 0) return; // data may be NULL for an empty update + const chunk_max: usize = std.math.maxInt(c_uint); + var bytes: [*]const u8 = @ptrCast(data.?); + var len = len_in; + while (len > chunk_max) { + _ = c.CC_SHA256_Update(backendState(ctx), bytes, @intCast(chunk_max)); + bytes += chunk_max; + len -= chunk_max; + } + if (len != 0) _ = c.CC_SHA256_Update(backendState(ctx), bytes, @intCast(len)); + } else { + cbm_sha256_update(backendState(ctx), data, len_in); + } +} + +pub export fn cbm_sha256_backend_final(ctx: *c.cbm_sha256_backend_ctx, out: [*]u8) void { + if (is_apple) { + _ = c.CC_SHA256_Final(out, backendState(ctx)); + } else { + cbm_sha256_final(backendState(ctx), out); + } +} + +pub export fn cbm_sha256_backend_is_accelerated() bool { + return is_apple; +} diff --git a/src/foundation/slab_alloc.c b/src/foundation/slab_alloc.c deleted file mode 100644 index 95d23587..00000000 --- a/src/foundation/slab_alloc.c +++ /dev/null @@ -1,445 +0,0 @@ -/* - * slab_alloc.c — Slab allocator for tree-sitter. - * - * Replaces malloc/calloc/realloc/free for ALL tree-sitter allocations, - * eliminating ptmalloc2's per-thread arena fragmentation (the root cause - * of 321GB VSZ when indexing large codebases with 12 workers). - * - * Tier 1 (≤64B): Fixed-size slab free list. - * Matches tree-sitter SubtreeHeapData (64 bytes). O(1) alloc/free. - * Backed by 64KB slab pages (malloc = mimalloc in production). - * - * All allocations >64B go directly to malloc() which is mimalloc - * in production builds (MI_OVERRIDE=1). This eliminates the complex - * tier2 bump allocator and its O(n) ownership checks. - * - * Cross-thread frees (tree-sitter's allocator callbacks are process-global, - * so a chunk allocated on parser thread A can be freed on thread B): - * - * - Each slab page is allocated SLAB_PAGE_SIZE-aligned, so the owning page - * of ANY chunk is recovered in O(1) by masking the pointer — no per-op - * scan and no global hot-path lock. A tiny 3-level radix page map (keyed - * by page number, lock-free reads, cold-path writes) tells slab_free - * whether a masked base is a real slab page or a plain heap pointer, - * without ever dereferencing an unrelated heap address. - * - A free by the owning thread returns the chunk to the thread-local free - * list (fast path, no atomics on the list). A foreign free is pushed onto - * that page's lock-free MPSC remote-free stack; the owner drains it on its - * next refill. Neither path takes a global lock. - * - * On reclaim/destroy: free pages with no live chunks; retire pages that still - * have live chunks (owner=NULL, kept reachable) and free them when the final - * chunk returns. Each page carries a refcount = (handed-out chunks) + (1 owner - * guard while owned); the thread that drops it to zero frees the page. A page - * holding a still-live tree-sitter lexer chunk is therefore never freed under - * it (fixes the cross-thread invalid free and the #852 use-after-free). - * realloc handles slab-to-heap promotion with minimal copying. - */ -#include "foundation/constants.h" -#include "foundation/slab_alloc.h" -#include "foundation/compat.h" -#include "foundation/compat_thread.h" - -#include -#include -#include -#include -#include -#include - -/* ── Tier 1: Fixed-size slab (≤64B) ──────────────────────────────── */ - -/* Chunk size matches tree-sitter SubtreeHeapData (64 bytes). - * All slab allocations are this size regardless of requested size. */ -#define SLAB_CHUNK_SIZE 64 - -/* Each slab page is a 64KB region, allocated aligned to its own size so that - * masking any interior chunk pointer with SLAB_PAGE_MASK yields the page base - * (which is where the page header lives). */ -#define SLAB_PAGE_SIZE ((size_t)65536) -#define SLAB_PAGE_SHIFT 16 /* log2(SLAB_PAGE_SIZE) */ -#define SLAB_PAGE_MASK (~((uintptr_t)SLAB_PAGE_SIZE - 1)) - -/* Chunk region starts after the page header. SLAB_DATA_OFFSET must be a - * multiple of SLAB_CHUNK_SIZE (so chunks stay 64-byte aligned) and at least - * sizeof(slab_page_t). 64 bytes leaves 1023 usable chunks per page. */ -#define SLAB_DATA_OFFSET 64 -#define SLAB_PAGE_CHUNKS ((SLAB_PAGE_SIZE - SLAB_DATA_OFFSET) / SLAB_CHUNK_SIZE) - -/* Free list node — occupies the first 8 bytes of a free chunk. */ -typedef struct slab_free_node { - struct slab_free_node *next; -} slab_free_node_t; - -typedef struct slab_state slab_state_t; - -/* One slab page. The header lives at the SLAB_PAGE_SIZE-aligned base; the - * SLAB_PAGE_CHUNKS chunks begin at (char*)page + SLAB_DATA_OFFSET. */ -typedef struct slab_page { - struct slab_page *next; /* owner's page list (owner thread only) */ - _Atomic(slab_state_t *) owner; /* owning TLS state; NULL once retired */ - _Atomic(slab_free_node_t *) remote_free_head; /* lock-free MPSC stack of cross-thread frees */ - atomic_uint refcount; /* handed-out chunks + 1 owner guard (while owned) */ -} slab_page_t; - -_Static_assert(sizeof(slab_page_t) <= SLAB_DATA_OFFSET, - "slab_page header must fit in SLAB_DATA_OFFSET"); -_Static_assert(SLAB_DATA_OFFSET % SLAB_CHUNK_SIZE == 0, "SLAB_DATA_OFFSET must be chunk-aligned"); - -/* Per-thread Tier 1 state. */ -struct slab_state { - slab_page_t *pages; /* linked list of pages owned by this thread */ - slab_free_node_t *freelist; /* singly-linked free list (thread-local) */ - bool installed; -}; - -static CBM_TLS slab_state_t tls_slab; - -/* ── Page map: safe O(1) "is this a slab page?" lookup ─────────────── - * - * A 3-level radix trie keyed by page number (base >> SLAB_PAGE_SHIFT) covering - * the low 32 bits of the page number — i.e. all 48-bit addresses that malloc/ - * posix_memalign/_aligned_malloc return on every supported platform. Reads are - * lock-free (atomic-acquire loads only, never dereferencing the queried - * pointer). Writes (page create / free / retire — all cold paths) take a - * dedicated spinlock that is NEVER touched on the alloc/free hot path. - * - * Intermediate tables are never freed during the run and stay reachable from - * the static root, so lock-free readers never touch freed table memory. */ -#define SLAB_MAP_L1_BITS 11 -#define SLAB_MAP_L2_BITS 11 -#define SLAB_MAP_L3_BITS 10 -#define SLAB_MAP_TOTAL_BITS (SLAB_MAP_L1_BITS + SLAB_MAP_L2_BITS + SLAB_MAP_L3_BITS) /* 32 */ -#define SLAB_MAP_L1_SIZE ((size_t)1 << SLAB_MAP_L1_BITS) -#define SLAB_MAP_L2_SIZE ((size_t)1 << SLAB_MAP_L2_BITS) -#define SLAB_MAP_L3_SIZE ((size_t)1 << SLAB_MAP_L3_BITS) - -typedef struct { - _Atomic(slab_page_t *) e[SLAB_MAP_L3_SIZE]; -} slab_map_l3_t; -typedef struct { - _Atomic(slab_map_l3_t *) e[SLAB_MAP_L2_SIZE]; -} slab_map_l2_t; - -static _Atomic(slab_map_l2_t *) g_slab_map_root[SLAB_MAP_L1_SIZE]; -static atomic_flag g_slab_map_lock = ATOMIC_FLAG_INIT; - -static void slab_map_lock(void) { - while (atomic_flag_test_and_set_explicit(&g_slab_map_lock, memory_order_acquire)) { - /* Spin — cold path only (page create/destroy/retire). */ - } -} -static void slab_map_unlock(void) { - atomic_flag_clear_explicit(&g_slab_map_lock, memory_order_release); -} - -static bool slab_map_indices(uintptr_t base, size_t *i1, size_t *i2, size_t *i3) { - uintptr_t pnum = base >> SLAB_PAGE_SHIFT; - if ((pnum >> SLAB_MAP_TOTAL_BITS) != 0) { - return false; /* address beyond coverage — never produced by malloc here */ - } - *i1 = (size_t)((pnum >> (SLAB_MAP_L2_BITS + SLAB_MAP_L3_BITS)) & (SLAB_MAP_L1_SIZE - 1)); - *i2 = (size_t)((pnum >> SLAB_MAP_L3_BITS) & (SLAB_MAP_L2_SIZE - 1)); - *i3 = (size_t)(pnum & (SLAB_MAP_L3_SIZE - 1)); - return true; -} - -/* Lock-free. Returns the slab page whose base == (ptr & mask), or NULL if the - * masked base is not a registered slab page (i.e. ptr is a plain heap block). */ -static slab_page_t *slab_map_lookup(uintptr_t base) { - size_t i1, i2, i3; - if (!slab_map_indices(base, &i1, &i2, &i3)) { - return NULL; - } - slab_map_l2_t *l2 = atomic_load_explicit(&g_slab_map_root[i1], memory_order_acquire); - if (!l2) { - return NULL; - } - slab_map_l3_t *l3 = atomic_load_explicit(&l2->e[i2], memory_order_acquire); - if (!l3) { - return NULL; - } - return atomic_load_explicit(&l3->e[i3], memory_order_acquire); -} - -/* Cold path. Sets the leaf entry for `base` to `val` (page to register, NULL to - * unregister), allocating intermediate tables on demand. Returns false only if - * a needed table allocation fails while registering. */ -static bool slab_map_set(uintptr_t base, slab_page_t *val) { - size_t i1, i2, i3; - if (!slab_map_indices(base, &i1, &i2, &i3)) { - return false; - } - slab_map_lock(); - slab_map_l2_t *l2 = atomic_load_explicit(&g_slab_map_root[i1], memory_order_relaxed); - if (!l2) { - if (!val) { - slab_map_unlock(); - return true; /* nothing to unregister */ - } - l2 = (slab_map_l2_t *)calloc(1, sizeof(*l2)); - if (!l2) { - slab_map_unlock(); - return false; - } - atomic_store_explicit(&g_slab_map_root[i1], l2, memory_order_release); - } - slab_map_l3_t *l3 = atomic_load_explicit(&l2->e[i2], memory_order_relaxed); - if (!l3) { - if (!val) { - slab_map_unlock(); - return true; - } - l3 = (slab_map_l3_t *)calloc(1, sizeof(*l3)); - if (!l3) { - slab_map_unlock(); - return false; - } - atomic_store_explicit(&l2->e[i2], l3, memory_order_release); - } - atomic_store_explicit(&l3->e[i3], val, memory_order_release); - slab_map_unlock(); - return true; -} - -/* ── Tier 1 helpers ────────────────────────────────────────────────── */ - -static void slab_free(void *ptr); -static bool slab_map_register_page(slab_page_t *page); -static void slab_map_unregister_page(slab_page_t *page); - -/* Allocate and register a new SLAB_PAGE_SIZE-aligned page, threading its chunks - * onto the thread-local free list. Returns false on OOM (caller falls back to - * malloc). Registration happens before any chunk is handed out so that a later - * cross-thread free can always recover the page. */ -static bool slab_grow(slab_state_t *s) { - void *mem = NULL; - if (cbm_aligned_alloc(&mem, SLAB_PAGE_SIZE, SLAB_PAGE_SIZE) != 0 || !mem) { - return false; - } - slab_page_t *page = (slab_page_t *)mem; - page->next = s->pages; - atomic_init(&page->owner, s); - atomic_init(&page->remote_free_head, (slab_free_node_t *)NULL); - atomic_init(&page->refcount, 1u); /* owner guard */ - - if (!slab_map_register_page(page)) { - cbm_aligned_free(page); - return false; - } - s->pages = page; - - for (size_t i = 0; i < SLAB_PAGE_CHUNKS; i++) { - slab_free_node_t *node = - (slab_free_node_t *)((char *)page + SLAB_DATA_OFFSET + i * SLAB_CHUNK_SIZE); - node->next = s->freelist; - s->freelist = node; - } - return true; -} - -/* Refill the thread-local free list: first reclaim any cross-thread frees - * queued on owned pages, then grow a fresh page if still empty. */ -static void slab_refill(slab_state_t *s) { - for (slab_page_t *p = s->pages; p; p = p->next) { - slab_free_node_t *rf = atomic_exchange_explicit( - &p->remote_free_head, (slab_free_node_t *)NULL, memory_order_acquire); - while (rf) { - slab_free_node_t *next = rf->next; - rf->next = s->freelist; - s->freelist = rf; - rf = next; - } - } - if (s->freelist) { - return; - } - (void)slab_grow(s); -} - -/* Retire ownership and drop the owner guard for every page this thread owns. - * A page with no live chunks is freed immediately; a page still holding - * foreign-live chunks is retired (owner=NULL, left registered) and freed by - * whichever thread returns its last chunk. */ -static void slab_reclaim_pages(slab_state_t *s, bool clear_installed) { - slab_page_t *p = s->pages; - while (p) { - slab_page_t *next = p->next; - atomic_store_explicit(&p->owner, (slab_state_t *)NULL, memory_order_relaxed); - unsigned prev = atomic_fetch_sub_explicit(&p->refcount, 1u, memory_order_acq_rel); - if (prev == 1u) { - slab_map_unregister_page(p); - cbm_aligned_free(p); - } - p = next; - } - s->pages = NULL; - s->freelist = NULL; - if (clear_installed) { - s->installed = false; - } -} - -/* ── Allocator functions (installed as tree-sitter callbacks) ───── */ - -static void *slab_malloc(size_t size) { - if (size == 0) { - size = SKIP_ONE; - } - /* Tier 1: ≤64B → slab free list */ - if (size <= SLAB_CHUNK_SIZE) { - slab_state_t *s = &tls_slab; - if (!s->freelist) { - slab_refill(s); - if (!s->freelist) { - return malloc(size); /* grow failed → heap fallback */ - } - } - slab_free_node_t *node = s->freelist; - s->freelist = node->next; - slab_page_t *page = (slab_page_t *)((uintptr_t)node & SLAB_PAGE_MASK); - atomic_fetch_add_explicit(&page->refcount, 1u, memory_order_relaxed); - return node; - } - - /* >64B: straight to malloc (= mimalloc in production) */ - return malloc(size); -} - -static void *slab_calloc(size_t count, size_t size) { - /* Overflow check */ - if (count > 0 && size > SIZE_MAX / count) { - return NULL; - } - size_t total = count * size; - void *ptr = slab_malloc(total); - if (ptr) { - /* Must zero: free-list recycled blocks contain stale data. */ - memset(ptr, 0, total); - } - return ptr; -} - -static void *slab_realloc(void *ptr, size_t new_size) { - if (!ptr) { - return slab_malloc(new_size); - } - if (new_size == 0) { - /* realloc(ptr, 0) = free + return NULL */ - slab_free(ptr); - return NULL; - } - - /* Case 1: ptr is in a slab page (≤64B block) */ - slab_page_t *page = slab_map_lookup((uintptr_t)ptr & SLAB_PAGE_MASK); - if (page) { - if (new_size <= SLAB_CHUNK_SIZE) { - /* Still fits in a slab chunk — reuse same slot */ - return ptr; - } - /* Promote slab → heap */ - void *new_ptr = malloc(new_size); - if (!new_ptr) { - return NULL; - } - memcpy(new_ptr, ptr, SLAB_CHUNK_SIZE); - slab_free(ptr); - return new_ptr; - } - - /* Case 2: heap pointer (from malloc) */ - return realloc(ptr, new_size); -} - -static void slab_free(void *ptr) { - if (!ptr) { - return; - } - uintptr_t base = (uintptr_t)ptr & SLAB_PAGE_MASK; - slab_page_t *page = slab_map_lookup(base); - if (!page) { - /* Not a slab chunk → plain heap pointer (>64B or grow-fallback). */ - free(ptr); - return; - } - - /* It is a slab chunk. The chunk itself keeps its page alive (refcount ≥ 1) - * until this very free, so the page cannot be freed underneath us. */ - slab_state_t *s = &tls_slab; - slab_free_node_t *node = (slab_free_node_t *)ptr; - - if (atomic_load_explicit(&page->owner, memory_order_relaxed) == s) { - /* Fast path: owner returns the chunk to its thread-local free list. */ - node->next = s->freelist; - s->freelist = node; - atomic_fetch_sub_explicit(&page->refcount, 1u, memory_order_acq_rel); - return; /* owner guard keeps refcount ≥ 1; page is reused, not freed */ - } - - /* Foreign (or retired) free: push onto the page's lock-free MPSC stack. */ - slab_free_node_t *head = atomic_load_explicit(&page->remote_free_head, memory_order_relaxed); - do { - node->next = head; - } while (!atomic_compare_exchange_weak_explicit(&page->remote_free_head, &head, node, - memory_order_release, memory_order_relaxed)); - unsigned prev = atomic_fetch_sub_explicit(&page->refcount, 1u, memory_order_acq_rel); - if (prev == 1u) { - /* We returned the final chunk of a retired page → release it. */ - slab_map_unregister_page(page); - cbm_aligned_free(page); - } -} - -/* ── Page-map registration wrappers (declared after slab_map_set) ── */ - -static bool slab_map_register_page(slab_page_t *page) { - return slab_map_set((uintptr_t)page, page); -} -static void slab_map_unregister_page(slab_page_t *page) { - (void)slab_map_set((uintptr_t)page, (slab_page_t *)NULL); -} - -/* ── Public API ─────────────────────────────────────────────────── */ - -/* Forward declaration of tree-sitter's allocator setter. */ -extern void ts_set_allocator(void *(*new_malloc)(size_t), void *(*new_calloc)(size_t, size_t), - void *(*new_realloc)(void *, size_t), void (*new_free)(void *)); - -void cbm_slab_install(void) { - ts_set_allocator(slab_malloc, slab_calloc, slab_realloc, slab_free); -} - -void cbm_slab_reset_thread(void) { - cbm_slab_reclaim(); -} - -void cbm_slab_destroy_thread(void) { - slab_reclaim_pages(&tls_slab, true); -} - -/* Reclaim slab memory owned by the current thread. - * - * Call ONLY when no LOCAL live allocations remain — i.e., after ts_tree_delete() - * AND ts_parser_delete() have freed this thread's parser-owned chunks. If a - * chunk from one of these pages is still held by another parser thread, the - * page is retired instead of freed and released on the final cross-thread free. - * This keeps peak memory bounded per-file without handing foreign slab chunks - * to plain free(). */ -void cbm_slab_reclaim(void) { - slab_reclaim_pages(&tls_slab, false); -} - -/* ── Test API (thin wrappers for unit testing) ──────────────────── */ - -void *cbm_slab_test_malloc(size_t size) { - return slab_malloc(size); -} -void cbm_slab_test_free(void *ptr) { - slab_free(ptr); -} -void *cbm_slab_test_realloc(void *ptr, size_t size) { - return slab_realloc(ptr, size); -} -void *cbm_slab_test_calloc(size_t count, size_t size) { - return slab_calloc(count, size); -} diff --git a/src/foundation/slab_alloc.zig b/src/foundation/slab_alloc.zig new file mode 100644 index 00000000..b114dd65 --- /dev/null +++ b/src/foundation/slab_alloc.zig @@ -0,0 +1,390 @@ +//! slab_alloc.zig — Slab allocator for tree-sitter. +//! +//! Implements src/foundation/slab_alloc.h. +//! +//! Replaces malloc/calloc/realloc/free for ALL tree-sitter allocations, +//! eliminating ptmalloc2's per-thread arena fragmentation (the root cause +//! of 321GB VSZ when indexing large codebases with 12 workers). +//! +//! Tier 1 (≤64B): Fixed-size slab free list. +//! Matches tree-sitter SubtreeHeapData (64 bytes). O(1) alloc/free. +//! Backed by 64KB slab pages (malloc = mimalloc in production). +//! +//! All allocations >64B go directly to malloc() which is mimalloc +//! in production builds (MI_OVERRIDE=1). This eliminates the complex +//! tier2 bump allocator and its O(n) ownership checks. +//! +//! Cross-thread frees (tree-sitter's allocator callbacks are process-global, +//! so a chunk allocated on parser thread A can be freed on thread B): +//! +//! - Each slab page is allocated SLAB_PAGE_SIZE-aligned, so the owning page +//! of ANY chunk is recovered in O(1) by masking the pointer — no per-op +//! scan and no global hot-path lock. A tiny 3-level radix page map (keyed +//! by page number, lock-free reads, cold-path writes) tells slabFree +//! whether a masked base is a real slab page or a plain heap pointer, +//! without ever dereferencing an unrelated heap address. +//! - A free by the owning thread returns the chunk to the thread-local free +//! list (fast path, no atomics on the list). A foreign free is pushed onto +//! that page's lock-free MPSC remote-free stack; the owner drains it on its +//! next refill. Neither path takes a global lock. +//! +//! On reclaim/destroy: free pages with no live chunks; retire pages that still +//! have live chunks (owner=NULL, kept reachable) and free them when the final +//! chunk returns. Each page carries a refcount = (handed-out chunks) + (1 owner +//! guard while owned); the thread that drops it to zero frees the page. A page +//! holding a still-live tree-sitter lexer chunk is therefore never freed under +//! it (fixes the cross-thread invalid free and the #852 use-after-free). +//! realloc handles slab-to-heap promotion with minimal copying. + +const std = @import("std"); +const c = @import("c"); + +const Atomic = std.atomic.Value; + +// ── Tier 1: Fixed-size slab (≤64B) ──────────────────────────────── + +/// Chunk size matches tree-sitter SubtreeHeapData (64 bytes). +/// All slab allocations are this size regardless of requested size. +const SLAB_CHUNK_SIZE: usize = 64; + +// Each slab page is a 64KB region, allocated aligned to its own size so that +// masking any interior chunk pointer with SLAB_PAGE_MASK yields the page base +// (which is where the page header lives). +const SLAB_PAGE_SIZE: usize = 65536; +const SLAB_PAGE_SHIFT = 16; // log2(SLAB_PAGE_SIZE) +const SLAB_PAGE_MASK: usize = ~(SLAB_PAGE_SIZE - 1); + +// Chunk region starts after the page header. SLAB_DATA_OFFSET must be a +// multiple of SLAB_CHUNK_SIZE (so chunks stay 64-byte aligned) and at least +// @sizeOf(Page). 64 bytes leaves 1023 usable chunks per page. +const SLAB_DATA_OFFSET: usize = 64; +const SLAB_PAGE_CHUNKS = (SLAB_PAGE_SIZE - SLAB_DATA_OFFSET) / SLAB_CHUNK_SIZE; + +/// Free list node — occupies the first 8 bytes of a free chunk. +const FreeNode = extern struct { + next: ?*FreeNode, +}; + +/// One slab page. The header lives at the SLAB_PAGE_SIZE-aligned base; the +/// SLAB_PAGE_CHUNKS chunks begin at page + SLAB_DATA_OFFSET. +const Page = extern struct { + next: ?*Page, // owner's page list (owner thread only) + owner: Atomic(?*State), // owning TLS state; NULL once retired + remote_free_head: Atomic(?*FreeNode), // lock-free MPSC stack of cross-thread frees + refcount: Atomic(c_uint), // handed-out chunks + 1 owner guard (while owned) +}; + +comptime { + if (@sizeOf(Page) > SLAB_DATA_OFFSET) @compileError("slab page header must fit in SLAB_DATA_OFFSET"); + if (SLAB_DATA_OFFSET % SLAB_CHUNK_SIZE != 0) @compileError("SLAB_DATA_OFFSET must be chunk-aligned"); +} + +/// Per-thread Tier 1 state. +const State = extern struct { + pages: ?*Page = null, // linked list of pages owned by this thread + freelist: ?*FreeNode = null, // singly-linked free list (thread-local) + installed: bool = false, +}; + +threadlocal var tls_slab: State = .{}; + +// ── Page map: safe O(1) "is this a slab page?" lookup ─────────────── +// +// A 3-level radix trie keyed by page number (base >> SLAB_PAGE_SHIFT) covering +// the low 32 bits of the page number — i.e. all 48-bit addresses that malloc/ +// posix_memalign/_aligned_malloc return on every supported platform. Reads are +// lock-free (atomic-acquire loads only, never dereferencing the queried +// pointer). Writes (page create / free / retire — all cold paths) take a +// dedicated spinlock that is NEVER touched on the alloc/free hot path. +// +// Intermediate tables are never freed during the run and stay reachable from +// the static root, so lock-free readers never touch freed table memory. + +const SLAB_MAP_L1_BITS = 11; +const SLAB_MAP_L2_BITS = 11; +const SLAB_MAP_L3_BITS = 10; +const SLAB_MAP_TOTAL_BITS = SLAB_MAP_L1_BITS + SLAB_MAP_L2_BITS + SLAB_MAP_L3_BITS; // 32 +const SLAB_MAP_L1_SIZE: usize = 1 << SLAB_MAP_L1_BITS; +const SLAB_MAP_L2_SIZE: usize = 1 << SLAB_MAP_L2_BITS; +const SLAB_MAP_L3_SIZE: usize = 1 << SLAB_MAP_L3_BITS; + +const MapL3 = extern struct { + e: [SLAB_MAP_L3_SIZE]Atomic(?*Page), +}; + +const MapL2 = extern struct { + e: [SLAB_MAP_L2_SIZE]Atomic(?*MapL3), +}; + +var g_slab_map_root: [SLAB_MAP_L1_SIZE]Atomic(?*MapL2) = @splat(.init(null)); +var g_slab_map_lock: Atomic(bool) = .init(false); + +fn slabMapLock() void { + while (g_slab_map_lock.swap(true, .acquire)) { + // Spin — cold path only (page create/destroy/retire). + } +} + +fn slabMapUnlock() void { + g_slab_map_lock.store(false, .release); +} + +const MapIndex = struct { i1: usize, i2: usize, i3: usize }; + +fn slabMapIndices(base: usize) ?MapIndex { + const pnum: u64 = @as(u64, base) >> SLAB_PAGE_SHIFT; + if ((pnum >> SLAB_MAP_TOTAL_BITS) != 0) { + return null; // address beyond coverage — never produced by malloc here + } + return .{ + .i1 = @intCast((pnum >> (SLAB_MAP_L2_BITS + SLAB_MAP_L3_BITS)) & (SLAB_MAP_L1_SIZE - 1)), + .i2 = @intCast((pnum >> SLAB_MAP_L3_BITS) & (SLAB_MAP_L2_SIZE - 1)), + .i3 = @intCast(pnum & (SLAB_MAP_L3_SIZE - 1)), + }; +} + +/// Lock-free. Returns the slab page whose base == (ptr & mask), or null if the +/// masked base is not a registered slab page (i.e. ptr is a plain heap block). +fn slabMapLookup(base: usize) ?*Page { + const idx = slabMapIndices(base) orelse return null; + const l2 = g_slab_map_root[idx.i1].load(.acquire) orelse return null; + const l3 = l2.e[idx.i2].load(.acquire) orelse return null; + return l3.e[idx.i3].load(.acquire); +} + +/// Cold path. Sets the leaf entry for `base` to `val` (page to register, null +/// to unregister), allocating intermediate tables on demand. Returns false only +/// if a needed table allocation fails while registering. +fn slabMapSet(base: usize, val: ?*Page) bool { + const idx = slabMapIndices(base) orelse return false; + slabMapLock(); + defer slabMapUnlock(); + const l2 = g_slab_map_root[idx.i1].load(.monotonic) orelse blk: { + if (val == null) return true; // nothing to unregister + const table: *MapL2 = @ptrCast(@alignCast(c.calloc(1, @sizeOf(MapL2)) orelse return false)); + g_slab_map_root[idx.i1].store(table, .release); + break :blk table; + }; + const l3 = l2.e[idx.i2].load(.monotonic) orelse blk: { + if (val == null) return true; + const table: *MapL3 = @ptrCast(@alignCast(c.calloc(1, @sizeOf(MapL3)) orelse return false)); + l2.e[idx.i2].store(table, .release); + break :blk table; + }; + l3.e[idx.i3].store(val, .release); + return true; +} + +// ── Tier 1 helpers ────────────────────────────────────────────────── + +fn slabMapRegisterPage(page: *Page) bool { + return slabMapSet(@intFromPtr(page), page); +} + +fn slabMapUnregisterPage(page: *Page) void { + _ = slabMapSet(@intFromPtr(page), null); +} + +fn pageOf(addr: usize) *Page { + return @ptrFromInt(addr & SLAB_PAGE_MASK); +} + +/// Allocate and register a new SLAB_PAGE_SIZE-aligned page, threading its chunks +/// onto the thread-local free list. Returns false on OOM (caller falls back to +/// malloc). Registration happens before any chunk is handed out so that a later +/// cross-thread free can always recover the page. +fn slabGrow(s: *State) bool { + var mem: ?*anyopaque = null; + if (c.cbm_aligned_alloc(&mem, SLAB_PAGE_SIZE, SLAB_PAGE_SIZE) != 0 or mem == null) { + return false; + } + const page: *Page = @ptrCast(@alignCast(mem)); + page.next = s.pages; + page.owner = .init(s); + page.remote_free_head = .init(null); + page.refcount = .init(1); // owner guard + if (!slabMapRegisterPage(page)) { + c.cbm_aligned_free(page); + return false; + } + s.pages = page; + for (0..SLAB_PAGE_CHUNKS) |i| { + const node: *FreeNode = @ptrFromInt(@intFromPtr(page) + SLAB_DATA_OFFSET + i * SLAB_CHUNK_SIZE); + node.next = s.freelist; + s.freelist = node; + } + return true; +} + +/// Refill the thread-local free list: first reclaim any cross-thread frees +/// queued on owned pages, then grow a fresh page if still empty. +fn slabRefill(s: *State) void { + var p = s.pages; + while (p) |page| : (p = page.next) { + var rf = page.remote_free_head.swap(null, .acquire); + while (rf) |node| { + const next = node.next; + node.next = s.freelist; + s.freelist = node; + rf = next; + } + } + if (s.freelist != null) return; + _ = slabGrow(s); +} + +/// Retire ownership and drop the owner guard for every page this thread owns. +/// A page with no live chunks is freed immediately; a page still holding +/// foreign-live chunks is retired (owner=NULL, left registered) and freed by +/// whichever thread returns its last chunk. +fn slabReclaimPages(s: *State, clear_installed: bool) void { + var p = s.pages; + while (p) |page| { + const next = page.next; + page.owner.store(null, .monotonic); + const prev = page.refcount.fetchSub(1, .acq_rel); + if (prev == 1) { + slabMapUnregisterPage(page); + c.cbm_aligned_free(page); + } + p = next; + } + s.pages = null; + s.freelist = null; + if (clear_installed) s.installed = false; +} + +// ── Allocator functions (installed as tree-sitter callbacks) ─────── + +fn slabMalloc(size_in: usize) callconv(.c) ?*anyopaque { + const size: usize = if (size_in == 0) c.SKIP_ONE else size_in; + // Tier 1: ≤64B → slab free list + if (size <= SLAB_CHUNK_SIZE) { + const s = &tls_slab; + if (s.freelist == null) { + slabRefill(s); + if (s.freelist == null) { + return c.malloc(size); // grow failed → heap fallback + } + } + const node = s.freelist.?; + s.freelist = node.next; + _ = pageOf(@intFromPtr(node)).refcount.fetchAdd(1, .monotonic); + return node; + } + // >64B: straight to malloc (= mimalloc in production) + return c.malloc(size); +} + +fn slabCalloc(count: usize, size: usize) callconv(.c) ?*anyopaque { + // Overflow check + if (count > 0 and size > std.math.maxInt(usize) / count) return null; + const total = count * size; + const ptr = slabMalloc(total) orelse return null; + // Must zero: free-list recycled blocks contain stale data. + @memset(@as([*]u8, @ptrCast(ptr))[0..total], 0); + return ptr; +} + +fn slabRealloc(ptr: ?*anyopaque, new_size: usize) callconv(.c) ?*anyopaque { + const old = ptr orelse return slabMalloc(new_size); + if (new_size == 0) { + // realloc(ptr, 0) = free + return NULL + slabFree(old); + return null; + } + + // Case 1: ptr is in a slab page (≤64B block) + if (slabMapLookup(@intFromPtr(old) & SLAB_PAGE_MASK) != null) { + if (new_size <= SLAB_CHUNK_SIZE) { + // Still fits in a slab chunk — reuse same slot + return old; + } + // Promote slab → heap + const new_ptr = c.malloc(new_size) orelse return null; + @memcpy(@as([*]u8, @ptrCast(new_ptr))[0..SLAB_CHUNK_SIZE], @as([*]const u8, @ptrCast(old))[0..SLAB_CHUNK_SIZE]); + slabFree(old); + return new_ptr; + } + + // Case 2: heap pointer (from malloc) + return c.realloc(old, new_size); +} + +fn slabFree(ptr: ?*anyopaque) callconv(.c) void { + const p = ptr orelse return; + const page = slabMapLookup(@intFromPtr(p) & SLAB_PAGE_MASK) orelse { + // Not a slab chunk → plain heap pointer (>64B or grow-fallback). + c.free(p); + return; + }; + + // It is a slab chunk. The chunk itself keeps its page alive (refcount ≥ 1) + // until this very free, so the page cannot be freed underneath us. + const s = &tls_slab; + const node: *FreeNode = @ptrCast(@alignCast(p)); + if (page.owner.load(.monotonic) == s) { + // Fast path: owner returns the chunk to its thread-local free list. + node.next = s.freelist; + s.freelist = node; + _ = page.refcount.fetchSub(1, .acq_rel); + return; // owner guard keeps refcount ≥ 1; page is reused, not freed + } + + // Foreign (or retired) free: push onto the page's lock-free MPSC stack. + var head = page.remote_free_head.load(.monotonic); + while (true) { + node.next = head; + head = page.remote_free_head.cmpxchgWeak(head, node, .release, .monotonic) orelse break; + } + const prev = page.refcount.fetchSub(1, .acq_rel); + if (prev == 1) { + // We returned the final chunk of a retired page → release it. + slabMapUnregisterPage(page); + c.cbm_aligned_free(page); + } +} + +// ── Public API ───────────────────────────────────────────────────── + +pub export fn cbm_slab_install() void { + c.ts_set_allocator(slabMalloc, slabCalloc, slabRealloc, slabFree); +} + +pub export fn cbm_slab_reset_thread() void { + cbm_slab_reclaim(); +} + +pub export fn cbm_slab_destroy_thread() void { + slabReclaimPages(&tls_slab, true); +} + +/// Reclaim slab memory owned by the current thread. +/// +/// Call ONLY when no LOCAL live allocations remain — i.e., after ts_tree_delete() +/// AND ts_parser_delete() have freed this thread's parser-owned chunks. If a +/// chunk from one of these pages is still held by another parser thread, the +/// page is retired instead of freed and released on the final cross-thread free. +/// This keeps peak memory bounded per-file without handing foreign slab chunks +/// to plain free(). +pub export fn cbm_slab_reclaim() void { + slabReclaimPages(&tls_slab, false); +} + +// ── Test API (thin wrappers for unit testing) ────────────────────── + +pub export fn cbm_slab_test_malloc(size: usize) ?*anyopaque { + return slabMalloc(size); +} + +pub export fn cbm_slab_test_free(ptr: ?*anyopaque) void { + slabFree(ptr); +} + +pub export fn cbm_slab_test_realloc(ptr: ?*anyopaque, size: usize) ?*anyopaque { + return slabRealloc(ptr, size); +} + +pub export fn cbm_slab_test_calloc(count: usize, size: usize) ?*anyopaque { + return slabCalloc(count, size); +} diff --git a/src/foundation/str_intern.c b/src/foundation/str_intern.c deleted file mode 100644 index 433db4b8..00000000 --- a/src/foundation/str_intern.c +++ /dev/null @@ -1,153 +0,0 @@ -/* - * str_intern.c — String interning pool. - * - * Uses a hash table for O(1) dedup lookup and an arena for string storage. - * All strings are copied into the arena — the pool owns the memory. - */ -#include "str_intern.h" -#include "arena.h" -#include "foundation/constants.h" - -enum { INTERN_LOAD_NUM = 7 }; -#include // uint32_t -#include -#include - -/* FNV-1a hash constants (published by Fowler/Noll/Vo) */ -#define FNV_OFFSET_BASIS 2166136261U -#define FNV_PRIME 16777619U - -/* FNV-1a for interning (matches hash_table.c) */ -static uint32_t intern_hash(const char *s, size_t len) { - uint32_t h = FNV_OFFSET_BASIS; - for (size_t i = 0; i < len; i++) { - h ^= (unsigned char)s[i]; - h *= FNV_PRIME; - } - return h; -} - -/* Bucket for open-addressing */ -typedef struct { - const char *str; /* arena-owned pointer */ - uint32_t hash; - uint32_t len; /* string length (excluding NUL) */ -} InternEntry; - -struct CBMInternPool { - CBMArena arena; - InternEntry *buckets; - uint32_t capacity; - uint32_t count; - uint32_t mask; - size_t total_bytes; /* sum of string lengths stored */ -}; - -CBMInternPool *cbm_intern_create(void) { - CBMInternPool *p = (CBMInternPool *)calloc(CBM_ALLOC_ONE, sizeof(*p)); - if (!p) { - return NULL; - } - cbm_arena_init(&p->arena); - p->capacity = CBM_SZ_256; - p->mask = p->capacity - SKIP_ONE; - p->buckets = (InternEntry *)calloc(p->capacity, sizeof(InternEntry)); - if (!p->buckets) { - cbm_arena_destroy(&p->arena); - free(p); - return NULL; - } - return p; -} - -void cbm_intern_free(CBMInternPool *pool) { - if (!pool) { - return; - } - cbm_arena_destroy(&pool->arena); - free(pool->buckets); - free(pool); -} - -static void intern_resize(CBMInternPool *p) { - uint32_t new_cap = p->capacity * PAIR_LEN; - uint32_t new_mask = new_cap - SKIP_ONE; - InternEntry *new_buckets = (InternEntry *)calloc(new_cap, sizeof(InternEntry)); - if (!new_buckets) { - return; - } - - for (uint32_t i = 0; i < p->capacity; i++) { - const InternEntry *e = &p->buckets[i]; - if (!e->str) { - continue; - } - uint32_t idx = e->hash & new_mask; - while (new_buckets[idx].str) { - idx = (idx + SKIP_ONE) & new_mask; - } - new_buckets[idx] = *e; - } - - free(p->buckets); - p->buckets = new_buckets; - p->capacity = new_cap; - p->mask = new_mask; -} - -const char *cbm_intern_n(CBMInternPool *pool, const char *s, size_t len) { - if (!pool || !s) { - return NULL; - } - - uint32_t h = intern_hash(s, len); - uint32_t idx = h & pool->mask; - - /* Probe for existing entry */ - for (;;) { - const InternEntry *e = &pool->buckets[idx]; - if (!e->str) { - break; /* empty slot — not found */ - } - if (e->hash == h && e->len == (uint32_t)len && memcmp(e->str, s, len) == 0) { - return e->str; /* found — return existing pointer */ - } - idx = (idx + SKIP_ONE) & pool->mask; - } - - /* Resize at 70% load */ - if (pool->count * CBM_DECIMAL_BASE >= pool->capacity * INTERN_LOAD_NUM) { - intern_resize(pool); - /* Re-probe after resize */ - idx = h & pool->mask; - while (pool->buckets[idx].str) { - idx = (idx + SKIP_ONE) & pool->mask; - } - } - - /* Copy string into arena */ - char *copy = cbm_arena_strndup(&pool->arena, s, len); - if (!copy) { - return NULL; - } - - pool->buckets[idx] = (InternEntry){.str = copy, .hash = h, .len = (uint32_t)len}; - pool->count++; - pool->total_bytes += len; - return copy; -} - -const char *cbm_intern(CBMInternPool *pool, const char *s) { - if (!s) { - return NULL; - } - return cbm_intern_n(pool, s, strlen(s)); -} - -uint32_t cbm_intern_count(const CBMInternPool *pool) { - return pool ? pool->count : 0; -} - -size_t cbm_intern_bytes(const CBMInternPool *pool) { - return pool ? pool->total_bytes : 0; -} diff --git a/src/foundation/str_intern.zig b/src/foundation/str_intern.zig new file mode 100644 index 00000000..03a613f7 --- /dev/null +++ b/src/foundation/str_intern.zig @@ -0,0 +1,132 @@ +//! str_intern.zig — String interning pool. +//! +//! Implements src/foundation/str_intern.h. Uses a hash table for O(1) dedup +//! lookup and an arena for string storage. All strings are copied into the +//! arena — the pool owns the memory. + +const std = @import("std"); +const c = @import("c"); +const arena = @import("arena.zig"); + +const INTERN_INITIAL_CAP: u32 = 256; +const INTERN_LOAD_NUM: u32 = 7; +const INTERN_LOAD_DEN: u32 = 10; + +// FNV-1a hash constants (published by Fowler/Noll/Vo) +const FNV_OFFSET_BASIS: u32 = 2166136261; +const FNV_PRIME: u32 = 16777619; + +/// FNV-1a for interning (matches hash_table.c) +fn internHash(s: []const u8) u32 { + var h: u32 = FNV_OFFSET_BASIS; + for (s) |ch| { + h ^= ch; + h *%= FNV_PRIME; + } + return h; +} + +/// Bucket for open-addressing +const InternEntry = extern struct { + str: ?[*:0]const u8, // arena-owned pointer + hash: u32, + len: u32, // string length (excluding NUL) +}; + +const CBMInternPool = extern struct { + arena: c.CBMArena, + buckets: [*]InternEntry, + capacity: u32, + count: u32, + mask: u32, + total_bytes: usize, // sum of string lengths stored +}; + +fn allocBuckets(n: u32) ?[*]InternEntry { + return @ptrCast(@alignCast(c.calloc(n, @sizeOf(InternEntry)))); +} + +pub export fn cbm_intern_create() ?*CBMInternPool { + const p: *CBMInternPool = @ptrCast(@alignCast(c.calloc(1, @sizeOf(CBMInternPool)) orelse return null)); + arena.cbm_arena_init(&p.arena); + p.capacity = INTERN_INITIAL_CAP; + p.mask = p.capacity - 1; + p.buckets = allocBuckets(p.capacity) orelse { + arena.cbm_arena_destroy(&p.arena); + c.free(p); + return null; + }; + return p; +} + +pub export fn cbm_intern_free(pool: ?*CBMInternPool) void { + const p = pool orelse return; + arena.cbm_arena_destroy(&p.arena); + c.free(p.buckets); + c.free(p); +} + +fn internResize(p: *CBMInternPool) void { + const new_cap = p.capacity *% 2; + const new_mask = new_cap -% 1; + const new_buckets = allocBuckets(new_cap) orelse return; + + for (p.buckets[0..p.capacity]) |e| { + if (e.str == null) continue; + var idx = e.hash & new_mask; + while (new_buckets[idx].str != null) idx = (idx +% 1) & new_mask; + new_buckets[idx] = e; + } + + c.free(p.buckets); + p.buckets = new_buckets; + p.capacity = new_cap; + p.mask = new_mask; +} + +pub export fn cbm_intern_n(pool: ?*CBMInternPool, s: ?[*]const u8, len: usize) ?[*:0]const u8 { + const p = pool orelse return null; + const src = (s orelse return null)[0..len]; + + const h = internHash(src); + var idx = h & p.mask; + + // Probe for existing entry + while (true) { + const e = &p.buckets[idx]; + const str = e.str orelse break; // empty slot — not found + if (e.hash == h and e.len == @as(u32, @truncate(len)) and std.mem.eql(u8, str[0..len], src)) { + return str; // found — return existing pointer + } + idx = (idx +% 1) & p.mask; + } + + // Resize at 70% load + if (p.count *% INTERN_LOAD_DEN >= p.capacity *% INTERN_LOAD_NUM) { + internResize(p); + // Re-probe after resize + idx = h & p.mask; + while (p.buckets[idx].str != null) idx = (idx +% 1) & p.mask; + } + + // Copy string into arena + const copy = arena.cbm_arena_strndup(&p.arena, src.ptr, len) orelse return null; + + p.buckets[idx] = .{ .str = copy, .hash = h, .len = @truncate(len) }; + p.count += 1; + p.total_bytes += len; + return copy; +} + +pub export fn cbm_intern(pool: ?*CBMInternPool, s: ?[*:0]const u8) ?[*:0]const u8 { + const src = s orelse return null; + return cbm_intern_n(pool, src, std.mem.len(src)); +} + +pub export fn cbm_intern_count(pool: ?*const CBMInternPool) u32 { + return if (pool) |p| p.count else 0; +} + +pub export fn cbm_intern_bytes(pool: ?*const CBMInternPool) usize { + return if (pool) |p| p.total_bytes else 0; +} diff --git a/src/foundation/str_util.c b/src/foundation/str_util.c deleted file mode 100644 index 9200fd69..00000000 --- a/src/foundation/str_util.c +++ /dev/null @@ -1,351 +0,0 @@ -/* - * str_util.c — Safe string operations (arena-allocated). - */ -#include "str_util.h" -#include "arena.h" // CBMArena, cbm_arena_alloc/strdup/strndup -#include "foundation/constants.h" -#include -#include -#include - -enum { - JSON_ESC_LEN = 2, /* escaped char takes 2 bytes (backslash + char) */ - JSON_NUL_RESERVE = 1, /* reserve 1 byte for NUL terminator */ - JSON_CTRL_LIMIT = 0x20, /* ASCII control character upper bound */ -}; - -char *cbm_path_join(CBMArena *a, const char *base, const char *name) { - if (!base || !name) { - return NULL; - } - size_t blen = strlen(base); - size_t nlen = strlen(name); - - /* Handle empty components */ - if (blen == 0) { - return cbm_arena_strdup(a, name); - } - if (nlen == 0) { - return cbm_arena_strdup(a, base); - } - - /* Strip trailing slash from base */ - while (blen > 0 && base[blen - SKIP_ONE] == '/') { - blen--; - } - /* Strip leading slash from name */ - while (nlen > 0 && *name == '/') { - name++; - nlen--; - } - - if (blen == 0) { - return cbm_arena_strndup(a, name, nlen); - } - if (nlen == 0) { - return cbm_arena_strndup(a, base, blen); - } - - char *result = (char *)cbm_arena_alloc(a, blen + SKIP_ONE + nlen + SKIP_ONE); - if (!result) { - return NULL; - } - memcpy(result, base, blen); - result[blen] = '/'; - memcpy(result + blen + SKIP_ONE, name, nlen); - result[blen + SKIP_ONE + nlen] = '\0'; - return result; -} - -char *cbm_path_join_n(CBMArena *a, const char **parts, int n) { - if (n <= 0 || !parts) { - return cbm_arena_strdup(a, ""); - } - if (n == SKIP_ONE) { - return cbm_arena_strdup(a, parts[0]); - } - - char *result = cbm_arena_strdup(a, parts[0]); - for (int i = SKIP_ONE; i < n; i++) { - result = cbm_path_join(a, result, parts[i]); - } - return result; -} - -const char *cbm_path_ext(const char *path) { - if (!path) { - return ""; - } - const char *dot = NULL; - const char *slash = NULL; - for (const char *p = path; *p; p++) { - if (*p == '.') { - dot = p; - } - if (*p == '/') { - slash = p; - } - } - /* dot must be after last slash and not at start of basename */ - if (!dot) { - return ""; - } - if (slash && dot < slash) { - return ""; - } - return dot + SKIP_ONE; -} - -const char *cbm_path_last_dot(const char *path) { - if (!path) { - return ""; - } - const char *dot = strrchr(path, '.'); - return dot ? dot : ""; -} - -const char *cbm_path_base(const char *path) { - if (!path) { - return ""; - } - const char *last_slash = NULL; - for (const char *p = path; *p; p++) { - if (*p == '/') { - last_slash = p; - } - } - return last_slash ? last_slash + SKIP_ONE : path; -} - -char *cbm_path_dir(CBMArena *a, const char *path) { - if (!path) { - return cbm_arena_strdup(a, "."); - } - const char *last_slash = NULL; - for (const char *p = path; *p; p++) { - if (*p == '/') { - last_slash = p; - } - } - if (!last_slash) { - return cbm_arena_strdup(a, "."); - } - return cbm_arena_strndup(a, path, (size_t)(last_slash - path)); -} - -bool cbm_str_starts_with(const char *s, const char *prefix) { - if (!s || !prefix) { - return false; - } - size_t plen = strlen(prefix); - return strncmp(s, prefix, plen) == 0; -} - -bool cbm_str_ends_with(const char *s, const char *suffix) { - if (!s || !suffix) { - return false; - } - size_t slen = strlen(s); - size_t xlen = strlen(suffix); - if (xlen > slen) { - return false; - } - return strcmp(s + slen - xlen, suffix) == 0; -} - -bool cbm_str_contains(const char *s, const char *sub) { - if (!s || !sub) { - return false; - } - if (sub[0] == '\0') { - return true; - } - return strstr(s, sub) != NULL; -} - -char *cbm_str_tolower(CBMArena *a, const char *s) { - if (!s) { - return NULL; - } - size_t len = strlen(s); - char *result = (char *)cbm_arena_alloc(a, len + SKIP_ONE); - if (!result) { - return NULL; - } - for (size_t i = 0; i < len; i++) { - result[i] = (char)tolower((unsigned char)s[i]); - } - result[len] = '\0'; - return result; -} - -char *cbm_str_replace_char(CBMArena *a, const char *s, char from, char to) { - if (!s) { - return NULL; - } - size_t len = strlen(s); - char *result = (char *)cbm_arena_alloc(a, len + SKIP_ONE); - if (!result) { - return NULL; - } - for (size_t i = 0; i < len; i++) { - result[i] = (s[i] == from) ? to : s[i]; - } - result[len] = '\0'; - return result; -} - -char *cbm_str_strip_ext(CBMArena *a, const char *path) { - if (!path) { - return NULL; - } - const char *dot = NULL; - const char *slash = NULL; - for (const char *p = path; *p; p++) { - if (*p == '.') { - dot = p; - } - if (*p == '/') { - slash = p; - } - } - if (!dot || (slash && dot < slash)) { - return cbm_arena_strdup(a, path); - } - return cbm_arena_strndup(a, path, (size_t)(dot - path)); -} - -char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count) { - if (!s || !out_count) { - return NULL; - } - - /* Count parts */ - int count = SKIP_ONE; - for (const char *p = s; *p; p++) { - if (*p == delim) { - count++; - } - } - - char **result = (char **)cbm_arena_alloc(a, (size_t)(count + SKIP_ONE) * sizeof(char *)); - if (!result) { - return NULL; - } - - int idx = 0; - const char *start = s; - for (const char *p = s;; p++) { - if (*p == delim || *p == '\0') { - size_t part_len = (size_t)(p - start); - result[idx++] = cbm_arena_strndup(a, start, part_len); - if (*p == '\0') { - break; - } - start = p + SKIP_ONE; - } - } - - result[idx] = NULL; - *out_count = count; - return result; -} - -bool cbm_validate_shell_arg(const char *s) { - if (!s) { - return false; - } - for (const char *p = s; *p; p++) { - switch (*p) { - case '\'': - case '"': - case ';': - case '|': - case '&': - case '$': - case '`': - case '<': - case '>': - case '\n': - case '\r': -#ifndef _WIN32 - case '\\': -#endif - return false; - default: - break; - } - } - return true; -} - -bool cbm_validate_project_name(const char *name) { - if (!name || !*name) - return false; - /* Reject directory traversal */ - if (strcmp(name, "..") == 0 || strstr(name, "..") != NULL) - return false; - /* Reject path separators */ - if (strchr(name, '/') || strchr(name, '\\')) - return false; - /* Reject leading dot (hidden files / relative refs) */ - if (name[0] == '.') - return false; - /* Allow only alphanumeric, dash, underscore, dot */ - for (const char *p = name; *p; p++) { - if (!(((*p >= 'a') && (*p <= 'z')) || ((*p >= 'A') && (*p <= 'Z')) || - ((*p >= '0') && (*p <= '9')) || *p == '-' || *p == '_' || *p == '.')) { - return false; - } - } - return true; -} - -int cbm_json_escape(char *buf, int bufsize, const char *src) { - if (!buf || bufsize <= 0) { - return 0; - } - if (!src) { - buf[0] = '\0'; - return 0; - } - int pos = 0; - for (int i = 0; src[i] && pos < bufsize - JSON_NUL_RESERVE; i++) { - unsigned char c = (unsigned char)src[i]; - if (c == '"' || c == '\\') { - if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) { - break; - } - buf[pos++] = '\\'; - buf[pos++] = (char)c; - } else if (c == '\n') { - if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) { - break; - } - buf[pos++] = '\\'; - buf[pos++] = 'n'; - } else if (c == '\r') { - if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) { - break; - } - buf[pos++] = '\\'; - buf[pos++] = 'r'; - } else if (c == '\t') { - if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) { - break; - } - buf[pos++] = '\\'; - buf[pos++] = 't'; - } else if (c < JSON_CTRL_LIMIT) { - /* Other control chars: escape as \u00XX */ - if (pos + 6 > bufsize - JSON_NUL_RESERVE) { - break; - } - pos += snprintf(buf + pos, 7, "\\u%04x", c); - } else { - buf[pos++] = (char)c; - } - } - buf[pos] = '\0'; - return pos; -} diff --git a/src/foundation/str_util.zig b/src/foundation/str_util.zig new file mode 100644 index 00000000..7c9956d9 --- /dev/null +++ b/src/foundation/str_util.zig @@ -0,0 +1,221 @@ +//! str_util.zig — Safe string operations (arena-allocated). +//! +//! Implements src/foundation/str_util.h. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const arena = @import("arena.zig"); + +const Str = [*:0]const u8; + +const JSON_ESC_LEN = 2; // escaped char takes 2 bytes (backslash + char) +const JSON_NUL_RESERVE = 1; // reserve 1 byte for NUL terminator +const JSON_CTRL_LIMIT = 0x20; // ASCII control character upper bound + +pub export fn cbm_path_join(a: ?*c.CBMArena, base: ?Str, name: ?Str) ?[*:0]u8 { + const b = base orelse return null; + var n = name orelse return null; + var blen = std.mem.len(b); + var nlen = std.mem.len(n); + + // Handle empty components + if (blen == 0) return arena.cbm_arena_strdup(a, n); + if (nlen == 0) return arena.cbm_arena_strdup(a, b); + + // Strip trailing slash from base + while (blen > 0 and b[blen - 1] == '/') blen -= 1; + // Strip leading slash from name + while (nlen > 0 and n[0] == '/') { + n += 1; + nlen -= 1; + } + + if (blen == 0) return arena.cbm_arena_strndup(a, n, nlen); + if (nlen == 0) return arena.cbm_arena_strndup(a, b, blen); + + const result: [*]u8 = @ptrCast(arena.cbm_arena_alloc(a, blen + 1 + nlen + 1) orelse return null); + @memcpy(result[0..blen], b[0..blen]); + result[blen] = '/'; + @memcpy(result[blen + 1 ..][0..nlen], n[0..nlen]); + result[blen + 1 + nlen] = 0; + return @ptrCast(result); +} + +pub export fn cbm_path_join_n(a: ?*c.CBMArena, parts: ?[*]?Str, n: c_int) ?[*:0]u8 { + const p = parts orelse return arena.cbm_arena_strdup(a, ""); + if (n <= 0) return arena.cbm_arena_strdup(a, ""); + var result = arena.cbm_arena_strdup(a, p[0]); + for (1..@intCast(n)) |i| { + result = cbm_path_join(a, result, p[i]); + } + return result; +} + +/// The last '.' and the last '/' of `path`, as indexes. +fn lastDotAndSlash(path: []const u8) struct { dot: ?usize, slash: ?usize } { + return .{ + .dot = std.mem.lastIndexOfScalar(u8, path, '.'), + .slash = std.mem.lastIndexOfScalar(u8, path, '/'), + }; +} + +pub export fn cbm_path_ext(path: ?Str) Str { + const p = path orelse return ""; + const marks = lastDotAndSlash(std.mem.span(p)); + // dot must be after last slash and not at start of basename + const dot = marks.dot orelse return ""; + if (marks.slash) |slash| { + if (dot < slash) return ""; + } + return p + dot + 1; +} + +pub export fn cbm_path_last_dot(path: ?Str) Str { + const p = path orelse return ""; + const dot = std.mem.lastIndexOfScalar(u8, std.mem.span(p), '.') orelse return ""; + return p + dot; +} + +pub export fn cbm_path_base(path: ?Str) Str { + const p = path orelse return ""; + const slash = std.mem.lastIndexOfScalar(u8, std.mem.span(p), '/') orelse return p; + return p + slash + 1; +} + +pub export fn cbm_path_dir(a: ?*c.CBMArena, path: ?Str) ?[*:0]u8 { + const p = path orelse return arena.cbm_arena_strdup(a, "."); + const slash = std.mem.lastIndexOfScalar(u8, std.mem.span(p), '/') orelse + return arena.cbm_arena_strdup(a, "."); + return arena.cbm_arena_strndup(a, p, slash); +} + +pub export fn cbm_str_starts_with(s: ?Str, prefix: ?Str) bool { + const str = s orelse return false; + const pre = prefix orelse return false; + return std.mem.startsWith(u8, std.mem.span(str), std.mem.span(pre)); +} + +pub export fn cbm_str_ends_with(s: ?Str, suffix: ?Str) bool { + const str = s orelse return false; + const suf = suffix orelse return false; + return std.mem.endsWith(u8, std.mem.span(str), std.mem.span(suf)); +} + +pub export fn cbm_str_contains(s: ?Str, sub: ?Str) bool { + const str = s orelse return false; + const needle = sub orelse return false; + return std.mem.indexOf(u8, std.mem.span(str), std.mem.span(needle)) != null; +} + +pub export fn cbm_str_tolower(a: ?*c.CBMArena, s: ?Str) ?[*:0]u8 { + const src = std.mem.span(s orelse return null); + const result: [*]u8 = @ptrCast(arena.cbm_arena_alloc(a, src.len + 1) orelse return null); + for (src, 0..) |ch, i| result[i] = std.ascii.toLower(ch); + result[src.len] = 0; + return @ptrCast(result); +} + +pub export fn cbm_str_replace_char(a: ?*c.CBMArena, s: ?Str, from: u8, to: u8) ?[*:0]u8 { + const src = std.mem.span(s orelse return null); + const result: [*]u8 = @ptrCast(arena.cbm_arena_alloc(a, src.len + 1) orelse return null); + for (src, 0..) |ch, i| result[i] = if (ch == from) to else ch; + result[src.len] = 0; + return @ptrCast(result); +} + +pub export fn cbm_str_strip_ext(a: ?*c.CBMArena, path: ?Str) ?[*:0]u8 { + const p = path orelse return null; + const marks = lastDotAndSlash(std.mem.span(p)); + const dot = marks.dot orelse return arena.cbm_arena_strdup(a, p); + if (marks.slash) |slash| { + if (dot < slash) return arena.cbm_arena_strdup(a, p); + } + return arena.cbm_arena_strndup(a, p, dot); +} + +pub export fn cbm_str_split(a: ?*c.CBMArena, s: ?Str, delim: u8, out_count: ?*c_int) ?[*]?[*:0]u8 { + const src = std.mem.span(s orelse return null); + const count_out = out_count orelse return null; + + // Count parts + const count = 1 + std.mem.count(u8, src, &.{delim}); + + const result: [*]?[*:0]u8 = @ptrCast(@alignCast(arena.cbm_arena_alloc(a, (count + 1) * @sizeOf(?[*:0]u8)) orelse return null)); + + var idx: usize = 0; + var parts = std.mem.splitScalar(u8, src, delim); + while (parts.next()) |part| : (idx += 1) { + result[idx] = arena.cbm_arena_strndup(a, part.ptr, part.len); + } + result[idx] = null; + count_out.* = @intCast(count); + return result; +} + +pub export fn cbm_validate_shell_arg(s: ?Str) bool { + const str = std.mem.span(s orelse return false); + for (str) |ch| { + switch (ch) { + '\'', '"', ';', '|', '&', '$', '`', '<', '>', '\n', '\r' => return false, + // A backslash is a path separator on Windows. + '\\' => if (builtin.os.tag != .windows) return false, + else => {}, + } + } + return true; +} + +pub export fn cbm_validate_project_name(name: ?Str) bool { + const n = std.mem.span(name orelse return false); + if (n.len == 0) return false; + // Reject directory traversal + if (std.mem.indexOf(u8, n, "..") != null) return false; + // Reject leading dot (hidden files / relative refs) + if (n[0] == '.') return false; + // Allow only alphanumeric, dash, underscore, dot (so no path separators) + for (n) |ch| { + if (!(std.ascii.isAlphanumeric(ch) or ch == '-' or ch == '_' or ch == '.')) return false; + } + return true; +} + +pub export fn cbm_json_escape(buf: ?[*]u8, bufsize: c_int, src: ?Str) c_int { + const out = buf orelse return 0; + if (bufsize <= 0) return 0; + const s = src orelse { + out[0] = 0; + return 0; + }; + + const limit: usize = @intCast(bufsize - JSON_NUL_RESERVE); + var pos: usize = 0; + var i: usize = 0; + while (s[i] != 0 and pos < limit) : (i += 1) { + const ch = s[i]; + const short: ?u8 = switch (ch) { + '"', '\\' => ch, + '\n' => 'n', + '\r' => 'r', + '\t' => 't', + else => null, + }; + if (short) |esc| { + if (pos + JSON_ESC_LEN > limit) break; + out[pos] = '\\'; + out[pos + 1] = esc; + pos += JSON_ESC_LEN; + } else if (ch < JSON_CTRL_LIMIT) { + // Other control chars: escape as \u00XX + if (pos + 6 > limit) break; + const hex = "0123456789abcdef"; + out[pos..][0..6].* = .{ '\\', 'u', '0', '0', hex[ch >> 4], hex[ch & 0xf] }; + pos += 6; + } else { + out[pos] = ch; + pos += 1; + } + } + out[pos] = 0; + return @intCast(pos); +} diff --git a/src/foundation/subprocess.c b/src/foundation/subprocess.c deleted file mode 100644 index c2476344..00000000 --- a/src/foundation/subprocess.c +++ /dev/null @@ -1,668 +0,0 @@ -/* - * subprocess.c — cross-platform spawn + supervise + classify. - * See subprocess.h. - */ -#include "subprocess.h" - -#include "compat.h" /* cbm_nanosleep */ -#include "compat_fs.h" -#include "platform.h" /* cbm_now_ms */ - -#include -#include - -#ifdef _WIN32 -#include -#include "win_utf8.h" /* cbm_utf8_to_wide — spawn the worker with a wide command line so a - * non-ASCII repo path survives CreateProcess (#423/#20) */ -#include /* free */ -#else -#include -#include -#include -#include -#include -#include -#endif -#ifdef __APPLE__ -#include -#endif - -/* NTSTATUS severity ERROR (top two bits set) covers the Windows crash exception - * exit codes: 0xC0000005 (access violation), 0xC00000FD (stack overflow), - * 0xC000001D (illegal instruction), 0xC0000094 (integer divide by zero), … */ -#define CBM_WIN_CRASH_CODE_MIN 0xC0000000u - -#ifndef _WIN32 -static bool cbm_is_fault_signal(int sig) { - switch (sig) { - case SIGSEGV: - case SIGBUS: - case SIGILL: - case SIGFPE: - case SIGABRT: - case SIGSYS: - return true; - default: - return false; - } -} -#endif - -cbm_proc_outcome_t cbm_proc_classify(bool exited_normally, int exit_code, int term_signal, - bool timed_out) { - if (timed_out) { - return CBM_PROC_HANG; - } - if (!exited_normally) { - /* POSIX signal death. */ -#ifndef _WIN32 - if (cbm_is_fault_signal(term_signal)) { - return CBM_PROC_CRASH; - } -#else - (void)term_signal; -#endif - return CBM_PROC_KILLED; - } - /* Exited with a code. A Windows NTSTATUS exception code is a crash; on POSIX - * exit codes are 0..255 so this branch never misfires there. */ - if ((unsigned)exit_code >= CBM_WIN_CRASH_CODE_MIN) { - return CBM_PROC_CRASH; - } - return (exit_code == 0) ? CBM_PROC_CLEAN : CBM_PROC_EXIT_NONZERO; -} - -const char *cbm_proc_outcome_str(cbm_proc_outcome_t o) { - switch (o) { - case CBM_PROC_CLEAN: - return "clean"; - case CBM_PROC_EXIT_NONZERO: - return "exit_nonzero"; - case CBM_PROC_CRASH: - return "crash"; - case CBM_PROC_HANG: - return "hang"; - case CBM_PROC_KILLED: - return "killed"; - case CBM_PROC_OUTPUT_LIMIT: - return "output_limit"; - case CBM_PROC_SPAWN_FAILED: - default: - return "spawn_failed"; - } -} - -/* Tail newly-appended complete lines from the child log, starting at *tail_pos. - * A partial (non-newline-terminated) final line is left buffered: *tail_pos is - * not advanced past it, so it is re-read once completed. Returns true if any - * complete line was consumed (i.e. there was progress). */ -static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb cb, void *ud) { - if (!log_file) { - return false; - } - FILE *lf = cbm_fopen(log_file, "r"); - if (!lf) { - return false; - } - bool progressed = false; - if (fseek(lf, *tail_pos, SEEK_SET) == 0) { - char line[1024]; - for (;;) { - long before = ftell(lf); - if (!fgets(line, sizeof(line), lf)) { - break; - } - size_t l = strlen(line); - bool complete = (l > 0 && line[l - 1] == '\n'); - if (complete) { - line[l - 1] = '\0'; - *tail_pos = ftell(lf); - progressed = true; - if (line[0] && cb) { - cb(line, ud); - } - } else if (l == sizeof(line) - 1) { - /* Oversized line filled the buffer without a newline — consume it - * anyway (counts as progress) so we never stall on one long line. */ - *tail_pos = ftell(lf); - progressed = true; - if (cb) { - cb(line, ud); - } - } else { - /* Genuine partial final line — keep it buffered for next poll. */ - *tail_pos = before; - break; - } - } - } - fclose(lf); - return progressed; -} - -/* ── Windows command-line quoting (pure; unit-tested on every platform) ─────── */ - -/* Append char `c` to buf[cap], reserving the final byte for a NUL terminator. - * On overflow: sets *ovf, stops writing, and returns pos UNCHANGED — callers detect - * the overflow via the *ovf flag (not via the return value). */ -static size_t cbm_cmdline_put(char *buf, size_t cap, size_t pos, char c, bool *ovf) { - if (pos + 1 >= cap) { - *ovf = true; - return pos; - } - buf[pos] = c; - return pos + 1; -} - -/* Append one argv element to the command line using the Microsoft C runtime - * quoting rules (see MS "Parsing C Command-Line Arguments"). CreateProcess takes - * a SINGLE string that the child re-parses back into argv, so any element with a - * space, tab or double-quote must be wrapped in quotes and its embedded quotes / - * preceding backslashes escaped. Without this a JSON argument like - * {"repo_path":"C:/r"} loses its inner quotes and the child receives the invalid - * {repo_path:C:/r} — the Windows-only index-worker cmdline-quoting bug (the worker exited - * non-zero at JSON-arg parse, misattributed to the last-marked file). POSIX is - * unaffected: cbm_run_posix passes the argv array straight to execv. */ -static size_t cbm_cmdline_append_arg(char *buf, size_t cap, size_t pos, const char *arg, bool first, - bool *ovf) { - if (!first) { - pos = cbm_cmdline_put(buf, cap, pos, ' ', ovf); - } - pos = cbm_cmdline_put(buf, cap, pos, '"', ovf); - for (const char *p = arg; *p;) { - size_t nbs = 0; - while (*p == '\\') { - nbs++; - p++; - } - if (*p == '\0') { - /* Trailing backslashes precede the closing quote: double them so the - * quote stays a delimiter, not an escaped literal. */ - for (size_t k = 0; k < nbs * 2; k++) { - pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf); - } - break; - } - if (*p == '"') { - /* N backslashes then a quote -> 2N+1 backslashes then an escaped quote. */ - for (size_t k = 0; k < nbs * 2 + 1; k++) { - pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf); - } - pos = cbm_cmdline_put(buf, cap, pos, '"', ovf); - p++; - } else { - for (size_t k = 0; k < nbs; k++) { - pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf); - } - pos = cbm_cmdline_put(buf, cap, pos, *p, ovf); - p++; - } - } - pos = cbm_cmdline_put(buf, cap, pos, '"', ovf); - return pos; -} - -/* Build a full Windows CreateProcess command line from a NULL-terminated argv, - * applying the MS C runtime quoting rules so the child re-parses byte-identical - * argv. Returns true on success, false if the result would overflow `buf`. - * - * Defined unconditionally (pure string logic, no Windows headers) so the quoting - * contract is unit-tested on Linux/macOS CI too — even though the real spawn path - * only runs on Windows. Shared by cbm_run_win AND the UI http_server index spawn - * so both escape identically; a naive `"%s"` wrap silently corrupts any argument - * containing a quote (e.g. the index JSON {"repo_path":"…"}), corrupting the - * spawned child's argv. */ -bool cbm_build_win_cmdline(char *buf, size_t cap, const char *const *argv) { - if (!buf || cap == 0 || !argv) { - return false; - } - size_t pos = 0; - bool ovf = false; - for (int i = 0; argv[i]; i++) { - pos = cbm_cmdline_append_arg(buf, cap, pos, argv[i], i == 0, &ovf); - if (ovf) { - buf[0] = '\0'; /* overflow: leave buf a valid (empty) string, never unterminated */ - return false; - } - } - buf[pos] = '\0'; - return true; -} - -/* Wall-clock deadline / output cap checks shared by both run loops. */ -static bool deadline_passed(const cbm_proc_opts_t *opts, uint64_t started) { - return opts->deadline_ms > 0 && (cbm_now_ms() - started) >= (uint64_t)opts->deadline_ms; -} - -static bool log_over_limit(const cbm_proc_opts_t *opts) { - if (opts->max_log_bytes <= 0 || !opts->log_file) { - return false; - } - FILE *lf = fopen(opts->log_file, "rb"); - if (!lf) { - return false; - } - bool over = fseek(lf, 0, SEEK_END) == 0 && ftell(lf) > opts->max_log_bytes; - (void)fclose(lf); - return over; -} - -#ifdef _WIN32 - -static int cbm_run_win(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) { - const char *bin = opts->bin; - const char *const default_argv[] = {bin, NULL}; - const char *const *argv = opts->argv ? opts->argv : default_argv; - - char cmdline[8192]; - if (!cbm_build_win_cmdline(cmdline, sizeof(cmdline), argv)) { - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - return -1; - } - /* Spawn via CreateProcessW with a WIDE command line. CreateProcessA would - * re-interpret our UTF-8 cmdline bytes through the ANSI code page (CP_ACP), - * re-mangling a non-ASCII repo path at the parent->worker boundary — so the - * worker's own wide-argv read could never recover it (#423/#20). */ - wchar_t *wcmd = cbm_utf8_to_wide(cmdline); - if (!wcmd) { - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - return -1; - } - - HANDLE hlog = INVALID_HANDLE_VALUE; - STARTUPINFOW si = {.cb = sizeof(si)}; - if (opts->log_file) { - /* STARTF_USESTDHANDLES needs inheritable handles; a default (NULL) - * security descriptor is not, and the child's output would be lost. */ - SECURITY_ATTRIBUTES sa = {.nLength = sizeof(sa), .bInheritHandle = TRUE}; - hlog = CreateFileA(opts->log_file, GENERIC_WRITE, FILE_SHARE_READ, &sa, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, NULL); - if (hlog != INVALID_HANDLE_VALUE) { - si.dwFlags = STARTF_USESTDHANDLES; - si.hStdError = hlog; - si.hStdOutput = hlog; - } - } - - PROCESS_INFORMATION pi = {0}; - BOOL ok = CreateProcessW(NULL, wcmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi); - free(wcmd); - if (hlog != INVALID_HANDLE_VALUE) { - CloseHandle(hlog); - } - if (!ok) { - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - return -1; - } - - long tail_pos = 0; - uint64_t started = cbm_now_ms(); - uint64_t last_activity = started; - bool timed_out = false; - bool output_limited = false; - for (;;) { - DWORD w = WaitForSingleObject(pi.hProcess, 200); - if (cbm_tail_log(opts->log_file, &tail_pos, opts->on_log_line, opts->log_ud)) { - last_activity = cbm_now_ms(); - } - if (w == WAIT_OBJECT_0) { - break; - } - output_limited = log_over_limit(opts); - timed_out = !output_limited && - ((opts->quiet_timeout_ms > 0 && - (cbm_now_ms() - last_activity) >= (uint64_t)opts->quiet_timeout_ms) || - deadline_passed(opts, started)); - if (timed_out || output_limited) { - TerminateProcess(pi.hProcess, 1); - WaitForSingleObject(pi.hProcess, INFINITE); - break; - } - } - - DWORD code = 1; - GetExitCodeProcess(pi.hProcess, &code); - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - if (opts->log_file && opts->delete_log_on_exit) { - DeleteFileA(opts->log_file); - } - - out->exit_code = (int)code; - out->term_signal = 0; - out->outcome = output_limited ? CBM_PROC_OUTPUT_LIMIT - : cbm_proc_classify(true, (int)code, 0, timed_out); - return 0; -} - -#else /* POSIX */ - -#if defined(__APPLE__) -#include - -#ifndef POSIX_SPAWN_CLOEXEC_DEFAULT -#define POSIX_SPAWN_CLOEXEC_DEFAULT 0x2000 -#endif - -static int cbm_run_apple_spawn(const cbm_proc_opts_t *opts, cbm_proc_result_t *out, pid_t *out_pid) { - posix_spawnattr_t attr; - if (posix_spawnattr_init(&attr) != 0) { - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - return -1; - } - short flags = POSIX_SPAWN_CLOEXEC_DEFAULT; - if (opts->own_process_group) { - flags |= POSIX_SPAWN_SETPGROUP; - (void)posix_spawnattr_setpgroup(&attr, 0); - } - (void)posix_spawnattr_setflags(&attr, flags); - - posix_spawn_file_actions_t actions; - if (posix_spawn_file_actions_init(&actions) != 0) { - (void)posix_spawnattr_destroy(&attr); - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - return -1; - } - - /* Stdin: explicitly open /dev/null so child never steals from stdin (e.g. MCP stdio transport) */ - (void)posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0); - - /* Redirect stdout and stderr to the log (or discard) */ - const char *target = opts->log_file ? opts->log_file : "/dev/null"; - (void)posix_spawn_file_actions_addopen(&actions, STDOUT_FILENO, target, - O_WRONLY | O_CREAT | O_TRUNC, 0644); - (void)posix_spawn_file_actions_adddup2(&actions, STDOUT_FILENO, STDERR_FILENO); - - const char *bin = opts->bin; - const char *const default_argv[] = {bin, NULL}; - const char *const *argv = opts->argv ? opts->argv : default_argv; - - pid_t pid = -1; - extern char **environ; - int rc = posix_spawnp(&pid, bin, &actions, &attr, (char *const *)argv, environ); - - (void)posix_spawn_file_actions_destroy(&actions); - (void)posix_spawnattr_destroy(&attr); - - if (rc != 0 || pid <= 0) { - if (opts->delete_log_on_exit && opts->log_file) { - (void)unlink(opts->log_file); - } - if (rc == ENOENT || rc == EACCES) { - /* Bogus binary path fails cleanly like execv failure (_exit 127) */ - out->outcome = CBM_PROC_EXIT_NONZERO; - out->exit_code = 127; - out->term_signal = 0; - return 0; - } - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - return -1; - } - *out_pid = pid; - return 1; -} -#endif - -static int cbm_run_posix(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) { - pid_t pid = -1; -#if defined(__APPLE__) - int spawned = cbm_run_apple_spawn(opts, out, &pid); - if (spawned <= 0) { - return spawned; - } -#else - pid = fork(); - if (pid < 0) { - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - return -1; - } - if (pid == 0) { - if (opts->own_process_group) { - (void)setpgid(0, 0); - } - /* Child: redirect stdout+stderr to the log (or discard), then exec. - * Use open()+dup2() (async-signal-safe, no malloc) rather than freopen(): - * the parent may be multithreaded (the MCP server holds worker/watcher/http - * threads plus mimalloc/sqlite global state), and a fork() copies - * only the calling thread — a malloc between fork and exec could deadlock on - * a lock another thread held at fork time. open/dup2/execv touch no heap. */ - const char *bin = opts->bin; - const char *const default_argv[] = {bin, NULL}; - const char *const *argv = opts->argv ? opts->argv : default_argv; - const char *target = opts->log_file ? opts->log_file : "/dev/null"; - int fd = open(target, O_WRONLY | O_CREAT | O_TRUNC, 0644); - if (fd >= 0) { - (void)dup2(fd, STDOUT_FILENO); - (void)dup2(fd, STDERR_FILENO); - if (fd > STDERR_FILENO) { - (void)close(fd); - } - } - /* Close all descriptors above stderr so children (workers, git, grep) - * never inherit open server descriptors or lock files (#77). */ - cbm_fd_close_above_stderr(); - execv(bin, (char *const *)argv); - _exit(127); /* exec failed */ - } - - if (opts->own_process_group) { - /* Also set from the parent so a kill issued before the child's own - * setpgid runs still reaches the group (EACCES after exec is fine). */ - (void)setpgid(pid, pid); - } -#endif - pid_t kill_target = opts->own_process_group ? -pid : pid; - - long tail_pos = 0; - uint64_t started = cbm_now_ms(); - uint64_t last_activity = started; - bool timed_out = false; - bool output_limited = false; - int wstatus = 0; - for (;;) { - pid_t wr; - do { - wr = waitpid(pid, &wstatus, WNOHANG); - } while (wr < 0 && errno == EINTR); - bool done = (wr == pid); - - if (cbm_tail_log(opts->log_file, &tail_pos, opts->on_log_line, opts->log_ud)) { - last_activity = cbm_now_ms(); - } - if (done) { - break; - } - output_limited = log_over_limit(opts); - timed_out = !output_limited && - ((opts->quiet_timeout_ms > 0 && - (cbm_now_ms() - last_activity) >= (uint64_t)opts->quiet_timeout_ms) || - deadline_passed(opts, started)); - if (timed_out || output_limited) { - kill(kill_target, SIGKILL); - do { - wr = waitpid(pid, &wstatus, 0); - } while (wr < 0 && errno == EINTR); - break; - } - struct timespec ts = {0, 100000000L}; /* 100 ms poll */ - cbm_nanosleep(&ts, NULL); - } - - if (opts->log_file && opts->delete_log_on_exit) { - (void)unlink(opts->log_file); - } - - if (output_limited) { - out->exit_code = -1; - out->term_signal = SIGKILL; - out->outcome = CBM_PROC_OUTPUT_LIMIT; - } else if (WIFEXITED(wstatus)) { - out->exit_code = WEXITSTATUS(wstatus); - out->term_signal = 0; - out->outcome = cbm_proc_classify(true, out->exit_code, 0, timed_out); - } else if (WIFSIGNALED(wstatus)) { - out->exit_code = -1; - out->term_signal = WTERMSIG(wstatus); - out->outcome = cbm_proc_classify(false, -1, out->term_signal, timed_out); - } else { - out->exit_code = -1; - out->term_signal = 0; - out->outcome = timed_out ? CBM_PROC_HANG : CBM_PROC_KILLED; - } - return 0; -} - -#endif - -int cbm_subprocess_run(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) { - cbm_proc_result_t local; - if (!out) { - out = &local; - } - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - if (!opts || !opts->bin || !opts->bin[0]) { - return -1; - } -#ifdef _WIN32 - return cbm_run_win(opts, out); -#else - return cbm_run_posix(opts, out); -#endif -} - -/* ── Own executable path ──────────────────────────────────────── */ - -static bool copy_path(char *out, size_t outsz, const char *path) { - if (!out || outsz == 0 || !path || !path[0]) { - return false; - } - int n = snprintf(out, outsz, "%s", path); - return n > 0 && (size_t)n < outsz; -} - -#ifndef _WIN32 -static bool is_executable_file(const char *path) { - struct stat st; - return path && stat(path, &st) == 0 && S_ISREG(st.st_mode) && access(path, X_OK) == 0; -} - -static bool resolve_from_path(const char *name, char *out, size_t outsz) { - const char *path = getenv("PATH"); - if (!name || !name[0] || strchr(name, '/') || !path || !path[0]) { - return false; - } - - const char *cur = path; - while (*cur) { - const char *colon = strchr(cur, ':'); - size_t dir_len = colon ? (size_t)(colon - cur) : strlen(cur); - if (dir_len > 0 && dir_len < 900) { - char candidate[1024]; - int n = snprintf(candidate, sizeof(candidate), "%.*s/%s", (int)dir_len, cur, name); - if (n > 0 && (size_t)n < sizeof(candidate) && is_executable_file(candidate)) { - return copy_path(out, outsz, candidate); - } - } - if (!colon) { - break; - } - cur = colon + 1; - } - return false; -} - -static bool resolve_self_executable(char *out, size_t outsz) { -#if defined(__APPLE__) - char buf[1024]; - uint32_t sz = sizeof(buf); - if (_NSGetExecutablePath(buf, &sz) == 0 && buf[0]) { - return copy_path(out, outsz, buf); - } - return false; -#else - char buf[1024]; - ssize_t len = readlink("/proc/self/exe", buf, sizeof(buf) - 1); - if (len > 0) { - buf[len] = '\0'; - return copy_path(out, outsz, buf); - } - return false; -#endif -} -#else -static bool resolve_self_executable(char *out, size_t outsz) { - char buf[1024]; - DWORD n = GetModuleFileNameA(NULL, buf, (DWORD)sizeof(buf)); - if (n > 0 && n < sizeof(buf)) { - return copy_path(out, outsz, buf); - } - return false; -} -#endif - -bool cbm_resolve_binary_path(const char *argv0, char *out, size_t outsz) { - if (!out || outsz == 0) { - return false; - } - out[0] = '\0'; - -#ifndef _WIN32 - if (argv0 && strchr(argv0, '/') && is_executable_file(argv0)) { - return copy_path(out, outsz, argv0); - } - if (resolve_from_path(argv0, out, outsz)) { - return true; - } -#else - if (argv0 && argv0[0]) { - DWORD attrs = GetFileAttributesA(argv0); - if (attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY)) { - return copy_path(out, outsz, argv0); - } - } -#endif - -#ifndef _WIN32 - /* #1204 (upstream 11bbf0d8, 5b96b067): never hand back a self-resolved - * path that is not executable. After an installer's atomic rename-over the - * image path no longer exists (Linux readlink reads " (deleted)"), - * and spawning it fails with ENOENT. */ - if (resolve_self_executable(out, outsz)) { - if (is_executable_file(out)) { - return true; - } -#if defined(__linux__) - /* The /proc/self/exe magic link still executes the in-memory old - * build, the only image whose build fingerprint the worker accepts. */ - return copy_path(out, outsz, "/proc/self/exe"); -#else - /* No magic link: fail closed, and the supervisor degrades to - * in-process indexing instead of spawning a missing binary. */ - out[0] = '\0'; - return false; -#endif - } -#else - if (resolve_self_executable(out, outsz)) { - return true; - } -#endif - return copy_path(out, outsz, argv0); -} diff --git a/src/foundation/subprocess.zig b/src/foundation/subprocess.zig new file mode 100644 index 00000000..a884614f --- /dev/null +++ b/src/foundation/subprocess.zig @@ -0,0 +1,551 @@ +//! subprocess.zig — cross-platform spawn + supervise + classify. +//! +//! Implements src/foundation/subprocess.h. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("interop.zig"); + +const Str = interop.Str; + +const is_windows = builtin.os.tag == .windows; +const is_apple = builtin.os.tag.isDarwin(); +const is_linux = builtin.os.tag == .linux; + +/// NTSTATUS severity ERROR (top two bits set) covers the Windows crash exception +/// exit codes: 0xC0000005 (access violation), 0xC00000FD (stack overflow), +/// 0xC000001D (illegal instruction), 0xC0000094 (integer divide by zero), … +const CBM_WIN_CRASH_CODE_MIN: c_uint = 0xC0000000; + +fn isFaultSignal(sig: c_int) bool { + return sig == c.SIGSEGV or sig == c.SIGBUS or sig == c.SIGILL or + sig == c.SIGFPE or sig == c.SIGABRT or sig == c.SIGSYS; +} + +pub export fn cbm_proc_classify(exited_normally: bool, exit_code: c_int, term_signal: c_int, timed_out: bool) c.cbm_proc_outcome_t { + if (timed_out) return c.CBM_PROC_HANG; + if (!exited_normally) { + // POSIX signal death. + if (!is_windows and isFaultSignal(term_signal)) return c.CBM_PROC_CRASH; + return c.CBM_PROC_KILLED; + } + // Exited with a code. A Windows NTSTATUS exception code is a crash; on POSIX + // exit codes are 0..255 so this branch never misfires there. + if (@as(c_uint, @bitCast(exit_code)) >= CBM_WIN_CRASH_CODE_MIN) return c.CBM_PROC_CRASH; + return if (exit_code == 0) c.CBM_PROC_CLEAN else c.CBM_PROC_EXIT_NONZERO; +} + +pub export fn cbm_proc_outcome_str(o: c.cbm_proc_outcome_t) Str { + return switch (o) { + c.CBM_PROC_CLEAN => "clean", + c.CBM_PROC_EXIT_NONZERO => "exit_nonzero", + c.CBM_PROC_CRASH => "crash", + c.CBM_PROC_HANG => "hang", + c.CBM_PROC_KILLED => "killed", + c.CBM_PROC_OUTPUT_LIMIT => "output_limit", + else => "spawn_failed", + }; +} + +/// Tail newly-appended complete lines from the child log, starting at tail_pos. +/// A partial (non-newline-terminated) final line is left buffered: tail_pos is +/// not advanced past it, so it is re-read once completed. Returns true if any +/// complete line was consumed (i.e. there was progress). +fn tailLog(log_file: ?Str, tail_pos: *c_long, cb: c.cbm_proc_log_cb, ud: ?*anyopaque) bool { + const path = log_file orelse return false; + const lf = c.cbm_fopen(path, "r"); + if (lf == null) return false; + var progressed = false; + if (c.fseek(lf, tail_pos.*, c.SEEK_SET) == 0) { + var line: [1024]u8 = undefined; + while (true) { + const before = c.ftell(lf); + if (c.fgets(&line, line.len, lf) == null) break; + const l = std.mem.len(@as(Str, @ptrCast(&line))); + const complete = l > 0 and line[l - 1] == '\n'; + if (complete) { + line[l - 1] = 0; + tail_pos.* = c.ftell(lf); + progressed = true; + if (line[0] != 0) { + if (cb) |on_line| on_line(&line, ud); + } + } else if (l == line.len - 1) { + // Oversized line filled the buffer without a newline — consume it + // anyway (counts as progress) so we never stall on one long line. + tail_pos.* = c.ftell(lf); + progressed = true; + if (cb) |on_line| on_line(&line, ud); + } else { + // Genuine partial final line — keep it buffered for next poll. + tail_pos.* = before; + break; + } + } + } + _ = c.fclose(lf); + return progressed; +} + +// ── Windows command-line quoting (pure; unit-tested on every platform) ─────── + +/// Bounded writer for the command line: reserves the final byte for a NUL +/// terminator. On overflow it sets `ovf` and stops writing. +const Cmdline = struct { + buf: [*]u8, + cap: usize, + pos: usize = 0, + ovf: bool = false, + + fn put(self: *Cmdline, ch: u8) void { + if (self.pos + 1 >= self.cap) { + self.ovf = true; + return; + } + self.buf[self.pos] = ch; + self.pos += 1; + } + + fn putN(self: *Cmdline, ch: u8, n: usize) void { + for (0..n) |_| self.put(ch); + } + + /// Append one argv element to the command line using the Microsoft C runtime + /// quoting rules (see MS "Parsing C Command-Line Arguments"). CreateProcess + /// takes a SINGLE string that the child re-parses back into argv, so any + /// element with a space, tab or double-quote must be wrapped in quotes and + /// its embedded quotes / preceding backslashes escaped. Without this a JSON + /// argument like {"repo_path":"C:/r"} loses its inner quotes and the child + /// receives the invalid {repo_path:C:/r} — the Windows-only index-worker + /// cmdline-quoting bug (the worker exited non-zero at JSON-arg parse, + /// misattributed to the last-marked file). POSIX is unaffected: runPosix + /// passes the argv array straight to execv. + fn appendArg(self: *Cmdline, arg: Str, first: bool) void { + if (!first) self.put(' '); + self.put('"'); + var p = arg; + while (p[0] != 0) { + var nbs: usize = 0; + while (p[0] == '\\') { + nbs += 1; + p += 1; + } + if (p[0] == 0) { + // Trailing backslashes precede the closing quote: double them so + // the quote stays a delimiter, not an escaped literal. + self.putN('\\', nbs * 2); + break; + } + if (p[0] == '"') { + // N backslashes then a quote -> 2N+1 backslashes then an escaped quote. + self.putN('\\', nbs * 2 + 1); + self.put('"'); + } else { + self.putN('\\', nbs); + self.put(p[0]); + } + p += 1; + } + self.put('"'); + } +}; + +/// Build a full Windows CreateProcess command line from a NULL-terminated argv, +/// applying the MS C runtime quoting rules so the child re-parses byte-identical +/// argv. Returns true on success, false if the result would overflow `buf`. +/// +/// Defined unconditionally (pure string logic, no Windows headers) so the quoting +/// contract is unit-tested on Linux/macOS CI too — even though the real spawn path +/// only runs on Windows. Shared by runWin AND the UI http_server index spawn +/// so both escape identically; a naive `"%s"` wrap silently corrupts any argument +/// containing a quote (e.g. the index JSON {"repo_path":"…"}), corrupting the +/// spawned child's argv. +pub export fn cbm_build_win_cmdline(buf: ?[*]u8, cap: usize, argv: ?[*]const ?Str) bool { + const out = buf orelse return false; + const args = argv orelse return false; + if (cap == 0) return false; + var line: Cmdline = .{ .buf = out, .cap = cap }; + var i: usize = 0; + while (args[i]) |arg| : (i += 1) { + line.appendArg(arg, i == 0); + if (line.ovf) { + out[0] = 0; // overflow: leave buf a valid (empty) string, never unterminated + return false; + } + } + out[line.pos] = 0; + return true; +} + +// Wall-clock deadline / output cap checks shared by both run loops. + +fn deadlinePassed(opts: *const c.cbm_proc_opts_t, started: u64) bool { + return opts.deadline_ms > 0 and (c.cbm_now_ms() -% started) >= @as(u64, @intCast(opts.deadline_ms)); +} + +fn quietTimedOut(opts: *const c.cbm_proc_opts_t, last_activity: u64) bool { + return opts.quiet_timeout_ms > 0 and + (c.cbm_now_ms() -% last_activity) >= @as(u64, @intCast(opts.quiet_timeout_ms)); +} + +fn logOverLimit(opts: *const c.cbm_proc_opts_t) bool { + if (opts.max_log_bytes <= 0 or opts.log_file == null) return false; + const lf = c.fopen(opts.log_file, "rb"); + if (lf == null) return false; + const over = c.fseek(lf, 0, c.SEEK_END) == 0 and c.ftell(lf) > opts.max_log_bytes; + _ = c.fclose(lf); + return over; +} + +fn spawnFailed(out: *c.cbm_proc_result_t) c_int { + out.outcome = c.CBM_PROC_SPAWN_FAILED; + out.exit_code = -1; + out.term_signal = 0; + return -1; +} + +/// opts->argv, or { bin, NULL } stored in `fallback` when it is NULL. +fn argvOf(opts: *const c.cbm_proc_opts_t, fallback: *[2]?Str) [*]const ?Str { + if (opts.argv != null) return @ptrCast(opts.argv); + fallback.* = .{ opts.bin, null }; + return fallback; +} + +// ── Windows ────────────────────────────────────────────────────── + +const GENERIC_WRITE: c_uint = 0x40000000; +const INVALID_FILE_ATTRIBUTES: c_uint = 0xFFFFFFFF; +const WAIT_OBJECT_0: c_uint = 0; +const INFINITE: c_uint = 0xFFFFFFFF; + +fn runWin(opts: *const c.cbm_proc_opts_t, out: *c.cbm_proc_result_t) c_int { + var default_argv: [2]?Str = undefined; + const argv = argvOf(opts, &default_argv); + + var cmdline: [8192]u8 = undefined; + if (!cbm_build_win_cmdline(&cmdline, cmdline.len, argv)) return spawnFailed(out); + // Spawn via CreateProcessW with a WIDE command line. CreateProcessA would + // re-interpret our UTF-8 cmdline bytes through the ANSI code page (CP_ACP), + // re-mangling a non-ASCII repo path at the parent->worker boundary — so the + // worker's own wide-argv read could never recover it (#423/#20). + const wcmd = c.cbm_utf8_to_wide(&cmdline); + if (wcmd == null) return spawnFailed(out); + + var hlog: c.HANDLE = interop.INVALID_HANDLE_VALUE; + var si = std.mem.zeroes(c.STARTUPINFOW); + si.cb = @sizeOf(c.STARTUPINFOW); + if (opts.log_file != null) { + // STARTF_USESTDHANDLES needs inheritable handles; a default (NULL) + // security descriptor is not, and the child's output would be lost. + var sa = std.mem.zeroes(c.SECURITY_ATTRIBUTES); + sa.nLength = @sizeOf(c.SECURITY_ATTRIBUTES); + sa.bInheritHandle = c.TRUE; + hlog = c.CreateFileA(opts.log_file, GENERIC_WRITE, c.FILE_SHARE_READ, &sa, c.CREATE_ALWAYS, c.FILE_ATTRIBUTE_NORMAL, null); + if (hlog != interop.INVALID_HANDLE_VALUE) { + si.dwFlags = c.STARTF_USESTDHANDLES; + si.hStdError = hlog; + si.hStdOutput = hlog; + } + } + + var pi = std.mem.zeroes(c.PROCESS_INFORMATION); + const ok = c.CreateProcessW(null, wcmd, null, null, c.TRUE, 0, null, null, &si, &pi); + c.free(wcmd); + if (hlog != interop.INVALID_HANDLE_VALUE) _ = c.CloseHandle(hlog); + if (ok == 0) return spawnFailed(out); + + var tail_pos: c_long = 0; + const started = c.cbm_now_ms(); + var last_activity = started; + var timed_out = false; + var output_limited = false; + while (true) { + const w = c.WaitForSingleObject(pi.hProcess, 200); + if (tailLog(opts.log_file, &tail_pos, opts.on_log_line, opts.log_ud)) { + last_activity = c.cbm_now_ms(); + } + if (w == WAIT_OBJECT_0) break; + output_limited = logOverLimit(opts); + timed_out = !output_limited and (quietTimedOut(opts, last_activity) or deadlinePassed(opts, started)); + if (timed_out or output_limited) { + _ = c.TerminateProcess(pi.hProcess, 1); + _ = c.WaitForSingleObject(pi.hProcess, INFINITE); + break; + } + } + + var code: c.DWORD = 1; + _ = c.GetExitCodeProcess(pi.hProcess, &code); + _ = c.CloseHandle(pi.hProcess); + _ = c.CloseHandle(pi.hThread); + if (opts.log_file != null and opts.delete_log_on_exit) _ = c.DeleteFileA(opts.log_file); + + const exit_code: c_int = @bitCast(@as(c_uint, @truncate(code))); + out.exit_code = exit_code; + out.term_signal = 0; + out.outcome = if (output_limited) c.CBM_PROC_OUTPUT_LIMIT else cbm_proc_classify(true, exit_code, 0, timed_out); + return 0; +} + +// ── POSIX ──────────────────────────────────────────────────────── + +/// Returns 1 with *out_pid set when the child was spawned; otherwise the value +/// runPosix returns (out is filled). +fn runAppleSpawn(opts: *const c.cbm_proc_opts_t, out: *c.cbm_proc_result_t, out_pid: *c.pid_t) c_int { + var attr: c.posix_spawnattr_t = undefined; + if (c.posix_spawnattr_init(&attr) != 0) return spawnFailed(out); + var flags: c_short = c.POSIX_SPAWN_CLOEXEC_DEFAULT; + if (opts.own_process_group) { + flags |= c.POSIX_SPAWN_SETPGROUP; + _ = c.posix_spawnattr_setpgroup(&attr, 0); + } + _ = c.posix_spawnattr_setflags(&attr, flags); + + var actions: c.posix_spawn_file_actions_t = undefined; + if (c.posix_spawn_file_actions_init(&actions) != 0) { + _ = c.posix_spawnattr_destroy(&attr); + return spawnFailed(out); + } + + // Stdin: explicitly open /dev/null so child never steals from stdin (e.g. MCP stdio transport) + _ = c.posix_spawn_file_actions_addopen(&actions, c.STDIN_FILENO, "/dev/null", c.O_RDONLY, 0); + + // Redirect stdout and stderr to the log (or discard) + const target: Str = if (opts.log_file != null) opts.log_file else "/dev/null"; + _ = c.posix_spawn_file_actions_addopen(&actions, c.STDOUT_FILENO, target, c.O_WRONLY | c.O_CREAT | c.O_TRUNC, 0o644); + _ = c.posix_spawn_file_actions_adddup2(&actions, c.STDOUT_FILENO, c.STDERR_FILENO); + + var default_argv: [2]?Str = undefined; + const argv = argvOf(opts, &default_argv); + + var pid: c.pid_t = -1; + const rc = c.posix_spawnp(&pid, opts.bin, &actions, &attr, @ptrCast(argv), @ptrCast(interop.environ())); + + _ = c.posix_spawn_file_actions_destroy(&actions); + _ = c.posix_spawnattr_destroy(&attr); + + if (rc != 0 or pid <= 0) { + if (opts.delete_log_on_exit and opts.log_file != null) _ = c.unlink(opts.log_file); + if (rc == c.ENOENT or rc == c.EACCES) { + // Bogus binary path fails cleanly like execv failure (_exit 127) + out.outcome = c.CBM_PROC_EXIT_NONZERO; + out.exit_code = 127; + out.term_signal = 0; + return 0; + } + return spawnFailed(out); + } + out_pid.* = pid; + return 1; +} + +fn waitpidRetry(pid: c.pid_t, wstatus: *c_int, options: c_int) c.pid_t { + while (true) { + const wr = c.waitpid(pid, wstatus, options); + if (!(wr < 0 and interop.errno() == c.EINTR)) return wr; + } +} + +fn runPosix(opts: *const c.cbm_proc_opts_t, out: *c.cbm_proc_result_t) c_int { + var pid: c.pid_t = -1; + if (is_apple) { + const spawned = runAppleSpawn(opts, out, &pid); + if (spawned <= 0) return spawned; + } else { + pid = c.fork(); + if (pid < 0) return spawnFailed(out); + if (pid == 0) { + if (opts.own_process_group) _ = c.setpgid(0, 0); + // Child: redirect stdout+stderr to the log (or discard), then exec. + // Use open()+dup2() (async-signal-safe, no malloc) rather than freopen(): + // the parent may be multithreaded (the MCP server holds worker/watcher/http + // threads plus mimalloc/sqlite global state), and a fork() copies + // only the calling thread — a malloc between fork and exec could deadlock on + // a lock another thread held at fork time. open/dup2/execv touch no heap. + var default_argv: [2]?Str = undefined; + const argv = argvOf(opts, &default_argv); + const target: Str = if (opts.log_file != null) opts.log_file else "/dev/null"; + const fd = c.open(target, c.O_WRONLY | c.O_CREAT | c.O_TRUNC, @as(c_int, 0o644)); + if (fd >= 0) { + _ = c.dup2(fd, c.STDOUT_FILENO); + _ = c.dup2(fd, c.STDERR_FILENO); + if (fd > c.STDERR_FILENO) _ = c.close(fd); + } + // Close all descriptors above stderr so children (workers, git, grep) + // never inherit open server descriptors or lock files (#77). + c.cbm_fd_close_above_stderr(); + _ = c.execv(opts.bin, @ptrCast(argv)); + c._exit(127); // exec failed + } + + if (opts.own_process_group) { + // Also set from the parent so a kill issued before the child's own + // setpgid runs still reaches the group (EACCES after exec is fine). + _ = c.setpgid(pid, pid); + } + } + const kill_target: c.pid_t = if (opts.own_process_group) -pid else pid; + + var tail_pos: c_long = 0; + const started = c.cbm_now_ms(); + var last_activity = started; + var timed_out = false; + var output_limited = false; + var wstatus: c_int = 0; + while (true) { + const done = waitpidRetry(pid, &wstatus, c.WNOHANG) == pid; + + if (tailLog(opts.log_file, &tail_pos, opts.on_log_line, opts.log_ud)) { + last_activity = c.cbm_now_ms(); + } + if (done) break; + output_limited = logOverLimit(opts); + timed_out = !output_limited and (quietTimedOut(opts, last_activity) or deadlinePassed(opts, started)); + if (timed_out or output_limited) { + _ = c.kill(kill_target, c.SIGKILL); + _ = waitpidRetry(pid, &wstatus, 0); + break; + } + const ts: c.struct_timespec = .{ .tv_sec = 0, .tv_nsec = 100000000 }; // 100 ms poll + _ = c.cbm_nanosleep(&ts, null); + } + + if (opts.log_file != null and opts.delete_log_on_exit) _ = c.unlink(opts.log_file); + + if (output_limited) { + out.exit_code = -1; + out.term_signal = c.SIGKILL; + out.outcome = c.CBM_PROC_OUTPUT_LIMIT; + } else if (interop.wifexited(wstatus)) { + out.exit_code = interop.wexitstatus(wstatus); + out.term_signal = 0; + out.outcome = cbm_proc_classify(true, out.exit_code, 0, timed_out); + } else if (interop.wifsignaled(wstatus)) { + out.exit_code = -1; + out.term_signal = interop.wtermsig(wstatus); + out.outcome = cbm_proc_classify(false, -1, out.term_signal, timed_out); + } else { + out.exit_code = -1; + out.term_signal = 0; + out.outcome = if (timed_out) c.CBM_PROC_HANG else c.CBM_PROC_KILLED; + } + return 0; +} + +pub export fn cbm_subprocess_run(opts: ?*const c.cbm_proc_opts_t, out_in: ?*c.cbm_proc_result_t) c_int { + var local: c.cbm_proc_result_t = undefined; + const out = out_in orelse &local; + out.outcome = c.CBM_PROC_SPAWN_FAILED; + out.exit_code = -1; + out.term_signal = 0; + const o = opts orelse return -1; + if (o.bin == null or o.bin[0] == 0) return -1; + return if (is_windows) runWin(o, out) else runPosix(o, out); +} + +// ── Own executable path ────────────────────────────────────────── + +/// snprintf(out, outsz, "%s", path): true when path is non-empty and fit. +fn copyPath(out: ?[*]u8, outsz: usize, path: ?Str) bool { + const dst = out orelse return false; + const src = std.mem.span(path orelse return false); + if (outsz == 0 or src.len == 0) return false; + const n = @min(src.len, outsz - 1); + @memcpy(dst[0..n], src[0..n]); + dst[n] = 0; + return src.len < outsz; +} + +fn isExecutableFile(path: ?Str) bool { + const p = path orelse return false; + var st: c.struct_stat = undefined; + return c.stat(p, &st) == 0 and (st.st_mode & c.S_IFMT) == c.S_IFREG and c.access(p, c.X_OK) == 0; +} + +fn resolveFromPath(name: ?Str, out: ?[*]u8, outsz: usize) bool { + const exe = std.mem.span(name orelse return false); + const path_env: ?Str = c.getenv("PATH"); + const path = std.mem.span(path_env orelse return false); + if (exe.len == 0 or std.mem.indexOfScalar(u8, exe, '/') != null or path.len == 0) return false; + + var cur = path; + while (cur.len != 0) { + const colon = std.mem.indexOfScalar(u8, cur, ':'); + const dir = cur[0 .. colon orelse cur.len]; + if (dir.len > 0 and dir.len < 900) { + var candidate: [1024]u8 = undefined; + const n = interop.bprint(&candidate, "{s}/{s}", .{ dir, exe }); + if (n < candidate.len and isExecutableFile(@ptrCast(&candidate))) { + return copyPath(out, outsz, @ptrCast(&candidate)); + } + } + cur = cur[(colon orelse break) + 1 ..]; + } + return false; +} + +fn resolveSelfExecutable(out: ?[*]u8, outsz: usize) bool { + var buf: [1024]u8 = undefined; + if (is_windows) { + const n = c.GetModuleFileNameA(null, &buf, buf.len); + if (n > 0 and n < buf.len) return copyPath(out, outsz, @ptrCast(&buf)); + return false; + } else if (is_apple) { + var sz: u32 = buf.len; + if (c._NSGetExecutablePath(&buf, &sz) == 0 and buf[0] != 0) { + return copyPath(out, outsz, @ptrCast(&buf)); + } + return false; + } else { + const len = c.readlink("/proc/self/exe", &buf, buf.len - 1); + if (len > 0) { + buf[@intCast(len)] = 0; + return copyPath(out, outsz, @ptrCast(&buf)); + } + return false; + } +} + +pub export fn cbm_resolve_binary_path(argv0: ?Str, out: ?[*]u8, outsz: usize) bool { + const dst = out orelse return false; + if (outsz == 0) return false; + dst[0] = 0; + + if (is_windows) { + if (argv0) |a0| { + if (a0[0] != 0) { + const attrs = c.GetFileAttributesA(a0); + if (attrs != INVALID_FILE_ATTRIBUTES and (attrs & c.FILE_ATTRIBUTE_DIRECTORY) == 0) { + return copyPath(dst, outsz, a0); + } + } + } + if (resolveSelfExecutable(dst, outsz)) return true; + } else { + if (argv0) |a0| { + if (std.mem.indexOfScalar(u8, std.mem.span(a0), '/') != null and isExecutableFile(a0)) { + return copyPath(dst, outsz, a0); + } + } + if (resolveFromPath(argv0, dst, outsz)) return true; + + // #1204 (upstream 11bbf0d8, 5b96b067): never hand back a self-resolved + // path that is not executable. After an installer's atomic rename-over the + // image path no longer exists (Linux readlink reads " (deleted)"), + // and spawning it fails with ENOENT. + if (resolveSelfExecutable(dst, outsz)) { + if (isExecutableFile(@ptrCast(dst))) return true; + if (is_linux) { + // The /proc/self/exe magic link still executes the in-memory old + // build, the only image whose build fingerprint the worker accepts. + return copyPath(dst, outsz, "/proc/self/exe"); + } + // No magic link: fail closed, and the supervisor degrades to + // in-process indexing instead of spawning a missing binary. + dst[0] = 0; + return false; + } + } + return copyPath(dst, outsz, argv0); +} diff --git a/src/foundation/system_info.c b/src/foundation/system_info.c deleted file mode 100644 index e3345fe8..00000000 --- a/src/foundation/system_info.c +++ /dev/null @@ -1,311 +0,0 @@ -/* - * system_info.c — CPU core count and RAM detection. - * - * macOS: sysctlbyname for core counts, hw.memsize for RAM. - * BSD: sysconf + sysctl(HW_PHYSMEM64 / HW_PHYSMEM). - * Linux: sysconf + sysinfo(), with cgroup-aware overrides when running - * inside a container so the limits reflect the cgroup's effective - * CPU quota and memory cap rather than the host's totals. - * Windows: GetSystemInfo + GlobalMemoryStatusEx. - * - * Results are cached after first call (immutable hardware properties). - */ -#include "foundation/constants.h" - -enum { DEFAULT_CORES = 1, MIN_WORKERS = 1, CBM_WORKERS_MAX = 256 }; -#include "foundation/log.h" -#include "foundation/platform.h" -#include "foundation/system_info_internal.h" -#include // uint64_t -#include // strtol -#include - -#ifdef _WIN32 -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#elif defined(__APPLE__) -#include -#elif defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__) -#include -#include -#include -#else /* Linux */ -/* limits.h for ULLONG_MAX, stdio.h for fopen/fread, stdlib.h for strto*. */ -#include -#include -#include -#include -#include -#endif - -/* ── macOS detection ─────────────────────────────────────────────── */ - -#ifdef __APPLE__ - -static int sysctl_int(const char *name, int fallback) { - int val = 0; - size_t len = sizeof(val); - if (sysctlbyname(name, &val, &len, NULL, 0) == 0 && val > 0) { - return val; - } - return fallback; -} - -static size_t sysctl_size(const char *name, size_t fallback) { - size_t val = 0; - size_t len = sizeof(val); - if (sysctlbyname(name, &val, &len, NULL, 0) == 0 && val > 0) { - return val; - } - /* Try CBM_SZ_64-bit variant */ - uint64_t val64 = 0; - len = sizeof(val64); - if (sysctlbyname(name, &val64, &len, NULL, 0) == 0 && val64 > 0) { - return (size_t)val64; - } - return fallback; -} - -static cbm_system_info_t detect_system_macos(void) { - cbm_system_info_t info; - memset(&info, 0, sizeof(info)); - - info.total_cores = sysctl_int("hw.ncpu", DEFAULT_CORES); - info.perf_cores = sysctl_int("hw.perflevel0.physicalcpu", info.total_cores); - - /* If perflevel sysctls fail (Intel Mac), perf = total */ - int eff = sysctl_int("hw.perflevel1.physicalcpu", 0); - if (info.perf_cores + eff > info.total_cores) { - info.perf_cores = info.total_cores; - } - - info.total_ram = sysctl_size("hw.memsize", 0); - return info; -} - -#elif defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__) - -static cbm_system_info_t detect_system_bsd(void) { - cbm_system_info_t info; - memset(&info, 0, sizeof(info)); - - long nprocs = sysconf(_SC_NPROCESSORS_ONLN); - info.total_cores = nprocs > 0 ? (int)nprocs : 1; - info.perf_cores = info.total_cores; - -#if defined(__OpenBSD__) - int mib[2] = {CTL_HW, HW_PHYSMEM}; -#else - int mib[2] = {CTL_HW, HW_PHYSMEM64}; -#endif - uint64_t physmem = 0; - size_t len = sizeof(physmem); - if (sysctl(mib, 2, &physmem, &len, NULL, 0) == 0 && physmem > 0) { - info.total_ram = (size_t)physmem; - } - - return info; -} - -#elif !defined(_WIN32) /* Linux */ - -/* Read up to (bufsz-1) bytes from `path` into `buf`, NUL-terminate, and strip - * trailing whitespace. Returns the (stripped) byte count, or -1 if the file - * could not be opened or read. */ -static int read_small_file(const char *path, char *buf, size_t bufsz) { - FILE *fp = fopen(path, "re"); - if (fp == NULL) { - return -1; - } - size_t n = fread(buf, 1, bufsz - 1, fp); - fclose(fp); - while (n > 0 && (buf[n - 1] == '\n' || buf[n - 1] == ' ' || buf[n - 1] == '\t')) { - n--; - } - buf[n] = '\0'; - return (int)n; -} - -/* Effective CPU count from a cgroup file tree. See header for contract. */ -int cbm_detect_cgroup_cpus(const char *cgroup_root) { - char path[CBM_PATH_MAX]; - char buf[CBM_SZ_64]; - - /* cgroup v2: "/cpu.max" — " " or "max ". */ - snprintf(path, sizeof(path), "%s/cpu.max", cgroup_root); - if (read_small_file(path, buf, sizeof(buf)) > 0) { - if (strncmp(buf, "max", 3) == 0) { - return -1; /* no quota → caller falls back to sysconf */ - } - long quota = 0; - long period = 0; - if (sscanf(buf, "%ld %ld", "a, &period) == 2 && quota > 0 && period > 0) { - long n = (quota + period - 1) / period; /* ceil(quota/period) */ - return n > 0 ? (int)n : MIN_WORKERS; - } - return -1; - } - - /* cgroup v1: ".../cpu/cpu.cfs_quota_us" and ".../cpu/cpu.cfs_period_us". - * A quota of -1 means unlimited in cgroup v1. */ - snprintf(path, sizeof(path), "%s/cpu/cpu.cfs_quota_us", cgroup_root); - if (read_small_file(path, buf, sizeof(buf)) <= 0) { - return -1; - } - long quota = strtol(buf, NULL, CBM_DECIMAL_BASE); - if (quota <= 0) { - return -1; - } - - snprintf(path, sizeof(path), "%s/cpu/cpu.cfs_period_us", cgroup_root); - if (read_small_file(path, buf, sizeof(buf)) <= 0) { - return -1; - } - long period = strtol(buf, NULL, CBM_DECIMAL_BASE); - if (period <= 0) { - return -1; - } - - long n = (quota + period - 1) / period; - return n > 0 ? (int)n : MIN_WORKERS; -} - -/* Effective memory limit from a cgroup file tree. See header for contract. */ -size_t cbm_detect_cgroup_mem(const char *cgroup_root) { - char path[CBM_PATH_MAX]; - char buf[CBM_SZ_64]; - - /* cgroup v2: "/memory.max" — "max" or integer bytes. */ - snprintf(path, sizeof(path), "%s/memory.max", cgroup_root); - if (read_small_file(path, buf, sizeof(buf)) > 0) { - if (strncmp(buf, "max", 3) == 0) { - return 0; - } - char *end = NULL; - unsigned long long n = strtoull(buf, &end, CBM_DECIMAL_BASE); - if (end == buf || n == 0) { - return 0; - } - return (size_t)n; - } - - /* cgroup v1: ".../memory/memory.limit_in_bytes". The sentinel for - * "unlimited" is a very large value (~PAGE_COUNTER_MAX); treat anything - * past half of ULLONG_MAX as effectively unlimited. */ - snprintf(path, sizeof(path), "%s/memory/memory.limit_in_bytes", cgroup_root); - if (read_small_file(path, buf, sizeof(buf)) <= 0) { - return 0; - } - char *end = NULL; - unsigned long long n = strtoull(buf, &end, CBM_DECIMAL_BASE); - if (end == buf || n == 0 || n >= (ULLONG_MAX / 2)) { - return 0; - } - return (size_t)n; -} - -static cbm_system_info_t detect_system_linux(void) { - cbm_system_info_t info; - memset(&info, 0, sizeof(info)); - - /* Host fallbacks. */ - long nprocs = sysconf(_SC_NPROCESSORS_ONLN); - int host_cpus = nprocs > 0 ? (int)nprocs : DEFAULT_CORES; - - size_t host_ram = 0; - struct sysinfo si; - if (sysinfo(&si) == 0) { - host_ram = (size_t)si.totalram * (size_t)si.mem_unit; - } - - /* Cgroup-aware overrides. min(cgroup, host) defends against - * mis-mounted cgroups that report values larger than the host. */ - int cg_cpus = cbm_detect_cgroup_cpus("/sys/fs/cgroup"); - info.total_cores = (cg_cpus > 0 && cg_cpus < host_cpus) ? cg_cpus : host_cpus; - info.perf_cores = info.total_cores; /* Linux doesn't distinguish P/E */ - - size_t cg_ram = cbm_detect_cgroup_mem("/sys/fs/cgroup"); - info.total_ram = (cg_ram > 0 && (host_ram == 0 || cg_ram < host_ram)) ? cg_ram : host_ram; - - return info; -} - -#endif /* __APPLE__ / BSD / Linux */ - -/* ── Windows detection ───────────────────────────────────────────── */ - -#ifdef _WIN32 -static cbm_system_info_t detect_system_windows(void) { - cbm_system_info_t info; - memset(&info, 0, sizeof(info)); - - SYSTEM_INFO si; - GetSystemInfo(&si); - info.total_cores = (int)si.dwNumberOfProcessors; - if (info.total_cores < 1) { - info.total_cores = SKIP_ONE; - } - info.perf_cores = info.total_cores; - - MEMORYSTATUSEX ms; - ms.dwLength = sizeof(ms); - if (GlobalMemoryStatusEx(&ms)) { - info.total_ram = (size_t)ms.ullTotalPhys; - } - - return info; -} -#endif - -/* ── Public API ──────────────────────────────────────────────────── */ - -static int info_cached = 0; -static cbm_system_info_t cached_info; - -cbm_system_info_t cbm_system_info(void) { - if (!info_cached) { -#ifdef _WIN32 - cached_info = detect_system_windows(); -#elif defined(__APPLE__) - cached_info = detect_system_macos(); -#elif defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__) - cached_info = detect_system_bsd(); -#else - cached_info = detect_system_linux(); -#endif - info_cached = SKIP_ONE; - } - return cached_info; -} - -int cbm_default_worker_count(bool initial) { - /* CBM_WORKERS env override. Zero means one worker; positive values are - * accepted in [1, CBM_WORKERS_MAX]. - * Useful inside containers where sysconf(_SC_NPROCESSORS_ONLN) - * reports host CPUs rather than the cgroup's effective CPU quota. - * Same precedence shape as other CBM_* env overrides: - * explicit override > implicit detection. */ - char buf[CBM_SZ_32]; - if (cbm_safe_getenv("CBM_WORKERS", buf, sizeof(buf), NULL) != NULL) { - char *end = NULL; - long n = strtol(buf, &end, CBM_DECIMAL_BASE); - if (end != buf && *end == '\0' && n == 0) { - return MIN_WORKERS; - } - if (end != buf && *end == '\0' && n >= MIN_WORKERS && n <= CBM_WORKERS_MAX) { - return (int)n; - } - cbm_log_warn("workers.env.invalid", "value", buf, "fallback", "sysconf"); - } - - cbm_system_info_t info = cbm_system_info(); - if (initial) { - /* Use all cores for initial indexing — user is waiting */ - return info.total_cores; - } - /* Incremental: leave headroom for user's apps */ - int workers = info.perf_cores - SKIP_ONE; - return workers > 0 ? workers : MIN_WORKERS; -} diff --git a/src/foundation/system_info.zig b/src/foundation/system_info.zig new file mode 100644 index 00000000..7da35b10 --- /dev/null +++ b/src/foundation/system_info.zig @@ -0,0 +1,245 @@ +//! system_info.zig — CPU core count and RAM detection. +//! +//! Implements cbm_system_info / cbm_default_worker_count from +//! src/foundation/platform.h and the cgroup helpers of system_info_internal.h. +//! +//! macOS: sysctlbyname for core counts, hw.memsize for RAM. +//! BSD: sysconf + sysctl(HW_PHYSMEM64 / HW_PHYSMEM). +//! Linux: sysconf + sysinfo(), with cgroup-aware overrides when running +//! inside a container so the limits reflect the cgroup's effective +//! CPU quota and memory cap rather than the host's totals. +//! Windows: GetSystemInfo + GlobalMemoryStatusEx. +//! +//! Results are cached after first call (immutable hardware properties). + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("interop.zig"); + +const os = builtin.os.tag; +const is_linux = os == .linux; + +const DEFAULT_CORES = 1; +const MIN_WORKERS = 1; +const CBM_WORKERS_MAX = 256; + +// ── macOS detection ────────────────────────────────────────────── + +fn sysctlInt(name: [*:0]const u8, fallback: c_int) c_int { + var val: c_int = 0; + var len: usize = @sizeOf(c_int); + if (c.sysctlbyname(name, &val, &len, null, 0) == 0 and val > 0) return val; + return fallback; +} + +fn sysctlSize(name: [*:0]const u8, fallback: usize) usize { + var val: usize = 0; + var len: usize = @sizeOf(usize); + if (c.sysctlbyname(name, &val, &len, null, 0) == 0 and val > 0) return val; + // Try 64-bit variant + var val64: u64 = 0; + len = @sizeOf(u64); + if (c.sysctlbyname(name, &val64, &len, null, 0) == 0 and val64 > 0) return @truncate(val64); + return fallback; +} + +fn detectSystemMacos() c.cbm_system_info_t { + var info = std.mem.zeroes(c.cbm_system_info_t); + + info.total_cores = sysctlInt("hw.ncpu", DEFAULT_CORES); + info.perf_cores = sysctlInt("hw.perflevel0.physicalcpu", info.total_cores); + + // If perflevel sysctls fail (Intel Mac), perf = total + const eff = sysctlInt("hw.perflevel1.physicalcpu", 0); + if (info.perf_cores +% eff > info.total_cores) info.perf_cores = info.total_cores; + + info.total_ram = sysctlSize("hw.memsize", 0); + return info; +} + +// ── BSD detection ──────────────────────────────────────────────── + +fn detectSystemBsd() c.cbm_system_info_t { + var info = std.mem.zeroes(c.cbm_system_info_t); + + const nprocs = c.sysconf(c._SC_NPROCESSORS_ONLN); + info.total_cores = if (nprocs > 0) @truncate(nprocs) else 1; + info.perf_cores = info.total_cores; + + var mib = [2]c_int{ c.CTL_HW, if (os == .openbsd) c.HW_PHYSMEM else c.HW_PHYSMEM64 }; + var physmem: u64 = 0; + var len: usize = @sizeOf(u64); + if (c.sysctl(&mib, 2, &physmem, &len, null, 0) == 0 and physmem > 0) { + info.total_ram = @truncate(physmem); + } + return info; +} + +// ── Linux detection ────────────────────────────────────────────── + +/// Read up to (buf.len-1) bytes from `path` into `buf`, NUL-terminate, and strip +/// trailing whitespace. Returns the (stripped) byte count, or -1 if the file +/// could not be opened or read. +fn readSmallFile(path: [*:0]const u8, buf: []u8) c_int { + const fp = c.fopen(path, "re") orelse return -1; + var n = c.fread(buf.ptr, 1, buf.len - 1, fp); + _ = c.fclose(fp); + while (n > 0 and (buf[n - 1] == '\n' or buf[n - 1] == ' ' or buf[n - 1] == '\t')) n -= 1; + buf[n] = 0; + return @intCast(n); +} + +/// The cgroup probes exist on Linux only (system_info_internal.h declares them +/// under __linux__). +pub const linux = if (is_linux) struct { + /// Effective CPU count from a cgroup file tree. See header for contract. + pub export fn cbm_detect_cgroup_cpus(cgroup_root: [*:0]const u8) c_int { + var path: [c.CBM_PATH_MAX]u8 = undefined; + var buf: [64]u8 = undefined; + + // cgroup v2: "/cpu.max" — " " or "max ". + _ = c.snprintf(&path, path.len, "%s/cpu.max", cgroup_root); + if (readSmallFile(@ptrCast(&path), &buf) > 0) { + if (std.mem.startsWith(u8, std.mem.sliceTo(&buf, 0), "max")) { + return -1; // no quota → caller falls back to sysconf + } + var quota: c_long = 0; + var period: c_long = 0; + if (c.sscanf(&buf, "%ld %ld", "a, &period) == 2 and quota > 0 and period > 0) { + const n = @divTrunc(quota +% period -% 1, period); // ceil(quota/period) + return if (n > 0) @truncate(n) else MIN_WORKERS; + } + return -1; + } + + // cgroup v1: ".../cpu/cpu.cfs_quota_us" and ".../cpu/cpu.cfs_period_us". + // A quota of -1 means unlimited in cgroup v1. + _ = c.snprintf(&path, path.len, "%s/cpu/cpu.cfs_quota_us", cgroup_root); + if (readSmallFile(@ptrCast(&path), &buf) <= 0) return -1; + const quota = c.strtol(&buf, null, 10); + if (quota <= 0) return -1; + + _ = c.snprintf(&path, path.len, "%s/cpu/cpu.cfs_period_us", cgroup_root); + if (readSmallFile(@ptrCast(&path), &buf) <= 0) return -1; + const period = c.strtol(&buf, null, 10); + if (period <= 0) return -1; + + const n = @divTrunc(quota +% period -% 1, period); + return if (n > 0) @truncate(n) else MIN_WORKERS; + } + + /// Effective memory limit from a cgroup file tree. See header for contract. + pub export fn cbm_detect_cgroup_mem(cgroup_root: [*:0]const u8) usize { + var path: [c.CBM_PATH_MAX]u8 = undefined; + var buf: [64]u8 = undefined; + + // cgroup v2: "/memory.max" — "max" or integer bytes. + _ = c.snprintf(&path, path.len, "%s/memory.max", cgroup_root); + if (readSmallFile(@ptrCast(&path), &buf) > 0) { + if (std.mem.startsWith(u8, std.mem.sliceTo(&buf, 0), "max")) return 0; + var end: [*c]u8 = null; + const n = c.strtoull(&buf, &end, 10); + if (end == @as([*c]u8, &buf) or n == 0) return 0; + return @truncate(n); + } + + // cgroup v1: ".../memory/memory.limit_in_bytes". The sentinel for + // "unlimited" is a very large value (~PAGE_COUNTER_MAX); treat anything + // past half of ULLONG_MAX as effectively unlimited. + _ = c.snprintf(&path, path.len, "%s/memory/memory.limit_in_bytes", cgroup_root); + if (readSmallFile(@ptrCast(&path), &buf) <= 0) return 0; + var end: [*c]u8 = null; + const n = c.strtoull(&buf, &end, 10); + if (end == @as([*c]u8, &buf) or n == 0 or n >= std.math.maxInt(c_ulonglong) / 2) return 0; + return @truncate(n); + } +} else struct {}; + +fn detectSystemLinux() c.cbm_system_info_t { + var info = std.mem.zeroes(c.cbm_system_info_t); + + // Host fallbacks. + const nprocs = c.sysconf(c._SC_NPROCESSORS_ONLN); + const host_cpus: c_int = if (nprocs > 0) @truncate(nprocs) else DEFAULT_CORES; + + var host_ram: usize = 0; + var si: c.struct_sysinfo = undefined; + if (c.sysinfo(&si) == 0) { + host_ram = @as(usize, si.totalram) *% @as(usize, si.mem_unit); + } + + // Cgroup-aware overrides. min(cgroup, host) defends against + // mis-mounted cgroups that report values larger than the host. + const cg_cpus = linux.cbm_detect_cgroup_cpus("/sys/fs/cgroup"); + info.total_cores = if (cg_cpus > 0 and cg_cpus < host_cpus) cg_cpus else host_cpus; + info.perf_cores = info.total_cores; // Linux doesn't distinguish P/E + + const cg_ram = linux.cbm_detect_cgroup_mem("/sys/fs/cgroup"); + info.total_ram = if (cg_ram > 0 and (host_ram == 0 or cg_ram < host_ram)) cg_ram else host_ram; + return info; +} + +// ── Windows detection ──────────────────────────────────────────── + +fn detectSystemWindows() c.cbm_system_info_t { + var info = std.mem.zeroes(c.cbm_system_info_t); + + var si: c.SYSTEM_INFO = undefined; + c.GetSystemInfo(&si); + info.total_cores = @bitCast(si.dwNumberOfProcessors); + if (info.total_cores < 1) info.total_cores = 1; + info.perf_cores = info.total_cores; + + var ms: c.MEMORYSTATUSEX = undefined; + ms.dwLength = @sizeOf(c.MEMORYSTATUSEX); + if (c.GlobalMemoryStatusEx(&ms) != 0) { + info.total_ram = @truncate(ms.ullTotalPhys); + } + return info; +} + +// ── Public API ─────────────────────────────────────────────────── + +var info_cached: bool = false; +var cached_info: c.cbm_system_info_t = undefined; + +pub export fn cbm_system_info() c.cbm_system_info_t { + if (!info_cached) { + cached_info = switch (os) { + .windows => detectSystemWindows(), + .macos => detectSystemMacos(), + .netbsd, .freebsd, .openbsd => detectSystemBsd(), + else => detectSystemLinux(), + }; + info_cached = true; + } + return cached_info; +} + +pub export fn cbm_default_worker_count(initial: bool) c_int { + // CBM_WORKERS env override. Zero means one worker; positive values are + // accepted in [1, CBM_WORKERS_MAX]. + // Useful inside containers where sysconf(_SC_NPROCESSORS_ONLN) + // reports host CPUs rather than the cgroup's effective CPU quota. + // Same precedence shape as other CBM_* env overrides: + // explicit override > implicit detection. + var buf: [32]u8 = undefined; + if (c.cbm_safe_getenv("CBM_WORKERS", &buf, buf.len, null) != null) { + var end: [*c]u8 = null; + const n = c.strtol(&buf, &end, 10); + const whole = end != @as([*c]u8, &buf) and end[0] == 0; + if (whole and n == 0) return MIN_WORKERS; + if (whole and n >= MIN_WORKERS and n <= CBM_WORKERS_MAX) return @intCast(n); + interop.logWarn("workers.env.invalid", .{ "value", @as(interop.Str, @ptrCast(&buf)), "fallback", "sysconf" }); + } + + const info = cbm_system_info(); + if (initial) { + // Use all cores for initial indexing — user is waiting + return info.total_cores; + } + // Incremental: leave headroom for user's apps + const workers = info.perf_cores -% 1; + return if (workers > 0) workers else MIN_WORKERS; +} diff --git a/src/foundation/yaml.c b/src/foundation/yaml.c deleted file mode 100644 index 466258f3..00000000 --- a/src/foundation/yaml.c +++ /dev/null @@ -1,423 +0,0 @@ -/* - * yaml.c — Minimal YAML parser for config files. - * - * Strategy: line-by-line parsing with indentation tracking. - * Each line is either: - * - Empty or comment (#) → skip - * - "key: value" → scalar entry in current map - * - "key:" (no value) → start of nested map or list - * - "- value" → list item - * - * Indentation determines nesting depth. Uses a stack of parent nodes. - */ -#include "foundation/constants.h" - -enum { YAML_INIT_CAP = 8, YAML_LIST_PREFIX = 2, YAML_ROOT_INDENT = -1 }; -#include "foundation/yaml.h" - -#include -#include // NULL -#include -#include -#include - -/* ── Node types ───────────────────────────────────────────────── */ - -typedef enum { - YAML_MAP, - YAML_LIST, - YAML_SCALAR, -} yaml_type_t; - -struct cbm_yaml_node { - yaml_type_t type; - char *key; /* NULL for list items and root */ - char *value; /* scalar value (NULL for map/list) */ - - /* Children (for map and list nodes) */ - cbm_yaml_node_t **children; - int child_count; - int child_cap; -}; - -/* ── Node lifecycle ───────────────────────────────────────────── */ - -static cbm_yaml_node_t *node_new(yaml_type_t type) { - cbm_yaml_node_t *n = calloc(CBM_ALLOC_ONE, sizeof(*n)); - if (n) { - n->type = type; - } - return n; -} - -static bool node_add_child(cbm_yaml_node_t *parent, cbm_yaml_node_t *child) { - if (!parent || !child) { - return false; - } - if (parent->child_count >= parent->child_cap) { - int new_cap = - parent->child_cap < YAML_INIT_CAP ? YAML_INIT_CAP : parent->child_cap * PAIR_LEN; - cbm_yaml_node_t **new_arr = realloc(parent->children, (size_t)new_cap * sizeof(*new_arr)); - if (!new_arr) { - return false; - } - parent->children = new_arr; - parent->child_cap = new_cap; - } - parent->children[parent->child_count++] = child; - return true; -} - -void cbm_yaml_free(cbm_yaml_node_t *root) { - if (!root) { - return; - } - enum { YAML_FREE_STACK = 256 }; - cbm_yaml_node_t *stack[YAML_FREE_STACK]; - int top = 0; - stack[top++] = root; - while (top > 0) { - cbm_yaml_node_t *node = stack[--top]; - for (int i = 0; i < node->child_count && top < YAML_FREE_STACK; i++) { - if (node->children[i]) { - stack[top++] = node->children[i]; - } - } - free(node->children); - free(node->key); - free(node->value); - free(node); - } -} - -/* ── String helpers ───────────────────────────────────────────── */ - -/* Duplicate a substring [start, end). Trims trailing whitespace. */ -static char *trim_dup(const char *start, const char *end) { - while (end > start && isspace((unsigned char)end[-SKIP_ONE])) { - end--; - } - while (start < end && isspace((unsigned char)*start)) { - start++; - } - if (start >= end) { - return strdup(""); - } - size_t len = (size_t)(end - start); - char *s = malloc(len + SKIP_ONE); - if (!s) { - return NULL; - } - memcpy(s, start, len); - s[len] = '\0'; - return s; -} - -/* Count leading spaces (not tabs). */ -static int leading_spaces(const char *line) { - int n = 0; - while (line[n] == ' ') { - n++; - } - return n; -} - -/* ── Parser ───────────────────────────────────────────────────── */ - -/* Stack entry for tracking parent context during parsing. */ -typedef struct { - cbm_yaml_node_t *node; - int indent; -} stack_entry_t; - -/* Parse a list item "- value" and add to the correct parent. */ -static void parse_list_item(const char *content, int content_len, cbm_yaml_node_t *parent) { - const char *item_start = content + YAML_LIST_PREFIX; - int item_len = content_len - YAML_LIST_PREFIX; - while (item_len > 0 && isspace((unsigned char)item_start[0])) { - item_start++; - item_len--; - } - - /* If parent is a map, the list items belong to the last child */ - cbm_yaml_node_t *list_parent = parent; - if (parent->type == YAML_MAP && parent->child_count > 0) { - cbm_yaml_node_t *last = parent->children[parent->child_count - SKIP_ONE]; - if (last->type == YAML_LIST) { - list_parent = last; - } - } - - cbm_yaml_node_t *item = node_new(YAML_SCALAR); - if (item) { - item->value = trim_dup(item_start, item_start + item_len); - if (!node_add_child(list_parent, item)) { - cbm_yaml_free(item); - } - } -} - -/* Strip inline comments from a value string (not inside quotes). - * Returns the adjusted length. */ -static int strip_inline_comment(const char *after, int after_len) { - if (after_len > 0 && after[0] != '"' && after[0] != '\'') { - for (int i = 0; i < after_len; i++) { - if (after[i] == '#' && i > 0 && after[i - SKIP_ONE] == ' ') { - after_len = i; - while (after_len > 0 && isspace((unsigned char)after[after_len - SKIP_ONE])) { - after_len--; - } - break; - } - } - } - return after_len; -} - -/* Peek ahead to check if next content line is a list item. */ -static bool peek_is_list(const char *eol, const char *end) { - const char *peek = (eol < end) ? eol + SKIP_ONE : end; - while (peek < end) { - const char *peek_eol = memchr(peek, '\n', (size_t)(end - peek)); - if (!peek_eol) { - peek_eol = end; - } - int pi = leading_spaces(peek); - const char *pc = peek + pi; - int pcl = (int)(peek_eol - pc); - if (pcl > 0 && pc[pcl - SKIP_ONE] == '\r') { - pcl--; - } - if (pcl == 0 || pc[0] == '#') { - peek = (peek_eol < end) ? peek_eol + SKIP_ONE : end; - continue; - } - return pc[0] == '-'; - } - return false; -} - -/* Parse a "key: value" or "key:" line. */ -static void parse_key_line(const char *content, int content_len, int indent, - cbm_yaml_node_t *parent, stack_entry_t *stack, int *stack_depth, - const char *eol, const char *end) { - const char *colon = memchr(content, ':', (size_t)content_len); - if (!colon) { - return; - } - - char *key = trim_dup(content, colon); - if (!key) { - return; - } - - /* After colon: value or nothing */ - const char *after = colon + SKIP_ONE; - int after_len = content_len - (int)(after - content); - while (after_len > 0 && isspace((unsigned char)*after)) { - after++; - after_len--; - } - - after_len = strip_inline_comment(after, after_len); - - if (after_len > 0) { - /* "key: value" — scalar */ - cbm_yaml_node_t *child = node_new(YAML_SCALAR); - if (child) { - child->key = key; - child->value = trim_dup(after, after + after_len); - if (!node_add_child(parent, child)) { - cbm_yaml_free(child); - } - } else { - free(key); - } - } else { - /* "key:" — could be map or list (determined by next lines) */ - bool is_list = peek_is_list(eol, end); - cbm_yaml_node_t *child = node_new(is_list ? YAML_LIST : YAML_MAP); - if (child) { - child->key = key; - if (!node_add_child(parent, child)) { - cbm_yaml_free(child); - } else if (*stack_depth < CBM_SZ_32) { - stack[(*stack_depth)++] = (stack_entry_t){.node = child, .indent = indent}; - } - } else { - free(key); - } - } -} - -cbm_yaml_node_t *cbm_yaml_parse(const char *text, int len) { - if (!text || len <= 0) { - return node_new(YAML_MAP); - } - - cbm_yaml_node_t *root = node_new(YAML_MAP); - if (!root) { - return NULL; - } - - /* Stack for tracking parent context */ - stack_entry_t stack[CBM_SZ_32]; - int stack_depth = 0; - stack[0] = (stack_entry_t){.node = root, .indent = YAML_ROOT_INDENT}; - stack_depth = SKIP_ONE; - - const char *p = text; - const char *end = text + len; - - while (p < end) { - /* Find end of line */ - const char *eol = memchr(p, '\n', (size_t)(end - p)); - if (!eol) { - eol = end; - } - int line_len = (int)(eol - p); - - /* Skip empty lines and comments */ - int indent = leading_spaces(p); - const char *content = p + indent; - int content_len = line_len - indent; - - /* Strip \r */ - if (content_len > 0 && content[content_len - SKIP_ONE] == '\r') { - content_len--; - } - - if (content_len == 0 || content[0] == '#') { - p = (eol < end) ? eol + SKIP_ONE : end; - continue; - } - - /* Pop stack to find parent at correct indentation */ - while (stack_depth > SKIP_ONE && stack[stack_depth - SKIP_ONE].indent >= indent) { - stack_depth--; - } - cbm_yaml_node_t *parent = stack[stack_depth - SKIP_ONE].node; - - if (content[0] == '-' && content_len >= PAIR_LEN && content[SKIP_ONE] == ' ') { - parse_list_item(content, content_len, parent); - } else { - parse_key_line(content, content_len, indent, parent, stack, &stack_depth, eol, end); - } - - p = (eol < end) ? eol + SKIP_ONE : end; - } - - return root; -} - -/* ── Query helpers ────────────────────────────────────────────── */ - -/* Find a child node by key in a map node. */ -static const cbm_yaml_node_t *find_child(const cbm_yaml_node_t *node, const char *key) { - if (!node || !key) { - return NULL; - } - for (int i = 0; i < node->child_count; i++) { - if (node->children[i]->key && strcmp(node->children[i]->key, key) == 0) { - return node->children[i]; - } - } - return NULL; -} - -/* Navigate to a node by dot-separated path. */ -static const cbm_yaml_node_t *navigate(const cbm_yaml_node_t *root, const char *path) { - if (!root || !path) { - return NULL; - } - - const cbm_yaml_node_t *cur = root; - char buf[CBM_SZ_256]; - const char *p = path; - - while (*p) { - const char *dot = strchr(p, '.'); - int seg_len; - if (dot) { - seg_len = (int)(dot - p); - } else { - seg_len = (int)strlen(p); - } - if (seg_len <= 0 || seg_len >= (int)sizeof(buf)) { - return NULL; - } - - memcpy(buf, p, (size_t)seg_len); - buf[seg_len] = '\0'; - - cur = find_child(cur, buf); - if (!cur) { - return NULL; - } - - p += seg_len; - if (*p == '.') { - p++; - } - } - - return cur; -} - -const char *cbm_yaml_get_str(const cbm_yaml_node_t *root, const char *path) { - const cbm_yaml_node_t *node = navigate(root, path); - if (!node || node->type != YAML_SCALAR) { - return NULL; - } - return node->value; -} - -double cbm_yaml_get_float(const cbm_yaml_node_t *root, const char *path, double default_val) { - const char *str = cbm_yaml_get_str(root, path); - if (!str) { - return default_val; - } - char *endptr; - double val = strtod(str, &endptr); - if (endptr == str) { - return default_val; - } - return val; -} - -bool cbm_yaml_get_bool(const cbm_yaml_node_t *root, const char *path, bool default_val) { - const char *str = cbm_yaml_get_str(root, path); - if (!str) { - return default_val; - } - - if (strcasecmp(str, "true") == 0 || strcasecmp(str, "yes") == 0 || strcasecmp(str, "on") == 0 || - strcmp(str, "1") == 0) { - return true; - } - if (strcasecmp(str, "false") == 0 || strcasecmp(str, "no") == 0 || - strcasecmp(str, "off") == 0 || strcmp(str, "0") == 0) { - return false; - } - return default_val; -} - -int cbm_yaml_get_str_list(const cbm_yaml_node_t *root, const char *path, const char **out, - int max_out) { - const cbm_yaml_node_t *node = navigate(root, path); - if (!node || node->type != YAML_LIST) { - return 0; - } - - int count = 0; - for (int i = 0; i < node->child_count && count < max_out; i++) { - if (node->children[i]->type == YAML_SCALAR && node->children[i]->value) { - out[count++] = node->children[i]->value; - } - } - return count; -} - -bool cbm_yaml_has(const cbm_yaml_node_t *root, const char *path) { - return navigate(root, path) != NULL; -} diff --git a/src/foundation/yaml.zig b/src/foundation/yaml.zig new file mode 100644 index 00000000..499cfc77 --- /dev/null +++ b/src/foundation/yaml.zig @@ -0,0 +1,310 @@ +//! yaml.zig — Minimal YAML parser for config files. +//! +//! Implements src/foundation/yaml.h. +//! +//! Strategy: line-by-line parsing with indentation tracking. +//! Each line is either: +//! - Empty or comment (#) → skip +//! - "key: value" → scalar entry in current map +//! - "key:" (no value) → start of nested map or list +//! - "- value" → list item +//! +//! Indentation determines nesting depth. Uses a stack of parent nodes. + +const std = @import("std"); +const c = @import("c"); + +const YAML_INIT_CAP = 8; +const YAML_LIST_PREFIX = 2; +const YAML_ROOT_INDENT = -1; +const YAML_MAX_DEPTH = 32; + +// ── Node types ─────────────────────────────────────────────────── + +const YamlType = enum(c_int) { map, list, scalar }; + +const Node = extern struct { + type: YamlType, + key: ?[*:0]u8, // NULL for list items and root + value: ?[*:0]u8, // scalar value (NULL for map/list) + + // Children (for map and list nodes) + children: ?[*]*Node, + child_count: c_int, + child_cap: c_int, + + fn childSlice(n: *const Node) []*Node { + const children = n.children orelse return &.{}; + return children[0..@intCast(n.child_count)]; + } +}; + +// ── Node lifecycle ─────────────────────────────────────────────── + +fn nodeNew(node_type: YamlType) ?*Node { + const n: *Node = @ptrCast(@alignCast(c.calloc(1, @sizeOf(Node)) orelse return null)); + n.type = node_type; + return n; +} + +fn nodeAddChild(parent: *Node, child: *Node) bool { + if (parent.child_count >= parent.child_cap) { + const new_cap: c_int = if (parent.child_cap < YAML_INIT_CAP) YAML_INIT_CAP else parent.child_cap * 2; + const new_arr: [*]*Node = @ptrCast(@alignCast(c.realloc(@ptrCast(parent.children), @as(usize, @intCast(new_cap)) * @sizeOf(*Node)) orelse return false)); + parent.children = new_arr; + parent.child_cap = new_cap; + } + parent.children.?[@intCast(parent.child_count)] = child; + parent.child_count += 1; + return true; +} + +pub export fn cbm_yaml_free(root: ?*Node) void { + const YAML_FREE_STACK = 256; + var stack: [YAML_FREE_STACK]*Node = undefined; + var top: usize = 0; + stack[top] = root orelse return; + top += 1; + + while (top > 0) { + top -= 1; + const node = stack[top]; + for (node.childSlice()) |child| { + if (top >= YAML_FREE_STACK) break; + stack[top] = child; + top += 1; + } + c.free(@ptrCast(node.children)); + c.free(node.key); + c.free(node.value); + c.free(node); + } +} + +// ── String helpers ─────────────────────────────────────────────── + +/// Duplicate a substring. Trims leading and trailing whitespace. +fn trimDup(text: []const u8) ?[*:0]u8 { + const trimmed = std.mem.trim(u8, text, &std.ascii.whitespace); + const s: [*]u8 = @ptrCast(c.malloc(trimmed.len + 1) orelse return null); + @memcpy(s[0..trimmed.len], trimmed); + s[trimmed.len] = 0; + return @ptrCast(s); +} + +/// Count leading spaces (not tabs). +fn leadingSpaces(line: []const u8) usize { + var n: usize = 0; + while (n < line.len and line[n] == ' ') n += 1; + return n; +} + +/// One line of `text` starting at `start`: its content after the indentation +/// (without a trailing '\r'), the indentation, and where the next line starts. +const Line = struct { + content: []const u8, + indent: usize, + next: usize, + + fn at(text: []const u8, start: usize) Line { + const eol = std.mem.indexOfScalarPos(u8, text, start, '\n') orelse text.len; + const line = text[start..eol]; + const indent = leadingSpaces(line); + var content = line[indent..]; + // Strip \r + if (content.len > 0 and content[content.len - 1] == '\r') content.len -= 1; + return .{ .content = content, .indent = indent, .next = if (eol < text.len) eol + 1 else text.len }; + } + + /// Empty lines and comments carry nothing. + fn isBlank(line: Line) bool { + return line.content.len == 0 or line.content[0] == '#'; + } +}; + +// ── Parser ─────────────────────────────────────────────────────── + +/// Stack entry for tracking parent context during parsing. +const StackEntry = struct { + node: *Node, + indent: c_int, +}; + +/// Parse a list item "- value" and add to the correct parent. +fn parseListItem(content: []const u8, parent: *Node) void { + const item_text = std.mem.trimStart(u8, content[YAML_LIST_PREFIX..], &std.ascii.whitespace); + + // If parent is a map, the list items belong to the last child + var list_parent = parent; + if (parent.type == .map and parent.child_count > 0) { + const last = parent.childSlice()[@intCast(parent.child_count - 1)]; + if (last.type == .list) list_parent = last; + } + + const item = nodeNew(.scalar) orelse return; + item.value = trimDup(item_text); + if (!nodeAddChild(list_parent, item)) cbm_yaml_free(item); +} + +/// Strip inline comments from a value string (not inside quotes). +fn stripInlineComment(after: []const u8) []const u8 { + if (after.len > 0 and after[0] != '"' and after[0] != '\'') { + for (after, 0..) |ch, i| { + if (ch == '#' and i > 0 and after[i - 1] == ' ') { + return std.mem.trimEnd(u8, after[0..i], &std.ascii.whitespace); + } + } + } + return after; +} + +/// Peek ahead to check if next content line is a list item. +fn peekIsList(text: []const u8, next_line: usize) bool { + var pos = next_line; + while (pos < text.len) { + const line = Line.at(text, pos); + if (line.isBlank()) { + pos = line.next; + continue; + } + return line.content[0] == '-'; + } + return false; +} + +/// Parse a "key: value" or "key:" line. +fn parseKeyLine(text: []const u8, line: Line, parent: *Node, stack: *[YAML_MAX_DEPTH]StackEntry, stack_depth: *usize) void { + const content = line.content; + const colon = std.mem.indexOfScalar(u8, content, ':') orelse return; + + const key = trimDup(content[0..colon]) orelse return; + + // After colon: value or nothing + const after = stripInlineComment(std.mem.trimStart(u8, content[colon + 1 ..], &std.ascii.whitespace)); + + if (after.len > 0) { + // "key: value" — scalar + const child = nodeNew(.scalar) orelse { + c.free(key); + return; + }; + child.key = key; + child.value = trimDup(after); + if (!nodeAddChild(parent, child)) cbm_yaml_free(child); + } else { + // "key:" — could be map or list (determined by next lines) + const is_list = peekIsList(text, line.next); + const child = nodeNew(if (is_list) .list else .map) orelse { + c.free(key); + return; + }; + child.key = key; + if (!nodeAddChild(parent, child)) { + cbm_yaml_free(child); + } else if (stack_depth.* < YAML_MAX_DEPTH) { + stack[stack_depth.*] = .{ .node = child, .indent = @intCast(line.indent) }; + stack_depth.* += 1; + } + } +} + +pub export fn cbm_yaml_parse(text_ptr: ?[*]const u8, len: c_int) ?*Node { + const root = nodeNew(.map) orelse return null; + const ptr = text_ptr orelse return root; + if (len <= 0) return root; + const text = ptr[0..@intCast(len)]; + + // Stack for tracking parent context + var stack: [YAML_MAX_DEPTH]StackEntry = undefined; + stack[0] = .{ .node = root, .indent = YAML_ROOT_INDENT }; + var stack_depth: usize = 1; + + var pos: usize = 0; + while (pos < text.len) { + const line = Line.at(text, pos); + pos = line.next; + + // Skip empty lines and comments + if (line.isBlank()) continue; + + // Pop stack to find parent at correct indentation + while (stack_depth > 1 and stack[stack_depth - 1].indent >= line.indent) stack_depth -= 1; + const parent = stack[stack_depth - 1].node; + + const content = line.content; + if (content[0] == '-' and content.len >= 2 and content[1] == ' ') { + parseListItem(content, parent); + } else { + parseKeyLine(text, line, parent, &stack, &stack_depth); + } + } + + return root; +} + +// ── Query helpers ──────────────────────────────────────────────── + +/// Find a child node by key in a map node. +fn findChild(node: *const Node, key: []const u8) ?*const Node { + for (node.childSlice()) |child| { + if (child.key) |child_key| { + if (std.mem.eql(u8, std.mem.span(child_key), key)) return child; + } + } + return null; +} + +/// Navigate to a node by dot-separated path. +fn navigate(root: ?*const Node, path: ?[*:0]const u8) ?*const Node { + var cur = root orelse return null; + var p = std.mem.span(path orelse return null); + + while (p.len > 0) { + const seg_len = std.mem.indexOfScalar(u8, p, '.') orelse p.len; + if (seg_len == 0 or seg_len >= 256) return null; + cur = findChild(cur, p[0..seg_len]) orelse return null; + p = p[seg_len..]; + if (p.len > 0) p = p[1..]; // the '.' + } + return cur; +} + +pub export fn cbm_yaml_get_str(root: ?*const Node, path: ?[*:0]const u8) ?[*:0]const u8 { + const node = navigate(root, path) orelse return null; + if (node.type != .scalar) return null; + return node.value; +} + +pub export fn cbm_yaml_get_float(root: ?*const Node, path: ?[*:0]const u8, default_val: f64) f64 { + const str = cbm_yaml_get_str(root, path) orelse return default_val; + var endptr: [*c]u8 = null; + const val = c.strtod(str, &endptr); + if (endptr == @as([*c]const u8, str)) return default_val; + return val; +} + +pub export fn cbm_yaml_get_bool(root: ?*const Node, path: ?[*:0]const u8, default_val: bool) bool { + const str = std.mem.span(cbm_yaml_get_str(root, path) orelse return default_val); + const eq = std.ascii.eqlIgnoreCase; + if (eq(str, "true") or eq(str, "yes") or eq(str, "on") or std.mem.eql(u8, str, "1")) return true; + if (eq(str, "false") or eq(str, "no") or eq(str, "off") or std.mem.eql(u8, str, "0")) return false; + return default_val; +} + +pub export fn cbm_yaml_get_str_list(root: ?*const Node, path: ?[*:0]const u8, out: [*]?[*:0]const u8, max_out: c_int) c_int { + const node = navigate(root, path) orelse return 0; + if (node.type != .list) return 0; + var count: c_int = 0; + for (node.childSlice()) |child| { + if (count >= max_out) break; + if (child.type == .scalar and child.value != null) { + out[@intCast(count)] = child.value; + count += 1; + } + } + return count; +} + +pub export fn cbm_yaml_has(root: ?*const Node, path: ?[*:0]const u8) bool { + return navigate(root, path) != null; +}