Repository navigation
fix(sandbox): keep the spawner healthy through restarts and stuck states - #4551
Merged
Merged
Conversation
yannickmonney
force-pushed
the
fix/sandbox-spawner-robustness
branch
from
October 8, 2026 15:48
f91f633 to
5b658a9
Compare
This was referenced Oct 9, 2026
yannickmonney
enabled auto-merge
October 9, 2026 02:40
yannickmonney
force-pushed
the
fix/sandbox-spawner-robustness
branch
2 times, most recently
from
October 9, 2026 02:40
152efa5 to
6d6026e
Compare
Session containers had a --cpus quota but no --cpu-shares, so on cgroup v2 every session ran at cpu.weight 100, the same as the database, backend and spawner. On a 4 vCPU / 16 GB host with six busy sessions the load sat between 26 and 244 with under 1% idle, and the control plane stalled for about 4 h 45 min (DB pool timeouts, 503s, docker ps timeouts). Agent sessions and their organization's build helpers now run at --cpu-shares 256 and default-profile sessions (code runs, page renders) at 128, against the 1024 every control-plane container keeps; runc maps these to cgroup v2 weights of about 10 and 5 against 100. The quotas stay, and weights only act under contention, so an idle host still grants each session its full quota. SANDBOX_AGENT_CPU_SHARES tunes the agent weight (2-262144, whole numbers). A busy helper takes the new weight in place via docker update; an idle one is recreated as for any other bound. Kubernetes needs no change: requests already set weights.
The session argv had no --pull flag, so on a host without the runtime image docker run tried an implicit pull of the ~2 GB image inside the 30 s run budget. It could never finish: every create held a Docker CLI slot for 30 s and failed with a 502. Stopped sessions keep no container, so an image prune on an idle host deletes the image, and the warmup ran only once at boot. A failed warmup also read as done: ensureImage returned false after three pulls, warmImage dropped it, and creates stopped getting 429 runtime_image. Session containers now start with --pull=never, so a missing image fails at once. That create tells the warmup (a new optional backend hook), which pulls again, and the create answers the existing 429 runtime_image instead of a 502. warmImage throws while the image stays absent; the warmup then keeps creates waiting and retries after 30 s, 1, 2, 5 and then every 10 minutes, with a Retry-After that follows the next attempt (5-60 s). /health reports the image as unchecked, pulling, ready or missing with the last error, without turning unhealthy.
listSessions marks any non-running container degraded and only exited or dead ones ended, and adoption hands degraded sessions to reapStaleSession, which only the Kubernetes backend implemented. A container left in created, paused or restarting (a spawner killed between the daemon's create and start, or a timed-out run whose cleanup also timed out) therefore held a capacity slot for ever, answered every create of the id 429 busy and acquire 503, pinned an old runtime image and survived spawner restarts. DockerSessionBackend.reapStaleSession removes such a container once the create's whole budget (SANDBOX_SESSION_CREATE_TIMEOUT_MS, 180 s by default) and a minute's slack have passed since both its tale.created stamp and Docker's own creation time. It is fenced like the other lifecycle transitions: the stamp must match the listed incarnation, and the removal targets the immutable container id read together with the state. Its inner image volume and pin marker go with it; the workspace stays, as with stop. Running, ended, unknown and fresh containers are left alone.
Each new per-organization cache volume was made writable by docker run busybox:1.36 chmod 1777 /cache. tale deploy does not pre-pull busybox, so the first create of every organization pulled it from Docker Hub: an air-gapped host could not, and anonymous pulls are rate-limited. The volume was labelled before the chmod, so a failed chmod left a labelled root:root 0755 volume that every later ensure accepted as ready, and that organization's pip/npm/bun caches failed EACCES for good. The chmod now runs the sessions' own runtime image, which is on the host already: --pull=never, --network none, root, with /bin/chmod (Debian bookworm coreutils) as its entrypoint, and a 30 s bound instead of 120 s now that nothing is pulled. When it fails the fresh volume is removed again, so the next create makes it afresh. A failure for want of the runtime image itself restarts the image warmup, as a session run that finds it missing does. The image conformance test runs the exact invocation.
stopSession removed the container but left /agent/.runtime/tmp (exec scratch, pip and npm staging, the runnerd replay spool of up to 256 MiB per session) until the next start, where the entrypoint ran rm -rf /agent/.runtime/tmp before runnerd started. A resume waited for however long that tree took to delete, and a session that never resumed kept the bytes for the workspace's whole life (30 days by default). The Docker stop now renames the exec temp into the session root's trash once the container is confirmed gone, inside the stop the routes already serialize with a resume; the trash's background pass deletes it, and an erasure of the workspace waits for it as for the rest. The workspace is agent-writable, so the workspace, .runtime and tmp are checked with lstat and anything that is not a plain directory (a planted symbolic link above all) is left alone; the move never falls back to a delete in place and never fails the stop. At every start the entrypoint now renames what is left aside as the profile uid (never into an older leftover) and deletes it in the background with nice 19 and the idle I/O class, so readiness no longer waits on it. The background job starts before PATH names the workspace's dependency dirs, which a root shell must not run from.
acquireSpawnerLock refuses while the lock is under 60 s old (it is refreshed every 20 s) unless the holder's PID is dead on the same host. The spawner is PID 1 of its container, so after an OOM kill at the 1g memory limit, a Bun crash or main() exiting 1, the restarted container (same hostname, PID 1 again) probed its own PID, found it alive and refused; a recreated container has another hostname and read the old holder as alive too. Init threw, the server exited 1 and Docker restart-looped it with backoff for 40-60 s. The lock now records a random instance id, the kernel's boot id and the process start time (/proc/self/stat starttime). On the same host a fresh lock is stale when its PID is ours (an earlier process in this slot), the boot id differs, no process has its PID, or the process under it started at another time. A lock from another hostname on the same kernel that names a container id is stale when Docker, which can see our own container, finds that container gone or stopped. A live process or container, another machine's lock, an unreadable payload or a daemon that cannot say still refuse, and the freshness window stays the arbiter for them.
Live restore is neither enabled by Tale nor checked, so any dockerd restart (a package upgrade, a daemon.json change) stops every session container and the spawner: running agent turns, builds and renders end mid-way. The host's daemon configuration is the operator's, so the spawner does not change it. After the lock and the boot sweep, the Docker backend now reads the daemon's live-restore and Swarm state with one docker info call (10 s bound, beside startup, never delaying it) and logs one warning when live restore is off: a Swarm node, which refuses live restore, is told so; any other host is told to set "live-restore": true in daemon.json and reload the daemon, which applies it without stopping containers. Both point to a new section of the self-hosted container architecture page (EN, DE, FR) that explains the setting, its patch-release-only upgrade limit and the Swarm incompatibility. A daemon that cannot say is logged and never fails the boot.
The CPU weight test was inserted between the note on the crawler's headless browser and the default-profile test it explains, so the note read as the reason for the CPU weights. It now sits above its test again; no assertion changes.
With set -e, a leftover exec temp tree that neither the rename nor the in-place delete could clear (an entry the profile uid may not remove, such as a directory an inner Docker daemon created as root, or a read-only module cache), and a stale steer queue that could not be removed, ended the entrypoint. Every later start of the session then failed the same way, and the only exit was a destroy, which deletes the workspace. Both now warn and let the boot go on: the execs use the exec temp as it is, and steer queues are per exec, so a leftover one is read by no later exec.
The automation coverage map now keeps one file per suite, so parallel changes stop appending to the same lines; this change's rows move there, after their own anchor row.
Cache volume permissions are now set with the runtime image already on the host (run with --pull=never and no network), so the digest-pinned busybox helper main had just introduced, its Renovate custom manager and the test that checked the manager found it are no longer used. The permission test now asserts the setup runs the local runtime image and no registry tag.
yannickmonney
force-pushed
the
fix/sandbox-spawner-robustness
branch
from
October 9, 2026 03:00
6d6026e to
a1e4ba0
Compare
This was referenced Oct 9, 2026
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.
Robustness gaps in the Docker spawner and session boot: sessions competed with the control plane at equal CPU weight (issue #3977 recorded a 4 h 45 min control-plane stall), a missing runtime image failed every create slowly and never healed, containers stuck in created/paused/restarting leaked slots forever, cache-volume permissions depended on Docker Hub (a failed chmod left a volume unwritable for good), a stopped session kept its exec temp and boot deleted it synchronously (or failed outright, bricking the session), the spawner refused its own restart for 40-60 s after a crash, and Docker live-restore went unchecked.
Changes
fix(sandbox): yield session CPU to the control plane under contention
Session containers had a --cpus quota but no --cpu-shares, so on cgroup
v2 every session ran at cpu.weight 100, the same as the database,
backend and spawner. On a 4 vCPU / 16 GB host with six busy sessions
the load sat between 26 and 244 with under 1% idle, and the control
plane stalled for about 4 h 45 min (DB pool timeouts, 503s, docker ps
timeouts).
Agent sessions and their organization's build helpers now run at
--cpu-shares 256 and default-profile sessions (code runs, page renders)
at 128, against the 1024 every control-plane container keeps; runc maps
these to cgroup v2 weights of about 10 and 5 against 100. The quotas
stay, and weights only act under contention, so an idle host still
grants each session its full quota. SANDBOX_AGENT_CPU_SHARES tunes the
agent weight (2-262144, whole numbers). A busy helper takes the new
weight in place via docker update; an idle one is recreated as for any
other bound. Kubernetes needs no change: requests already set weights.
fix(sandbox): heal a missing runtime image instead of failing creates
The session argv had no --pull flag, so on a host without the runtime
image docker run tried an implicit pull of the ~2 GB image inside the
30 s run budget. It could never finish: every create held a Docker CLI
slot for 30 s and failed with a 502. Stopped sessions keep no container,
so an image prune on an idle host deletes the image, and the warmup ran
only once at boot. A failed warmup also read as done: ensureImage
returned false after three pulls, warmImage dropped it, and creates
stopped getting 429 runtime_image.
Session containers now start with --pull=never, so a missing image
fails at once. That create tells the warmup (a new optional backend
hook), which pulls again, and the create answers the existing 429
runtime_image instead of a 502. warmImage throws while the image stays
absent; the warmup then keeps creates waiting and retries after 30 s,
1, 2, 5 and then every 10 minutes, with a Retry-After that follows the
next attempt (5-60 s). /health reports the image as unchecked, pulling,
ready or missing with the last error, without turning unhealthy.
fix(sandbox): reap Docker session containers stuck before they ran
listSessions marks any non-running container degraded and only exited
or dead ones ended, and adoption hands degraded sessions to
reapStaleSession, which only the Kubernetes backend implemented. A
container left in created, paused or restarting (a spawner killed
between the daemon's create and start, or a timed-out run whose cleanup
also timed out) therefore held a capacity slot for ever, answered every
create of the id 429 busy and acquire 503, pinned an old runtime image
and survived spawner restarts.
DockerSessionBackend.reapStaleSession removes such a container once the
create's whole budget (SANDBOX_SESSION_CREATE_TIMEOUT_MS, 180 s by
default) and a minute's slack have passed since both its tale.created
stamp and Docker's own creation time. It is fenced like the other
lifecycle transitions: the stamp must match the listed incarnation, and
the removal targets the immutable container id read together with the
state. Its inner image volume and pin marker go with it; the workspace
stays, as with stop. Running, ended, unknown and fresh containers are
left alone.
fix(sandbox): set cache volume modes with the runtime image
Each new per-organization cache volume was made writable by
docker run busybox:1.36 chmod 1777 /cache. tale deploy does not pre-pull
busybox, so the first create of every organization pulled it from
Docker Hub: an air-gapped host could not, and anonymous pulls are
rate-limited. The volume was labelled before the chmod, so a failed
chmod left a labelled root:root 0755 volume that every later ensure
accepted as ready, and that organization's pip/npm/bun caches failed
EACCES for good.
The chmod now runs the sessions' own runtime image, which is on the
host already: --pull=never, --network none, root, with /bin/chmod
(Debian bookworm coreutils) as its entrypoint, and a 30 s bound instead
of 120 s now that nothing is pulled. When it fails the fresh volume is
removed again, so the next create makes it afresh. A failure for want
of the runtime image itself restarts the image warmup, as a session run
that finds it missing does. The image conformance test runs the exact
invocation.
fix(sandbox): retire exec temp on stop and purge it beside runnerd
stopSession removed the container but left /agent/.runtime/tmp (exec
scratch, pip and npm staging, the runnerd replay spool of up to
256 MiB per session) until the next start, where the entrypoint ran
rm -rf /agent/.runtime/tmp before runnerd started. A resume waited for
however long that tree took to delete, and a session that never
resumed kept the bytes for the workspace's whole life (30 days by
default).
The Docker stop now renames the exec temp into the session root's
trash once the container is confirmed gone, inside the stop the routes
already serialize with a resume; the trash's background pass deletes
it, and an erasure of the workspace waits for it as for the rest. The
workspace is agent-writable, so the workspace, .runtime and tmp are
checked with lstat and anything that is not a plain directory (a
planted symbolic link above all) is left alone; the move never falls
back to a delete in place and never fails the stop.
At every start the entrypoint now renames what is left aside as the
profile uid (never into an older leftover) and deletes it in the
background with nice 19 and the idle I/O class, so readiness no longer
waits on it. The background job starts before PATH names the
workspace's dependency dirs, which a root shell must not run from.
fix(sandbox): let a restarted spawner take its dead predecessor's lock
acquireSpawnerLock refuses while the lock is under 60 s old (it is
refreshed every 20 s) unless the holder's PID is dead on the same host.
The spawner is PID 1 of its container, so after an OOM kill at the 1g
memory limit, a Bun crash or main() exiting 1, the restarted container
(same hostname, PID 1 again) probed its own PID, found it alive and
refused; a recreated container has another hostname and read the old
holder as alive too. Init threw, the server exited 1 and Docker
restart-looped it with backoff for 40-60 s.
The lock now records a random instance id, the kernel's boot id and
the process start time (/proc/self/stat starttime). On the same host a
fresh lock is stale when its PID is ours (an earlier process in this
slot), the boot id differs, no process has its PID, or the process
under it started at another time. A lock from another hostname on the
same kernel that names a container id is stale when Docker, which can
see our own container, finds that container gone or stopped. A live
process or container, another machine's lock, an unreadable payload or
a daemon that cannot say still refuse, and the freshness window stays
the arbiter for them.
fix(sandbox): warn at boot when Docker live restore is off
Live restore is neither enabled by Tale nor checked, so any dockerd
restart (a package upgrade, a daemon.json change) stops every session
container and the spawner: running agent turns, builds and renders end
mid-way. The host's daemon configuration is the operator's, so the
spawner does not change it.
After the lock and the boot sweep, the Docker backend now reads the
daemon's live-restore and Swarm state with one docker info call (10 s
bound, beside startup, never delaying it) and logs one warning when live
restore is off: a Swarm node, which refuses live restore, is told so;
any other host is told to set "live-restore": true in daemon.json and
reload the daemon, which applies it without stopping containers. Both
point to a new section of the self-hosted container architecture page
(EN, DE, FR) that explains the setting, its patch-release-only upgrade
limit and the Swarm incompatibility. A daemon that cannot say is logged
and never fails the boot.
test(sandbox): keep the render pids note beside its own test
The CPU weight test was inserted between the note on the crawler's
headless browser and the default-profile test it explains, so the note
read as the reason for the CPU weights. It now sits above its test
again; no assertion changes.
fix(sandbox): never fail a session's boot on leftover exec temp
With set -e, a leftover exec temp tree that neither the rename nor the
in-place delete could clear (an entry the profile uid may not remove, such
as a directory an inner Docker daemon created as root, or a read-only
module cache), and a stale steer queue that could not be removed, ended
the entrypoint. Every later start of the session then failed the same
way, and the only exit was a destroy, which deletes the workspace.
Both now warn and let the boot go on: the execs use the exec temp as it
is, and steer queues are per exec, so a leftover one is read by no later
exec.
Verification
exec-temp-startup.test.ts+entrypoint-dispatch.test.tspass, the new boot-brick test fails against the previous helper;@tale/sandboxand@tale/sandbox-runtime-daemontypecheck and lint clean.session-routes.test.tsandrunnerd-client.test.tscancellation tests hang on macOS identically on unmodified main (pre-existing); CI runs them on Linux.Part of the sandbox footprint and robustness pass (#4533, #4538 and siblings).