Skip to content

Does devlaunch's setup pass earn its per-container zellij install? #391

Closed

Description

@blooop

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

  1. self-assigned this
    on Aug 24, 2026
  2. blooop commented on Aug 24, 2026

    @blooop
    OwnerAuthor

    breadcrumb: re-measured the stage, and 78% of it is not zellij. A/B on cold
    launches of blooop/dotfiles (same scratch DEVPOD_HOME/XDG_CACHE_HOME, same
    DinD box, dl 0.13.0, DEVLAUNCH_TIMING=1), reading the setup-pass trip:

    setup-pass trip
    DEVLAUNCH_NO_ZELLIJ=1 2.720s, 2.352s
    stage on, shared pixi cache warm 4.915s, 4.600s
    stage on, shared pixi cache cold 6.044s (and it filled 168MB of cache)

    So 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:ubuntu with PIXI_CACHE_DIR bind-mounted the
    way launch.rs mounts it:

    PIXI_BOOTSTRAP (curl | bash) 1703 / 1785 / 1699 ms
    pixi global install zellij (warm cache) 482 / 488 / 512 ms
    whole stage 2186 / 2273 / 2211 ms

    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_script is never
    reached (provision.rs returns Provisioning::Lent before it), so on the common
    cold 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.

  3. blooop commented on Aug 24, 2026

    @blooop
    OwnerAuthor

    breadcrumb: the lend option dies on the host, not on the container. host_payload
    is 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 claude download, which is the exact outcome the
    three-state probe exists to prevent (#141, #151). An optional member is the
    half-lent state the type refuses, and transfer_script is set -eu with a
    --version gate per member: an optional third one needs a non-fatal sub-gate inside
    a 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 already
    refused the neighbouring version of this ("Deliberately not a third row in
    REQUIRED_TOOLS"); this extends that refusal to the payload rather than contradicting
    it.

  4. blooop commented on Aug 24, 2026

    @blooop
    OwnerAuthor

    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 — and DEVLAUNCH_NO_ZELLIJ
    retires, 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_ZELLIJ
    turns off the install, DEVLAUNCH_ZELLIJ turns on the session. Four combinations,
    and three of them are not things anyone wants:

    neither set — today's default installed on every cold launch, never used
    NO_ZELLIJ=1 + ZELLIJ=1 asks for the capability, guaranteed not to get it — README calls this "a session setup that fails and a command that runs anyway"
    NO_ZELLIJ=1 only coherent: want neither
    ZELLIJ=1 only coherent: want both

    So 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 -eu script (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

    Switches is already carried into the verdict cache's marker as
    MarkerSwitches { tools, zellij }, and trusted already returns false when the
    recorded 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=1 on a workspace that already
    probed provisioned invalidates its marker, the next up re-runs the pass, and the
    stage 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

    1. A documented guarantee shrinks. README opens "A terminal beside the agent"
      with "Every workspace dl opens also has zellij on PATH" and argues for it the
      way the lending section argues — nothing has to cooperate, it works in images dl
      has 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.
    2. One use loses its signal: the human who attaches interactively and types
      zellij attach -c devlaunch, which README advertises at the end of the wrap
      section. They now set DEVLAUNCH_ZELLIJ=1 — once, in a shell profile — or get
      command not found. DEVLAUNCH_ZELLIJ stops meaning "wrap the command" and
      starts 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.
    3. A toggle costs a redundant trip. Flipping the variable off again makes the
      marker untrusted and re-runs the pass, where the stage is not even added. That is
      today's behaviour for DEVLAUNCH_NO_ZELLIJ togglers, and it is the direction the
      verdict 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_ZELLIJ ever becomes on-by-default, which
    inverts the whole argument.

    How it gets tested

    • setup_stages, where the switch already has coverage. The existing
      the_zellij_opt_out_drops_only_the_zellij_stage inverts: the default carries no
      ZELLIJ_STAGE, the requested case does, and the hostname and title stages are
      untouched either way.
    • One signal, two readers, pinned against drift — the Three-state tools probe: only the official claude layout counts as provisioned #151 shape. For every value
      in the shared denial/consent lists that
      the_zellij_switch_reads_the_same_denials_as_the_others already walks, assert that
      ZellijWrap::from_host and whether setup_stages carries the zellij stage agree.
      Two places reading one variable is precisely what that ticket spent three review
      rounds removing.
    • The retired variable must be inert, never an enable. A stale
      DEVLAUNCH_NO_ZELLIJ=1 export in somebody's profile may not turn provisioning
      on. Cheap, and the one way this change could bite silently.
    • The verdict-cache test that already exists, re-pointed: a launch that wants the
      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.
    • README, as a drift guard rather than as prose. provision/lending_contract.rs
      already 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.
    • Before/after numbers, per this map's destination. The "before" is in the
      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.rs plus README:

    1. ZellijSwitch::from_env reads DEVLAUNCH_ZELLIJ with the sense inverted (present
      and truthy → Install; absent → Skip), through the same provisioning_disabled
      parse so the two spellings of consent cannot disagree. ZELLIJ_DISABLE_VAR and
      its parse go.
    2. ZellijWrap::from_host and setup_stages' zellij gate come off that one signal,
      with the drift test above.
    3. The ToolsSwitch::Skip and still holds: DEVLAUNCH_NO_TOOLS=1 continues to
      drop the stage, because installing zellij is still tool provisioning.
    4. README: the guarantee sentence, the "What it costs" table (with the bootstrap term
      in it), the "Existing workspaces" paragraph, the two-variables-are-orthogonal
      paragraph, and the variable table at line ~968 — plus the lending_contract-style
      guard over the parts that are claims rather than explanation.
    5. CHANGELOG: this is a user-visible default change and a retired variable.

    Nothing in verdict_cache.rs changes except the sense the bool records, and nothing
    in the payload, the probe or transfer_script is touched at all.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions