#1748 · Server heap is sized from host RAM, not the cgroup limit, so it fills the memory cap with garbage
TL;DR
Plain-language framing. bb's launcher (bb-app) starts the server as a plain node apps/server/dist/index.js child. Node's V8 engine picks a maximum JavaScript heap ("heap_size_limit") when it starts. The reporter runs bb inside a systemd user unit with MemoryHigh=3G/MemoryMax=3800M on a 4.9 GiB host, sees the server sawtooth between ~625 and ~1088 MB RSS, measured a heap limit of 1728 MB, and concludes that V8 "sizes its heap from total host RAM and ignores any cgroup memory limit", so bb should pass --max-old-space-size derived from /sys/fs/cgroup/memory.max.
That premise is wrong, and the reporter's own number proves it. Node has been cgroup-aware for the heap since v12 (SetIsolateCreateParamsForNode feeds min(total RAM, uv_get_constrained_memory()) into V8's ConfigureDefaults; libuv reads memory.max and memory.high of the process's cgroup and takes the smaller). On this 58 GiB machine, node 24.18 in a scope with the reporter's exact properties reports heap_size_limit = 1728 MiB — exactly the reporter's value — and that 1728 is 3 GiB / 2 + 192, i.e. derived from MemoryHigh=3G. A 4.9 GiB host with no cgroup limit gives 2240 MiB (V8's 2 GiB old-generation cap + young generation), so 1728 cannot have come from the host RAM. I confirmed the same with the real bb server binary via a Node diagnostic report (javascriptHeap.memoryLimit). The bb code claim ("grep max-old-space-size returns nothing; the launcher does not set it") is true, but nothing needs to for the cgroup to be honored.
What remains true is a narrower, policy-level point: V8's heuristic hands each Node process roughly half of the constrained memory, and a bb unit contains several Node processes (server, host daemon, one bridge process per live provider session) that all share the same 3 GiB, so the server's ceiling is generous relative to what the unit can afford. And NODE_OPTIONS is indeed inherited by the daemon and by every provider bridge process (sanitizeInheritedChildProcessEnv keeps it, guarded by an existing unit test), so an operator has no way to cap only the server today. That is a feature request (a per-process heap knob or a smarter default), not the bug as reported. PR #1757 adds an opt-in BB_SERVER_MAX_OLD_SPACE_MB, but restates the false premise in the docs, does not honor bb-app env set although its docs say it does, and its validation error is swallowed and misreported as a health-check timeout by bb-app start.
Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
bb never sets --max-old-space-size; not in the launcher, spawn paths, or docs | Verified | grep -rn "max-old-space-size\|NODE_OPTIONS\|execArgv" apps packages docs at 16ceb3a54 finds no heap flag; the server is spawned as spawn(process.execPath, [serverEntry]) (launcher.ts#L2901-L2907, #L2671-L2678). |
| V8 auto-sizes its heap limit from total host RAM and ignores any cgroup memory limit | Refuted | Node 24.18 (libuv 1.52.1) in a scope with MemoryMax=512M → 259 MiB; 1G → 560; 2G → 1120; 3G → 1728; 3800M → 2092; unconstrained on this 58 GiB host → 4288. MemoryHigh alone is honored too (cgroup-table.out). Node source: src/api/environment.cc SetIsolateCreateParamsForNode; libuv uv__get_cgroup2_memory_limits reads memory.max and memory.high (Root cause). |
| "V8's default heap limit on this host measures 1728 MB, derived from the 4.9 GiB of physical RAM" | Refuted (number verified, attribution wrong) | 1728 MiB is exactly what node produces under MemoryHigh=3G MemoryMax=3800M (min = 3 GiB → old gen 1536 + young 192). A 4.9 GiB (5017 MiB) budget with no cgroup gives 2240 MiB. Reproduced with the real bb server: server-heap-limit.out. |
| Server "may grow a heap that is 56% of the entire service budget" | Verified as V8 policy, not a bb defect | V8's Heap::HeapSizeFromPhysicalMemory uses old-gen ≈ memory/2 (capped at 2 GiB below 16 GiB), and Node feeds it the cgroup limit. So yes: 1536 MiB old gen out of 3072. This is the default for every Node process in the unit, including the daemon and each bridge. |
Sawtooth 625→1088 MB RSS, 427 MB drop in 20 s, 1008 MB anonymous at 1110 MB RSS, 2578 MemoryHigh breaches, one OOM kill | Unverifiable | Host-specific measurements; no data attached. Consistent with normal V8 major-GC behavior under a ~1.7 GB ceiling and with the co-location concerns in #1334/#1363/#1393. Whether the ~625 MB trough is live set (a leak / retention issue) or not is the actually interesting question and is not answered by the issue. |
sanitizeInheritedChildProcessEnv strips only NODE_ENV and BB_*, so NODE_OPTIONS reaches spawned provider processes | Verified (code + existing test) | process-utils/src/index.ts#L182-L199; the test at index.test.ts#L283-L300 asserts NODE_OPTIONS survives. The daemon spawns each provider bridge as a node process with {...sanitizeInheritedChildProcessEnv({env: process.env}), ...} (runtime-provider-process.ts#L493-L506). Not exercised live (a real turn would be needed). |
Setting NODE_OPTIONS at the unit level "would cap the agents too and risk crashing them mid-turn" | Partly true | True for the daemon and the Node bridge process of every session. Not true for a native claude binary: on this machine NODE_OPTIONS=--max-old-space-size=1 claude --version prints 2.1.234 (Claude Code) and exits 0 (Bun-compiled ELF ignores it); codex is a Rust binary. It would apply if claude is the npm/Node install. |
Suggested fix: pass --max-old-space-size sized from /sys/fs/cgroup/memory.max | Would only replicate what Node already does | Node already sizes from min(memory.max, memory.high, RAM). A different fraction (or subtracting the daemon/bridge budget) is the only thing bb could add. |
Environment
- bb
16ceb3a54(main, 2026-08-18) in worktree/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-5; PR #1757 checked out as6ed285ae6for the PR checks. - Linux 7.0.0-29-generic, cgroup v2 with a delegated user session (so
systemd-run --user --scope -p MemoryMax=…works), 58987 MiB RAM. Node v24.18.0 (libuv 1.52.1); reporter: Node v24.19.0. Node v26.5.0 checked once (1632 MiB under the same scope, still cgroup-derived; node26-check.out). - No dev instance was needed. Real server binary runs used
BB_DATA_DIR=/tmp/1748-server-data-*, port 41750; launcher checks used/tmp/1748-bbapp-data, ports 41748/41749. No screenshots (not a visual bug). - claude 2.1.234 native binary at
~/.local/bin/claude.
Minimal reproduction
Goal: check the issue's central claim (heap limit derived from host RAM, cgroup ignored) and reproduce the reporter's 1728 MB number. Requires node and a cgroup-v2 systemd user session (systemd-run --user --scope). Nothing bb-specific is needed for step 1; step 2 uses the real bb server file.
1. Node's default heap limit follows the cgroup limit
Script 1748/repro/cgroup-table.sh (also heap-limit.sh, the first pass with the same result plus the execArgv/NODE_OPTIONS inheritance check):
#!/usr/bin/env bash
# Issue #1748 experiment 2: V8's default heap_size_limit as a function of the cgroup memory.max
# the node process runs under (systemd-run --user --scope puts the command in a fresh cgroup with
# the given properties). Also checks whether MemoryHigh alone (no MemoryMax) is honored.
set -u
show='const v8=require("v8");const os=require("os");console.log(" totalmem MiB =",Math.round(os.totalmem()/1048576),"heap_size_limit MiB =",Math.round(v8.getHeapStatistics().heap_size_limit/1048576))'
echo "node $(node --version), host RAM $(node -p 'Math.round(require("os").totalmem()/1048576)') MiB"
echo "--- no cgroup limit (plain shell):"; node -e "$show"
for mm in 512M 1G 2G 3G 3800M 4G 5017M 8G 16G 32G; do
echo "--- MemoryMax=$mm:"
systemd-run --user --scope -q -p MemoryMax=$mm bash -c "echo ' memory.max='\$(cat /sys/fs/cgroup\$(cut -d: -f3 /proc/self/cgroup)/memory.max); node -e '$show'" 2>&1
done
echo "--- MemoryHigh=3G only (no MemoryMax), like the reporter's MemoryHigh:"
systemd-run --user --scope -q -p MemoryHigh=3G bash -c "echo ' memory.max='\$(cat /sys/fs/cgroup\$(cut -d: -f3 /proc/self/cgroup)/memory.max) 'memory.high='\$(cat /sys/fs/cgroup\$(cut -d: -f3 /proc/self/cgroup)/memory.high); node -e '$show'" 2>&1
echo "--- reporter's exact unit properties MemoryHigh=3G MemoryMax=3800M:"
systemd-run --user --scope -q -p MemoryHigh=3G -p MemoryMax=3800M bash -c "echo ' memory.max='\$(cat /sys/fs/cgroup\$(cut -d: -f3 /proc/self/cgroup)/memory.max) 'memory.high='\$(cat /sys/fs/cgroup\$(cut -d: -f3 /proc/self/cgroup)/memory.high); node -e '$show'" 2>&1
echo "--- MemoryMax on the PARENT cgroup only (child cgroup nested inside a limited scope): does node see it?"
systemd-run --user --scope -q -p MemoryMax=1G -p Delegate=yes bash -c 'me=/sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup); mkdir -p $me/child 2>/dev/null && echo $$ > $me/child/cgroup.procs 2>/dev/null; echo " own cgroup memory.max=$(cat /sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)/memory.max) parent memory.max=$(cat $me/memory.max)"; node -e '"$show"'' 2>&1
Output (cgroup-table.out; the last "parent cgroup" step has a quoting bug and is not part of the evidence):
node v24.18.0, host RAM 58987 MiB
--- no cgroup limit (plain shell):
totalmem MiB = 58987 heap_size_limit MiB = 4288
--- MemoryMax=512M:
memory.max=536870912
totalmem MiB = 58987 heap_size_limit MiB = 259
--- MemoryMax=1G:
memory.max=1073741824
totalmem MiB = 58987 heap_size_limit MiB = 560
--- MemoryMax=2G:
memory.max=2147483648
totalmem MiB = 58987 heap_size_limit MiB = 1120
--- MemoryMax=3G:
memory.max=3221225472
totalmem MiB = 58987 heap_size_limit MiB = 1728
--- MemoryMax=3800M:
memory.max=3984588800
totalmem MiB = 58987 heap_size_limit MiB = 2092
--- MemoryMax=4G:
memory.max=4294967296
totalmem MiB = 58987 heap_size_limit MiB = 2240
--- MemoryMax=5017M:
memory.max=5260705792
totalmem MiB = 58987 heap_size_limit MiB = 2240
--- MemoryMax=8G:
memory.max=8589934592
totalmem MiB = 58987 heap_size_limit MiB = 2240
--- MemoryMax=16G:
memory.max=17179869184
totalmem MiB = 58987 heap_size_limit MiB = 4288
--- MemoryMax=32G:
memory.max=34359738368
totalmem MiB = 58987 heap_size_limit MiB = 4288
--- MemoryHigh=3G only (no MemoryMax), like the reporter's MemoryHigh:
memory.max=max memory.high=3221225472
totalmem MiB = 58987 heap_size_limit MiB = 1728
--- reporter's exact unit properties MemoryHigh=3G MemoryMax=3800M:
memory.max=3984588800 memory.high=3221225472
totalmem MiB = 58987 heap_size_limit MiB = 1728
--- MemoryMax on the PARENT cgroup only (child cgroup nested inside a limited scope): does node see it?
/usr/bin/bash: -c: line 1: syntax error near unexpected token `('
/usr/bin/bash: -c: line 1: `me=/sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup); mkdir -p $me/child 2>/dev/null && echo $ > $me/child/cgroup.procs 2>/dev/null; echo " own cgroup memory.max=$(cat /sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)/memory.max) parent memory.max=$(cat $me/memory.max)"; node -e const v8=require("v8");const os=require("os");console.log(" totalmem MiB =",Math.round(os.totalmem()/1048576),"heap_size_limit MiB =",Math.round(v8.getHeapStatistics().heap_size_limit/1048576))'
Expected per the issue: the limit stays at the host-RAM value (4288 MiB here) regardless of the cgroup. Actual: it tracks the cgroup: 512M→259, 1G→560, 2G→1120, 3G→1728, 3800M→2092, and the reporter's exact MemoryHigh=3G MemoryMax=3800M pair yields 1728 MiB, the very number in the issue. MemoryHigh alone is honored as well.
2. Same thing with the real bb server process
Script 1748/repro/server-heap-limit.sh starts apps/server/dist/index.js (the file bb-app spawns) with --report-on-signal, waits for /health, sends SIGUSR2, and reads javascriptHeap.memoryLimit from the diagnostic report:
#!/usr/bin/env bash
# Issue #1748 experiment 3: the REAL bb server (apps/server/dist/index.js, the file bb-app spawns)
# started under the reporter's exact systemd memory properties (MemoryHigh=3G, MemoryMax=3800M),
# then asked for a Node diagnostic report (SIGUSR2) whose javascriptHeap.memoryLimit is V8's
# heap_size_limit. Compared with the same server started with no cgroup limit, and inside a
# 5017M (= 4.9 GiB) cgroup, which is what a 4.9 GiB host with NO cgroup limit would produce.
# Usage: BB_REPO=<built bb worktree> ./server-heap-limit.sh
set -u
: "${BB_REPO:?set BB_REPO}"
cd "$BB_REPO"
run_case() {
local label="$1"; shift
local data=/tmp/1748-server-data-$label; rm -rf "$data"; mkdir -p "$data/report"
echo "=== $label"
BB_DATA_DIR="$data" BB_SERVER_PORT=41750 BB_HOST_DAEMON_PORT=41751 NODE_ENV=production \
"$@" node --report-on-signal --report-signal=SIGUSR2 --report-directory="$data/report" \
apps/server/dist/index.js > "$data/server.log" 2>&1 &
local wrapper=$!
for _ in $(seq 1 150); do
sleep 0.2
curl -sf http://127.0.0.1:41750/health >/dev/null 2>&1 && break
done
local pid
pid=$(pgrep -f "report-directory=$data/report" | head -1)
echo " cgroup: $(cut -d: -f3 /proc/$pid/cgroup) memory.max=$(cat /sys/fs/cgroup$(cut -d: -f3 /proc/$pid/cgroup)/memory.max) memory.high=$(cat /sys/fs/cgroup$(cut -d: -f3 /proc/$pid/cgroup)/memory.high)"
kill -USR2 "$pid"; sleep 1.5
local rep; rep=$(ls "$data"/report/*.json 2>/dev/null | head -1)
node -e '
const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
const h=r.javascriptHeap; const mib=(n)=>Math.round(n/1048576);
console.log(" node", r.header.nodejsVersion, "commandLine:", r.header.commandLine.slice(0,4).join(" "), "...");
console.log(" javascriptHeap.memoryLimit (heap_size_limit) MiB =", mib(h.memoryLimit), " usedMemory MiB =", mib(h.usedMemory), " totalMemory MiB =", mib(h.totalMemory), " rss MiB =", mib(r.resourceUsage.rss ?? r.resourceUsage.maxRss*1024));
' "$rep"
kill -TERM "$pid" "$wrapper" 2>/dev/null; wait "$wrapper" 2>/dev/null; sleep 0.5
}
run_case unconstrained env
run_case reporter-unit systemd-run --user --scope -q -p MemoryHigh=3G -p MemoryMax=3800M
run_case host-4.9GiB-no-cgroup systemd-run --user --scope -q -p MemoryMax=5017M
$ BB_REPO=$PWD 1748/repro/server-heap-limit.sh === unconstrained cgroup: /user.slice/user-1000.slice/session-2.scope memory.max=max memory.high=max node v24.18.0 commandLine: node --report-on-signal --report-signal=SIGUSR2 --report-directory=/tmp/1748-server-data-unconstrained/report ... javascriptHeap.memoryLimit (heap_size_limit) MiB = 4288 usedMemory MiB = 165 totalMemory MiB = 250 rss MiB = 370 === reporter-unit cgroup: /user.slice/user-1000.slice/user@1000.service/app.slice/run-p2297252-i2319025.scope memory.max=3984588800 memory.high=3221225472 node v24.18.0 commandLine: /home/sawyer/.nvm/versions/node/v24.18.0/bin/node --report-on-signal --report-signal=SIGUSR2 --report-directory=/tmp/1748-server-data-reporter-unit/report ... javascriptHeap.memoryLimit (heap_size_limit) MiB = 1728 usedMemory MiB = 122 totalMemory MiB = 224 rss MiB = 319 === host-4.9GiB-no-cgroup cgroup: /user.slice/user-1000.slice/user@1000.service/app.slice/run-p2297486-i2326942.scope memory.max=5260705792 memory.high=max node v24.18.0 commandLine: /home/sawyer/.nvm/versions/node/v24.18.0/bin/node --report-on-signal --report-signal=SIGUSR2 --report-directory=/tmp/1748-server-data-host-4.9GiB-no-cgroup/report ... javascriptHeap.memoryLimit (heap_size_limit) MiB = 2240 usedMemory MiB = 120 totalMemory MiB = 246 rss MiB = 365
Raw reports: server-report-reporter-unit.json, server-report-4.9GiB-no-cgroup.json. The unbounded first run in server-heap-limit.out was re-run once after an EADDRINUSE on port 41750; the recorded line is from the successful re-run.
Reading: the bb server under the reporter's unit gets 1728 MiB (from the 3 GiB MemoryHigh); a 4.9 GiB host without a cgroup would give it 2240 MiB. The issue's "derived from the 4.9 GiB of physical RAM" is therefore not what happened on the reporter's host; the reporter measured the cgroup-derived value and mis-attributed it.
Root cause
Why bb does not need to set the flag for cgroups to count. Node computes the isolate's heap constraints itself, before any bb code runs (src/api/environment.cc, Node v24.18.0):
void SetIsolateCreateParamsForNode(Isolate::CreateParams* params) {
const uint64_t constrained_memory = uv_get_constrained_memory();
const uint64_t total_memory = constrained_memory > 0 ?
std::min(uv_get_total_memory(), constrained_memory) :
uv_get_total_memory();
if (total_memory > 0 &&
params->constraints.max_old_generation_size_in_bytes() == 0) {
// V8 defaults to 700MB or 1.4GB on 32 and 64 bit platforms respectively.
// This default is based on browser use-cases. Tell V8 to configure the
// heap based on the actual physical memory.
params->constraints.ConfigureDefaults(total_memory, 0);
}
and libuv (deps/uv/src/unix/linux.c, 1.52.1) resolves the constrained memory from the process's own cgroup, taking the smaller of memory.max and memory.high (and of RLIMIT_AS/DATA):
static void uv__get_cgroup2_memory_limits(char buf[static 1024], uint64_t* high, uint64_t* max) {
...
snprintf(filename, sizeof(filename), "/sys/fs/cgroup/%.*s/memory.max", n, p);
*max = uv__read_uint64(filename);
snprintf(filename, sizeof(filename), "/sys/fs/cgroup/%.*s/memory.high", n, p);
*high = uv__read_uint64(filename);
}
static uint64_t uv__get_cgroup_constrained_memory(char buf[static 1024]) {
...
return high < max ? high : max;
}
V8's ConfigureDefaults(3 GiB, 0) then sets old generation = 3072/2 = 1536 MiB (V8 caps old gen at 2048 MiB below 16 GiB, 4096 above) plus a young generation, giving the observed 1728 MiB. Note libuv reads only the process's own cgroup, not ancestors; systemd places unit processes directly in the unit's cgroup, so MemoryHigh/MemoryMax on the service are seen. (A container that puts the process in a nested, unlimited child cgroup would not be seen — that is not the reporter's setup, whose measured value proves the limit was seen.)
Why the symptom still looks like "the heap is too big". The reporter's unit hosts the server, the host daemon, one Node bridge process per live provider session, and the provider CLIs, all under one 3 GiB MemoryHigh. Node's heuristic gives every one of those Node processes a ~1.5 GiB old-generation ceiling as if it were alone in the cgroup, and V8's growing strategy lets the old generation reach a multiple of the live size between major GCs. A single server that peaks at ~1.1 GB RSS with ~625 MB trough is unremarkable under a 1.7 GB ceiling; add the daemon and a few bridges and the unit crosses MemoryHigh. So the operational pain is plausible, but its cause is "several Node processes share one cgroup and each is sized as if alone" plus whatever the server's true live set is (#1334/#1363/#1393 territory), not "the cgroup is ignored".
What bb currently offers to steer this: nothing per-process. The launcher spawns the server, daemon, and (via the daemon) each bridge with process.execPath and no node flags; execArgv is not inherited by spawn (see heap-limit.out: a parent started with --max-old-space-size=300 spawns a child at 4288 MiB), while NODE_OPTIONS is inherited by all of them because sanitizeInheritedChildProcessEnv deliberately passes it through. That is the only kernel of the issue that is a real gap.
Proposed fix (first principles)
- Do not implement the issue as filed. Deriving
--max-old-space-sizefrom/sys/fs/cgroup/memory.maxre-implements Node's default and would produce the same 1728 MiB. Close or retitle the issue to the real request: "no way to cap the server heap independently of the daemon/providers" (or "server heap default should account for co-located Node processes"). - If a knob is wanted, keep it opt-in and read it from the launcher's resolved env (
runtime.serverEnv/ the env passed tocreateServerEnv), sobb-app env setand the process env both work; apply at both server spawn sites inlauncher.ts; validate at option-resolution time so an invalid value produces its own error instead of the "health check timed out" message; document it indocs/configuration.mdwithout the wrong premise; add a launcher unit test for parsing plus one for the argv shape. - A better default, if the maintainers want one: pass
--max-old-space-sizeonly when a cgroup limit is detected and set it to a fraction that leaves room for the daemon and N bridges (say 40% ofmin(memory.high, memory.max)for the server, a smaller share for the daemon). This is a judgement call and needs measurement on a memory-capped host; a heap cap below the true live set turns "sawtooth" into "FATAL ERROR: heap out of memory", so it must be conservative and overridable. - Independently worth checking (the more likely real problem): what the server's ~625 MB trough is made of. A heap snapshot at the trough would tell whether it is retained live state (a leak or unbounded cache) versus fragmentation. Nothing in this issue answers that.
PR review
#1757 · feat: allow capping the server's V8 heap independently of providers — REQUEST CHANGES
What it changes (pr1757.diff, 44+/9−): adds resolveServerNodeFlags(process.env) in packages/bb-app/src/launcher.ts that turns BB_SERVER_MAX_OLD_SPACE_MB into --max-old-space-size=N, prepends it to the server argv in runBbServer and startFullStackServerProcess, and adds a row to docs/configuration.md. Opt-in; no test.
Does it address the root cause? It does not claim to fix the reported behavior automatically (good), but it is built on the same false premise: the new doc row and the code comment state that Node "sizes the heap from total system memory and ignores any cgroup limit". Both are wrong (see Root cause). The knob is a legitimate escape hatch for the co-location problem, but the change should not ship documentation that tells operators something untrue about Node.
Findings (PR head 6ed285ae6):
- [high] Env-file path silently ignored, docs say it works.
launcher.ts:2675and:2934callresolveServerNodeFlags(process.env). The launcher's env file (bb-app env set …) is merged intoruntime.serverEnv/managedEnv, never intoprocess.env(applyManagedConfigEnv/createServerBaseEnv,launcher.ts:863-890). Verified live with the real launcher (pr1757-launcher-check.out): afterbb-app env set BB_SERVER_MAX_OLD_SPACE_MB 444the server child is started asnode …/apps/server/dist/index.jswith no flag, while its environment does containBB_SERVER_MAX_OLD_SPACE_MB=444. The docs row says "bb-app envor environment". Fix: read from the resolved env (runtime.serverEnv/args.env). - [medium] Validation error swallowed in the full-stack path. In
runBbAppStartthe throw fromresolveServerNodeFlagshappens insidestartServer(), whosecatch {}printsServer failed to start (health check timed out)and points at an empty log dir (pr1757-fullstack-invalid.out). The operator never sees "must be a positive integer". Thebb-serverpath does surface it. Validate once at runtime-resolution time, before spawning. - [medium] Wrong premise in
docs/configuration.mdand the JSDoc. "Node sizes the heap from total system memory and ignores any cgroup limit" is false for every supported Node; the reporter's 1728 MB is the cgroup-derived value. Rewrite as: "V8 sizes each Node process's heap as if it owned the whole cgroup; set this to give the server a smaller share when the daemon and providers run in the same unit." - [low] Lenient parsing.
Number.parseInt("12abc", 10)is 12, soBB_SERVER_MAX_OLD_SPACE_MB=12abcis accepted; a strict/^\d+$/check would be safer. Also, values below what the server actually needs turn a sawtooth into a hard OOM crash; the docs should say so. - [low] No tests, and the description admits neither typecheck nor tests were run. I ran
pnpm exec turbo run typecheck --filter=bb-appand… test --filter=bb-appon the branch: both pass (65 tests). Add a unit test forresolveServerNodeFlagsand the argv shape. - [info] Scope. Only the server is covered; the host daemon and the per-session bridge processes are the other Node heaps in the same cgroup and remain at V8's default. Not a blocker for an opt-in server knob, but the PR text implies the server is the whole problem.
- No wire-shape change → no
HOST_DAEMON_PROTOCOL_VERSIONconcern. Layer is right (launcher owns process spawn flags). No casts. Behavior unchanged when unset (verified: default run has no flag).
Tests I ran: gh pr checkout 1757; pnpm exec turbo run typecheck --filter=bb-app (pass, log); pnpm exec turbo run test --filter=bb-app (2 files, 65 tests pass, log); live launcher checks with pr1757-launcher-check.sh:
#!/usr/bin/env bash
# Issue #1748 / PR #1757 check: does the real launcher (bb-server, source-checkout mode) pass
# --max-old-space-size to the spawned server when BB_SERVER_MAX_OLD_SPACE_MB is set
# (a) in the process environment, and
# (b) via `bb-app env set` (the env file), as the PR's docs row claims?
# Usage: BB_REPO=<worktree with PR #1757 checked out and built> ./pr1757-launcher-check.sh
set -u
: "${BB_REPO:?set BB_REPO to the bb worktree root}"
DATA_DIR=/tmp/1748-bbapp-data
PORT=41748
rm -rf "$DATA_DIR"; mkdir -p "$DATA_DIR"
cd "$BB_REPO"
run_and_inspect() {
local label="$1"; shift
echo "=== $label"
# bb-server spawns `node <serverEntry>` with stdio inherit; we only need the child's cmdline.
env "$@" BB_DATA_DIR="$DATA_DIR" BB_SERVER_PORT="$PORT" BB_HOST_DAEMON_PORT=41749 \
pnpm exec tsx packages/bb-app/src/bin/bb-server.ts > "$DATA_DIR/$label.log" 2>&1 &
local launcher=$!
local child=""
for _ in $(seq 1 100); do
sleep 0.2
child=$(pgrep -f "apps/server/dist/index.js" | while read -r p; do
if grep -q "BB_DATA_DIR=$DATA_DIR" /proc/$p/environ 2>/dev/null; then echo "$p"; break; fi; done)
[ -n "$child" ] && break
if ! kill -0 "$launcher" 2>/dev/null; then break; fi
done
if [ -n "$child" ]; then
echo "server pid $child cmdline:"; tr '\0' ' ' < /proc/$child/cmdline; echo
echo "server env BB_SERVER_MAX_OLD_SPACE_MB=$(tr '\0' '\n' < /proc/$child/environ | grep '^BB_SERVER_MAX_OLD_SPACE_MB=' | cut -d= -f2-)"
else
echo "no server child found; launcher output:"; tail -5 "$DATA_DIR/$label.log"
fi
kill -TERM "$launcher" 2>/dev/null; [ -n "$child" ] && kill -TERM "$child" 2>/dev/null
wait "$launcher" 2>/dev/null
sleep 0.5
}
run_and_inspect a-process-env BB_SERVER_MAX_OLD_SPACE_MB=333
echo "=== writing the knob with bb-app env set (env file):"
BB_DATA_DIR="$DATA_DIR" BB_SERVER_PORT="$PORT" pnpm exec tsx packages/bb-app/src/bin/bb-app.ts env set BB_SERVER_MAX_OLD_SPACE_MB 444 2>&1 | tail -2
echo "env file contents:"; cat "$DATA_DIR/env" 2>/dev/null || cat "$DATA_DIR"/*.env 2>/dev/null || ls "$DATA_DIR"
run_and_inspect b-env-file
run_and_inspect c-invalid-value BB_SERVER_MAX_OLD_SPACE_MB=abc
$ BB_REPO=$PWD 1748/repro/pr1757-launcher-check.sh
=== a-process-env
server pid 2274967 cmdline:
/home/sawyer/.nvm/versions/node/v24.18.0/bin/node --max-old-space-size=333 /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-5/apps/server/dist/index.js
server env BB_SERVER_MAX_OLD_SPACE_MB=333
=== writing the knob with bb-app env set (env file):
Set BB_SERVER_MAX_OLD_SPACE_MB in /tmp/1748-bbapp-data/env.json
Reloaded running bb server config.
env file contents:
a-process-env.log
env.json
logs
=== b-env-file
server pid 2275465 cmdline:
/home/sawyer/.nvm/versions/node/v24.18.0/bin/node /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-5/apps/server/dist/index.js
server env BB_SERVER_MAX_OLD_SPACE_MB=444
=== c-invalid-value
no server child found; launcher output:
Error: BB_SERVER_MAX_OLD_SPACE_MB must be a positive integer number of megabytes; received "abc".
at resolveServerNodeFlags (/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-5/packages/bb-app/src/launcher.ts:2922:11)
at runBbServer (/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-5/packages/bb-app/src/launcher.ts:2675:9)
$ BB_SERVER_MAX_OLD_SPACE_MB=abc BB_DATA_DIR=/tmp/1748-bbapp-data BB_SERVER_PORT=41748 BB_HOST_DAEMON_PORT=41749 pnpm exec tsx packages/bb-app/src/bin/bb-app.ts start
bb
○ Starting server ✗ Server failed to start (health check timed out)
Check logs: /tmp/1748-bbapp-data/logs/
● Shutting down
Note on the "Reloaded running bb server config." line: my first run of the env set step omitted BB_SERVER_PORT, so the launcher pinged the default port 38886 (the machine's real bb) to reload config; the saved script now passes the port. The env file written is pr1757-env.json.
Verdict: REQUEST CHANGES. Fix the env source (read the resolved launcher env, not process.env), surface the validation error in bb-app start, correct the docs/comment premise, tighten parsing, add a test. With those, an opt-in knob is reasonable; the maintainers may also prefer to fold it into a co-location-aware default rather than a raw megabyte knob.
Related issues
- #1334: Co-located execution workload can starve the bb server and leave threads spinning (same reporter family; CPU/IO side of co-location).
- #1363: Provider processes need one host-daemon lease owner tied to active turns.
- #1393: Host lacks global admission control across work sources (the natural home for "N sessions fit in this cgroup").
- PR #1757: opt-in
BB_SERVER_MAX_OLD_SPACE_MB(reviewed above).
Appendix
Commands run
# worktree /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-5 at 16ceb3a54 pnpm install --frozen-lockfile --prefer-offline # 1748/install.log pnpm exec turbo run build # 1748/build.log grep -rn "max-old-space-size\|NODE_OPTIONS\|execArgv" apps packages scripts docs 1748/repro/heap-limit.sh > 1748/repro/heap-limit.out 1748/repro/cgroup-table.sh > 1748/repro/cgroup-table.out BB_REPO=$PWD 1748/repro/server-heap-limit.sh > 1748/repro/server-heap-limit.out # real server + diagnostic report systemd-run --user --scope -q -p MemoryHigh=3G -p MemoryMax=3800M ~/.nvm/versions/node/v26.5.0/bin/node -e '…heap_size_limit…' # node26-check.out NODE_OPTIONS=--max-old-space-size=1 claude --version # native claude ignores NODE_OPTIONS curl -sL https://raw.githubusercontent.com/nodejs/node/v24.18.0/src/api/environment.cc ; …/deps/uv/src/unix/linux.c # source excerpts quoted above gh pr checkout 1757 ; gh pr diff 1757 > 1748/pr1757.diff pnpm exec turbo run typecheck --filter=bb-app > 1748/pr-typecheck.log pnpm exec turbo run test --filter=bb-app > 1748/pr-tests.log BB_REPO=$PWD 1748/repro/pr1757-launcher-check.sh > 1748/repro/pr1757-launcher-check.out BB_SERVER_MAX_OLD_SPACE_MB=abc BB_DATA_DIR=/tmp/1748-bbapp-data BB_SERVER_PORT=41748 BB_HOST_DAEMON_PORT=41749 pnpm exec tsx packages/bb-app/src/bin/bb-app.ts start # pr1757-fullstack-invalid.out git checkout 16ceb3a54
heap-limit.out (first pass: flag vs NODE_OPTIONS vs cgroup, and spawn inheritance)
node: v24.18.0
physical RAM (MiB): 58987
--- default:
heap_size_limit MiB = 4288 execArgv= ["-e","const v8=require(\"v8\");const s=v8.getHeapStatistics();console.log(\"heap_size_limit MiB =\", Math.round(s.heap_size_limit/1048576), \" execArgv=\", JSON.stringify(process.execArgv), \" NODE_OPTIONS=\", process.env.NODE_OPTIONS ?? \"(unset)\")"] NODE_OPTIONS= (unset)
--- --max-old-space-size=512 flag:
heap_size_limit MiB = 704 execArgv= ["--max-old-space-size=512","-e","const v8=require(\"v8\");const s=v8.getHeapStatistics();console.log(\"heap_size_limit MiB =\", Math.round(s.heap_size_limit/1048576), \" execArgv=\", JSON.stringify(process.execArgv), \" NODE_OPTIONS=\", process.env.NODE_OPTIONS ?? \"(unset)\")"] NODE_OPTIONS= (unset)
--- NODE_OPTIONS=--max-old-space-size=512:
heap_size_limit MiB = 704 execArgv= ["-e","const v8=require(\"v8\");const s=v8.getHeapStatistics();console.log(\"heap_size_limit MiB =\", Math.round(s.heap_size_limit/1048576), \" execArgv=\", JSON.stringify(process.execArgv), \" NODE_OPTIONS=\", process.env.NODE_OPTIONS ?? \"(unset)\")"] NODE_OPTIONS= --max-old-space-size=512
--- inside a cgroup with MemoryMax=512M MemoryHigh=400M (systemd-run --user --scope):
memory.max=536870912 memory.high=419430400
heap_size_limit MiB = 259 execArgv= ["-e","const v8=require(\"v8\");const s=v8.getHeapStatistics();console.log(\"heap_size_limit MiB =\", Math.round(s.heap_size_limit/1048576), \" execArgv=\", JSON.stringify(process.execArgv), \" NODE_OPTIONS=\", process.env.NODE_OPTIONS ?? \"(unset)\")"] NODE_OPTIONS= (unset)
--- a child spawned with spawn(process.execPath,[script]) (what bb-app does for the server) does NOT inherit the parent's execArgv ...
parent execArgv= ["--max-old-space-size=300","-e","const {spawnSync}=require(\"child_process\");console.log(\" parent execArgv=\",JSON.stringify(process.execArgv));const r=spawnSync(process.execPath,[\"/tmp/1748-child.cjs\"],{encoding:\"utf8\"});process.stdout.write(r.stdout)"]
child heap_size_limit MiB = 4288 execArgv= [] NODE_OPTIONS= (unset)
--- ... but DOES inherit NODE_OPTIONS:
child heap_size_limit MiB = 492 execArgv= [] NODE_OPTIONS= --max-old-space-size=300
Where NODE_OPTIONS travels in bb (code path)
bb-app launcher (systemd unit env, incl. NODE_OPTIONS)
├─ spawn(process.execPath, [serverEntry], {env: createServerEnv({...runtime.serverEnv})}) → server inherits NODE_OPTIONS
└─ spawn(process.execPath, [daemonEntry], {env: createDaemonEnv(...)}) → daemon inherits NODE_OPTIONS
└─ agent-runtime spawnProvider: env = {...sanitizeInheritedChildProcessEnv({env: process.env}), ...}
strips only NODE_ENV and BB_* → each provider bridge (`node bridge-bootstrap …`) inherits NODE_OPTIONS
└─ claude-code bridge passes that env to the Claude Agent SDK, which execs `claude`
(native binary here: ignores NODE_OPTIONS; npm/Node install would honor it)