Skip to content

container: exec into a reused per-shape session container instead of a run per step - #249

Open
mobileoverlord wants to merge 5 commits into
jschneck/skip-unchanged-sysroot-imagesfrom
jschneck/session-container-routing
Open

container: exec into a reused per-shape session container instead of a run per step#249
mobileoverlord wants to merge 5 commits into
jschneck/skip-unchanged-sysroot-imagesfrom
jschneck/session-container-routing

Conversation

@mobileoverlord

@mobileoverlord mobileoverlord commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Owner: this PR is mine (the fast-rebuilds series), not the feeds series.
Both series push under the same account, so authorship does not distinguish
them.

Every build step was its own docker run --rm. Profiling a no-op avocado build with a PATH shim over docker put 71% of all container time in startup
alone
: 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, created
on 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.yaml out of the SDK
volume. Config composition re-reads every extension discovered so far after each
fetch, so an sdk install on a three-extension project did that 24 times,
each a ~0.52s container start for a ~5ms cat — 12.9s of a 19s command. Also
routed: ext checkout's three volume probes and its docker cp, profiles'
stone-manifest read, save's du and tar, and host_copy, which now
docker cps straight out of a running container instead of creating and
removing one per file. That drops the busybox and alpine dependencies from
those paths in favour of the project's own SDK image, which is local already.
avocado load deliberately keeps busybox: it restores the project's config as
it runs, so the SDK image is not yet known and may not be pulled.

Four things this has to get right

  • Shape and environment come from the generated run argv, not the
    caller's env_vars, because the command builder 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 — with a plausible-looking error, which is what makes it dangerous.
  • The container parks on sleep and runs its mounts as a separate synchronous
    exec.
    run -d returns when the container starts, not when its command has
    finished, so a step could otherwise exec into a container whose /opt/src was
    not mounted yet. The exec's own prologue omits the mount block; re-running
    bindfs stacks a second mount.
  • No blocking work under a shared lock. An earlier revision held one global
    lock across docker ps, docker run -d and the mount exec. install runs
    steps 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.
  • Teardown covers every exit. Nine call sites reach std::process::exit and
    never return to main, so teardown is registered with atexit, which fires
    for those and for a normal return. A signal skips atexit, so Ctrl-C is
    handled 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 dev drives 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 to
create or reach a session container falls through to the run that would have
happened anyway, and AVOCADO_NO_SESSION_CONTAINER=1 opts out — the escape
hatch 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:

per-step run session containers
no-op build, time in the container tool 13.8s 8.7s
no-op build, wall 11.9s 10.7s
sdk install, 3 remote extensions 31 container starts, 23.3s 6 starts, 9.0s

Wall 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 cp out of a running container is a real behaviour change in
host_copy and ext checkout.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 SessionContainers registry in utils/container.rs to reuse containers by derived docker run shape and exec steps into them.
  • Route various volume-based utilities (host_copy, extension config reads, profiles, save, ext checkout probes/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.

Comment thread src/utils/container.rs
Comment thread src/utils/container.rs
Comment thread src/utils/container.rs
Comment thread CHANGELOG.md
Comment thread src/main.rs
@mobileoverlord
mobileoverlord force-pushed the jschneck/skip-unchanged-sysroot-images branch from a477d77 to 55e0a3c Compare September 7, 2026 17:27
@mobileoverlord
mobileoverlord force-pushed the jschneck/session-container-routing branch from f74c890 to ae0e814 Compare September 7, 2026 17:27
@mobileoverlord
mobileoverlord force-pushed the jschneck/skip-unchanged-sysroot-images branch from 55e0a3c to 6e1d184 Compare September 7, 2026 17:54
@mobileoverlord
mobileoverlord force-pushed the jschneck/session-container-routing branch from ae0e814 to e2371eb Compare September 7, 2026 17:54
@mobileoverlord

Copy link
Copy Markdown
Contributor Author

@copilot review

@mobileoverlord
mobileoverlord force-pushed the jschneck/skip-unchanged-sysroot-images branch from 6e1d184 to d526653 Compare September 7, 2026 18:23
@mobileoverlord
mobileoverlord force-pushed the jschneck/session-container-routing branch from e2371eb to 89c352e Compare September 7, 2026 18:23
@mobileoverlord
mobileoverlord force-pushed the jschneck/skip-unchanged-sysroot-images branch from d526653 to fc70ae8 Compare September 7, 2026 18:59
@mobileoverlord
mobileoverlord force-pushed the jschneck/session-container-routing branch from 89c352e to dde2275 Compare September 7, 2026 18:59
@mobileoverlord
mobileoverlord force-pushed the jschneck/skip-unchanged-sysroot-images branch from fc70ae8 to c1f9a4b Compare September 7, 2026 21:00
@mobileoverlord
mobileoverlord force-pushed the jschneck/session-container-routing branch from dde2275 to d971244 Compare September 7, 2026 21:00
@mobileoverlord
mobileoverlord force-pushed the jschneck/skip-unchanged-sysroot-images branch from c1f9a4b to c65555c Compare September 8, 2026 13:03
@mobileoverlord
mobileoverlord force-pushed the jschneck/session-container-routing branch from d971244 to 9149002 Compare September 8, 2026 13:03
@mobileoverlord
mobileoverlord force-pushed the jschneck/skip-unchanged-sysroot-images branch from c65555c to 2c8f38e Compare September 8, 2026 15:00
@mobileoverlord
mobileoverlord force-pushed the jschneck/session-container-routing branch from 9149002 to 583ac63 Compare September 8, 2026 15:00
@mobileoverlord
mobileoverlord force-pushed the jschneck/skip-unchanged-sysroot-images branch from 2c8f38e to 2c7d0de Compare September 9, 2026 19:23
@mobileoverlord
mobileoverlord force-pushed the jschneck/session-container-routing branch from 583ac63 to 6b48da5 Compare September 9, 2026 19:23
@mobileoverlord mobileoverlord reopened this Sep 9, 2026
@mobileoverlord
mobileoverlord force-pushed the jschneck/session-container-routing branch from 6b48da5 to 5562824 Compare September 9, 2026 20:02
@mobileoverlord
mobileoverlord force-pushed the jschneck/skip-unchanged-sysroot-images branch from 2c7d0de to f52a791 Compare September 9, 2026 20:02
`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
mobileoverlord force-pushed the jschneck/session-container-routing branch from 5562824 to 4fdb5a1 Compare September 9, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants