← reports

#1748 · Server heap is sized from host RAM, not the cgroup limit, so it fills the memory cap with garbage

Type: (unset) Medium Effort: (unset) perf host open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: NOT REPRODUCED (core claim refuted) · root-cause confidence: high · linked open PRs: #1757 (REQUEST CHANGES)

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

ClaimStatusEvidence
bb never sets --max-old-space-size; not in the launcher, spawn paths, or docsVerifiedgrep -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 limitRefutedNode 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 defectV8'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 killUnverifiableHost-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 processesVerified (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 trueTrue 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.maxWould only replicate what Node already doesNode 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

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)

  1. Do not implement the issue as filed. Deriving --max-old-space-size from /sys/fs/cgroup/memory.max re-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").
  2. If a knob is wanted, keep it opt-in and read it from the launcher's resolved env (runtime.serverEnv / the env passed to createServerEnv), so bb-app env set and the process env both work; apply at both server spawn sites in launcher.ts; validate at option-resolution time so an invalid value produces its own error instead of the "health check timed out" message; document it in docs/configuration.md without the wrong premise; add a launcher unit test for parsing plus one for the argv shape.
  3. A better default, if the maintainers want one: pass --max-old-space-size only when a cgroup limit is detected and set it to a fraction that leaves room for the daemon and N bridges (say 40% of min(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.
  4. 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):

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

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)