feat(dev): cap each lab session's open-file budget so a runaway can't wedge the VM #77

Merged
dominik.polakovics merged 1 commit from feat/lab-session-nofile into main 2026-06-02 14:21:21 +02:00

What

lab spawned every agent as a bare tmux new-session -d, so the process tree inherited the system-default RLIMIT_NOFILE (~524288 hard). A single agent leaking file descriptors could drive the whole dev microvm to system-wide fd exhaustion (ENFILE) — on 2026-06-02 it took lab and SSH down together, recoverable only by a reboot (#76).

This pins every spawned session's soft and hard RLIMIT_NOFILE to a configurable cap via prlimit, so a descriptor leak hits the agent's own EMFILE long before it can deplete the system pool. sshd, nscd, and lab keep their high default ceiling and stay responsive.

Design (honoring the issue's settled decisions)

  • Per-process rlimit, not cgroups. The failure was fd exhaustion; NOFILE is the exact lever — no new privilege, no user-manager lingering, no controller delegation.
  • NOFILE only — no NPROC (enforced per-UID, would punish sibling agents + SSH logins) or AS (node/V8 reserve huge virtual space; an AS cap kills claude).
  • Applied at the single chokepoint. All three spawn paths — manual instances (Start), AFK runs (StartCommand + seed prompt), the login flow (StartCommand) — route through StartCommand. The wrapper is prepended there, composing with %s session-name substitution and AFK's trailing seed-prompt arg.
  • On the inner pane command, not the tmux call. prlimit --nofile=N:N -- <cmd> is the pane's command, so the bound reaches the whole agent tree (claude + node + LSPs + watchers) and survives new-session -d daemonizing the pane under the shared server. Both limits set equal so the process can't raise its soft limit back toward the system ceiling; the trailing -- lets the command's own flags (claude --remote-control …) pass through.
  • Single shared tmux server kept — capping the inner command makes daemonization irrelevant.
  • prlimit pinned behind a -prlimit flag, resolved off the service PATH (util-linux added), matching the tmux/claude/git/tea convention.

Changes

  • sessions.goprlimitBin + nofile fields; pure newSessionArgs() prefixes prlimit --nofile=N:N -- onto the substituted command when nofile > 0, bare otherwise (zero value leaves existing tests / minimal configs unaffected).
  • main.go-prlimit (default prlimit) and -session-nofile (default 16384) flags; set on Sessions right after construction.
  • default.nix — util-linux on the service PATH + nativeCheckInputs; -session-nofile 16384 set on the service via ExecStart.

Tests

go test ./... green (run locally — the pre-commit hook is eval-only for lab Go; fw dry-build: OK).

  • Unit TestSessions_newSessionArgsNofileCap — argv carries the prlimit wrapper (with %s substituted) when capped, bare at 0.
  • Integration TestSessions_nofileCapPropagatesThroughDaemonization — real tmux; the spawned pane reports soft+hard NOFILE == cap, proving the limit survives new-session -d.
  • Integration TestSessions_nofileCapBindsEachSessionOnSharedServer — two capped sessions on one shared server both report the cap, proving attach-to-a-running-server doesn't bypass the per-pane wrapper.

Headroom

max-instances = 6 (login excluded) → ≤ 7 capped sessions including login. Worst case 7 × 16384 = 114,688 fds.

  • vs fs.file-max = LONG_MAX ≈ 9.2e18 (measured on dev during the 2026-06-02 incident, per #76) → ratio ~1e-14; the system file table cannot be depleted by capped agents.
  • vs fs.nr_open (per-process hard ceiling, kernel default 1,048,576) → the 16384 cap is ~1.6% of it, comfortably settable by prlimit.

The arithmetic is conclusive on documented values. One belt-and-suspenders item is pending: a fresh live read-only sysctl fs.file-max fs.nr_open on dev to re-confirm against current kernel state — that SSH needs explicit approval; I'll append the output here once approved.

Acceptance criteria

  • Every spawned session (manual, AFK, login) runs with soft+hard NOFILE = the configured cap.
  • The cap is a flag with a sensible default, set on the running service via its ExecStart.
  • Unit test asserts the spawn argv is prefixed with the cap wrapper when configured, bare when 0.
  • Integration test: a capped session's stand-in reports soft+hard NOFILE == cap (survives new-session -d).
  • Integration test: two-session shared-server case, both panes report the cap.
  • max-instances × capfs.file-max/fs.nr_open — arithmetic recorded above; live dev sysctl re-confirmation pending SSH approval.

Closes #76

## What lab spawned every agent as a bare `tmux new-session -d`, so the process tree inherited the system-default `RLIMIT_NOFILE` (~524288 hard). A single agent leaking file descriptors could drive the whole dev microvm to **system-wide** fd exhaustion (ENFILE) — on 2026-06-02 it took lab and SSH down together, recoverable only by a reboot (#76). This pins every spawned session's **soft and hard** `RLIMIT_NOFILE` to a configurable cap via `prlimit`, so a descriptor leak hits the agent's own EMFILE long before it can deplete the system pool. sshd, nscd, and lab keep their high default ceiling and stay responsive. ## Design (honoring the issue's settled decisions) - **Per-process rlimit, not cgroups.** The failure was fd exhaustion; NOFILE is the exact lever — no new privilege, no user-manager lingering, no controller delegation. - **NOFILE only** — no NPROC (enforced per-UID, would punish sibling agents + SSH logins) or AS (node/V8 reserve huge virtual space; an AS cap kills claude). - **Applied at the single chokepoint.** All three spawn paths — manual instances (`Start`), AFK runs (`StartCommand` + seed prompt), the login flow (`StartCommand`) — route through `StartCommand`. The wrapper is prepended there, composing with `%s` session-name substitution and AFK's trailing seed-prompt arg. - **On the inner pane command, not the tmux call.** `prlimit --nofile=N:N -- <cmd>` *is* the pane's command, so the bound reaches the whole agent tree (claude + node + LSPs + watchers) and survives `new-session -d` daemonizing the pane under the shared server. Both limits set equal so the process can't raise its soft limit back toward the system ceiling; the trailing `--` lets the command's own flags (`claude --remote-control …`) pass through. - **Single shared tmux server kept** — capping the inner command makes daemonization irrelevant. - **prlimit pinned behind a `-prlimit` flag**, resolved off the service PATH (util-linux added), matching the tmux/claude/git/tea convention. ## Changes - `sessions.go` — `prlimitBin` + `nofile` fields; pure `newSessionArgs()` prefixes `prlimit --nofile=N:N --` onto the substituted command when `nofile > 0`, bare otherwise (zero value leaves existing tests / minimal configs unaffected). - `main.go` — `-prlimit` (default `prlimit`) and `-session-nofile` (default `16384`) flags; set on `Sessions` right after construction. - `default.nix` — util-linux on the service PATH + `nativeCheckInputs`; `-session-nofile 16384` set on the service via ExecStart. ## Tests `go test ./...` green (run locally — the pre-commit hook is eval-only for lab Go; `fw` dry-build: OK). - **Unit** `TestSessions_newSessionArgsNofileCap` — argv carries the prlimit wrapper (with `%s` substituted) when capped, bare at 0. - **Integration** `TestSessions_nofileCapPropagatesThroughDaemonization` — real tmux; the spawned pane reports soft+hard NOFILE == cap, proving the limit survives `new-session -d`. - **Integration** `TestSessions_nofileCapBindsEachSessionOnSharedServer` — two capped sessions on one shared server both report the cap, proving attach-to-a-running-server doesn't bypass the per-pane wrapper. ## Headroom `max-instances` = 6 (login excluded) → ≤ **7** capped sessions including login. Worst case **7 × 16384 = 114,688** fds. - vs `fs.file-max` = `LONG_MAX` ≈ 9.2e18 (measured on dev during the 2026-06-02 incident, per #76) → ratio ~1e-14; the system file table cannot be depleted by capped agents. - vs `fs.nr_open` (per-process hard ceiling, kernel default 1,048,576) → the 16384 cap is ~1.6% of it, comfortably settable by prlimit. The arithmetic is conclusive on documented values. One belt-and-suspenders item is **pending**: a fresh live read-only `sysctl fs.file-max fs.nr_open` on dev to re-confirm against current kernel state — that SSH needs explicit approval; I'll append the output here once approved. ## Acceptance criteria - [x] Every spawned session (manual, AFK, login) runs with soft+hard NOFILE = the configured cap. - [x] The cap is a flag with a sensible default, set on the running service via its ExecStart. - [x] Unit test asserts the spawn argv is prefixed with the cap wrapper when configured, bare when 0. - [x] Integration test: a capped session's stand-in reports soft+hard NOFILE == cap (survives `new-session -d`). - [x] Integration test: two-session shared-server case, both panes report the cap. - [ ] `max-instances × cap` ≪ `fs.file-max`/`fs.nr_open` — arithmetic recorded above; live dev sysctl re-confirmation pending SSH approval. Closes #76
lab spawned every agent as a bare `tmux new-session -d`, so the process tree
inherited the system-default RLIMIT_NOFILE (~524288 hard) and a single agent
leaking file descriptors could drive the whole dev microvm to system-wide fd
exhaustion (ENFILE) — taking lab and SSH down together, recoverable only by a
reboot (Cloonar/nixos#76).

Pin every spawned session's soft AND hard RLIMIT_NOFILE to a configurable cap
via prlimit, so a descriptor leak hits the agent's own EMFILE long before it
can deplete the system pool; sshd, nscd, and lab keep their high default
ceiling and stay responsive. The cap is applied to the inner pane command (not
the tmux call) so it reaches the whole agent tree and survives new-session's
daemonization, and it sits in the single StartCommand chokepoint every spawn
path routes through (manual instances, AFK runs, the login flow).

- sessions.go: prlimitBin + nofile fields; pure newSessionArgs() prefixes
  `prlimit --nofile=N:N --` onto the substituted command when nofile > 0, bare
  otherwise (zero value leaves existing tests/minimal configs unaffected).
- main.go: -prlimit (default prlimit) and -session-nofile (default 16384) flags.
- default.nix: util-linux on the service PATH + nativeCheckInputs; -session-nofile
  16384 set on the service via ExecStart.
- tests: unit test asserts the wrapper is present when capped and bare at 0; two
  real-tmux integration tests assert the spawned pane reports soft+hard NOFILE
  equal to the cap — one single-session (proving survival of new-session -d
  daemonization), one two-session on a shared server (proving attach to a
  running server doesn't bypass the per-pane wrapper).
dominik.polakovics deleted branch feat/lab-session-nofile 2026-06-02 14:21:22 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/nixos!77
No description provided.