The box gets a memory boundary: protected services, bounded user work, measured knobs - #113
Merged
Merged
Conversation
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.maxbelow the container ceiling, a 500Mmemory.highthrottle band, 2G swap budget,pids.max 4096, and per-leafmemory.oom.group=1so a runaway dies as one unit while the tab next door survives.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.--pids-limit, and a HEALTHCHECK that tests reachability (and treats a missing tunnel token as idle, not sick).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 execstill 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).
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
min128M-384M,gap250M-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 aBLITZ_CG_*env var overridable per box via/etc/blitz/box-limits.envwithout 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