container: exec into a reused per-shape session container instead of a run per step - #249
Open
mobileoverlord wants to merge 5 commits into
Open
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
It introduces confirmed correctness/operational issues in the new session-container infrastructure (SIGINT reaper arming, blocking exec in async contexts, and PID liveness probing) that should be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR reduces container startup overhead by introducing per-invocation, per-shape reusable “session containers” and routing multiple previously one-shot container operations through docker exec into those long-lived containers, with teardown on exit/Ctrl-C.
Changes:
- Add
SessionContainersregistry inutils/container.rsto reuse containers by deriveddocker runshape and exec steps into them. - Route various volume-based utilities (
host_copy, extension config reads,profiles,save,ext checkoutprobes/cp) through volume-scoped session containers. - Add teardown paths (main exit,
atexit, Ctrl-C) and update docs/changelog accordingly.
File summaries
| File | Description |
|---|---|
| src/utils/container.rs | Implements session-container registry, shape/env splitting, teardown, and exec routing. |
| src/utils/host_copy.rs | Switches volume copy-out to docker cp from a running session container. |
| src/utils/ext_source_reader.rs | Reads extension configs via session container using SDK image when available. |
| src/utils/config.rs | Passes SDK image into extension discovery for container-based reads. |
| src/commands/save.rs | Uses volume session container for du and streaming tar. |
| src/commands/profiles.rs | Reads stone manifest via volume session container exec. |
| src/commands/ext/checkout.rs | Reuses one volume session container for probes and docker cp. |
| src/commands/rootfs/image.rs | Updates copy_volume_path_to_host call for new SDK image parameter. |
| src/commands/kernel/image.rs | Updates copy_volume_path_to_host call for new SDK image parameter. |
| src/commands/initramfs/image.rs | Updates copy_volume_path_to_host call for new SDK image parameter. |
| src/utils/stamps.rs | Ensures session containers are torn down on stamp failure exit path. |
| src/main.rs | Adds normal-exit session container shutdown. |
| CHANGELOG.md | Documents the new session container behavior and teardown. |
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
mobileoverlord
force-pushed
the
jschneck/skip-unchanged-sysroot-images
branch
from
September 7, 2026 17:27
a477d77 to
55e0a3c
Compare
mobileoverlord
force-pushed
the
jschneck/session-container-routing
branch
from
September 7, 2026 17:27
f74c890 to
ae0e814
Compare
mobileoverlord
force-pushed
the
jschneck/skip-unchanged-sysroot-images
branch
from
September 7, 2026 17:54
55e0a3c to
6e1d184
Compare
mobileoverlord
force-pushed
the
jschneck/session-container-routing
branch
from
September 7, 2026 17:54
ae0e814 to
e2371eb
Compare
Contributor
Author
|
@copilot review |
mobileoverlord
force-pushed
the
jschneck/skip-unchanged-sysroot-images
branch
from
September 7, 2026 18:23
6e1d184 to
d526653
Compare
mobileoverlord
force-pushed
the
jschneck/session-container-routing
branch
from
September 7, 2026 18:23
e2371eb to
89c352e
Compare
mobileoverlord
force-pushed
the
jschneck/skip-unchanged-sysroot-images
branch
from
September 7, 2026 18:59
d526653 to
fc70ae8
Compare
mobileoverlord
force-pushed
the
jschneck/session-container-routing
branch
from
September 7, 2026 18:59
89c352e to
dde2275
Compare
mobileoverlord
force-pushed
the
jschneck/skip-unchanged-sysroot-images
branch
from
September 7, 2026 21:00
fc70ae8 to
c1f9a4b
Compare
mobileoverlord
force-pushed
the
jschneck/session-container-routing
branch
from
September 7, 2026 21:00
dde2275 to
d971244
Compare
mobileoverlord
force-pushed
the
jschneck/skip-unchanged-sysroot-images
branch
from
September 8, 2026 13:03
c1f9a4b to
c65555c
Compare
mobileoverlord
force-pushed
the
jschneck/session-container-routing
branch
from
September 8, 2026 13:03
d971244 to
9149002
Compare
mobileoverlord
force-pushed
the
jschneck/skip-unchanged-sysroot-images
branch
from
September 8, 2026 15:00
c65555c to
2c8f38e
Compare
mobileoverlord
force-pushed
the
jschneck/session-container-routing
branch
from
September 8, 2026 15:00
9149002 to
583ac63
Compare
mobileoverlord
force-pushed
the
jschneck/skip-unchanged-sysroot-images
branch
from
September 9, 2026 19:23
2c8f38e to
2c7d0de
Compare
mobileoverlord
force-pushed
the
jschneck/session-container-routing
branch
from
September 9, 2026 19:23
583ac63 to
6b48da5
Compare
mobileoverlord
force-pushed
the
jschneck/session-container-routing
branch
from
September 9, 2026 20:02
6b48da5 to
5562824
Compare
mobileoverlord
force-pushed
the
jschneck/skip-unchanged-sysroot-images
branch
from
September 9, 2026 20:02
2c7d0de to
f52a791
Compare
`ExtSourceReader` prefers the volume's host mountpoint and falls back to a
throwaway container when that is unreadable. Under rootful Docker the
mountpoint is root-owned, and on macOS/Windows the volume lives inside a
VM, so the fallback is not a fallback — it is the normal path.
It ran 33 times across one `avocado install` plus one `avocado build` on a
four-extension project: config composition happens many times per
invocation, and each one re-reads every remote extension's `avocado.yaml`.
Each read was a `docker run` on `busybox:latest`, with `alpine:latest`
behind it — images the project does not control, is not guaranteed to have
locally, and would pull over the network on a cold machine. At ~0.52 s of
container startup apiece that is around 17 s of the loop spent starting
other people's containers to `cat` four small files.
Use the project's own SDK image. It is already pulled, because every other
step in the command needs it.
Two details worth stating:
- The image is read from the config being composed, and at that point
`sdk.image` may still carry `{{ … }}` placeholders. A literal template is
not a runnable image, so it is filtered out and the caller passes `None`.
- With `None` — no config to name an image — the busybox/alpine chain
stays. That path is unchanged and still works; it is simply no longer
what a normal build takes.
Verified on a real project: a full `avocado build` now makes 34 container
invocations, all on the SDK image, and zero on busybox or alpine.
This does not touch the other third-party image sites (`docker create`
helpers for `docker cp`, `save`/`load`, the remote NFS health check, and
`tonistiigi/binfmt`, which registers host binfmt_misc and genuinely cannot
run in the SDK image). Those are separate and mostly off the hot path.
…hape Every build step was a `docker run --rm`. Profiling a no-op `avocado build` with a PATH shim over `docker` put 71% of all container time in startup: a run costs ~0.52s before the command starts, an exec into a live container ~0.045s. Steps whose container shape is identical — image, platform, mounts, devices, capabilities, container_args — now share one detached container per shape, created on first use and removed at exit. Three things this has to get right: - The shape and the environment both come from the generated `run` argv, not from the caller's `env_vars`, because `build_container_command` injects AVOCADO_TARGET, the host uid/gid and the signing and ext-mount variables itself. A dropped AVOCADO_TARGET leaves AVOCADO_PREFIX empty and every stamp lookup silently misses. - The session container parks on `sleep 3600` and runs the mount block as a separate synchronous exec, because `run -d` returns before its command has finished and a step could otherwise exec before /opt/src existed. The exec's own prologue omits that block; re-running bindfs stacks a second mount. - Teardown is registered synchronously as well as async, because `print_and_exit` calls `process::exit` and never returns to `main`. Detached, interactive and named steps keep their own run, as does `--runs-on`, whose remote path short-circuits before any of this. Any failure to create or reach the session container falls through to the run that would have happened anyway; AVOCADO_NO_SESSION_CONTAINER=1 opts out. No-op `build`: 13 of 20 steps move to exec. Time inside the container tool 13.8s -> 8.7s; wall 11.9s -> 10.7s, the smaller share because steps run concurrently and some startup was already overlapped.
…ainer Eight sites outside run_in_container started their own `docker run`. Profiling `sdk install` on a three-extension project put 24 of them in one place: reading an extension's avocado.yaml out of the SDK volume, ~0.52s of container start for a ~5ms `cat`, 12.9s of a 19s command. Composition re-reads every extension discovered so far after each fetch, so the count grows with the square of the extension count. Three changes: - The registry is synchronous. The config reader runs before any async context exists, so an async-only entry point could never serve it; making creation a short blocking call paid once per shape is what lets every caller reach it. This also deletes the mirror registry that existed only because the tokio mutex was held across an await. - `volume_container` / `volume_exec` are the entry point for anything that just needs a volume mounted, keyed on the volume spec and image so a read-only and a read-write user of one volume correctly get different containers. Routed: the config reader, ext checkout's three probes and its docker cp, profiles' stone manifest read, save's du and tar, and host_copy — which no longer creates and removes a container per file, because docker cp works against a running one. busybox and alpine leave those paths in favour of the SDK image. `avocado load` keeps busybox on purpose: it restores the config as it runs. - Teardown moves to atexit. Nine sites call process::exit and never reach main's cleanup; a failed sdk install leaked two parked containers. atexit fires for those and for a normal return, so one registration covers paths added later too. A signal still skips it, which is what the pid sweep is for. The escape hatch had to follow: AVOCADO_NO_SESSION_CONTAINER=1 now gives each caller its own container rather than being silently ignored on the new path. sdk install, three remote extensions: 31 container starts -> 6, 23.3s -> 9.0s.
…call The registry took one global lock and held it through `docker ps`, `docker run -d` and the mount exec — roughly half a second of blocking work, on a lock every routed step needs. `install` runs its steps in parallel, so each one waiting for a container parked a tokio worker there. Enough at once and nothing else could be polled, including the scheduler's own `select!`; its Ctrl-C branch never fired, so the command appeared to hang and would not respond to Ctrl-C. Three changes, all about not doing slow things under a shared lock: - The map lock now only hands out a per-shape slot and is released immediately. - Creation happens under that slot's lock, so two callers wanting the same shape still produce exactly one container, while a caller wanting a different shape is not blocked behind it at all. - The blocking work runs in `block_in_place`, which moves the worker's queue to another thread first. Guarded on the multi-thread flavour and on there being a runtime at all, because the config reader calls this before one exists. The sweep moved out from under the map lock too — it shells out to `docker ps`.
…d arming Four defects in the session-container work, found in review and by a peer. `atexit` does not run for a signal, so an interrupted run left its session containers parked, holding the project volume and bind-mounting the project. The `sleep` backstop frees them within the hour and the next invocation sweeps them by pid, so the leak was bounded — but until one of those happened a dead run still owned the volume. Confirmed in the act: two containers from a killed run were still up thirteen minutes later. A SIGINT handler now reaps and exits 130, armed lazily from the moment this process first creates a session container and only then, because registering a SIGINT handler suppresses the default terminate-the-process disposition process-wide and commands that never create one of these — `container dev` drives its own shutdown off this signal — must keep the Ctrl-C they have. `arm_sigint_reaper` originally returned early from *inside* `SIGINT_REAPER.call_once` when no runtime was current. `call_once` is spent whether or not its closure does anything, so that permanently prevented arming, and the first session container is easily created before a runtime exists — exactly the path that hit it. The runtime check now precedes the `call_once`. `pid_is_alive` read any `kill(pid, 0)` failure as "gone". `EPERM` means the process exists and belongs to another user, and any other errno is uninterpretable; only `ESRCH` proves absence. The sweep reaps by pid, so this could have removed the containers of a build running under a different user. `volume_exec` shells out and waits, and several callers are async — `ext checkout`, the profile reader — so it needed the same `block_in_place_if_async` wrapper as container creation. Also marks the two `stdio-flags-ok` sites the stdio guard flags: the match arm that consumes `-i`/`-t` out of an argv when deriving a container shape, and the `-i` in that parser's own test fixture. Neither chooses stdio flags. Measured, pty-driven, interrupting at two points: before, exit -2 leaving two containers; after, exit 130 within 0.7s leaving none.
mobileoverlord
force-pushed
the
jschneck/session-container-routing
branch
from
September 9, 2026 20:07
5562824 to
4fdb5a1
Compare
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.
Every build step was its own
docker run --rm. Profiling a no-opavocado buildwith a PATH shim overdockerput 71% of all container time in startupalone: a run costs ~0.52s before the command begins, an exec into a live
container ~0.045s.
What
Steps whose container shape is identical — image, platform, mounts, devices,
capabilities,
container_args— share one detached container per shape, createdon first use and removed at exit. Then the same mechanism is extended to the
eight sites that started containers outside the reusable path.
The worst of those was reading an extension's
avocado.yamlout of the SDKvolume. Config composition re-reads every extension discovered so far after each
fetch, so an
sdk installon a three-extension project did that 24 times,each a ~0.52s container start for a ~5ms
cat— 12.9s of a 19s command. Alsorouted:
ext checkout's three volume probes and itsdocker cp,profiles'stone-manifest read,
save'sduandtar, andhost_copy, which nowdocker cps straight out of a running container instead of creating andremoving one per file. That drops the
busyboxandalpinedependencies fromthose paths in favour of the project's own SDK image, which is local already.
avocado loaddeliberately keepsbusybox: it restores the project's config asit runs, so the SDK image is not yet known and may not be pulled.
Four things this has to get right
runargv, not thecaller's
env_vars, because the command builder injectsAVOCADO_TARGET, thehost uid/gid and the signing and ext-mount variables itself. A dropped
AVOCADO_TARGETleavesAVOCADO_PREFIXempty and every stamp lookup silentlymisses — with a plausible-looking error, which is what makes it dangerous.
sleepand runs its mounts as a separate synchronousexec.
run -dreturns when the container starts, not when its command hasfinished, so a step could otherwise exec into a container whose
/opt/srcwasnot mounted yet. The exec's own prologue omits the mount block; re-running
bindfs stacks a second mount.
lock across
docker ps,docker run -dand the mount exec.installrunssteps in parallel, so each one parked a tokio worker on that lock; with enough
of them nothing else could be polled, including the scheduler's
select!,whose Ctrl-C branch then never fired. The map lock now only hands out a
per-shape slot; creation happens under that slot's own lock, inside
block_in_place.std::process::exitandnever return to
main, so teardown is registered withatexit, which firesfor those and for a normal return. A signal skips
atexit, so Ctrl-C ishandled separately: a SIGINT reaper, armed lazily from the moment this process
first creates a session container and only then — registering a SIGINT
handler suppresses default termination process-wide, and commands that never
create one of these (
container devdrives its shutdown off the same signal)must keep the Ctrl-C they have.
Detached, interactive and named steps keep their own
docker run, as does--runs-on, whose remote path short-circuits before any of this. Any failure tocreate or reach a session container falls through to the run that would have
happened anyway, and
AVOCADO_NO_SESSION_CONTAINER=1opts out — the escapehatch reaches the new path too, giving each caller its own container rather than
being silently ignored.
Results
Measured with a PATH shim over
docker, toggled only by the escape hatch:runbuild, time in the container toolbuild, wallsdk install, 3 remote extensionsWall gains less than container time because steps run concurrently and some
startup was already overlapped — that is the number to plan against.
Ctrl-C, pty-driven at two interrupt points: before, exit -2 leaving two
containers holding the project volume; after, exit 130 within 0.7s leaving none.
Review notes
Two things worth a second pair of eyes rather than trust: this has only been
exercised against Docker on Linux — no podman, no macOS/Windows VM path — and
docker cpout of a running container is a real behaviour change inhost_copyandext checkout.