Skip to content

The box gets a memory boundary: protected services, bounded user work, measured knobs - #113

Merged
pythonlearner1025 merged 5 commits into
mainfrom
box-memory-boundary
Aug 29, 2026
Merged

The box gets a memory boundary: protected services, bounded user work, measured knobs#113
pythonlearner1025 merged 5 commits into
mainfrom
box-memory-boundary

Conversation

@pythonlearner1025

Copy link
Copy Markdown
Member

The box was one flat memory pool. A workspace under pressure died one of two ways: a fast squeeze let the kernel pick victims by size alone (dockerd, the actor, or PID 1 — a PID 1 kill lost every session), and a slow squeeze pushed the whole VM into direct reclaim, where cloudflared stalled while alive and the workspace read "connecting" with no OOM line anywhere. The second one took down a real workspace, motivated this work, and was reproduced end-to-end in the load campaign below.

The boundary

  • blitz-system.slice — s6, cloudflared, gateway, sshd, dufs, ttyd, watch. memory.min 256M, oom_score_adj -900, pids.max 512. The kernel must not reclaim the services that carry the box to its user.
  • blitz-user.slice — terminal tabs, ssh sessions, the actor (its SDK spawns agents beyond any wrapper), Remote Control, DinD. memory.max below the container ceiling, a 500M memory.high throttle band, 2G swap budget, pids.max 4096, and per-leaf memory.oom.group=1 so a runaway dies as one unit while the tab next door survives.
  • Placement rides where work actually starts: the tmux pane command, an sshd ForceCommand, dockerd --cgroup-parent, and the service run scripts. uid 1000 is delegated exactly enough to move its own work between its own leaves; the system slice stays root-owned.
  • The VM gains zram (25%, with the linux-modules-extra fallback the stock image needs), a container ceiling that reserves 512M for the host, --pids-limit, and a HEALTHCHECK that tests reachability (and treats a missing tunnel token as idle, not sick).
  • A box that cannot own cgroups — unprivileged, or box-in-box dev — bails to flat cleanly: no slice, no noise, every enter a passthrough. The smoke suite asserts the full layout where the memory controller is delegated and the clean bail where it is not. Side effect on real VMs: inner containers get real memory accounting for the first time (today's fleet runs DinD with "No memory limit support").

Assertion zero

Enabling controllers on the container root triggers cgroup v2's no-internal-process rule, so the whole design hinged on one unverifiable-in-nested-DinD fact: does docker exec still work? Measured on a real cx23: runc attaches exec processes to PID 1's cgroup (the system slice), not the namespace root. Exec, the bootstrap health loop, the box updater, and the smoke suite all keep working.

The load campaign (real Hetzner VMs, real Cloudflare tunnel)

Two cx23s, label+prefix-guarded lifecycle, pre-run inventory diffed at teardown (proof: all 9 pre-existing servers untouched). Oracles that never sit inside the victim: a 4 Hz host-side probe of the box's sshd, cgroup accounting read from the host cgroupfs, and a real cfd_tunnel polled at 2 Hz through the Cloudflare edge. Baseline = same image, boundary off, no limits, no swap (today's fleet).

Scenario Baseline (today) Boundary (this PR)
l1 6G balloon 2303 ms host freeze, probe+tunnel losses, P1+P6 FAIL 272 ms, clean
l2 8x900M build 989 ms, 35 kernel OOM lines 344 ms, 0 lines
l3 DinD bomb kernel kill, 459 ms contained in docker.slice, 277 ms
l7 combined 1042 ms, 14 OOM lines 291 ms, kills stay in user leaves
l8 95% ramp 1865 ms freeze, kernel kill 284 ms, bounded leaf kills
l9 hot near-edge hold collapse at +114 s, 91/474 probes lost, 0 OOM lines, no recovery, even root ssh dead — power reset required. The production failure, reproduced. 0/1710 probes lost, 292 ms worst gap, new session accepted

Tunnel view: 0.0% dead polls inside every treatment window; the baseline l9 window shows a 14.8 s silence and multi-second latencies — the state the webapp renders as "connecting". Recovery from the wedged state: Hetzner power reset to origin-answering tunnel in 45 s (the control plane currently has no reboot action; worth adding as a rescue path).

Knob sweep (10 configs)

No criterion flips across min 128M-384M, gap 250M-1G, zram 0-50%. Shipped: min 256M (~3x the measured 85 MB protected set; validated under the combined scenario), gap 500M, zram 25%. z50 absorbed the l8 ramp with 300x fewer throttle events — the smoothness knob if bigger boxes ever want it. Every knob is a BLITZ_CG_* env var overridable per box via /etc/blitz/box-limits.env without an image rebuild.

Full design and campaign docs: docs/MEMORY-BOUNDARY.md. Harness: packages/box/test/{memory-load.sh,hetzner-load-lab.sh,tunnel-oracle.sh,analyze-load.py}.

Rollout notes: image + control-plane bootstrap ship together in the next tag; existing VMs keep their image for life, so the boundary reaches the fleet as workspaces recycle. The maximal recipe bootstrap stays under Hetzner's 32K user-data cap (1032 bytes of headroom; the zram/limits reasoning lives in TS comments, which never ship).

🤖 Generated with Claude Code

pythonlearner1025 and others added 5 commits August 29, 2026 19:47
…ed user work

The box was one flat memory pool. Under a slow squeeze the VM entered direct
reclaim and cloudflared stalled while alive: the workspace read 'connecting'
with no OOM line anywhere. Under a fast squeeze the kernel picked its victim
by size alone — dockerd, the actor, or PID 1 were all legal targets, and a
PID 1 kill restarted the container and lost every session.

Two cgroups now split the box. blitz-system.slice (s6, cloudflared, gateway,
sshd, dufs, ttyd, watch) carries memory.min plus oom_score_adj -900: the
services that carry the box to its user cannot be reclaimed into a stall and
are the last OOM candidates. blitz-user.slice (agents, tabs, ssh sessions,
remote-control, actor, DinD) carries memory.max, a memory.high throttle band,
swap, pids.max, and per-leaf memory.oom.group=1 — a runaway dies as a unit,
and every other tab survives it.

Placement rides the paths work actually starts on: the tmux PANE command (a
pane forks from the server, not from blitz-term), an sshd ForceCommand (a
session forks from sshd in the protected slice), dockerd --cgroup-parent
(inner containers otherwise land outside every limit), and the actor's s6 run
(its SDK spawns agents beyond any wrapper's reach). uid 1000 gets the narrow
delegation it needs to move its own work: the root cgroup.procs and the user
slice, never the system slice.

The VM side gains zram (pressure degrades before it kills; the stock image
needs linux-modules-extra for the module), a container memory ceiling below
VM RAM so host sshd and the updater always survive, and a HEALTHCHECK that
tests reachability, not just liveness.

Verified: assertion zero on a real cx23 — docker exec follows PID 1 into the
system slice, so the no-internal-process rule never breaks exec. The full
tree stands on that VM. The smoke suite asserts the layout wherever the
memory controller is delegated, and asserts the clean flat-mode bail where
it is not (nested DinD).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pid=$(start_probe ...) never returned: the backgrounded child inherits the
command-substitution pipe as stdout, and holding it open is enough to block
the capture forever — every scenario hung after starting its probe, and the
outer timeouts made the runs LOOK complete while no load ever ran. The probe
and sampler children now detach from stdout/stderr; only the pid printf
touches the pipe. Also: sftp takes its port as -P (lowercase -p is
preserve-times), and the lab's liveness checks no longer concatenate curl's
own '000' with the fallback echo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…check

The load campaign (16 matrix verdicts on cx23, 10 knob configs, a real
Cloudflare tunnel as the user-path oracle) sized the defaults:

- memory.min 256M, memory.low 384M. The floor sweep flipped no criterion
  down to 128M, and the combined scenario passed at 256M. The protected set
  measures ~85 MB once the actor sits user-side, so 256M is ~3x actual, and
  small boxes get 128M of ceiling back versus the first guess.
- memory.high gap 500M and zram 25% stay. The gap swept 250M-1G with no
  flip; z50 showed smoother absorption (the ramp completed with 300x fewer
  throttle events) but z25 already yields zero user-visible impact.
- The l9 pair is the proof the boundary exists for: a hot near-edge hold
  wedged the flat box until a power reset (91/474 probes lost, zero OOM
  lines, no recovery), and the bounded box shrugged the same load off at
  0/1710 lost with a 292 ms worst gap.

The healthcheck now treats a missing tunnel token as idle, not unhealthy:
cloudflared waits for tokens by design, so its absence in that state is the
control plane's status to report, not a box fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The five new-session argv expectations gain the blitz-cgroup enter prefix
the boundary added to every pane command. Also reword one bootstrap comment
whose prose read as an import to the core-imports scanner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pythonlearner1025
pythonlearner1025 merged commit bc825f0 into main Aug 29, 2026
8 checks passed
pythonlearner1025 added a commit that referenced this pull request Aug 30, 2026
main gained the memory boundary (#113), machine-stats (#112), env-file
credential import (#114), the managed-count fix (#117), canary's R2 box
image (#115) and the connections/mobile fixes. This branch is the Lody
port, which deleted the box actor entirely.

How each side was chosen:

- `packages/box/actor/**` stays deleted. That was a direct user order and
  nothing here softens it. main's `actor/run` change is dropped with the
  service it wrapped.
- machine-stats is actor-independent guest work, so it stays: the s6
  service, `blitz-machine-stats` and the CP consumer land unchanged, and
  its conformance test moves to `packages/box/guest-tests/test/` the way
  the deletion commit re-homed every other guest test. The relative paths
  the test reads (`../../rootfs`, `../../../schema/fixtures`) resolve at
  the same depth, so nothing in it needed editing.
- The memory boundary's one actor dependency was the placement of the
  process that hosts agents. The Lody daemon is that process now, so it
  inherits the scope: `lody-daemon/run` enters `user/lody.scope`, and
  `docs/MEMORY-BOUNDARY.md`, `blitz-cgroup` and `smoke.sh` say so. The
  daemon is dark by default, so smoke.sh reads the run script rather than
  looking for a node that is not there.
- `.github/workflows/**` takes main's side whole — this branch never meant
  to touch canary.yml, and after the merge the directory is byte-identical
  to origin/main.
- `CreateRecipeScreen.tsx` takes main's `cfg-` settings-surface styling and
  drops the chat-harness copy and `chatNeedsModel`, which have no referent
  on this branch.
- `WorkspaceDetailsDialog.test.tsx` takes main's `IMPORT_PREVIEW_DEBOUNCE_MS`
  import beside this branch's `SessionRail` rename.
- `shell-smoke.test.tsx`: main's new mobile-drawer test passed an `acp` port
  that `StandalonePorts` no longer has.

Adding a file under `core/` touches three hand-maintained lists, and the
merge is where two branches' additions meet. All three take the union:
`worker-source.mjs` gains main's `workspace-credential-import.ts` beside
this branch's `workspace-drain.ts`; `core-imports.test.ts` counts 109
(104 at the fork, +2 from main, +3 here); and the managed upload set in
`blitzdev-emitter.test.ts` gains `core/wire-sharing.ts`,
`core/session-shares.ts` and `core/workspace-drain.ts` to reach 112.
Those three are 2.2 KB, 14 KB and 2.9 KB of source, so the emitter's
1 MiB-per-file platform limit still holds with four orders of magnitude
to spare — and the test asserts it on every file, not just the new ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vk3ghWvYvp74ae9EUQPPkJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant