diff --git a/src/foundation/system_info.c b/src/foundation/system_info.c index e5ba8a65..84970e8a 100644 --- a/src/foundation/system_info.c +++ b/src/foundation/system_info.c @@ -3,7 +3,9 @@ * * macOS: sysctlbyname for core counts, hw.memsize for RAM. * BSD: sysconf + sysctl(HW_PHYSMEM64 / HW_PHYSMEM). - * Linux: sysconf + sysinfo(). + * 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). @@ -12,6 +14,7 @@ enum { DEFAULT_CORES = 1, MIN_WORKERS = 1 }; #include "foundation/platform.h" +#include "foundation/system_info_internal.h" #include // uint64_t #include @@ -27,9 +30,12 @@ enum { DEFAULT_CORES = 1, MIN_WORKERS = 1 }; #include #include #else /* Linux */ -#include +/* limits.h for ULLONG_MAX, stdio.h for fopen/fread, stdlib.h for strto*. */ +#include +#include +#include #include - +#include #endif /* ── macOS detection ─────────────────────────────────────────────── */ @@ -103,19 +109,124 @@ static cbm_system_info_t detect_system_bsd(void) { #else /* 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); - info.total_cores = nprocs > 0 ? (int)nprocs : 1; - info.perf_cores = info.total_cores; /* Linux doesn't distinguish P/E */ + int host_cpus = nprocs > 0 ? (int)nprocs : DEFAULT_CORES; + size_t host_ram = 0; struct sysinfo si; if (sysinfo(&si) == 0) { - info.total_ram = (size_t)si.totalram * (size_t)si.mem_unit; + 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; } diff --git a/src/foundation/system_info_internal.h b/src/foundation/system_info_internal.h new file mode 100644 index 00000000..e4bd4d53 --- /dev/null +++ b/src/foundation/system_info_internal.h @@ -0,0 +1,44 @@ +/* + * system_info_internal.h — Internal helpers exposed for testing. + * + * These functions are implementation details of system_info.c; they are + * declared here only so that test_platform.c can drive them against a + * fake cgroup filesystem. Production code outside system_info.c should + * use the public APIs in platform.h instead. + */ +#ifndef CBM_FOUNDATION_SYSTEM_INFO_INTERNAL_H +#define CBM_FOUNDATION_SYSTEM_INFO_INTERNAL_H + +#include + +#ifdef __linux__ + +/* + * Effective CPU count for the cgroup rooted at `cgroup_root`. + * + * Reads (in order): + * 1. cgroup v2: "/cpu.max" (" " or "max ...") + * 2. cgroup v1: "/cpu/cpu.cfs_quota_us" + ".../cpu.cfs_period_us" + * + * Returns ceil(quota / period) (>= 1) when a valid CPU quota is in place. + * Returns -1 when no cgroup limit is present (caller should fall back to + * sysconf(_SC_NPROCESSORS_ONLN)). + */ +int cbm_detect_cgroup_cpus(const char *cgroup_root); + +/* + * Effective memory limit (bytes) for the cgroup rooted at `cgroup_root`. + * + * Reads (in order): + * 1. cgroup v2: "/memory.max" ("max" or integer bytes) + * 2. cgroup v1: "/memory/memory.limit_in_bytes" + * + * Returns the byte count when a finite limit is in place. Returns 0 when + * no cgroup limit is present, the limit is "max"/unlimited, or the value + * is so large it represents the cgroup-v1 "unlimited" sentinel. + */ +size_t cbm_detect_cgroup_mem(const char *cgroup_root); + +#endif /* __linux__ */ + +#endif /* CBM_FOUNDATION_SYSTEM_INFO_INTERNAL_H */ diff --git a/tests/test_platform.c b/tests/test_platform.c index 7a502ccc..f2793ddf 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -3,8 +3,18 @@ */ #include "test_framework.h" #include "../src/foundation/platform.h" +#include "../src/foundation/system_info_internal.h" #include +#ifdef __linux__ +/* Linux-only cgroup tests need stdio for FILE*, stdlib for mkdtemp, + * string for strncpy/strchr, sys/stat for mkdir. */ +#include +#include +#include +#include +#endif + TEST(platform_now_ns) { uint64_t t1 = cbm_now_ns(); ASSERT_GT(t1, 0); @@ -68,6 +78,162 @@ TEST(platform_mmap_nonexistent) { PASS(); } +/* ── cgroup-aware detection (Linux only) ─────────────────────────── */ + +#ifdef __linux__ + +/* Create a unique tmp directory the caller will own; returns 0 on success. */ +static int cgroup_test_setup(char *root, size_t root_sz) { + strncpy(root, "/tmp/cbm_cgroup_test_XXXXXX", root_sz); + return mkdtemp(root) != NULL ? 0 : -1; +} + +/* Write `content` to "/". Creates parent subdir if needed. + * Returns 0 on success, -1 on any failure. */ +static int cgroup_test_write(const char *root, const char *relpath, const char *content) { + char path[1024]; + const char *slash = strchr(relpath, '/'); + if (slash != NULL) { + char subdir[1024]; + size_t n = (size_t)(slash - relpath); + if (n >= sizeof(subdir)) { + return -1; + } + memcpy(subdir, relpath, n); + subdir[n] = '\0'; + snprintf(path, sizeof(path), "%s/%s", root, subdir); + (void)mkdir(path, S_IRWXU); + } + snprintf(path, sizeof(path), "%s/%s", root, relpath); + FILE *fp = fopen(path, "we"); + if (fp == NULL) { + return -1; + } + size_t n = strlen(content); + int rc = (fwrite(content, 1, n, fp) == n) ? 0 : -1; + fclose(fp); + return rc; +} + +/* Recursively remove a tmp dir created by cgroup_test_setup. Best-effort. */ +static void cgroup_test_teardown(const char *root) { + char cmd[1280]; + snprintf(cmd, sizeof(cmd), "rm -rf -- '%s'", root); + (void)system(cmd); +} + +TEST(cgroup_v2_cpu_quota) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + /* 200ms quota in a 100ms period → 2 effective CPUs. */ + ASSERT_EQ(cgroup_test_write(root, "cpu.max", "200000 100000\n"), 0); + ASSERT_EQ(cbm_detect_cgroup_cpus(root), 2); + cgroup_test_teardown(root); + PASS(); +} + +TEST(cgroup_v2_cpu_quota_rounds_up) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + /* 150ms quota / 100ms period = 1.5 → ceil = 2. */ + ASSERT_EQ(cgroup_test_write(root, "cpu.max", "150000 100000\n"), 0); + ASSERT_EQ(cbm_detect_cgroup_cpus(root), 2); + cgroup_test_teardown(root); + PASS(); +} + +TEST(cgroup_v2_cpu_unlimited) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + ASSERT_EQ(cgroup_test_write(root, "cpu.max", "max 100000\n"), 0); + ASSERT_EQ(cbm_detect_cgroup_cpus(root), -1); + cgroup_test_teardown(root); + PASS(); +} + +TEST(cgroup_v1_cpu_quota) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + ASSERT_EQ(cgroup_test_write(root, "cpu/cpu.cfs_quota_us", "200000"), 0); + ASSERT_EQ(cgroup_test_write(root, "cpu/cpu.cfs_period_us", "100000"), 0); + ASSERT_EQ(cbm_detect_cgroup_cpus(root), 2); + cgroup_test_teardown(root); + PASS(); +} + +TEST(cgroup_v1_cpu_unlimited) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + /* quota=-1 is the cgroup-v1 sentinel for "no quota". */ + ASSERT_EQ(cgroup_test_write(root, "cpu/cpu.cfs_quota_us", "-1"), 0); + ASSERT_EQ(cgroup_test_write(root, "cpu/cpu.cfs_period_us", "100000"), 0); + ASSERT_EQ(cbm_detect_cgroup_cpus(root), -1); + cgroup_test_teardown(root); + PASS(); +} + +TEST(cgroup_no_cpu_files) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + /* Empty tmp dir: no v2 file, no v1 file → fall through to sysconf. */ + ASSERT_EQ(cbm_detect_cgroup_cpus(root), -1); + cgroup_test_teardown(root); + PASS(); +} + +TEST(cgroup_v2_mem) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + /* 2 GiB. */ + ASSERT_EQ(cgroup_test_write(root, "memory.max", "2147483648\n"), 0); + ASSERT_EQ(cbm_detect_cgroup_mem(root), (size_t)2147483648UL); + cgroup_test_teardown(root); + PASS(); +} + +TEST(cgroup_v2_mem_unlimited) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + ASSERT_EQ(cgroup_test_write(root, "memory.max", "max\n"), 0); + ASSERT_EQ(cbm_detect_cgroup_mem(root), (size_t)0); + cgroup_test_teardown(root); + PASS(); +} + +TEST(cgroup_v1_mem) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + /* 1 GiB. */ + ASSERT_EQ(cgroup_test_write(root, "memory/memory.limit_in_bytes", "1073741824"), 0); + ASSERT_EQ(cbm_detect_cgroup_mem(root), (size_t)1073741824UL); + cgroup_test_teardown(root); + PASS(); +} + +TEST(cgroup_v1_mem_unlimited_sentinel) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + /* cgroup v1 reports a huge near-ULLONG_MAX value when unlimited + * (PAGE_COUNTER_MAX). Our parser treats anything >= ULLONG_MAX/2 + * as effectively unlimited. */ + ASSERT_EQ(cgroup_test_write(root, "memory/memory.limit_in_bytes", + "9223372036854775807"), + 0); + ASSERT_EQ(cbm_detect_cgroup_mem(root), (size_t)0); + cgroup_test_teardown(root); + PASS(); +} + +TEST(cgroup_no_mem_files) { + char root[64]; + ASSERT_EQ(cgroup_test_setup(root, sizeof(root)), 0); + ASSERT_EQ(cbm_detect_cgroup_mem(root), (size_t)0); + cgroup_test_teardown(root); + PASS(); +} + +#endif /* __linux__ */ + SUITE(platform) { RUN_TEST(platform_now_ns); RUN_TEST(platform_now_ms); @@ -77,4 +243,17 @@ SUITE(platform) { RUN_TEST(platform_file_size); RUN_TEST(platform_mmap); RUN_TEST(platform_mmap_nonexistent); +#ifdef __linux__ + RUN_TEST(cgroup_v2_cpu_quota); + RUN_TEST(cgroup_v2_cpu_quota_rounds_up); + RUN_TEST(cgroup_v2_cpu_unlimited); + RUN_TEST(cgroup_v1_cpu_quota); + RUN_TEST(cgroup_v1_cpu_unlimited); + RUN_TEST(cgroup_no_cpu_files); + RUN_TEST(cgroup_v2_mem); + RUN_TEST(cgroup_v2_mem_unlimited); + RUN_TEST(cgroup_v1_mem); + RUN_TEST(cgroup_v1_mem_unlimited_sentinel); + RUN_TEST(cgroup_no_mem_files); +#endif }