Skip to content

Collapse the three sequential devpod ssh round trips on the cold path #157

Closed

Description

@blooop

Question

Cold interactive launch issues three sequential devpod ssh exec round trips into the same container — tools probe, tools transfer, hostname — before the attach. At the map's working number of ~0.45–0.56s per devpod round trip, that is roughly 1.4s of the cold path spent on process spawn and connection setup rather than work. Decide whether these collapse, and into what.

This fog was parked pending the tools-transfer decision, which is now settled on #141: the probe becomes three-state (provisioned/lendable/absent), the payload stays all-or-nothing, and ensure_tools never runs on fast-attach — so this is a cold-path-only question and the warm path must not be touched.

Decide against the principles (maintainability > simplicity):

  1. Collapse or overlap? Options include: one devpod ssh invocation running a single composed remote script that probes, receives, and reports the hostname in one round trip; keeping them separate but overlapping the independent ones concurrently; or leaving them sequential because the coupling costs more in legibility than 1.4s buys. Note the probe's result decides whether the transfer happens, so probe→transfer is a genuine data dependency — a naive merge would either always transfer or need the remote script to branch. The hostname fetch has no such dependency.
  2. Where does the composed script live? Three-state tools probe: only the official claude layout counts as provisioned #151 built a pure probe_script that reports resolved paths as marked key value lines and names no state at all — the host decides the state from those facts via _is_official_claude, the same predicate _claude_source uses, so the two sides cannot drift. (This supersedes the earlier "prints exactly one of three states" wording, which is what let a shim under versions/ be trusted; see the amendment on Three-state tools probe: only the official claude layout counts as provisioned #151.) It never executes the candidate binary. If a composed script is the answer, it must reuse that script rather than growing a second copy of the probe logic (principle 2: one concept beats two that overlap).
  3. Failure legibility. Three round trips fail with three distinguishable errors; one composed script can fail opaquely. Say how a failure in the merged form still names which stage failed — this is the main cost against merging and must be answered, not waved.
  4. How is the outcome tested? test/test_devpod_spawn_counts.py pins exact devpod argv sequences per command and is the established seam for round-trip regressions; a reduction here should show up as a changed pin there, so the saving cannot silently regress.

Code changes graduate as build ticket(s); this ticket only decides. Measure the actual per-round-trip cost before and after rather than assuming the ~0.5s working number — PR #156 adds env-gated timing instrumentation that should make this measurable.

Blocked by #151 — the probe's final shape is an input to this decision.

Amendment (2026-08-09). Question 2 originally described #151's probe as printing one of three state tokens. It does not, and deliberately so — see the amendment on #151. Any composed script this ticket designs must therefore compose a script that reports facts, and must keep the state decision on the host side; re-introducing state naming in the container would restore the two-predicate drift that #151 removed. The "reuse rather than grow a second copy" constraint is unchanged and now matters more, since the thing being reused is a fact reporter rather than a classifier.

Activity

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

    @blooop
    OwnerAuthor

    breadcrumb: measured, not inherited — a devpod ssh --command round trip costs ~1.73s, not ~0.45–0.56s. The map's working number is real but is for a different command shape: test_devpod_spawn_counts.py's own docstring attributes 0.44s to devpod list and 0.56s to devpod status, which are local devpod CLI calls, not an exec into a container.

    Method (reproducible, this machine, 2026-08-09): isolated DEVPOD_HOME under the scratchpad, docker provider, one throwaway workspace grill157 on mcr.microsoft.com/devcontainers/base:ubuntu-24.04, deleted afterwards; the shared devpod list was verified unchanged. /usr/bin/time, median of n:

    what median n
    devpod ssh ws --command 'true' 1.73s 7
    devpod ssh ws --command 'sudo hostname ws' 1.75s 5
    devpod ssh ws --command "bash -lc <the shipped probe_script()>" 1.75s 5
    devpod status ws --output json 0.50s 5
    devpod list --output json 0.42s 5
    hostname stage + probe composed into one trip 1.64s 5
    the same two as two sequential trips 3.56s 5

    Two things fall out. The real probe costs the same as true, so a round trip is ~99% connection/process setup and ~0% work — collapsing N trips into 1 saves (N-1) × ~1.7s essentially exactly. And the ticket's "roughly 1.4s of the cold path" is understated by ~3.5x: three ssh trips are ~5.2s.

    Not measured, and not claimed: the transfer trip's end-to-end cost (it streams ~342MB), and any of this on hardware other than this one. PR #156 is still open, so no instrumented in-tree number exists; this is external time around real devpod.

  3. blooop commented on Aug 9, 2026

    @blooop
    OwnerAuthor

    breadcrumb: two findings from the same scratch container that bear on the answer, both observed rather than reasoned.

    1. sudo hostname cannot succeed in a default devcontainer. In grill157 (unprivileged docker provider, stock mcr.microsoft.com/devcontainers/base:ubuntu-24.04): whoami → vscode, sudo -n true → rc 0, and sudo hostname grill157 → rc 1, hostname: you must be root to change the host name. hostname afterwards still reports the container id. sudo is not the blocker — setting a hostname needs CAP_SYS_ADMIN, which Docker drops by default. This repo's own devcontainer is privileged (the docker-in-docker feature brings "privileged": true), which is why it works here and is invisible in this repo's own testing. So setup_hostname is paying a measured ~1.75s round trip to fail silently for every unprivileged container — attach_workspace ignores its return value, and the failure is on stderr nobody reads. That is on the warm interactive attach too, where it is now the single largest item on the path.

    2. The composed-trip failure legibility mechanism works, demonstrated by accident. The composed run above printed:

    devlaunch-probe stage hostname-failed
    devlaunch-probe tools missing
    

    The hostname stage failed, said so on a marked line, and the probe still answered correctly in the same trip. That is question 3's answer executing rather than being argued: a stage that names itself is more legible than what ships today, where the same failure is silent.

    Also verified: ProbeResult.parse needs no change to compose. It walks lines, skips anything not carrying PROBE_MARK, and only reads the tools/versions/claude keys — so extra stage output, in front of or behind the probe, is already inert to it. Reuse of probe_script() verbatim is therefore free, and question 2's "no second copy of the probe logic" costs nothing to honour.

  4. blooop commented on Aug 9, 2026

    @blooop
    OwnerAuthor

    breadcrumb: question 4's seam checked by driving it, not by reading it. test/test_devpod_spawn_counts.py's existing DevpodSpawns recorder already reaches the cold path — workspace_up("brand-new", workspace_id=..., workspace_identity=...) under the recorder produced, in order:

    ['context', 'options', '--output', 'json']
    ['up', 'brand-new', '--id', 'brand-new', '--ide', 'none', '--init-env', 'DEVLAUNCH_WORKSPACE_ID=brand-new']
    ['ssh', 'brand-new', '--command', "bash -lc 'set -u\nif ! { command -v gh ...'"]     # the probe
    ['ssh', 'brand-new', '--command', 'bash -c \'set -eu\nexec >&2\necho "devlaunch: lending claude...'"]  # the transfer
    

    So the file needs no new fake to pin a cold launch; it just has no cold pin at all today — every existing pin is warm (status/list). That absence is why the trip count on the path this ticket is about can move without any test noticing.

    One isolation hazard found in passing, which any cold pin must handle: that call ran the real host_payload() against this developer's machine, and the transfer trip above appeared only because this host happens to have a claude and a gh to lend. On a host with neither, the same code takes the absent arm and spawns a provision_script trip instead — so a cold pin that does not stub host_payload asserts a different argv sequence depending on whose laptop runs it. test_starting_a_workspace_drops_the_snapshot already calls into this today without stubbing; it survives only because it asserts on the workspace list rather than on the spawns.

  5. blooop commented on Aug 9, 2026

    @blooop
    OwnerAuthor

    agent-decided (maintainability): the three trips collapse to two, not one. The hostname folds into the probe's trip as a named stage of a single cold-path setup pass; probe → transfer stays two trips and must not be merged, because every way of merging it either pays the ~342MB stream on the path that exists to skip it, or gives the container back an opinion about "the official layout" — the two-predicate drift #151 spent three review rounds removing. Failure legibility is not a cost here but a gain: each stage names itself on a marked line, so the merged form makes visible a hostname failure that today is silent.

    Measured, not inherited — see the breadcrumbs above for method and raw numbers.


    The number the ticket asked me to check was wrong, and in the direction that matters

    The ticket, and the map's Notes, price a round trip at ~0.45–0.56s and the three trips at "roughly 1.4s". That figure is real but belongs to a different command shape. test_devpod_spawn_counts.py's own docstring attributes it to devpod list (0.44s) and devpod status (0.56s) — local devpod CLI calls that never enter a container. I reproduced both (0.42s, 0.50s) and then measured the shape this ticket is actually about:

    • devpod ssh <ws> --command 'true' — 1.73s (median of 7)
    • the shipped probe_script() over bash -lc — 1.75s (median of 5)

    The probe costs the same as true. A round trip is ~99% connection and process setup and ~0% work, which means collapsing N trips into one saves (N−1) × ~1.7s, near-exactly — there is no amortization to hope for and none to lose. Three ssh trips are ~5.2s, not 1.4s.

    Directly measured on the merge this decides:

    • hostname stage + probe composed into one trip — 1.64s (median of 5)
    • the same two as two sequential trips — 3.56s (median of 5)
    • saving: ~1.92s per cold launch.

    PR #156 is still open, so none of this used in-tree instrumentation; it is external /usr/bin/time around real devpod against a throwaway workspace under an isolated DEVPOD_HOME, deleted afterwards. Not measured and not claimed: the transfer trip's end-to-end cost, and any of this on other hardware. This machine, this date, one image.

    I record this the way #158 recorded its correction: the ~0.45–0.56s working number must not be quoted for a devpod ssh --command trip. It is right for list/status and understates an exec-into-container trip by ~3.5x.


    Q1 — Collapse or overlap? Collapse the hostname into the probe. Leave probe → transfer alone.

    probe → transfer: no, and this is principle 1 deciding

    The ticket names the data dependency and asks me not to paper over it. There are exactly three ways to merge, and all three are worse than the 1.7s they buy:

    • Always transfer. The provisioned image is the jackpot #141 designed the whole three-state probe to reward — one trip, zero bytes. Merging by always streaming charges that path a ~342MB tar plus its staging write (Cache the staged tools tar instead of re-staging ~342MB every transfer #158: 0.13s warm / 2.6s cold) to save 1.7s. It makes the best case pay for the worst.
    • The remote script branches. This is the one the amendment forbids, and it deserves more than a citation. To branch, the container must evaluate the relation "is this claude the official layout". That relation lives in _is_official_claude and is asked by both _claude_source and ProbeResult.parse — one definition, one language. A shell copy of it is a second definition, and the history here is not hypothetical: the first copy trusted a shim parked under versions/ that the host refused, and the review that caught it needed two further rounds to close the symlinked-$HOME and stock-~/.profile faces of the same defect. Buying 1.7s by re-creating that seam fails principle 1 outright — whoever reads this in a year would find two answers to one question, and no way to tell which one ran.
    • Send the tar, let the container decide whether to unpack. Both costs at once. No.

    There is no fourth option, because the branch has to happen somewhere and the two candidates are "the host, after a trip" (status quo) or "the container, with its own copy of the predicate" (forbidden). Sequential is not a concession here; it is the shape that keeps the predicate singular.

    hostname → probe: yes

    The hostname has no data dependency at all, the merge is measured at ~1.92s, and — decisively — the merged form is the one that makes an existing silent failure visible (Q3). Merging beats overlapping-concurrently: concurrency would save the same wall clock while keeping two devpod processes, two failure paths, and interleaved output, and would need threading on a path that currently has none. The map's own cost model counts processes; principle 2 says one concept beats two that overlap.

    Was "leave them sequential" seriously weighed? Yes, and it lost on evidence I did not expect

    The honest case for leaving them alone is that ~1.9s is noise beside a devpod up that runs for seconds to minutes. What defeats it is not the 1.9s. It is that the trip being folded in does not work, and nobody can see that it doesn't. In the scratch container: sudo -n true → rc 0, sudo hostname grill157 → rc 1, hostname: you must be root to change the host name. Setting a hostname needs CAP_SYS_ADMIN, which Docker drops by default; this repo's own devcontainer is privileged (docker-in-docker brings "privileged": true), which is exactly why the repo cannot see this in its own testing. So today devlaunch spends a measured ~1.75s per launch on a call that fails for every unprivileged container, and attach_workspace discards the boolean that says so.

    A decision that leaves it sequential leaves that invisible. The merge is the change that makes it speak.


    Q2 — Where does the composed script live?

    Composed on the host, from probe_script() verbatim plus independent stage snippets. Nothing about the probe is copied, re-expressed, or moved into the container.

    This costs nothing to honour, because ProbeResult.parse already composes. It walks the report line by line, skips every line that does not carry PROBE_MARK, and reads only the tools / versions / claude keys. Foreign output before or after the probe is already inert to it — that tolerance was built for login-profile banners, and a stage line is the same shape of noise. ProbeResult.parse and _is_official_claude are unchanged by this ticket. The container gains stage-outcome lines and gains no opinions.

    Binding constraints on the composed form:

    1. One cold-path setup pass, replacing the separate hostname trip. Composed from probe_script() verbatim — a string-level test should assert the composed script contains probe_script(), so a future edit cannot fork it.
    2. No set -e spanning stage boundaries. A stage's failure is contained to that stage. transfer_script uses set -eu correctly for what it is — an all-or-nothing sequence — and that is precisely why it must not be a stage in this pass.
    3. The pass still exits 0 in every state, preserving the discrimination _probe already relies on: a non-zero devpod ssh means the transport failed, never that a stage did.
    4. The state decision stays on the host. The pass reports facts and stage outcomes; nothing else.
    5. It must not be gated on provisioning_disabled(). ensure_tools returns before probing when DEVLAUNCH_NO_TOOLS is set. If the hostname rides inside a pass that inherits that gate, disabling tools silently disables the hostname — an unrelated coupling introduced by the merge, and exactly the kind of thing that is discovered a year later. Whether the pass runs and whether the tools stages do anything are two questions.
    6. The warm path is untouched. attach_workspace keeps its standalone setup_hostname for a warm attach, where there is no pass to fold into. Only the cold path stops paying separately.
    7. The cold path's "already named" signal must be an explicit value, carried from the launch that ran the pass to the attach that would otherwise repeat it — never module-level mutable state and never a re-probe. And "ran and failed" counts as attempted: retrying standalone would cost another measured ~1.7s to fail again.

    Accepted, and small: a cold dl <ws> -- cmd and a cold dl <ws> up currently set no hostname and would now set one for free, inside a trip they already pay. That is a behaviour change, and it is an improvement — a container created by a one-shot launch is presently nameless until somebody attaches interactively.


    Q3 — Failure legibility. Concretely, and it improves rather than degrades

    The objection is right in general: one script can fail opaquely. It does not apply to this composition, and I can show it rather than argue it. The composed run I timed printed:

    devlaunch-probe stage hostname-failed
    devlaunch-probe tools missing
    

    The hostname stage failed, named itself, and the probe still answered correctly in the same trip. Compare the status quo, where that same failure produces nothing at all: setup_hostname returns False and attach_workspace throws it away.

    The contract that makes this hold:

    • Every stage reports its own outcome as a marked line, written by the composer, not by the stage. The shape is if <stage>; then echo "<MARK> stage <name> ok"; else echo "<MARK> stage <name> failed $?"; fi.
    • Three outcomes per stage, and they are distinguishable: ok, failed(rc), and not reached — the last being the absence of any line for that stage, which is what a truncated report or a mid-script death looks like. That is the real state space, so it is modelled as one (principle 3): a two-valued reading would have to call "never ran" and "ran fine" the same thing, or invent a sentinel.
    • The host logs a warning naming any stage that is not ok. This is the whole legibility claim, and it is what three separate trips give for free today only in principle — in practice the hostname trip's failure is already discarded, so three trips are not currently three distinguishable errors. Two of them are, and the third is silence.
    • Transport failure keeps its own signal: the pass exits 0 regardless of stage outcomes, so a non-zero exit from devpod ssh still means the connection did not get through, distinct from any stage's failure. That discrimination exists today in _probe and the merge preserves it exactly.

    So the answer to "how does a failure in the merged form name which stage failed" is: because naming the stage is the composition's job, and it is the only part of the composition that is new. A merged form without per-stage outcomes would indeed be a legibility regression and should not be merged.


    Q4 — How the outcome is tested

    test/test_devpod_spawn_counts.py is the seam, as the ticket says — and I drove it to check the claim rather than assert it. Its existing DevpodSpawns recorder already reaches the cold path: workspace_up(...) under it records context options, up, the probe ssh, and the transfer ssh, in order (breadcrumb above has the argv). No new fake is needed.

    What it lacks is a cold pin at all — every pin in the file today is warm (status / list). That absence is why the trip count on this ticket's own path can move with nothing failing. So:

    • A new cold-launch pin asserting the exact argv sequence: one ssh --command carrying the composed pass, then (payload permitting) the transfer, and no separate sudo hostname spawn. The saving is then pinned as a count, not a hope.
    • The existing warm pin test_attaching_to_a_running_workspace must stay green untouched — it still expects status, ssh --command sudo hostname, ssh. It is the guard that the warm path was not touched, so a diff that edits it has broken the scope line.
    • Isolation, mandatory: any cold pin must stub host_payload. The run above took the transfer arm only because this host has a claude and a gh to lend; a host with neither takes the absent arm and spawns provision_script instead, so an unstubbed pin asserts a different sequence per developer.
    • test/unit/test_tools.py for the pass itself, in the kinds already there: string assertions (the composed script contains probe_script(); no set -e spans stages), real-bash runs against scratch $HOMEs (a failing stage still lets the probe answer, and its marked line appears), and the Runner fake for trip counts per probe answer.
    • The DEVLAUNCH_NO_TOOLS case gets its own test: tools disabled, hostname still handled. Constraint 5 is invisible without it.
    • A stage-outcome test for not reached: a report truncated before a stage's line must read as not-reached, not as ok.

    What this opens, and why the build is sequenced behind it

    The CAP_SYS_ADMIN finding is not this ticket's to decide — it lands on the warm interactive attach, which this ticket is scoped out of, and the hostname is user-visible behaviour that does work in privileged containers including this repo's own. But it makes "fold the hostname in" conditional on the hostname trip being worth keeping in any form, and there is at least one zero-trip alternative worth weighing (devlaunch already edits the container's login profile in transfer_script and provision_script, and a prompt is not the same thing as a hostname).

    Building the fold before that is settled risks building a fold around something that gets deleted. So the grilling ticket goes first and blocks the build ticket. Recorded plainly rather than merged into this ticket's answer, because it is warm-path fog my measurement opened, not a question #157 was asked.

  6. blooop commented on Aug 9, 2026

    @blooop
    OwnerAuthor

    breadcrumb: graduated. #167 (wayfinder:grilling) — does the hostname round trip earn its ~1.75s, given sudo hostname cannot succeed without CAP_SYS_ADMIN; warm-path, so #157 could not decide it. #168 (wayfinder:build) — the one cold-path setup pass with per-stage outcomes. Both parented to map #139; #167 blocks #168, because folding around a trip that may be deleted is waste. No build ticket exists for merging probe→transfer: that merge is refused, not deferred.

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