diff --git a/src/foundation/system_info.c b/src/foundation/system_info.c index 21e0a9cb3..472c139cf 100644 --- a/src/foundation/system_info.c +++ b/src/foundation/system_info.c @@ -128,29 +128,27 @@ static int read_small_file(const char *path, char *buf, size_t bufsz) { return (int)n; } -/* Effective CPU count from a cgroup file tree. See header for contract. */ -int cbm_detect_cgroup_cpus(const char *cgroup_root) { +/* Parse a "cpu.max" v2 payload from `buf`. Returns effective CPUs or -1. */ +static int parse_cpu_max_v2(const char *buf) { + 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; +} + +/* Read v1 quota/period from a directory that directly contains + * "cpu.cfs_quota_us" and "cpu.cfs_period_us". Returns CPU count or -1. */ +static int parse_cpu_v1_from_dir(const char *dir) { 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); + snprintf(path, sizeof(path), "%s/cpu.cfs_quota_us", dir); if (read_small_file(path, buf, sizeof(buf)) <= 0) { return -1; } @@ -159,7 +157,7 @@ int cbm_detect_cgroup_cpus(const char *cgroup_root) { return -1; } - snprintf(path, sizeof(path), "%s/cpu/cpu.cfs_period_us", cgroup_root); + snprintf(path, sizeof(path), "%s/cpu.cfs_period_us", dir); if (read_small_file(path, buf, sizeof(buf)) <= 0) { return -1; } @@ -172,30 +170,51 @@ int cbm_detect_cgroup_cpus(const char *cgroup_root) { 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) { +/* 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: "/memory.max" — "max" or integer bytes. */ - snprintf(path, sizeof(path), "%s/memory.max", cgroup_root); + /* 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 0; - } - char *end = NULL; - unsigned long long n = strtoull(buf, &end, CBM_DECIMAL_BASE); - if (end == buf || n == 0) { - return 0; + return parse_cpu_max_v2(buf); + } + + /* cgroup v1: ".../cpu/cpu.cfs_quota_us" and ".../cpu/cpu.cfs_period_us". */ + snprintf(path, sizeof(path), "%s/cpu", cgroup_root); + return parse_cpu_v1_from_dir(path); +} + +/* Effective CPU count from an already-resolved path. See header. */ +int cbm_detect_cgroup_cpus_file(const char *path, int is_v2) { + if (path == NULL) { + return -1; + } + if (is_v2) { + char buf[CBM_SZ_64]; + if (read_small_file(path, buf, sizeof(buf)) <= 0) { + return -1; } - return (size_t)n; + return parse_cpu_max_v2(buf); } + return parse_cpu_v1_from_dir(path); +} - /* 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) { +/* Effective memory limit from a single file. See header for contract. + * + * The v2 "memory.max" and v1 "memory.limit_in_bytes" grammars are close + * enough (integer bytes, plus v2's "max" literal and v1's near-ULLONG_MAX + * sentinel) that one parser covers both. */ +size_t cbm_detect_cgroup_mem_file(const char *mem_file_path) { + if (mem_file_path == NULL) { + return 0; + } + char buf[CBM_SZ_64]; + if (read_small_file(mem_file_path, buf, sizeof(buf)) <= 0) { + return 0; + } + if (strncmp(buf, "max", 3) == 0) { return 0; } char *end = NULL; @@ -206,6 +225,270 @@ size_t cbm_detect_cgroup_mem(const char *cgroup_root) { return (size_t)n; } +/* 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]; + + /* cgroup v2: "/memory.max". */ + snprintf(path, sizeof(path), "%s/memory.max", cgroup_root); + size_t v2 = cbm_detect_cgroup_mem_file(path); + if (v2 > 0) { + return v2; + } + /* If the file existed but reported "max"/unlimited, we can't tell v2 + * apart from "file missing" here without a stat — but the fallback to + * v1 below is safe: on a v2-only host the v1 path won't exist either + * and we'll still return 0. */ + + /* cgroup v1: ".../memory/memory.limit_in_bytes". */ + snprintf(path, sizeof(path), "%s/memory/memory.limit_in_bytes", cgroup_root); + return cbm_detect_cgroup_mem_file(path); +} + +/* ── /proc/self/cgroup resolvers ───────────────────────────────────── + * + * On Linux a process rarely lives at the cgroup filesystem root. Under + * Docker, Kubernetes, systemd, ECS Fargate, etc. it sits in a nested + * sub-cgroup like "/ecs//" (v2) or "/docker/" (v1), + * and the effective limits live at that nested path. Reading only the + * root files silently reports the host's totals — which is the source + * of the OOM-in-container bug this module now guards against. + * + * These helpers parse /proc/self/cgroup (a tiny file: v2 has one line, + * v1 has one line per controller) and combine the process path with the + * cgroup filesystem root to produce a concrete file path to read. */ + +/* Return 1 iff `path` exists as any file/dir kind. Used to detect v2. */ +static int path_exists(const char *path) { + FILE *fp = fopen(path, "re"); + if (fp != NULL) { + fclose(fp); + return 1; + } + return 0; +} + +/* Strip trailing newline in-place. */ +static void chomp(char *s) { + size_t n = strlen(s); + while (n > 0 && (s[n - 1] == '\n' || s[n - 1] == '\r')) { + s[--n] = '\0'; + } +} + +/* Read /proc/self/cgroup into `buf`. Returns bytes read (>0) or -1. */ +static int read_proc_cgroup(const char *proc_path, char *buf, size_t bufsz) { + FILE *fp = fopen(proc_path, "re"); + if (fp == NULL) { + return -1; + } + size_t n = fread(buf, 1, bufsz - 1, fp); + fclose(fp); + if (n == 0) { + return -1; + } + buf[n] = '\0'; + return (int)n; +} + +/* Copy the cgroup path portion of a v2 "0::PATH" line into `out`. + * Returns 0 on success, -1 on malformed input or buffer overflow. */ +static int extract_v2_path(const char *proc_content, char *out, size_t out_sz) { + /* v2 grammar: single line "0::/path" (no controllers column). */ + const char *p = strstr(proc_content, "0::"); + if (p == NULL) { + /* Some kernels emit the hierarchy id ahead of the sentinel; be + * lenient and fall back to the first "::" occurrence. */ + p = strstr(proc_content, "::"); + if (p == NULL) { + return -1; + } + p += 2; + } else { + p += 3; + } + /* Copy until newline or EOS. */ + size_t i = 0; + while (p[i] != '\0' && p[i] != '\n' && p[i] != '\r') { + if (i + 1 >= out_sz) { + return -1; + } + out[i] = p[i]; + i++; + } + out[i] = '\0'; + if (i == 0) { + return -1; + } + return 0; +} + +/* Extract the sub-cgroup path for `controller` from a v1 /proc/self/cgroup + * dump. Matches lines "N:controllers:PATH" where `controllers` is a + * comma-separated list containing an exact-token match for `controller`. + * Returns 0 on success, -1 if no matching line was found. */ +static int extract_v1_path(const char *proc_content, const char *controller, + char *out, size_t out_sz) { + size_t clen = strlen(controller); + const char *line = proc_content; + while (line != NULL && *line != '\0') { + const char *eol = strchr(line, '\n'); + size_t line_len = (eol != NULL) ? (size_t)(eol - line) : strlen(line); + + /* Skip "N:" hierarchy id */ + const char *c1 = memchr(line, ':', line_len); + if (c1 != NULL) { + c1++; + size_t rest_len = line_len - (size_t)(c1 - line); + const char *c2 = memchr(c1, ':', rest_len); + if (c2 != NULL) { + /* [c1, c2) is the controllers field; [c2+1, line+line_len) is path. + * Scan comma-separated controller tokens for an exact match. */ + const char *tok = c1; + while (tok < c2) { + const char *comma = memchr(tok, ',', (size_t)(c2 - tok)); + size_t tok_len = (comma != NULL) ? (size_t)(comma - tok) + : (size_t)(c2 - tok); + if (tok_len == clen && memcmp(tok, controller, clen) == 0) { + const char *path = c2 + 1; + size_t path_len = line_len - (size_t)(path - line); + if (path_len + 1 > out_sz) { + return -1; + } + memcpy(out, path, path_len); + out[path_len] = '\0'; + return 0; + } + if (comma == NULL) { + break; + } + tok = comma + 1; + } + } + } + if (eol == NULL) { + break; + } + line = eol + 1; + } + return -1; +} + +/* Compose "" into `out`, collapsing the redundant slash + * that appears when the process cgroup path is exactly "/". Returns 0 on + * success, -1 on buffer overflow. */ +static int compose_cgroup_path(const char *root, const char *sub, + const char *suffix, char *out, size_t out_sz) { + int n; + if (strcmp(sub, "/") == 0) { + n = snprintf(out, out_sz, "%s%s", root, suffix); + } else { + n = snprintf(out, out_sz, "%s%s%s", root, sub, suffix); + } + if (n < 0 || (size_t)n >= out_sz) { + return -1; + } + return 0; +} + +/* Detect cgroup v2 by looking for a controllers file at the fs root. */ +static int cgroup_fs_is_v2(const char *cgroup_fs_root) { + char probe[CBM_PATH_MAX]; + int n = snprintf(probe, sizeof(probe), "%s/cgroup.controllers", cgroup_fs_root); + if (n < 0 || (size_t)n >= sizeof(probe)) { + return 0; + } + return path_exists(probe); +} + +int cbm_resolve_process_cgroup_mem_path(const char *proc_self_cgroup_path, + const char *cgroup_fs_root, + char *out, + size_t out_sz, + int *out_is_v2) { + if (proc_self_cgroup_path == NULL || cgroup_fs_root == NULL || out == NULL || + out_sz == 0 || out_is_v2 == NULL) { + return -1; + } + char proc_buf[CBM_SZ_64K]; + if (read_proc_cgroup(proc_self_cgroup_path, proc_buf, sizeof(proc_buf)) < 0) { + return -1; + } + + int is_v2 = cgroup_fs_is_v2(cgroup_fs_root); + *out_is_v2 = is_v2; + + char sub[CBM_PATH_MAX]; + if (is_v2) { + if (extract_v2_path(proc_buf, sub, sizeof(sub)) != 0) { + return -1; + } + chomp(sub); + return compose_cgroup_path(cgroup_fs_root, sub, "/memory.max", out, out_sz); + } + + if (extract_v1_path(proc_buf, "memory", sub, sizeof(sub)) != 0) { + return -1; + } + chomp(sub); + /* v1: "/memory/memory.limit_in_bytes". "memory" here is the + * controller mount subdir; `sub` starts with "/" already. */ + int n; + if (strcmp(sub, "/") == 0) { + n = snprintf(out, out_sz, "%s/memory/memory.limit_in_bytes", cgroup_fs_root); + } else { + n = snprintf(out, out_sz, "%s/memory%s/memory.limit_in_bytes", + cgroup_fs_root, sub); + } + if (n < 0 || (size_t)n >= out_sz) { + return -1; + } + return 0; +} + +int cbm_resolve_process_cgroup_cpu_path(const char *proc_self_cgroup_path, + const char *cgroup_fs_root, + char *out, + size_t out_sz, + int *out_is_v2) { + if (proc_self_cgroup_path == NULL || cgroup_fs_root == NULL || out == NULL || + out_sz == 0 || out_is_v2 == NULL) { + return -1; + } + char proc_buf[CBM_SZ_64K]; + if (read_proc_cgroup(proc_self_cgroup_path, proc_buf, sizeof(proc_buf)) < 0) { + return -1; + } + + int is_v2 = cgroup_fs_is_v2(cgroup_fs_root); + *out_is_v2 = is_v2; + + char sub[CBM_PATH_MAX]; + if (is_v2) { + if (extract_v2_path(proc_buf, sub, sizeof(sub)) != 0) { + return -1; + } + chomp(sub); + return compose_cgroup_path(cgroup_fs_root, sub, "/cpu.max", out, out_sz); + } + + /* v1 cpu controller: often listed as "cpu" or "cpu,cpuacct". */ + if (extract_v1_path(proc_buf, "cpu", sub, sizeof(sub)) != 0) { + return -1; + } + chomp(sub); + int n; + if (strcmp(sub, "/") == 0) { + n = snprintf(out, out_sz, "%s/cpu", cgroup_fs_root); + } else { + n = snprintf(out, out_sz, "%s/cpu%s", cgroup_fs_root, sub); + } + if (n < 0 || (size_t)n >= out_sz) { + return -1; + } + return 0; +} + static cbm_system_info_t detect_system_linux(void) { cbm_system_info_t info; memset(&info, 0, sizeof(info)); @@ -220,13 +503,41 @@ static cbm_system_info_t detect_system_linux(void) { 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"); + /* Cgroup-aware overrides. Prefer the process's own sub-cgroup path + * (resolved via /proc/self/cgroup) because on Docker / Kubernetes / + * ECS Fargate the effective limits live in a nested cgroup, not at + * the fs root. If resolving that path fails for any reason we fall + * back to reading the root — which preserves the pre-fix behaviour + * for environments where the process really does live at the root. + * min(cgroup, host) defends against mis-mounted cgroups that report + * values larger than the host. */ + int cg_cpus = -1; + { + char cpu_path[CBM_PATH_MAX]; + int is_v2 = 0; + if (cbm_resolve_process_cgroup_cpu_path("/proc/self/cgroup", "/sys/fs/cgroup", + cpu_path, sizeof(cpu_path), &is_v2) == 0) { + cg_cpus = cbm_detect_cgroup_cpus_file(cpu_path, is_v2); + } + } + if (cg_cpus <= 0) { + 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"); + size_t cg_ram = 0; + { + char mem_path[CBM_PATH_MAX]; + int is_v2 = 0; + if (cbm_resolve_process_cgroup_mem_path("/proc/self/cgroup", "/sys/fs/cgroup", + mem_path, sizeof(mem_path), &is_v2) == 0) { + cg_ram = cbm_detect_cgroup_mem_file(mem_path); + } + } + if (cg_ram == 0) { + 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 index e4bd4d539..f2386bd60 100644 --- a/src/foundation/system_info_internal.h +++ b/src/foundation/system_info_internal.h @@ -39,6 +39,69 @@ int cbm_detect_cgroup_cpus(const char *cgroup_root); */ size_t cbm_detect_cgroup_mem(const char *cgroup_root); +/* + * Effective memory limit (bytes) read directly from `mem_file_path`. + * + * `mem_file_path` must point at either a cgroup-v2 "memory.max" file or + * a cgroup-v1 "memory.limit_in_bytes" file — the two share a compatible + * grammar (integer bytes, or "max" / the v1 near-ULLONG_MAX sentinel for + * "unlimited"). Used by callers that have already resolved the process's + * own sub-cgroup path via /proc/self/cgroup rather than blindly reading + * the cgroup filesystem root. + * + * Returns the byte count when a finite limit is in place, or 0 when the + * file is missing, the limit is "max"/unlimited, or the value trips the + * cgroup-v1 unlimited sentinel. + */ +size_t cbm_detect_cgroup_mem_file(const char *mem_file_path); + +/* + * Effective CPU count from an already-resolved cgroup path. + * + * When `is_v2` is non-zero, `path` must be a "cpu.max" file (v2 grammar: + * " " or "max ..."). When `is_v2` is zero, `path` must be + * a directory containing v1's "cpu.cfs_quota_us" and "cpu.cfs_period_us". + * + * Returns ceil(quota / period) when a valid CPU quota is present, or -1 + * when the limit is "max"/unlimited or the files are missing/malformed. + */ +int cbm_detect_cgroup_cpus_file(const char *path, int is_v2); + +/* + * Resolve the memory-cgroup file path for the current process. + * + * Parses `proc_self_cgroup_path` (typically "/proc/self/cgroup") to find + * the process's own sub-cgroup and combines it with `cgroup_fs_root` + * (typically "/sys/fs/cgroup") to produce the exact file the caller + * should read. The cgroup version is auto-detected: v2 if + * "/cgroup.controllers" exists, otherwise v1. + * + * On success writes the resolved path into `out` (of size `out_sz`), + * sets `*out_is_v2` to 1 (v2) or 0 (v1), and returns 0. Returns -1 on + * any error (unreadable /proc file, malformed line, missing memory + * controller entry in v1, buffer overflow). + */ +int cbm_resolve_process_cgroup_mem_path(const char *proc_self_cgroup_path, + const char *cgroup_fs_root, + char *out, + size_t out_sz, + int *out_is_v2); + +/* + * Resolve the cpu-cgroup path for the current process. + * + * For v2 the output is a "cpu.max" file path; for v1 it is the directory + * containing "cpu.cfs_quota_us" and "cpu.cfs_period_us" (which the v1 + * controller path already gives us — no extra "cpu/" prefix is added + * beyond the sub-cgroup path itself). Auto-detects v2/v1 the same way + * as the memory resolver. See its docstring for arguments and return. + */ +int cbm_resolve_process_cgroup_cpu_path(const char *proc_self_cgroup_path, + const char *cgroup_fs_root, + char *out, + size_t out_sz, + int *out_is_v2); + #endif /* __linux__ */ #endif /* CBM_FOUNDATION_SYSTEM_INFO_INTERNAL_H */ diff --git a/tests/test_platform.c b/tests/test_platform.c index 7ae9237c3..ec302a9be 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -3,6 +3,7 @@ */ #include "test_framework.h" #include "../src/foundation/compat.h" /* cbm_setenv / cbm_unsetenv (Windows-portable) */ +#include "../src/foundation/constants.h" /* CBM_PATH_MAX */ #include "../src/foundation/platform.h" #include "../src/foundation/system_info_internal.h" #include @@ -302,6 +303,238 @@ TEST(cgroup_no_mem_files) { PASS(); } +/* ── /proc/self/cgroup resolver tests ───────────────────────────── + * + * These exercise the fix for the OOM-in-container bug: on Fargate/ + * Docker/Kubernetes the process lives in a nested sub-cgroup, so the + * limits at "/sys/fs/cgroup/{memory.max,...}" report the host's + * totals instead of the container's cap. The resolver reads + * /proc/self/cgroup, parses out the sub-path, and composes the real + * file path. Each test mocks both files under /tmp. */ + +TEST(resolve_v2_mem_sub_cgroup) { + char proc_root[64]; + char fs_root[64]; + ASSERT_EQ(cgroup_test_setup(proc_root, sizeof(proc_root)), 0); + ASSERT_EQ(cgroup_test_setup(fs_root, sizeof(fs_root)), 0); + + /* Simulate Fargate: process sits at /ecs/task-abc/cont-def. */ + ASSERT_EQ(cgroup_test_write(proc_root, "cgroup", "0::/ecs/task-abc/cont-def\n"), 0); + /* Mark the fs as v2 by writing cgroup.controllers at the root. */ + ASSERT_EQ(cgroup_test_write(fs_root, "cgroup.controllers", "cpu memory io\n"), 0); + /* The container limit lives in the sub-cgroup, not the root. */ + ASSERT_EQ(cgroup_test_write(fs_root, "ecs/task-abc/cont-def/memory.max", + "8388608000\n"), + 0); + + char proc_path[128]; + snprintf(proc_path, sizeof(proc_path), "%s/cgroup", proc_root); + + char out[CBM_PATH_MAX]; + int is_v2 = -1; + ASSERT_EQ(cbm_resolve_process_cgroup_mem_path(proc_path, fs_root, + out, sizeof(out), &is_v2), + 0); + ASSERT_EQ(is_v2, 1); + ASSERT_EQ(cbm_detect_cgroup_mem_file(out), (size_t)8388608000UL); + + cgroup_test_teardown(proc_root); + cgroup_test_teardown(fs_root); + PASS(); +} + +TEST(resolve_v2_mem_root) { + char proc_root[64]; + char fs_root[64]; + ASSERT_EQ(cgroup_test_setup(proc_root, sizeof(proc_root)), 0); + ASSERT_EQ(cgroup_test_setup(fs_root, sizeof(fs_root)), 0); + + /* Process really lives at the root — the special case that would + * otherwise produce a double-slash "//memory.max" path. */ + ASSERT_EQ(cgroup_test_write(proc_root, "cgroup", "0::/\n"), 0); + ASSERT_EQ(cgroup_test_write(fs_root, "cgroup.controllers", "cpu memory\n"), 0); + ASSERT_EQ(cgroup_test_write(fs_root, "memory.max", "4194304000\n"), 0); + + char proc_path[128]; + snprintf(proc_path, sizeof(proc_path), "%s/cgroup", proc_root); + + char out[CBM_PATH_MAX]; + int is_v2 = -1; + ASSERT_EQ(cbm_resolve_process_cgroup_mem_path(proc_path, fs_root, + out, sizeof(out), &is_v2), + 0); + ASSERT_EQ(is_v2, 1); + ASSERT_EQ(cbm_detect_cgroup_mem_file(out), (size_t)4194304000UL); + + cgroup_test_teardown(proc_root); + cgroup_test_teardown(fs_root); + PASS(); +} + +TEST(resolve_v2_mem_unlimited) { + char proc_root[64]; + char fs_root[64]; + ASSERT_EQ(cgroup_test_setup(proc_root, sizeof(proc_root)), 0); + ASSERT_EQ(cgroup_test_setup(fs_root, sizeof(fs_root)), 0); + + ASSERT_EQ(cgroup_test_write(proc_root, "cgroup", "0::/ecs/x\n"), 0); + ASSERT_EQ(cgroup_test_write(fs_root, "cgroup.controllers", "memory\n"), 0); + ASSERT_EQ(cgroup_test_write(fs_root, "ecs/x/memory.max", "max\n"), 0); + + char proc_path[128]; + snprintf(proc_path, sizeof(proc_path), "%s/cgroup", proc_root); + + char out[CBM_PATH_MAX]; + int is_v2 = -1; + ASSERT_EQ(cbm_resolve_process_cgroup_mem_path(proc_path, fs_root, + out, sizeof(out), &is_v2), + 0); + ASSERT_EQ(cbm_detect_cgroup_mem_file(out), (size_t)0); + + cgroup_test_teardown(proc_root); + cgroup_test_teardown(fs_root); + PASS(); +} + +TEST(resolve_v1_mem_sub_cgroup) { + char proc_root[64]; + char fs_root[64]; + ASSERT_EQ(cgroup_test_setup(proc_root, sizeof(proc_root)), 0); + ASSERT_EQ(cgroup_test_setup(fs_root, sizeof(fs_root)), 0); + + /* Multi-line v1 /proc/self/cgroup; the memory controller sits in + * its own row. No cgroup.controllers file → detected as v1. */ + const char *proc_content = + "12:memory:/docker/abc123\n" + "11:cpuset:/docker/abc123\n" + "9:cpu,cpuacct:/docker/abc123\n"; + ASSERT_EQ(cgroup_test_write(proc_root, "cgroup", proc_content), 0); + ASSERT_EQ(cgroup_test_write(fs_root, "memory/docker/abc123/memory.limit_in_bytes", + "2097152000"), + 0); + + char proc_path[128]; + snprintf(proc_path, sizeof(proc_path), "%s/cgroup", proc_root); + + char out[CBM_PATH_MAX]; + int is_v2 = -1; + ASSERT_EQ(cbm_resolve_process_cgroup_mem_path(proc_path, fs_root, + out, sizeof(out), &is_v2), + 0); + ASSERT_EQ(is_v2, 0); + ASSERT_EQ(cbm_detect_cgroup_mem_file(out), (size_t)2097152000UL); + + cgroup_test_teardown(proc_root); + cgroup_test_teardown(fs_root); + PASS(); +} + +TEST(resolve_v1_combined_controller_no_memory_match) { + /* If the /proc/self/cgroup dump only has "cpu,cpuacct" (i.e. no + * memory row at all), the memory resolver must fail cleanly rather + * than silently pick up an unrelated controller's path. */ + char proc_root[64]; + char fs_root[64]; + ASSERT_EQ(cgroup_test_setup(proc_root, sizeof(proc_root)), 0); + ASSERT_EQ(cgroup_test_setup(fs_root, sizeof(fs_root)), 0); + + ASSERT_EQ(cgroup_test_write(proc_root, "cgroup", "9:cpu,cpuacct:/docker/xyz\n"), 0); + /* No cgroup.controllers → v1. */ + + char proc_path[128]; + snprintf(proc_path, sizeof(proc_path), "%s/cgroup", proc_root); + + char out[CBM_PATH_MAX]; + int is_v2 = -1; + ASSERT_EQ(cbm_resolve_process_cgroup_mem_path(proc_path, fs_root, + out, sizeof(out), &is_v2), + -1); + + cgroup_test_teardown(proc_root); + cgroup_test_teardown(fs_root); + PASS(); +} + +TEST(resolve_missing_proc_file_returns_error) { + /* Non-existent /proc/self/cgroup mock → resolver fails, caller + * falls back to the pre-fix root-reading behaviour. */ + char fs_root[64]; + ASSERT_EQ(cgroup_test_setup(fs_root, sizeof(fs_root)), 0); + ASSERT_EQ(cgroup_test_write(fs_root, "cgroup.controllers", "memory\n"), 0); + + char out[CBM_PATH_MAX]; + int is_v2 = -1; + ASSERT_EQ(cbm_resolve_process_cgroup_mem_path( + "/tmp/definitely-does-not-exist-cbm-cgroup-xyz", + fs_root, out, sizeof(out), &is_v2), + -1); + + /* And the root-reading fallback still works on the mock. */ + ASSERT_EQ(cbm_detect_cgroup_mem(fs_root), (size_t)0); + + cgroup_test_teardown(fs_root); + PASS(); +} + +TEST(resolve_v2_cpu_sub_cgroup) { + char proc_root[64]; + char fs_root[64]; + ASSERT_EQ(cgroup_test_setup(proc_root, sizeof(proc_root)), 0); + ASSERT_EQ(cgroup_test_setup(fs_root, sizeof(fs_root)), 0); + + ASSERT_EQ(cgroup_test_write(proc_root, "cgroup", "0::/ecs/task-abc/cont-def\n"), 0); + ASSERT_EQ(cgroup_test_write(fs_root, "cgroup.controllers", "cpu memory\n"), 0); + ASSERT_EQ(cgroup_test_write(fs_root, "ecs/task-abc/cont-def/cpu.max", + "200000 100000\n"), + 0); + + char proc_path[128]; + snprintf(proc_path, sizeof(proc_path), "%s/cgroup", proc_root); + + char out[CBM_PATH_MAX]; + int is_v2 = -1; + ASSERT_EQ(cbm_resolve_process_cgroup_cpu_path(proc_path, fs_root, + out, sizeof(out), &is_v2), + 0); + ASSERT_EQ(is_v2, 1); + ASSERT_EQ(cbm_detect_cgroup_cpus_file(out, is_v2), 2); + + cgroup_test_teardown(proc_root); + cgroup_test_teardown(fs_root); + PASS(); +} + +TEST(resolve_v1_cpu_combined_controller) { + /* v1 lists cpu as "cpu,cpuacct" — must still resolve. */ + char proc_root[64]; + char fs_root[64]; + ASSERT_EQ(cgroup_test_setup(proc_root, sizeof(proc_root)), 0); + ASSERT_EQ(cgroup_test_setup(fs_root, sizeof(fs_root)), 0); + + const char *proc_content = + "12:memory:/docker/abc\n" + "9:cpu,cpuacct:/docker/abc\n"; + ASSERT_EQ(cgroup_test_write(proc_root, "cgroup", proc_content), 0); + /* v1 cpu path is a directory containing quota/period. */ + ASSERT_EQ(cgroup_test_write(fs_root, "cpu/docker/abc/cpu.cfs_quota_us", "400000"), 0); + ASSERT_EQ(cgroup_test_write(fs_root, "cpu/docker/abc/cpu.cfs_period_us", "100000"), 0); + + char proc_path[128]; + snprintf(proc_path, sizeof(proc_path), "%s/cgroup", proc_root); + + char out[CBM_PATH_MAX]; + int is_v2 = -1; + ASSERT_EQ(cbm_resolve_process_cgroup_cpu_path(proc_path, fs_root, + out, sizeof(out), &is_v2), + 0); + ASSERT_EQ(is_v2, 0); + ASSERT_EQ(cbm_detect_cgroup_cpus_file(out, is_v2), 4); + + cgroup_test_teardown(proc_root); + cgroup_test_teardown(fs_root); + PASS(); +} + #endif /* __linux__ */ SUITE(platform) { @@ -328,5 +561,13 @@ SUITE(platform) { RUN_TEST(cgroup_v1_mem); RUN_TEST(cgroup_v1_mem_unlimited_sentinel); RUN_TEST(cgroup_no_mem_files); + RUN_TEST(resolve_v2_mem_sub_cgroup); + RUN_TEST(resolve_v2_mem_root); + RUN_TEST(resolve_v2_mem_unlimited); + RUN_TEST(resolve_v1_mem_sub_cgroup); + RUN_TEST(resolve_v1_combined_controller_no_memory_match); + RUN_TEST(resolve_missing_proc_file_returns_error); + RUN_TEST(resolve_v2_cpu_sub_cgroup); + RUN_TEST(resolve_v1_cpu_combined_controller); #endif }