Skip to content

Commit 04f95ce

Browse files
committed
feat(mem): pure budget resolver with strict parse + clamp for CBM_MEM_BUDGET_MB
Fold the inline CBM_MEM_BUDGET_MB override logic in cbm_mem_init into a pure, testable cbm_mem_resolve_budget() that returns a result struct (budget/source/clamped/invalid), so the parse + clamp lives in exactly one place and cbm_mem_init only surfaces the outcome as log lines. Parsing now matches the strict src/foundation/limits.c convention (errno/ERANGE, reject trailing garbage, positive-only): a fat-fingered value like "8GB" or a 20-digit typo becomes a warning + safe ram_fraction fallback instead of a silently wrong budget. A valid-but-huge value clamps to detected total RAM (logged mem.budget.clamped) rather than overflowing the MiB->bytes multiply and wrapping to a near-zero budget (which would pin cbm_mem_over_budget() true and stall indexing). mem.init now logs source= on both the override and fraction paths, and the README gains the CBM_MEM_BUDGET_MB row it lacked. Hardening of the existing DeusData#363 knob. Signed-off-by: Sam Li <yangsec888@gmail.com>
1 parent b637e33 commit 04f95ce

4 files changed

Lines changed: 210 additions & 15 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ codebase-memory-mcp config reset auto_index # reset to default
482482
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |
483483
| `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. |
484484
| `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. |
485+
| `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`). |
485486
| `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. |
486487

487488
```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/test_mem.c‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#include "cbm.h"
1717

1818
#include <stdatomic.h>
19+
#include <stdint.h>
1920
#include <sys/stat.h>
2021
#include <mimalloc.h>
2122
#ifndef _WIN32
@@ -403,6 +404,112 @@ TEST(mem_init_second_call_noop) {
403404
PASS();
404405
}
405406

407+
/* ── CBM_MEM_BUDGET_MB budget override (pure resolver) ────────────
408+
* cbm_mem_init is one-shot per process, so the override logic lives in the
409+
* pure cbm_mem_resolve_budget() helper which we can exercise directly. */
410+
411+
#define CBM_TEST_MB ((size_t)1024 * 1024)
412+
413+
TEST(resolve_budget_no_override_uses_fraction) {
414+
/* No env override → ram_fraction × total_ram, source=ram_fraction. */
415+
size_t total = 8192 * CBM_TEST_MB;
416+
cbm_mem_budget_t r = cbm_mem_resolve_budget(total, 0.5, NULL);
417+
ASSERT_EQ(r.budget, 4096 * CBM_TEST_MB);
418+
ASSERT_STR_EQ(r.source, "ram_fraction");
419+
ASSERT_FALSE(r.clamped);
420+
ASSERT_FALSE(r.invalid);
421+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.25, "").budget, 2048 * CBM_TEST_MB);
422+
PASS();
423+
}
424+
425+
TEST(resolve_budget_invalid_fraction_defaults) {
426+
/* Out-of-range fractions fall back to the 0.5 default. */
427+
size_t total = 8192 * CBM_TEST_MB;
428+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.0, NULL).budget, 4096 * CBM_TEST_MB);
429+
ASSERT_EQ(cbm_mem_resolve_budget(total, -1.0, NULL).budget, 4096 * CBM_TEST_MB);
430+
ASSERT_EQ(cbm_mem_resolve_budget(total, 1.5, NULL).budget, 4096 * CBM_TEST_MB);
431+
PASS();
432+
}
433+
434+
TEST(resolve_budget_override_wins) {
435+
/* The key use case: pin a budget *below* the fraction default. */
436+
size_t total = 8192 * CBM_TEST_MB;
437+
cbm_mem_budget_t below = cbm_mem_resolve_budget(total, 0.5, "2048");
438+
ASSERT_EQ(below.budget, 2048 * CBM_TEST_MB);
439+
ASSERT_STR_EQ(below.source, "CBM_MEM_BUDGET_MB");
440+
ASSERT_FALSE(below.clamped);
441+
ASSERT_FALSE(below.invalid);
442+
/* Override above the fraction default is also honored (up to total_ram). */
443+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.5, "6144").budget, 6144 * CBM_TEST_MB);
444+
PASS();
445+
}
446+
447+
TEST(resolve_budget_override_clamped_to_total) {
448+
/* Override larger than physical/cgroup RAM clamps to total_ram. */
449+
size_t total = 1024 * CBM_TEST_MB;
450+
cbm_mem_budget_t r = cbm_mem_resolve_budget(total, 0.5, "100000");
451+
ASSERT_EQ(r.budget, total);
452+
ASSERT_TRUE(r.clamped);
453+
ASSERT_STR_EQ(r.source, "CBM_MEM_BUDGET_MB");
454+
PASS();
455+
}
456+
457+
TEST(resolve_budget_override_when_total_unknown) {
458+
/* Detection failed (total_ram == 0): override still yields a usable budget
459+
* and is not clamped to zero. */
460+
cbm_mem_budget_t r = cbm_mem_resolve_budget(0, 0.5, "512");
461+
ASSERT_EQ(r.budget, 512 * CBM_TEST_MB);
462+
ASSERT_FALSE(r.clamped);
463+
ASSERT_FALSE(r.invalid);
464+
PASS();
465+
}
466+
467+
TEST(resolve_budget_invalid_override_falls_back) {
468+
/* Non-numeric, zero, negative, trailing-garbage, and ERANGE-overflow
469+
* overrides are all rejected (invalid=true) → fraction budget, source
470+
* stays ram_fraction. Strict parse matches src/foundation/limits.c. */
471+
size_t total = 8192 * CBM_TEST_MB;
472+
size_t fraction_budget = 4096 * CBM_TEST_MB;
473+
const char *bad[] = {
474+
"abc", "0", "-512", "512MB", "512x", "0x400", "99999999999999999999999999",
475+
};
476+
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
477+
cbm_mem_budget_t r = cbm_mem_resolve_budget(total, 0.5, bad[i]);
478+
ASSERT_EQ(r.budget, fraction_budget);
479+
ASSERT_TRUE(r.invalid);
480+
ASSERT_STR_EQ(r.source, "ram_fraction");
481+
}
482+
PASS();
483+
}
484+
485+
/* Abuse guard: a ~2^44 MiB request (14 digits — fits the 31-char env buffer) is
486+
* a VALID long long, so it passes the strict parse; the unguarded want_mb × MiB
487+
* byte multiply would then overflow size_t and wrap to 0 (0 is not > total_ram,
488+
* so a naive clamp misses it), pinning cbm_mem_over_budget() permanently true.
489+
* The MiB-space clamp must instead clamp to total_ram. */
490+
TEST(resolve_budget_override_overflow_clamps_to_total) {
491+
size_t total = 2048 * CBM_TEST_MB;
492+
/* 2^44 MiB: (size_t)2^44 * (2^20 bytes/MiB) == 2^64 == 0 on wrap. */
493+
cbm_mem_budget_t r = cbm_mem_resolve_budget(total, 0.5, "17592186044416");
494+
ASSERT_EQ(r.budget, total);
495+
ASSERT_TRUE(r.clamped);
496+
ASSERT_FALSE(r.invalid);
497+
PASS();
498+
}
499+
500+
/* Abuse guard: RAM detection failed (total_ram == 0, so no clamp target) AND
501+
* the request is a valid-but-astronomical value. The multiply must not wrap to
502+
* a small budget — cap at SIZE_MAX instead. */
503+
TEST(resolve_budget_override_overflow_total_unknown_caps) {
504+
/* 1e17 MiB: valid long long (< LLONG_MAX) but > SIZE_MAX / MiB. */
505+
cbm_mem_budget_t r = cbm_mem_resolve_budget(0, 0.5, "99999999999999999");
506+
ASSERT_EQ(r.budget, SIZE_MAX);
507+
ASSERT_FALSE(r.invalid);
508+
PASS();
509+
}
510+
511+
#undef CBM_TEST_MB
512+
406513
/* ── Arena integration tests ──────────────────────────────────── */
407514

408515
TEST(arena_alloc_and_destroy) {
@@ -1049,6 +1156,15 @@ SUITE(mem) {
10491156
RUN_TEST(mem_init_negative_fraction);
10501157
RUN_TEST(mem_init_over_one_fraction);
10511158
RUN_TEST(mem_init_second_call_noop);
1159+
/* CBM_MEM_BUDGET_MB budget override */
1160+
RUN_TEST(resolve_budget_no_override_uses_fraction);
1161+
RUN_TEST(resolve_budget_invalid_fraction_defaults);
1162+
RUN_TEST(resolve_budget_override_wins);
1163+
RUN_TEST(resolve_budget_override_clamped_to_total);
1164+
RUN_TEST(resolve_budget_override_when_total_unknown);
1165+
RUN_TEST(resolve_budget_invalid_override_falls_back);
1166+
RUN_TEST(resolve_budget_override_overflow_clamps_to_total);
1167+
RUN_TEST(resolve_budget_override_overflow_total_unknown_caps);
10521168
/* Arena integration */
10531169
RUN_TEST(arena_alloc_and_destroy);
10541170
RUN_TEST(arena_grow_tracks_sizes);

0 commit comments

Comments
 (0)