Skip to content

Commit 0906b53

Browse files
authored
Merge pull request DeusData#954 from yangsec888/feat/mem-budget-resolver-clamp
feat(mem): pure resolver + strict parse + clamp for CBM_MEM_BUDGET_MB
2 parents 3772777 + cbdd60b commit 0906b53

5 files changed

Lines changed: 240 additions & 93 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ codebase-memory-mcp config reset auto_index # reset to default
489489
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |
490490
| `CBM_LOG_LEVEL` | `info` | Set the minimum log level. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none` — or their numeric equivalents `0`–`4` matching the internal enum. Logs go to stderr; stdout is reserved for MCP JSON-RPC. |
491491
| `CBM_WORKERS` | *(detected)* | Override the parallel-indexing worker count returned by `cbm_default_worker_count`. Useful inside containers where `sysconf(_SC_NPROCESSORS_ONLN)` reports host CPUs rather than the cgroup's effective quota. Range 1–256; invalid values are ignored with a warning. |
492+
| `CBM_MEM_BUDGET_MB` | *(detected)* | Override the in-memory graph budget with an explicit cap in MiB, taking precedence over the `ram_fraction × total_RAM` default. Useful on bare-metal hosts without a cgroup limit, or to pin a budget *below* the cgroup limit so headroom is left for sibling processes. Must be a positive integer; it is clamped to detected total RAM (logged as `mem.budget.clamped`), and non-numeric or non-positive values are ignored with a warning (`mem.budget.env.invalid`). |
492493
| `CBM_DUMP_VERIFY_MIN_RATIO` | `0.5` | After indexing, compare persisted SQLite node count to the in-memory dump count. When persisted nodes fall below this fraction of committed nodes (and committed > 50), `index_repository` returns `status:"degraded"` instead of silent `indexed`. Range 0–1; set `0` to disable. Invalid values are ignored with a warning. |
493494

494495
```bash

‎src/foundation/mem.c‎

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414

1515
#define MAX_RAM_FRACTION 1.0
1616
#define DEFAULT_RAM_FRACTION 0.5
17+
#include <errno.h>
1718
#include <mimalloc.h>
1819
#include <stdatomic.h>
20+
#include <stdint.h>
1921
#include <stdio.h>
22+
#include <stdlib.h>
2023

2124
#ifdef _WIN32
2225
#ifndef WIN32_LEAN_AND_MEAN
@@ -121,6 +124,55 @@ double cbm_mem_ram_fraction_for_total(size_t total_ram_bytes) {
121124
return RAM_FRACTION_DEFAULT;
122125
}
123126

127+
cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction,
128+
const char *budget_mb) {
129+
if (ram_fraction <= 0.0 || ram_fraction > MAX_RAM_FRACTION) {
130+
ram_fraction = DEFAULT_RAM_FRACTION;
131+
}
132+
cbm_mem_budget_t result = {
133+
.budget = (size_t)((double)total_ram * ram_fraction),
134+
.source = "ram_fraction",
135+
.clamped = false,
136+
.invalid = false,
137+
};
138+
139+
if (budget_mb == NULL || budget_mb[0] == '\0') {
140+
return result; /* no override → fraction-derived budget */
141+
}
142+
143+
/* Strict parse, matching the src/foundation/limits.c convention: reject
144+
* trailing garbage (`*end`), overflow (errno==ERANGE), and non-positive
145+
* values. This turns a fat-fingered value (e.g. "8GB", or a 20-digit typo)
146+
* into a clean fallback-with-warning rather than a silently wrong budget. */
147+
errno = 0;
148+
char *end = NULL;
149+
long long want_mb = strtoll(budget_mb, &end, CBM_DECIMAL_BASE);
150+
if (errno != 0 || end == budget_mb || *end != '\0' || want_mb <= 0) {
151+
result.invalid = true; /* keep the fraction-derived budget */
152+
return result;
153+
}
154+
155+
result.source = "CBM_MEM_BUDGET_MB";
156+
size_t want = (size_t)want_mb;
157+
if (total_ram > 0) {
158+
/* Compare in MiB space so a valid-but-huge request (e.g. 2^44 MiB, which
159+
* would overflow the ×MiB byte multiply) clamps cleanly to total_ram. */
160+
if (want > total_ram / MB_DIVISOR) {
161+
result.budget = total_ram;
162+
result.clamped = true;
163+
} else {
164+
result.budget = want * MB_DIVISOR;
165+
}
166+
} else if (want > SIZE_MAX / MB_DIVISOR) {
167+
/* RAM detection failed (no clamp target) and the request is
168+
* astronomically large — cap at SIZE_MAX rather than wrap. */
169+
result.budget = SIZE_MAX;
170+
} else {
171+
result.budget = want * MB_DIVISOR;
172+
}
173+
return result;
174+
}
175+
124176
void cbm_mem_init(double ram_fraction) {
125177
int expected = 0;
126178
if (!atomic_compare_exchange_strong(&g_initialized, &expected, 1)) {
@@ -150,28 +202,33 @@ void cbm_mem_init(double ram_fraction) {
150202
/* CBM_MEM_BUDGET_MB env override (memory analogue of CBM_WORKERS).
151203
* Lets users cap the budget directly without an enclosing cgroup —
152204
* useful on bare-metal hosts where cgroup memory limits are absent
153-
* (#363). Explicit override > implicit RAM/cgroup detection. */
205+
* (#363). Explicit override > implicit RAM/cgroup detection. The budget
206+
* math (fraction default, override, clamp-to-total) lives in the pure,
207+
* testable cbm_mem_resolve_budget(); this path only reads the env and
208+
* emits the log/warn lines. */
209+
cbm_system_info_t info = cbm_system_info();
210+
154211
char env_buf[CBM_SZ_32];
155-
if (cbm_safe_getenv("CBM_MEM_BUDGET_MB", env_buf, sizeof(env_buf), NULL) != NULL) {
156-
long mb = strtol(env_buf, NULL, CBM_DECIMAL_BASE);
157-
if (mb > 0) {
158-
g_budget = (size_t)mb * MB_DIVISOR;
159-
char ovr_mb[CBM_SZ_32];
160-
snprintf(ovr_mb, sizeof(ovr_mb), "%ld", mb);
161-
cbm_log_info("mem.init", "budget_mb", ovr_mb, "source", "CBM_MEM_BUDGET_MB");
162-
return;
163-
}
164-
cbm_log_warn("mem.budget.env.invalid", "value", env_buf, "fallback", "ram_fraction");
165-
}
212+
const char *env = cbm_safe_getenv("CBM_MEM_BUDGET_MB", env_buf, sizeof(env_buf), NULL);
213+
cbm_mem_budget_t resolved = cbm_mem_resolve_budget(info.total_ram, ram_fraction, env);
214+
g_budget = resolved.budget;
166215

167-
cbm_system_info_t info = cbm_system_info();
168-
g_budget = (size_t)((double)info.total_ram * ram_fraction);
216+
/* The resolver is the single source of truth for the parse + clamp; this
217+
* path only surfaces its outcome as log lines. */
218+
if (resolved.invalid) {
219+
cbm_log_warn("mem.budget.env.invalid", "value", env, "fallback", "ram_fraction");
220+
} else if (resolved.clamped) {
221+
char cap_mb[CBM_SZ_32];
222+
snprintf(cap_mb, sizeof(cap_mb), "%zu", info.total_ram / MB_DIVISOR);
223+
cbm_log_warn("mem.budget.clamped", "requested_mb", env, "cap_mb", cap_mb);
224+
}
169225

170226
char budget_mb[CBM_SZ_32];
171227
char ram_mb[CBM_SZ_32];
172228
snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR);
173229
snprintf(ram_mb, sizeof(ram_mb), "%zu", info.total_ram / MB_DIVISOR);
174-
cbm_log_info("mem.init", "budget_mb", budget_mb, "total_ram_mb", ram_mb);
230+
cbm_log_info("mem.init", "budget_mb", budget_mb, "total_ram_mb", ram_mb, "source",
231+
resolved.source);
175232
}
176233

177234
size_t cbm_mem_rss(void) {

‎src/foundation/mem.h‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,31 @@
1515
double cbm_mem_ram_fraction_for_total(size_t total_ram_bytes);
1616

1717
/* Initialize memory budget = ram_fraction * total_physical_ram.
18+
* The CBM_MEM_BUDGET_MB env var, when set to a positive integer, overrides
19+
* this with an explicit budget in MiB (clamped to physical/cgroup RAM).
1820
* Thread-safe: only the first call takes effect.
1921
* Configures mimalloc options for reduced upfront memory. */
2022
void cbm_mem_init(double ram_fraction);
2123

24+
/* Result of cbm_mem_resolve_budget: the resolved budget plus the metadata
25+
* cbm_mem_init logs — so the parse/clamp logic lives in exactly ONE place and
26+
* the caller never re-parses the env string. */
27+
typedef struct {
28+
size_t budget; /* resolved budget in bytes */
29+
const char *source; /* log token: "ram_fraction" | "CBM_MEM_BUDGET_MB" */
30+
bool clamped; /* override was valid but exceeded total_ram → clamped down */
31+
bool invalid; /* override was present but unparseable / out-of-range / ≤0 */
32+
} cbm_mem_budget_t;
33+
34+
/* Pure budget resolver shared by cbm_mem_init (exposed for testing).
35+
* Returns ram_fraction * total_ram, unless `budget_mb` is a STRICTLY valid
36+
* positive integer string (the CBM_MEM_BUDGET_MB override) — then it returns
37+
* that many MiB, clamped to total_ram when total_ram > 0. Trailing garbage,
38+
* overflow (ERANGE), and non-positive values are rejected (invalid=true) and
39+
* fall back to the fraction-derived value. Reads no globals/env. */
40+
cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction,
41+
const char *budget_mb);
42+
2243
/* Current RSS in bytes via mi_process_info().
2344
* Falls back to OS-specific queries when MI_OVERRIDE=0 (ASan builds). */
2445
size_t cbm_mem_rss(void);

‎tests/repro/repro_issue363.c‎

Lines changed: 30 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,38 @@
11
/*
2-
* repro_issue363.c — Reproduce-first case for OPEN bug #363.
2+
* repro_issue363.c — Regression guard for bug #363 (both axes now FIXED).
33
*
44
* Issue: #363 — "Linux: cbm_system_info / cbm_default_worker_count don't
55
* respect cgroup CPU/memory limits"
66
*
7-
* ROOT CAUSE (two distinct axes):
7+
* Both axes of #363 have shipped; this case is now a permanent GREEN guard:
88
*
99
* CPU axis — FIXED in v0.8.0 (commit a5a3d1d).
1010
* cbm_detect_cgroup_cpus() reads /sys/fs/cgroup/cpu.max (v2) or
11-
* .../cpu/cpu.cfs_quota_us + .../cpu/cpu.cfs_period_us (v1) and the
12-
* result is used by detect_system_linux() in system_info.c:226.
13-
* cbm_default_worker_count() also honours the CBM_WORKERS env override
14-
* (commit d952238). Both are thoroughly tested in test_platform.c.
11+
* .../cpu/cpu.cfs_quota_us + .../cpu/cpu.cfs_period_us (v1); the result
12+
* feeds detect_system_linux(). cbm_default_worker_count() also honours the
13+
* CBM_WORKERS env override (commit d952238). Both tested in test_platform.c.
1514
*
16-
* Memory axis — STILL OPEN (confirmed by reporter @mayurpise in the last
17-
* open comment on #363, 2026-06-25).
18-
* cbm_detect_cgroup_mem() similarly reads /sys/fs/cgroup/memory.max (v2)
19-
* or .../memory/memory.limit_in_bytes (v1), and detect_system_linux()
20-
* uses it (system_info.c:229). BUT: there is NO env-override knob on
21-
* the memory axis. The CPU axis has CBM_WORKERS; the memory side has
22-
* nothing. On a bare-metal host with no enclosing cgroup, users cannot
23-
* cap cbm_mem_init's budget without wrapping the process in a cgroup
24-
* scope (as @mayurpise's workaround shows).
15+
* Memory axis — FIXED.
16+
* cbm_detect_cgroup_mem() reads /sys/fs/cgroup/memory.max (v2) or
17+
* .../memory/memory.limit_in_bytes (v1); detect_system_linux() applies
18+
* min(cgroup, host). The missing env knob — the "EXACT OPEN GAP" this
19+
* repro was filed for — now exists: cbm_mem_init() reads CBM_MEM_BUDGET_MB
20+
* and routes it through the pure cbm_mem_resolve_budget() (explicit
21+
* override > implicit detection, mirroring CBM_WORKERS).
2522
*
26-
* EXACT OPEN GAP:
27-
* A CBM_MEM_BUDGET_MB environment variable (analogous to CBM_WORKERS) that
28-
* cbm_mem_init() checks before computing g_budget from info.total_ram.
29-
* If set to a valid integer N, cbm_mem_init() should set
30-
* g_budget = N * 1024 * 1024, honouring it regardless of cgroup or host RAM.
31-
*
32-
* WHY THIS TEST IS RED:
33-
* cbm_mem_init() (src/foundation/mem.c) reads cbm_system_info().total_ram
34-
* and multiplies by ram_fraction. It does NOT call cbm_safe_getenv for
35-
* CBM_MEM_BUDGET_MB — the override path does not exist. Setting
36-
* CBM_MEM_BUDGET_MB=4096 has no effect; cbm_mem_budget() returns a value
37-
* derived from host RAM (or cgroup RAM when inside a container), not from
38-
* the env var. The assertion ASSERT_EQ(cbm_mem_budget(), 4096*1024*1024)
39-
* therefore fails on any host whose cgroup or physical RAM != exactly 4 GiB.
40-
*
41-
* ROOT CAUSE LOCATION:
42-
* src/foundation/mem.c, cbm_mem_init(), after the mimalloc option block
43-
* (currently around line 126):
44-
* cbm_system_info_t info = cbm_system_info();
45-
* g_budget = (size_t)((double)info.total_ram * ram_fraction);
46-
* The fix is to insert a cbm_safe_getenv("CBM_MEM_BUDGET_MB", ...) lookup
47-
* BEFORE this line and, if valid, set g_budget directly without involving
48-
* info.total_ram — mirroring the CBM_WORKERS pattern in
49-
* cbm_default_worker_count() (system_info.c:290).
50-
*
51-
* INTENDED FIX:
52-
* 1. In cbm_mem_init(): read CBM_MEM_BUDGET_MB; if set to a valid positive
53-
* integer, use that value (in bytes) as g_budget and log it.
54-
* 2. Test: set CBM_MEM_BUDGET_MB=4096, call cbm_mem_init(0.5), assert
55-
* cbm_mem_budget() == 4096 * 1024 * 1024. This test goes GREEN when
56-
* the override is wired.
57-
* 3. Complementary: on Linux, confirm cbm_system_info().total_ram is capped
58-
* by the cgroup memory limit when present — already covered in
59-
* test_platform.c via cbm_detect_cgroup_mem() unit tests, but an
60-
* integration path via cbm_system_info() is untestable without a seam
61-
* that lets callers override the hardcoded "/sys/fs/cgroup" root in
62-
* detect_system_linux() (system_info.c:229).
23+
* IMPORTANT — clamp semantics:
24+
* cbm_mem_resolve_budget() clamps the override to effective (cgroup/host) RAM
25+
* so it can never claim more than the box has. So a *large* override is NOT
26+
* honoured verbatim on a small host — it clamps to total_ram. This guard
27+
* therefore uses a small budget (REPRO363_BUDGET_MB) that is at/below any
28+
* realistic host's RAM, so it is honoured exactly and the assertion is stable
29+
* on every runner. The clamp behaviour itself is unit-tested separately in
30+
* tests/test_mem.c (resolve_budget_override_clamped_to_total).
6331
*
6432
* NOTE on cbm_mem_init() caching:
65-
* g_budget is initialised once via atomic_compare_exchange_strong.
66-
* The test must run in a process where cbm_mem_init() has NOT been called
67-
* yet, OR the test must reset g_initialized — neither is supported today.
68-
* The repro works as written because the repro runner does not call
69-
* cbm_mem_init() before this suite. If the initialisation guard is an
70-
* issue, the fix also needs a cbm_mem_reset_for_test() hook (test-only,
71-
* guarded by CBM_TEST_HOOKS or similar).
33+
* g_budget is initialised once via atomic_compare_exchange_strong, so this
34+
* guard relies on running before any suite that calls cbm_mem_init(); the
35+
* repro runner does not init the budget before this suite.
7236
*/
7337

7438
#include "test_framework.h"
@@ -77,39 +41,27 @@
7741
#include <stdint.h>
7842
#include <stdlib.h>
7943

80-
#define REPRO363_BUDGET_MB 4096UL
44+
/* Deliberately small so the resolver honours it exactly (never clamps) on any
45+
* host — see the clamp-semantics note above. */
46+
#define REPRO363_BUDGET_MB 128UL
8147
#define REPRO363_BUDGET_BYTES (REPRO363_BUDGET_MB * 1024UL * 1024UL)
8248

8349
/*
8450
* repro_issue363_mem_budget_env_override
8551
*
86-
* Precondition: CBM_MEM_BUDGET_MB=4096 is set before cbm_mem_init() is
87-
* called. The budget should be 4096 MiB regardless of host RAM or cgroup.
88-
*
89-
* RED condition (current code):
90-
* cbm_mem_init() ignores CBM_MEM_BUDGET_MB entirely; cbm_mem_budget()
91-
* returns host-RAM * fraction, not 4 GiB. The assertion fires unless the
92-
* test runner happens to be on a machine whose effective RAM is exactly
93-
* 8 GiB with fraction=0.5 — essentially never.
94-
*
95-
* GREEN condition (after fix):
96-
* cbm_mem_init() reads CBM_MEM_BUDGET_MB, finds "4096", sets
97-
* g_budget = 4096 * 1024 * 1024. The assertion passes on any machine.
52+
* Precondition: CBM_MEM_BUDGET_MB is set before the process's first
53+
* cbm_mem_init(). The budget must equal the override (honoured exactly, since
54+
* it is below any host's effective RAM), proving the env knob is wired.
9855
*/
9956
TEST(repro_issue363_mem_budget_env_override) {
100-
cbm_setenv("CBM_MEM_BUDGET_MB", "4096", 1);
57+
cbm_setenv("CBM_MEM_BUDGET_MB", "128", 1);
10158

10259
cbm_mem_init(0.5);
10360

10461
size_t budget = cbm_mem_budget();
10562

10663
cbm_unsetenv("CBM_MEM_BUDGET_MB");
10764

108-
/*
109-
* RED on current code: budget derives from host/cgroup RAM, not the env
110-
* var. On any machine where effective RAM != 8192 MiB this fails.
111-
* GREEN once CBM_MEM_BUDGET_MB is wired in cbm_mem_init().
112-
*/
11365
ASSERT_EQ((long long)budget, (long long)REPRO363_BUDGET_BYTES);
11466

11567
PASS();

0 commit comments

Comments
 (0)