Repository navigation
Does devlaunch's setup pass earn its per-container zellij install? #391
Description
Question
Should devlaunch's setup pass install zellij into every fresh container?
It does today, and it is not free: A/B'd on otherwise identical cold launches of
the same repo, the probe trip goes 2.32s -> 4.27s with the zellij stage on,
i.e. ~1.95s of every cold launch, spent doing a pixi global install of a
static binary (and installing pixi itself first when the image has none — the
first cold run of the day measured 6.71s on that trip).
This is squarely devlaunch's own cost, not the user's: it is a stage this repo
composes into its own setup pass (setup_stages in flows/provision.rs, gated on
ToolsSwitch::Install + ZellijSwitch::Install). It is not covered by this
map's out-of-scope line for dotfiles/tool provisioning, which is about the user's
dotfiles repo and devpod's dotfiles hook.
Note what already exists in the neighbourhood: claude and gh are lent from the
host rather than installed from the network, precisely to avoid a per-container
package install; and README already tells users they may get zellij "another way
— their own dotfiles, a base image, a devcontainer feature". So the options are:
- Lend it like claude and gh. It is a single static binary, far smaller than
either. It would join the existing payload and the existing arch/libc gate, and
cost roughly nothing on top of a transfer that is already happening. - Keep the install but move it off the critical path — detached, or on the
first attach that actually wants a multiplexer. - Drop it and document it. The opt-out (
DEVLAUNCH_NO_ZELLIJ) already
exists; this makes skip the default. - Keep it as-is and accept ~1.95s, if the value of zellij-always-present
outweighs it.
Whichever it is, say how it is tested — the switch already has unit coverage over
setup_stages, so a change of default or of mechanism has a natural place to be
pinned.
Where these numbers come from
All figures below were measured on 2026-08-24 against the Rust build
(rust/target/release/dl, dl 0.13.0), driven from inside this repo's own
devcontainer (nested docker-in-docker, devpod 0.26.1, docker 29.7.2, 8 CPUs).
DEVPOD_HOME, DEVPOD_SSH_CONFIG and XDG_CACHE_HOME were scoped to a scratch
directory; the target was blooop/dotfiles with a warm image and a warm bare
clone cache.
Caveat, and it is not small: nested DinD is not the host. Absolute seconds
here run higher than a host launch, and this is not the CI trend point
#292 is about. What transfers
is the ratios and the floors — the pieces are A/B'd against each other in one
environment. The host cross-check is that README's own host medians (warm
2.176s, cold-recreate 15.899s) sit close to what was measured here (warm
2.7-3.5s, cold 13.1-15.5s).
Activity
blooop commentedon Aug 24, 2026
breadcrumb: re-measured the stage, and 78% of it is not zellij. A/B on cold
launches of
blooop/dotfiles(same scratchDEVPOD_HOME/XDG_CACHE_HOME, sameDinD box,
dl 0.13.0,DEVLAUNCH_TIMING=1), reading the setup-pass trip:DEVLAUNCH_NO_ZELLIJ=1So the stage is ~2.2s warm / ~3.5s cold, not the ~1.95s the ticket opened with.
Then split the halves by hand, three fresh containers off
mcr.microsoft.com/devcontainers/base:ubuntuwithPIXI_CACHE_DIRbind-mounted theway
launch.rsmounts it:PIXI_BOOTSTRAP(curl | bash)pixi global install zellij(warm cache)The install half is exactly what README's "What it costs" table claims (0.23–0.56s).
The bootstrap half is not in that table at all, and it is the dominant term — and it
is the one the shared package cache cannot amortize, because it is pixi's own
installer over the network, not a conda package.
Which lands somewhere unflattering: on the lend path
provision_scriptis neverreached (
provision.rsreturnsProvisioning::Lentbefore it), so on the commoncold launch the zellij stage is the only thing that drags pixi into the container
at all. 1.7s of the 2.2s buys a package manager nothing else on that path wants.
Images that ship pixi — this repo's own devcontainer, for one — pay only the ~0.5s,
which is very likely how the README figure came to be measured.
blooop commentedon Aug 24, 2026
breadcrumb: the lend option dies on the host, not on the container.
host_payloadis all-or-nothing by construction — "a host missing either tool falls back to the
network path for both, rather than growing a per-tool matrix of half-lent states" —
and this host has no zellij on PATH (nor do most; it is not a tool devlaunch
requires). A third required member would therefore break the lend on every such
host and send them to a ~285MB
claudedownload, which is the exact outcome thethree-state probe exists to prevent (#141, #151). An optional member is the
half-lent state the type refuses, and
transfer_scriptisset -euwith a--versiongate per member: an optional third one needs a non-fatal sub-gate insidea script whose whole design is prove all, then move all.
And even done perfectly it only covers the lendable path. A container that probes
provisioned takes no transfer trip — that is the point of the three-state probe —
so it would get no zellij, turning an unconditional guarantee into one that depends
on what the host happens to have installed.
ZELLIJ_TOOL's own doc comment alreadyrefused the neighbouring version of this ("Deliberately not a third row in
REQUIRED_TOOLS"); this extends that refusal to the payload rather than contradictingit.
blooop commentedon Aug 24, 2026
agent-decided (2): the stage stays, and it stops being unconditional. Gate it
on the request: the zellij stage runs only when the launch asks for zellij, which
is a signal that already exists —
DEVLAUNCH_ZELLIJ— andDEVLAUNCH_NO_ZELLIJretires, because with skip as the default it has no remaining job.
What decided it
Not the seconds. The seconds only made it worth looking at. What decided it is that
the install is opt-out while every use of it is opt-in, and the product of those
two defaults is incoherent.
zellij has two switches, documented as "orthogonal on purpose":
DEVLAUNCH_NO_ZELLIJturns off the install,
DEVLAUNCH_ZELLIJturns on the session. Four combinations,and three of them are not things anyone wants:
NO_ZELLIJ=1+ZELLIJ=1NO_ZELLIJ=1onlyZELLIJ=1onlySo the default combination pays ~2.2s of cold launch to provision a capability that,
by devlaunch's own default, nothing will then use. One switch collapses the table to
its two coherent rows.
That is principle 2, and it is 2 rather than 1 because this is the only option on
the table that removes machinery instead of adding it. Lending adds an optional
payload member and a non-fatal sub-gate to a
set -euscript (breadcrumb above).Detaching adds a second detached child and a window in which the capability is
advertised and absent. Keeping as-is adds nothing but keeps two variables, four
states and a pixi bootstrap in every lent container. Gating on the request deletes a
variable, deletes a type, and drops the cost as a side effect.
The measurement is what promoted it from tidying to worth doing: ~2.2s of a ~15s cold
launch, of which 1.70s is installing pixi — and on the lend path the zellij stage
is the only thing that puts pixi in the container, so that 1.70s buys a package
manager nothing else there wants.
It needs no new machinery, which is the load-bearing part
Switchesis already carried into the verdict cache's marker asMarkerSwitches { tools, zellij }, andtrustedalready returns false when therecorded switches differ from the requested ones — with a test named for exactly this
hazard ("a launch that wants the zellij stage was told a pass that skipped it had
already run"). So a user who sets
DEVLAUNCH_ZELLIJ=1on a workspace that alreadyprobed provisioned invalidates its marker, the next
upre-runs the pass, and thestage lands. The mechanism that makes on-request provisioning correct was built for
the opt-out and works unchanged for the opt-in. Only the sense of the bool moves.
What it costs, and this is a real cost
with "Every workspace
dlopens also has zellij onPATH" and argues for it theway the lending section argues — nothing has to cooperate, it works in images
dlhas never seen. After this, it holds for launches that asked. That sentence and
the "What it costs" table both have to be rewritten, not footnoted.
zellij attach -c devlaunch, which README advertises at the end of the wrapsection. They now set
DEVLAUNCH_ZELLIJ=1— once, in a shell profile — or getcommand not found.DEVLAUNCH_ZELLIJstops meaning "wrap the command" andstarts meaning "I want zellij in my containers", which is the wider and more
honest reading, and the reading that makes it fine for an interactive attach to
set it while having no command to wrap.
marker untrusted and re-runs the pass, where the stage is not even added. That is
today's behaviour for
DEVLAUNCH_NO_ZELLIJtogglers, and it is the direction theverdict cache says is allowed to be wrong.
Not taken, with the condition that would reopen each: lend it — reopen only if a
host-side zellij cache is on the table for some other reason, since the objection is
that the host has no zellij to lend and the provisioned path takes no transfer trip
at all, not that ~15MB on a 342MB payload is expensive (#158 says it is not).
Detach it — reopen if a use of zellij appears that no launch-time signal can
predict, which is the only thing that would make a race worth buying 2.2s.
Keep as-is — reopen if
DEVLAUNCH_ZELLIJever becomes on-by-default, whichinverts the whole argument.
How it gets tested
setup_stages, where the switch already has coverage. The existingthe_zellij_opt_out_drops_only_the_zellij_stageinverts: the default carries noZELLIJ_STAGE, the requested case does, and the hostname and title stages areuntouched either way.
in the shared denial/consent lists that
the_zellij_switch_reads_the_same_denials_as_the_othersalready walks, assert thatZellijWrap::from_hostand whethersetup_stagescarries the zellij stage agree.Two places reading one variable is precisely what that ticket spent three review
rounds removing.
DEVLAUNCH_NO_ZELLIJ=1export in somebody's profile may not turn provisioningon. Cheap, and the one way this change could bite silently.
stage does not trust a marker written by a pass that skipped it. This is what makes
"set the variable on an existing workspace and it appears" true.
provision/lending_contract.rsalready holds README's lending section against the code, scoped to the subsection
that carries each claim. The same treatment for "A terminal beside the agent": the
guarantee sentence's polarity, and — separately — that the "What it costs" table
cannot advertise a cost the stage does not pay. That table is wrong today: it
reports the 0.48s install and omits the 1.70s bootstrap that dominates on any image
without pixi.
breadcrumb above: setup-pass trip 4.915s / 4.600s with the stage, 2.720s / 2.352s
without, same box, same warm cache. The "after" is the default path becoming the
second row.
The build slice
One slice,
rust/devlaunch-core/src/flows/provision.rsplus README:ZellijSwitch::from_envreadsDEVLAUNCH_ZELLIJwith the sense inverted (presentand truthy →
Install; absent →Skip), through the sameprovisioning_disabledparse so the two spellings of consent cannot disagree.
ZELLIJ_DISABLE_VARandits parse go.
ZellijWrap::from_hostandsetup_stages' zellij gate come off that one signal,with the drift test above.
ToolsSwitch::Skipand still holds:DEVLAUNCH_NO_TOOLS=1continues todrop the stage, because installing zellij is still tool provisioning.
in it), the "Existing workspaces" paragraph, the two-variables-are-orthogonal
paragraph, and the variable table at line ~968 — plus the
lending_contract-styleguard over the parts that are claims rather than explanation.
Nothing in
verdict_cache.rschanges except the sense the bool records, and nothingin the payload, the probe or
transfer_scriptis touched at all.