Skip to content

Commit 59ff31e

Browse files
authored
feat: hibernate idle sessions, and a runtime built for coding agents (#76)
* feat: hibernate idle sessions instead of closing them At the ceiling, sandboxd closed the least recently used idle session: its container went, and so did its token, its event log and the caller's ability to come back to it. Eviction now gives up only the sandbox. The record stays, `GET /sessions` reports it as `state: "hibernated"`, and the next request wakes it where it left off. That splits one number into two, which is the point. `max_sessions` now bounds resident sandboxes -- the RAM number -- while the new `max_open_sessions` bounds sessions that exist at all, resident and hibernated together, which is a disk number and properly much larger. On a 4 GB host that is ten resident against a couple of hundred open. Also here: - `memswap_limit` on DockerSandbox, SandboxRuntime and SandboxdConfig. Swap stays pinned to `mem_limit` by default, which is right when swap is a disk; raising it is for hosts backed by zram, where the pages stay in RAM compressed and the alternative is an OOM kill. - scripts/bench_density.py, which measures on the host being sized what no blog post can: marginal MemAvailable per session, per container management overhead, time to first command, and wake latency from hibernation. - The reasoning, in docs/plans/sandbox-density-on-small-hosts.md, including a correction to the earlier runtime-alternatives note: microVMs buy isolation, not density, and lose badly under a hard memory ceiling. Three things this uncovered on the way. `sweep_workspaces` would have deleted a hibernated session's workspace, since its keep-set came from the manager alone. `SessionManager.release` returns False for a session it does not hold, so `on_release` never fires and `close_session` has to drop the records itself. And a purge of a hibernated session left its persisted container behind, there being no sandbox object to stop. * feat: a runtime built for coding agents, and two bugs it exposed Measured rather than reasoned about, on Docker 29.2.1. Running the sandbox the way an agent uses it turned up two things that were broken before any tuning, both hitting the tool a coding agent reaches for most. Every orphaned process was a permanent zombie. Containers run `sleep infinity` as PID 1, and `sleep` never calls `wait()`, so a backgrounded server or anything the command timeout killed was reparented to it and stayed. Measured: ten orphans, ten zombies, and the process table growing 4 -> 13. They accumulate against `pids_limit` until every command in the session fails to fork. Docker's `init` reaps them for 488 kB. git refused to work in the workspace. `workspace_root` bind-mounts a host directory the container does not own, so every git command failed with "detected dubious ownership" -- status, diff, log, commit alike -- and past that a commit failed again with "Author identity unknown". An agent asked to save its work could not. Configured now through `GIT_CONFIG_*` on the container rather than in an image, which is what makes it reach the ready-made runtimes that build nothing of their own. The runtime itself, `coding`, is 99.7 MB and eleven seconds to build. `git` is 33.1 MB of that and unavoidable; ripgrep, fd, jq, less and procps come to 4.3 MB between them, which is not a saving worth making -- without `rg` an agent falls back to `grep -r` and floods its own context, and without `procps` it has no way to see or stop the server it just started. `build-essential` is refused at 94 MB to compile wheels manylinux already ships built, and the base stays Debian slim because on musl a package without a musllinux wheel builds from source. `uv` is there because an agent installs packages inside somebody's turn: 8485 ms -> 1169 ms for pandas. The surprise was at the ceiling -- uncapped, uv is OOM-killed by a 128 MB limit that pip survives, because its parallelism is memory. Capped at two it fits, and is still 6.6x faster than pip. Also here: `polyglot` gets npm 10, since Debian ships a current Node with an npm a major version behind; and `default_runtime` now takes the first entry in `runtimes` rather than the literal alias "python", which had forced every custom allowlist to contain that key. Reasoning and the full measurements in docs/plans/coding-agent-runtime.md.
1 parent ca4a8eb commit 59ff31e

16 files changed

Lines changed: 1839 additions & 91 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **`SandboxdConfig.default_runtime` defaults to the first entry in `runtimes`** rather than to the literal alias `"python"`. The old default meant every custom allowlist had to contain a key named `python` or the config refused to construct, which is a coupling nothing asked for. The shipped allowlist lists `coding` first, so that is what a default service now hands out; naming an alias explicitly still wins, and naming one that is not allowed is still refused.
13+
14+
### Fixed
15+
16+
- **An agent can use git in its sandbox.** Every git command in a bind-mounted workspace failed with `detected dubious ownership`, because the directory belongs to whoever the service runs as and the container does not — measured on `status`, `diff`, `log` and `commit` alike. Past that, a commit failed again with `Author identity unknown`. Both are now configured through `GIT_CONFIG_*` on the container, which is what makes it reach the ready-made runtimes (`bun`, `deno`, `go`, `rust`) that build no image of their own.
17+
- **A long-lived session no longer runs out of processes.** Containers run `sleep infinity` as PID 1, and `sleep` never calls `wait()` — so every process an agent orphans, a backgrounded server or anything the command timeout kills, was reparented to it and stayed a zombie for the life of the container. Measured: ten orphans, ten permanent zombies, accumulating against `pids_limit` (512) until every command in the session failed to fork. Containers now start with Docker's `init`, which costs 488 kB and reaps them.
18+
19+
### Added
20+
21+
- **An evicted session is hibernated rather than closed.** At the ceiling, `sandboxd` used to close the least recently used idle session: its container went, and so did its token, its event log and the caller's ability to come back to it. It now gives up only the sandbox. The record stays, `GET /sessions` reports it as `state: "hibernated"`, and the next request wakes it where it left off — measured at 0.09 s for a persisted container. Nothing a client holds stops being valid.
22+
- **`SandboxdConfig.max_open_sessions`**, which is the point of the above. `max_sessions` now means *resident* sandboxes — the number the host's RAM has to hold — while `max_open_sessions` bounds the sessions that exist at all, resident and hibernated together, which is a disk number and properly much larger. On a 4 GB host that is ten resident against a couple of hundred open. At the open ceiling the longest-asleep session is closed for good; with every open session in use, the caller gets `429`. A hibernated session is ended by `idle_timeout` like any other.
23+
- **`memswap_limit` on `DockerSandbox`, `SandboxRuntime` and `SandboxdConfig`.** Swap is still pinned to `mem_limit` by default, because a container swapping to a disk starves every other one on the host. That is the wrong trade where swap is `zram`: the pages stay in RAM compressed at roughly 3:1, and the alternative to a little swapping is an OOM kill mid-command. Set it above `mem_limit` there and nowhere else.
24+
- **A `coding` runtime, and it is the shipped default.** Python with git, ripgrep, fd, jq, less, procps and `uv` — 99.7 MB, eleven seconds to build, measured. `git` is 33.1 MB of that and unavoidable; the five tools an agent looks at a codebase with come to 4.3 MB between them. `build-essential` is deliberately absent at 94 MB to compile wheels manylinux already ships built. It is the first entry in `DEFAULT_RUNTIMES`, with `python` and `node` staying beside it as ready-made fallbacks for a host that cannot reach a Debian mirror.
25+
- **Every sandbox starts with a working environment**, applied at the container so it reaches images we did not build: `PYTHONUNBUFFERED` so a command killed by the timeout still returns what it printed, `NO_COLOR` and `PAGER=cat` so escape sequences do not fill the model's context, `LANG=C.UTF-8` because `node:20-slim` ships no locale, and `UV_CONCURRENT_DOWNLOADS=2` — uv's parallelism is memory, and uncapped it is OOM-killed by a 128 MB ceiling that pip survives, where capped it fits and stays 6.6× faster than pip. A runtime overrides any of it through its own `env_vars`.
26+
- **`polyglot` now carries npm 10.** Debian 13 ships a current Node (20.19.2 against `node:20-slim`'s 20.20.2) but an npm a major version behind, so the generalist runtime quietly behaved differently from the dedicated Node ones.
27+
- **`scripts/bench_density.py`**, which measures on the host being sized what no blog post can: marginal `MemAvailable` per session, per-container management overhead, time to first command, and wake latency from hibernation. Reasoning behind it in [`docs/plans/sandbox-density-on-small-hosts.md`](docs/plans/sandbox-density-on-small-hosts.md).
28+
1029
## [0.2.18] - 2026-08-01
1130

1231
### Added

docs/concepts/docker.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ sandbox = DockerSandbox(runtime=runtime)
6464

6565
| Runtime | Image | What it adds |
6666
|---|---|---|
67+
| `coding` | built on python:3.12-slim | git, ripgrep, fd, jq, less, procps, uv |
68+
| `polyglot` | built on python:3.12-slim | Python and Node together, curl, git, numpy, duckdb, polars, httpx |
6769
| `python-minimal` | python:3.12-slim | standard library only |
6870
| `python-datascience` | built on python:3.12-slim | pandas, numpy, matplotlib, scikit-learn, seaborn |
6971
| `python-analytics` | built on python:3.12-slim | duckdb, polars, pyarrow |
@@ -82,6 +84,37 @@ A runtime naming an `image` starts as fast as a pull. One naming a `base_image`
8284
plus `packages` builds an image on first use and hits the cache afterwards, which
8385
is worth it when installing them per session would dominate.
8486

87+
**`coding` is the one to reach for when the agent's job is code.** Measured at
88+
99.7 MB and eleven seconds to build: `git` is 33.1 MB of that and unavoidable,
89+
while `ripgrep`, `fd`, `jq`, `less` and `procps` come to 4.3 MB between them.
90+
`uv` is there because an agent installs packages inside its own turn — measured
91+
5–7× faster than pip on the same package set. What is deliberately absent is
92+
`build-essential`: 94 MB to compile wheels that manylinux already ships built.
93+
94+
### What every sandbox gets, whatever its runtime
95+
96+
Some settings are applied to the container rather than baked into an image, so
97+
they reach the ready-made runtimes too — `bun`, `deno`, `go` and `rust` build
98+
nothing, so a Dockerfile could never have carried them. A runtime overrides any
99+
of it through its own `env_vars`.
100+
101+
- **git is configured through `GIT_CONFIG_*`.** Without it, every git command in
102+
a bind-mounted workspace fails with `detected dubious ownership` — the
103+
directory belongs to whoever the service runs as, and the container does not —
104+
and a commit fails again with `Author identity unknown`. Both measured.
105+
- **An init process reaps orphans.** `sleep infinity` as PID 1 never calls
106+
`wait()`, so a backgrounded server or anything the command timeout kills stays
107+
a zombie for the life of the container. Measured: ten orphans left ten
108+
permanent zombies, accumulating against `pids_limit` until the session could
109+
not fork. The reaper costs 488 kB.
110+
- **Output stays readable**: `PYTHONUNBUFFERED` so a command killed by the
111+
timeout still returns what it printed rather than an empty string, and
112+
`NO_COLOR` / `PAGER=cat` so escape sequences do not fill the model's context.
113+
- **`uv` is capped at two concurrent downloads.** Its parallelism is memory:
114+
measured installing pandas, uncapped uv is OOM-killed by a 128 MB ceiling that
115+
pip survives. Capped it fits, and is still 6.6× faster than pip.
116+
- **`LANG=C.UTF-8`**, because `node:20-slim` ships no locale at all.
117+
85118
## SessionManager for Multi-User
86119

87120
For web apps where each user needs isolated execution:

docs/concepts/remote.md

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,18 @@ SandboxdConfig(
232232
token=token,
233233
runtimes=SUGGESTED_RUNTIMES,
234234
prewarm=True, # build and pull the allowlist at startup, not on a request
235-
persist_containers=True, # a reaped session restarts instead of rebuilding
235+
persist_containers=True, # a woken session restarts instead of rebuilding
236236
tmpfs_size="64m", # scratch writes stay in RAM, off the overlay
237237
cpu_shares=1024, # weight rather than a hard cap, so idle cores get used
238238
cpus=None,
239239
)
240240
```
241241

242+
None of them moves the ceiling as far as separating resident sessions from open
243+
ones does — see [A working set, not a hard cap](#a-working-set-not-a-hard-cap),
244+
which is where the order-of-magnitude is. These four are what make each resident
245+
session cheap once that split is in place.
246+
242247
**`prewarm`** pulls and builds the whole allowlist in the background as the
243248
service starts, sequentially so several builds do not fight over one small host.
244249
Without it the first session on a built runtime pays for the image — measured at
@@ -275,6 +280,45 @@ of the runtime, so every edit mints a new one and would otherwise leave a few
275280
hundred megabytes behind for good — an image still backing a running container is
276281
left alone.
277282

283+
### What the host is worth, before any of the above
284+
285+
Three changes outside this library, in descending order of megabytes recovered.
286+
None of them needs a code change here.
287+
288+
**`crun` as the daemon's default runtime.** `runc` is Go, with a garbage
289+
collector and a supervising process per container; `crun` is the same interface
290+
in C. Reported at roughly twice the container-lifecycle speed and 30–40% less
291+
per-container overhead, which at a hundred sessions is a gigabyte the sandboxes
292+
get instead:
293+
294+
```json
295+
{ "default-runtime": "crun", "runtimes": { "crun": { "path": "/usr/bin/crun" } } }
296+
```
297+
298+
This is a different question from `oci_runtime`, which selects an *isolation
299+
model* per runtime alias. `crun` is a faster `runc`, not a different boundary, so
300+
it belongs in the daemon's own config where it applies to everything.
301+
302+
**`zram`, and then `memswap_limit`.** A sandbox is denied swap by default —
303+
`memswap_limit` is pinned to `mem_limit` — because a container swapping to a disk
304+
starves every other one on the host. That reasoning does not hold when swap is
305+
`zram`: the pages stay in RAM compressed, idle Python heaps at roughly 3:1, and
306+
the alternative to a little swapping is an OOM kill mid-command. On a host with
307+
`zram` configured, and only there:
308+
309+
```python
310+
SandboxdConfig(token=token, mem_limit="384m", memswap_limit="512m")
311+
```
312+
313+
**One base image across the allowlist.** Read-only pages are shared between
314+
containers only when the layer is literally the same file. Runtimes built from
315+
one `python:3.12-slim` share the interpreter and the standard library; runtimes
316+
built from different bases share nothing. `SUGGESTED_RUNTIMES` is arranged this
317+
way — every Python entry on `python:3.12-slim`, every Node one on `node:20-slim`
318+
— and a custom allowlist is worth checking against the same rule. It is also the
319+
reason containers beat microVMs badly on a small host: a microVM caches those
320+
pages once per guest, so a hundred of them hold a hundred copies.
321+
278322
### The library matters more than the tuning
279323

280324
Measured on one 188 MB CSV, the same `GROUP BY` in each:
@@ -362,33 +406,57 @@ sessions nobody is using: an agent is turned away while a hundred containers sit
362406
idle holding slots.
363407

364408
`evict_idle_after` changes that. At the ceiling, the least recently used session
365-
idle for at least that long is closed to make room:
409+
idle for at least that long is **hibernated** to make room:
366410

367411
```python
368412
SandboxdConfig(
369413
token=token,
370-
max_sessions=100, # live sandboxes — the working set
414+
max_sessions=10, # resident sandboxes — what the RAM has to hold
415+
max_open_sessions=200, # sessions that exist — what the disk has to hold
371416
evict_idle_after=120, # idle two minutes? your slot can be reused
372417
workspace_root="/var/lib/sandboxd",
373418
persist_containers=True,
374419
)
375420
```
376421

377-
With a workspace on disk, eviction costs the evicted session nothing but its
378-
container: its next request re-attaches, finds its files, and pays a container
379-
start — measured at 0.09 s for a persisted one. So the number of sessions that
380-
may *exist* becomes unbounded while the number *running at once* stays at 100.
422+
Hibernating is not closing. The container, the process supervising it and the
423+
memory both hold are given up; the session's token, its runtime, its event log
424+
and its files stay. Its next request wakes it, finds its work, and pays a
425+
container start — measured at 0.09 s for a persisted one. Nothing the client
426+
holds stops being valid, so it never has to learn that any of this happened.
427+
428+
That splits one number into two, which is the point:
429+
430+
- **`max_sessions`** bounds *resident* sandboxes. This is the RAM number: it
431+
times the largest runtime ceiling is the worst case the host must survive.
432+
- **`max_open_sessions`** bounds sessions that *exist*, resident and hibernated
433+
together. This is the disk number, and it is properly much larger. At the
434+
ceiling the session that has been asleep longest is closed for good; with every
435+
open session resident, the caller gets `429`.
436+
437+
On a small host that gap is the whole design. Ten resident sessions at 384 MB is
438+
a machine that fits in 4 GB; two hundred open ones is what it can advertise.
439+
440+
A hibernated session shows in `GET /sessions` with `state: "hibernated"` and
441+
`alive: false`, and as **asleep** in the dashboard. `idle_timeout` still ends it:
442+
a session asleep longer than that is closed on the next sweep, since one nobody
443+
came back to is one nobody is coming back to.
381444

382445
Two deliberate limits on it:
383446

384447
- **A busy session is never evicted.** Only sessions idle for at least
385448
`evict_idle_after` are candidates, because killing an agent's work to serve
386449
somebody else's first request is worse than making them wait. With every
387450
session genuinely busy the caller still gets `429` — backpressure is the honest
388-
answer there, not an unbounded queue.
389-
- **It requires `workspace_root`.** Evicting a session whose files live only in
390-
its container would discard them silently, so the configuration refuses rather
391-
than making that trade quietly.
451+
answer there, not an unbounded queue. Waking is subject to the same rule: a
452+
hibernated session whose slot cannot be freed is answered `429` rather than
453+
taking one from somebody working.
454+
- **It requires `workspace_root`.** Hibernating a session whose files live only
455+
in its container would discard them silently, so the configuration refuses
456+
rather than making that trade quietly.
457+
- **What does not survive is process state.** A hibernated session's background
458+
processes — a dev server left running through the console toolset — are stopped
459+
with the container. Files survive; a long-lived process does not.
392460

393461
`max_sessions=None` removes the ceiling entirely, for hosts where something else
394462
does the bounding.
@@ -504,12 +572,15 @@ all needs to know why.
504572

505573
## Capacity and reaping
506574

507-
- `max_sessions` caps concurrency; beyond it, opening a session answers `429`
508-
rather than starting unbounded containers. `max_sessions_per_tenant` applies the
509-
same ceiling per `tenant` label.
575+
- `max_sessions` caps *resident* sandboxes; beyond it, opening a session either
576+
hibernates an idle one or answers `429` rather than starting unbounded
577+
containers. `max_open_sessions` caps how many sessions exist at all, resident
578+
and hibernated together. `max_sessions_per_tenant` applies a ceiling per
579+
`tenant` label.
510580
- `idle_timeout` reaps sessions that have gone quiet, and the reaping is visible
511581
to the service's own bookkeeping — a reaped session's token and event log are
512-
released with it.
582+
released with it. It applies to hibernated sessions too, which the sweep ends
583+
once they have been asleep that long.
513584
- `execute_timeout` clamps every command, so one client cannot occupy a worker
514585
indefinitely.
515586
- `workspace_ttl` sweeps workspaces no session has opened for that long, on the

0 commit comments

Comments
 (0)