Files
codebase-memory-mcp/src/foundation/system_info.c
T
Martin Vogel 9db483402e fix(mem,spill): close two data races the worker threads exposed
TSan failed on all three platforms in PR CI. Both races are in code this branch
added, and both were invisible to the local ladder because it has no TSan leg —
that lane is CI-only, so "macOS and Linux green" said nothing about either.

1. system_info.c: the cached system info

   cbm_system_info() published its cache with a plain flag:

       if (!info_cached) { cached_info = detect_...(); info_cached = 1; }

   Fine while only the main thread asked. The memory-relief work started calling
   cbm_mem_system_under_pressure() from the per-file extract loop, so several
   workers now race on the flag AND on the struct — and a struct assignment is
   not atomic, so a reader could see half of one. Reported at
   system_info.c:284 against :274, from extract_worker, on macOS, ubuntu-arm and
   ubuntu-latest alike.

   Now a three-state atomic: whichever thread wins the compare-exchange is the
   only writer of cached_info, a loser returns the copy it detected itself (the
   same answer — detection only reads the OS), and later readers see the
   published struct through the acquire/release pair. No mutex, so nothing has
   to be initialised before first use.

2. pass_parallel.c: the spill pointer

   pp_spill_active() tested ec->pctx->spill BEFORE its atomic spill_mode gate,
   so it read an 8-byte pointer with no synchronisation while pp_spill_enter was
   publishing that pointer under spill_mu. pp_spill_enter assigns the pointer
   and only then release-stores spill_mode, so gating on the atomic first makes
   the pointer read ordered by that release. Short-circuit order is load bearing
   in that expression now, and the comment says so.

Found by running the TSan lane locally with halt_on_error=0 rather than CI's
halt_on_error=1: the first setting stops at the first race, so each fix would
have revealed the next one a CI round trip later. Enumerating instead showed
the second race was the last one in that suite.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-19 22:24:11 +02:00

441 lines
16 KiB
C

/*
* 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/mem_core.h" /* cbm_alloc: one accounted allocation path */
#include "foundation/platform.h"
#include "foundation/system_info_internal.h"
#include <stdatomic.h> // the system-info cache is published across worker threads
#include <stdint.h> // uint64_t
#include <stdlib.h> // strtol
#include <string.h>
#ifndef _WIN32
#include <sys/statvfs.h> /* cbm_fs_free_bytes: free space before we spill into it */
#endif
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#elif defined(__APPLE__)
#include <mach/mach.h> /* host_statistics64 - reclaimable page accounting */
#include <sys/sysctl.h>
#elif defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__)
#include <unistd.h>
#include <sys/types.h>
#include <sys/sysctl.h>
#else /* Linux */
/* limits.h for ULLONG_MAX, stdio.h for fopen/fread, stdlib.h for strto*. */
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/sysinfo.h>
#include <unistd.h>
#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__) || defined(__FreeBSD__)
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: "<root>/cpu.max" — "<quota> <period>" or "max <period>". */
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", &quota, &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: "<root>/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 ──────────────────────────────────────────────────── */
/* The cache is published once, by whichever thread gets there first.
*
* This used to be a plain `if (!info_cached) { cached_info = detect(); }`, which
* was fine while only the main thread asked. It stopped being fine when the
* memory-relief path started calling cbm_mem_system_under_pressure() from the
* extract workers: several threads then raced on the flag AND on the struct,
* and a struct assignment is not atomic, so a reader could see half of one.
* TSan caught it on all three platforms (system_info.c:284 against :274, from
* extract_worker).
*
* Three states rather than a flag, so the winner of the compare-exchange is the
* only writer of cached_info: a loser returns the copy it detected itself,
* which is the same answer, and a later reader sees the published struct
* through the acquire/release pair. No mutex, so nothing has to be initialised
* before first use, and no reader can observe a half-written struct. */
enum { INFO_EMPTY = 0, INFO_CLAIMED = 1, INFO_READY = 2 };
static _Atomic int info_state = INFO_EMPTY;
static cbm_system_info_t cached_info;
static cbm_system_info_t detect_system_now(void) {
#ifdef _WIN32
return detect_system_windows();
#elif defined(__APPLE__)
return detect_system_macos();
#elif defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__)
return detect_system_bsd();
#else
return detect_system_linux();
#endif
}
cbm_system_info_t cbm_system_info(void) {
if (atomic_load_explicit(&info_state, memory_order_acquire) == INFO_READY) {
return cached_info;
}
/* Detection only reads the OS (sysconf/sysctl/GetSystemInfo), so racing
* threads doing it twice during startup costs a little and changes nothing. */
cbm_system_info_t local = detect_system_now();
int expected = INFO_EMPTY;
if (atomic_compare_exchange_strong_explicit(&info_state, &expected, INFO_CLAIMED,
memory_order_acq_rel, memory_order_relaxed)) {
cached_info = local;
atomic_store_explicit(&info_state, INFO_READY, memory_order_release);
}
return local;
}
int cbm_default_worker_count(bool initial) {
/* CBM_WORKERS env override (clamped to [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) {
long n = strtol(buf, NULL, CBM_DECIMAL_BASE);
if (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;
}
/* -- Available RAM --------------------------------------------------
*
* Bytes the system could hand out right now, or 0 when the platform cannot
* answer. Deliberately NOT cached: core counts and total RAM are immutable
* hardware facts, but this changes continuously and the entire point is to
* observe it DURING an index.
*
* Why this exists: the indexer decided "out of memory" by comparing its own
* RSS against a static fraction of TOTAL ram. That refuses work a machine can
* plainly do -- measured 2026-09-13, the linux kernel needs 31.75 GB on a
* 48 GB host (66 percent) against a 0.5 default budget, and the same index
* completed on the previous release by overshooting to 33.56 GB. Whether
* memory is actually scarce is a property of the SYSTEM, not of a constant. */
size_t cbm_system_available_ram(void) {
#ifdef _WIN32
MEMORYSTATUSEX status;
status.dwLength = sizeof(status);
if (GlobalMemoryStatusEx(&status)) {
return (size_t)status.ullAvailPhys;
}
return 0;
#elif defined(__APPLE__)
/* free + inactive + purgeable. Counting only free_count would report
* pressure on any machine that is merely warm, because inactive and
* purgeable pages are reclaimed on demand. */
mach_port_t host = mach_host_self();
vm_size_t page_size = 0;
if (host_page_size(host, &page_size) != KERN_SUCCESS || page_size == 0) {
return 0;
}
vm_statistics64_data_t vm_stat = {0};
mach_msg_type_number_t count = HOST_VM_INFO64_COUNT;
if (host_statistics64(host, HOST_VM_INFO64, (host_info64_t)&vm_stat, &count) != KERN_SUCCESS) {
return 0;
}
uint64_t pages = (uint64_t)vm_stat.free_count + (uint64_t)vm_stat.inactive_count +
(uint64_t)vm_stat.purgeable_count;
return (size_t)(pages * (uint64_t)page_size);
#elif !defined(__NetBSD__) && !defined(__FreeBSD__) && !defined(__OpenBSD__)
/* Linux: MemAvailable is the kernel estimate and accounts for reclaimable
* slab and page cache, which MemFree does not. */
FILE *meminfo = fopen("/proc/meminfo", "re");
if (!meminfo) {
return 0;
}
char line[CBM_SZ_256];
size_t available = 0;
while (fgets(line, sizeof(line), meminfo) != NULL) {
unsigned long long kb = 0;
if (sscanf(line, "MemAvailable: %llu kB", &kb) == 1) {
available = (size_t)(kb * (unsigned long long)CBM_SZ_1K);
break;
}
}
(void)fclose(meminfo);
return available;
#else
return 0; /* BSD: unknown rather than guessed */
#endif
}
size_t cbm_fs_free_bytes(const char *path) {
if (!path || !path[0]) {
return 0;
}
#ifdef _WIN32
/* The QUOTA figure, not the volume's: on a disk with per-user quotas the
* volume's free space is not what this process may actually write. */
ULARGE_INTEGER avail = {0};
/* Widened through the memory core rather than cbm_utf8_to_wide(), which
* allocates with raw malloc: every allocation in this binary goes through
* one accounted path (src/foundation/mem_core.h). */
int wide_len = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, NULL, 0);
if (wide_len <= 0) {
return 0;
}
wchar_t *wide = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)wide_len * sizeof(wchar_t));
if (!wide) {
return 0;
}
size_t free_bytes = 0;
if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, wide, wide_len) == wide_len &&
GetDiskFreeSpaceExW(wide, &avail, NULL, NULL)) {
free_bytes = (size_t)avail.QuadPart;
}
cbm_free(CBM_MEM_CLASS_OTHER, wide);
return free_bytes;
#else
struct statvfs st;
if (statvfs(path, &st) != 0) {
return 0;
}
/* f_bavail, not f_bfree: blocks free for an UNPRIVILEGED writer, which is
* what this process is. f_frsize is the fragment size the counts are in. */
uint64_t unit = st.f_frsize ? (uint64_t)st.f_frsize : (uint64_t)st.f_bsize;
return (size_t)((uint64_t)st.f_bavail * unit);
#endif
}