Repository navigation
What a devpod ssh trip costs per connection, and whether it can be reused #389
Description
Question
What does one devpod ssh <ws> --command <cmd> invocation actually do, and
what would make a second one cheap?
An empty-payload trip costs 2.07-2.33s. That number has been treated as
irreducible for the whole life of this map: #157
measured ~1.73s/trip and responded by removing a trip
(#168 folded the hostname into
the probe), and the probe->transfer merge was refused on correctness grounds, so
trip-removal is out of road at three cold trips. Nobody has asked the other
question: whether a trip has to cost that.
The decomposition says it does not:
| what | seconds |
|---|---|
devpod ssh <ws> --command true |
2.07-2.33 |
raw ssh -F <devpod's ssh config> <host>.devpod true |
1.79-1.85 |
same, second call with ControlMaster/ControlPersist |
0.010-0.015 |
docker exec <cid> bash -lc true on the same container |
0.07 |
devpod --help (bare CLI init) |
0.05 |
devpod status <ws> |
0.43-0.74 |
So the ~1.8s is not devpod's binary starting up (that is 0.05s) — it is what
happens per connection, through the ProxyCommand devpod writes into its ssh
config, which spawns a second devpod process (devpod ssh --stdio) per
connection.
Find out, from outside this working directory:
- What
devpod ssh --commanddoes per invocation — where the ~1.8s goes between
the ProxyCommand spawn, devpod's own connection to the container, and the
agent-side sshd handshake. Which of those is per-connection and which is
per-container-lifetime. - Whether devpod already has, or upstream has discussed, connection reuse /
multiplexing / a persistent control path fordevpod ssh. Flags, config,
open issues, PRs, version differences (this tree is pinned against 0.26.1;
0.8 vs 0.26 already differ on whetherssh --commandasks for a pty). - Whether
ControlMaster/ControlPersistover devpod's own generated ssh
config is a supported thing to do or an accident that works. What devpod does
with$DEVPOD_SSH_CONFIG, who rewrites it and when, and whether a master
outliving adevpod up --recreatewould connect to a dead container or fail
cleanly. - What a reused connection would break:
--send-env(dl passesGH_TOKENthis
way),ForwardAgent yes, and devpod's stderr protocol for reporting the
remote exit status, whichclients/devpod.rs::interpretandStderrFilter
depend on to tell "the remote command exited 3" from "devpod failed".
Findings onto a research/devpod-ssh-trip branch, linked back here. This is
knowledge-gathering only — what dl does about it is the blocked sibling
ticket.
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:
ssh -vvvputs all of the 1.67s in one gap — between OpenSSH writing its version string into the ProxyCommand and reading the remote banner back. KEX + auth + channel + exec + exit is ~11ms. The cost is entirelydevpod ssh --stdiogetting a Go ssh-server up in the container; inside it, agent-injection ~531ms and the agent-version handshake ~531ms are the two big blocks, and both re-verify per-container-lifetime facts on every single connection.blooop commentedon Aug 24, 2026
breadcrumb: Two measurements that change the shape of the sibling. (1) The ticket says a reused master still carries
--send-env. It does not unless the master was opened declaring that variable:SendEnvis filtered by the master process against its ownsend_envlist, so a reuse whose master lacks it gets an emptyGH_TOKENsilently. (2)devpod ssh --stdiotakes a per-workspace lock, so N concurrent non-multiplexed trips serialize: 8 parallelsshruns finished 2s apart in a staircase (9.9s..23.9s), the same 8 over one master all finished at 8.02s.blooop commentedon Aug 24, 2026
agent-decided (1):
Findings on branch
research/devpod-ssh-trip, full write-up atdocs/research/devpod-ssh-trip.md(commit9209abe). Principle (1) governed the shape of the answer: the two things worth carrying out of this are a safety fact and a silent-failure correction, both of which are about what a change here would cost to live with, not about seconds.Measured 2026-08-24 inside this repo's devcontainer, devpod v0.26.1, OpenSSH_9.6p1, docker 29.7.2, 8 CPUs, throwaway one-line devcontainer workspace,
DEVPOD_HOME/DEVPOD_SSH_CONFIGscoped to/tmp/res389. Workspace and container deleted afterwards. Disclosure: three other agents were running docker/devpod experiments in the same container for part of the run, so late readings are 20-60% above early ones on identical commands. Both are given in the write-up; nothing rests on a gap smaller than that spread.1. Where the 1.8s goes
All of it is one gap, and the gap is before ssh does any work. Timestamped
ssh -vvv:KEX, host key, userauth, channel open,
exec, exit-status: 11ms, all of it after the gap. Also noteremote software version Go: there is nosshdin the container, devpod starts its own Go ssh server per connection.Inside the ProxyCommand (
devpod ssh --stdio --debug, timestamps relative):acquired workspace lockstarting agent injection->line=donedone exec->remote agent version matches expected versionconnected to containersu -c 'devpod helper ssh-server --stdio'-> the banner OpenSSH was waiting forThe two 531ms blocks are 1.06s of the 1.66s, and both are the in-container agent binary being asked to print its own version string. Source,
skevetter/devpodv0.26.1:pkg/agent/inject.go::versionChecker.buildExistsChecktemplates the inject guard as! { [ -x "<p>" ] && [ "$("<p>" version)" = "<ver>" ]; }, anddetectRemoteAgentVersionthen runs<p> versionagain as its own exec. Measured directly:docker exec <cid> /usr/local/bin/devpod version= 479-501ms, againstdocker exec <cid> true= 65-74ms.74+490twice is 1128ms against 1062ms observed. The arithmetic closes.And ~0.78s of that is telemetry.
cmd/version.goprints one string; the time istelemetry.StartCLIin root'sPersistentPreRunEplusCollectorCLI.Flush()inExecute(cmd/root.go). A/B: hostdevpod version398-441ms, withDEVPOD_DISABLE_TELEMETRY=true45-55ms; in-container agent 479-501ms, withdocker exec -e DEVPOD_DISABLE_TELEMETRY=true99-111ms.devpod --helpshort-circuits both at 46-55ms.Per-connection vs per-container-lifetime: none of the 1.66s is per-connection in substance. Every part of it answers a question about the container ("is the agent installed", "which version", "where is the workspace info") whose answer cannot change while the container lives. It is re-derived per connection only because each ProxyCommand is a fresh process holding no state. The genuinely per-connection floor is one
docker execplus an ssh handshake: 75-150ms against 1800ms paid.2. Does devpod reuse connections? No, and nobody upstream is asking
No flag for reuse, multiplexing, a control socket or a persistent tunnel in
devpod ssh --helpat 0.26.1. The two that look promising do nothing, as expected since services start after the tunnel: baseline 2074-2222ms,--start-services=false2004-2168ms,--agent-forwarding=false2024-2319ms.Which upstream matters here. The pinned devpod is
github.com/skevetter/devpodv0.26.1 (read out of the shipped binary's Go build info), a fork ofloft-sh/devpod. loft-sh's own latest release is v0.6.15 (2025-03-10), prereleases to v0.7.0-alpha.34 (2025-06-23), last push 2025-11-14. The fork releases on its own line (v0.24.0 to v0.26.1, 2026-05-20 to 2026-06-29). So this repo's version numbers are the fork's, and any 0.26-era question has to be asked of skevetter, not loft-sh. Worth writing down: it is easy to search the wrong repo. (GitHub code search does not index forks, so its source was read from raw refs at tagv0.26.1.)Searched both repos for ControlMaster, multiplex, connection reuse, ssh slow/performance/latency, telemetry, inject: no issue or PR proposes or discusses connection reuse. One is relevant for another reason: loft-sh/devpod#1929, see §5.
3. ControlMaster over devpod's config: incidental, and the config is rewritten
Incidental.
pkg/ssh/config.go::sshConfigBuilder.addSSHOptionsemits exactlyForwardAgent yes,LogLevel error,StrictHostKeyChecking no,UserKnownHostsFile /dev/null,HostKeyAlgorithms …, plusProxyCommandandUser.ControlMasterappears nowhere in the v0.26.1 tree. It works because devpod writes an ordinaryHoststanza and OpenSSH multiplexes a ProxyCommand transport like any other. Nothing in devpod knows about it, so nothing promises it. That also means it does not need devpod's cooperation, only devpod continuing to write a normal ssh config.Who writes it, when.
ConfigureSSHConfigtargets, in order: context optionSSH_CONFIG_INCLUDE_PATH, else contextSSH_CONFIG_PATH/devpod up --ssh-config/DEVPOD_SSH_CONFIG, else~/.ssh/config. It strips the workspace's marker section, re-adds it, and rewrites the whole file withos.WriteFile(path, content, 0o600). Only guard is a process-localsync.Mutex, so two devpod processes writing one config is unprotected.Measured:
Hostblock outside themdevpod ssh --command truedevpod up(no--recreate)Any
devpod up, not just recreate. So editing devpod's block is not a route. Two that survive:-oon the ssh command line (whichclients/ssh.rs::command_argsalready does forSendEnv), and aHost *.devpodblock outside the markers, which I verified resolves (ssh -Greportscontrolmaster autoalongside devpod'sforwardagent yes), survivesdevpod up, and gives 9ms reuse. It wins only because devpod's block sets noControlMaster; a wildcard could never overrideForwardAgent.And:
devpod ssh --command truestays at 2098/2084ms with a live master and a matching wildcard block, because devpod's ssh client is Go and never reads an OpenSSH config. Multiplexing can only help a path that runs OpenSSH.The safety fact: it fails closed, four ways
Measured four ways; no measurement produced a session that silently ran against a dead or wrong container. The mechanism is why: the master's ProxyCommand holds a live
docker execinto one container; destroy it and the exec dies, the ProxyCommand dies, the master exits, OpenSSH unlinks its socket.(a)
--recreate, master idle.ssh -O check->Control socket connect(...): No such file or directory, rc=255, socket gone.(b) 40 back-to-back trips through the master, recreate running concurrently. Each trip read a generation marker in the old container's
/tmpand the container hostname:One trip errors loudly; the next reopens against the new container and reports it correctly, paying the full 1.8s. Failure mode is "one trip errors, the next is correct and slow", never "trips keep succeeding against a ghost". One wrinkle: rc=255 is OpenSSH's transport-failure code and is indistinguishable from a remote command that genuinely exited 255, and trip 29 emitted partial output first. That is inherent to ssh, not made worse by mux.
(c)
docker stop/start, same container id. Master dies and unlinks; next trip 1862ms, reaches the restarted container, reads back the file it wrote before the stop.(d) master
SIGKILLed, socket orphaned. Next trip logsStale control socket ..., unlinking/setting up multiplex master socket, costs 1823ms, succeeds; reuse after that 11-12ms. Self-healing.Two more, because they would have been hazards: a live master does not block devpod (
devpod status578-640ms,devpod ssh --command2145/2578ms,devpod up2101ms, all normal, so the per-workspace lock is released after tunnel setup rather than held for the connection's life); and a plaindevpod upon a running container leaves the master alive and still fast (24ms then 12ms).Caveat:
ControlPath %Chashes local host, remote host, port, user, not the container's identity, so successive generations of a workspace share one socket path. That is fine only because the master reliably dies. It is not defence in depth.4. What a reused connection breaks
--send-env: this contradicts the ticket, and it fails silentlyThe ticket says a reused master carries
SendEnv, "env still arriving". It does not, unless the master itself was opened declaring that variable. WithGH_TOKEN_TEST=hello-389:-o SendEnv=GH_TOKEN_TESTGOT=[hello-389]-t -o SendEnv=...GOT=[hello-389]devpod ssh --send-env GH_TOKEN_TEST --command ...GOT=[hello-389]SendEnv, client passes itGOT=[]GOT=[hello-389]GH_TOKEN_TEST, client passesSendEnv=OTHER_VAROTHER=[]Both ends must permit the name: the client puts its environment in the mux request, and the master filters it against the master's own
SendEnvlist before emitting theenvchannel requests. No warning, no "Sending environment" line on the mux path, exit status 0.Manageable for
dl, whose one variable isGH_TOKEN: open the master with-o SendEnv=GH_TOKEN. One further measurement says the values stay fresh even when the permit list is stale, a master opened withGH_TOKEN_TEST=hello-389deliveredROTATED-VALUEto a later reuse. But the failure mode if this is ever got wrong is the worst available: a workspace comes up with an emptyGH_TOKEN, silently, andghinside it is simply unauthenticated. Any multiplexing design needs the master'sSendEnvlist pinned by a test, not by a comment. (devpod's Go server has noAcceptEnvallowlist to worry about; arbitrary names arrive on a fresh connection.)ForwardAgent yes: works, and pins the agent to the masterSurvives reuse (
ssh-add -lin the container listed the host key over both fresh and reused). But it pins to whichever agent opened the master, for the master's whole life: reuse from a client pointing at a second agent with a different key still saw the first agent's key, and reuse from a client with noSSH_AUTH_SOCKat all still got a forwarded socket with the first agent's key. AControlPersistwindow is a window in which every session into that workspace reaches an agent the current caller may not have, and may not want forwarded.The remote exit status: a reused ssh does not break it, it removes the need for the machinery
Confirmed both sides. Observed:
That sentence is exactly
devpod.rs'sREMOTE_EXIT_MARKERand thefataltag isFATAL_TAG, so the shipped recovery is reading this. Why it is needed is now confirmed at source:cmd/root.go::Executedoes try to propagate,if sshExitErr, ok := err.(*ssh.ExitError); ok { os.Exit(sshExitErr.ExitStatus()) }, an unwrapped type assertion against an error wrapped twice (tunnel to container:thenrun in container:), so it fails and devpod exits 1. TheTry using the --debug flaglineStderrFilterholds back is emitted a few lines below in the same function.OpenSSH has none of this. Remote status becomes ssh's own status, identically with and without multiplexing:
devpod sshSo a direct ssh path needs neither
StderrFilternorinterpret, which is whatclients/ssh.rs's own doc comment already claims; this confirms it and extends it to the multiplexed case. The cost of the simplification is the 255 ambiguity in §3(d): devpod's stderr protocol, for all its awkwardness, distinguishes "transport failed" from "exited 255".5. Not asked for, and possibly the biggest thing here: concurrency
devpod ssh --stdiotakes a per-workspace lock (acquired workspace lock,workspace_client.go:284/:289), so non-multiplexed trips to one workspace do not merely cost 1.8s each, they cost 1.8s each in series. Eight concurrentsshruns each doingsleep 8, no mux:A 2s staircase, and devpod says why on stderr:
Trying to lock workspace, seems like another process is running that blocks this workspace(machine_client.go:311). The same eight over one master: all 8019-8026ms. Perfectly parallel.Same shape as loft-sh/devpod#1929 ("only 4 can be established, the 5th hangs"), which at 0.26.1 presents as serialization rather than failure. Multiplexing is a mitigation: N logical sessions collapse to one
devpod ssh --stdio, so the lock is taken once.Reference (quiet machine)
devpod ssh <ws> --command truessh -F <devpod config> <ws>.devpod true, fresh-t(pty)ControlMaster/ControlPersist-tdocker exec <cid> bash -lc truedocker exec <cid> truedevpod status <ws>devpod --helpdevpod version/ telemetry offReuse is 150-200x cheaper than a fresh trip;
docker execis 13-27x cheaper. Both figures in the ticket's table reproduce.What this does NOT settle
dlshould do. That is The trip floor, not the trip count: how dl reaches into a running container #390, deliberately. Nothing here argues for multiplexing, fordocker exec, or for leaving the trip alone.DEVPOD_DISABLE_TELEMETRYon the host process (saves ~350ms, one host-side flush: 2074-2222ms -> 1716-1980ms) nor contextTELEMETRY=falsepropagates into those invocations, and one guess at an in-container config file did not either. It might be reachable by an upstream change tobuildExistsCheck, by dropping the redundant secondversionexec, or by an env var the docker driver already forwards. Untested, and the most valuable open thread here: a ~40% cut to the trip that asks nothing ofdl's architecture.docker execdying with the container. A ssh or kubernetes provider's ProxyCommand dies for different reasons, or possibly does not.ControlPersistwindows. Everything used 600s and reused within seconds. Nothing measured about a master idle for minutes, about devpod's--ssh-keepalive-interval 55sinteracting with an idle master, or about a laptop suspending under one.ForwardAgentpolicy. That a reused master pins forwarding to the first caller's agent is measured. Whether that is acceptable is a judgement aboutdl's users.clients/ssh.rs::config_path()hardcodes~/.ssh/config, while devpod 0.26.1 writes toDEVPOD_SSH_CONFIG/SSH_CONFIG_PATH/SSH_CONFIG_INCLUDE_PATHwhen any is set, and writes only there. UnderDEVPOD_SSH_CONFIG(what a scratch-scoped run uses)~/.ssh/configwas never created at all, sodevpod_host_configuredanswers false anddlsilently keeps every command on the devpod transport, losing the pty path.Branch:
research/devpod-ssh-trip(docs/research/devpod-ssh-trip.md).The trend is not dark, and the chart's first 35 points are the real gap
feat: one reusable OpenSSH connection per workspace
feat: one reusable OpenSSH connection per workspace
feat: one reusable OpenSSH connection per workspace