Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
passing its own token further down, and `DEVLAUNCH_NO_CLAUDE_TOKEN` still beats
everything.

- **A `devpod up` that has gone quiet says so, every 30 seconds, with the number
going up.** A cold launch spends minutes on a single `postCreateCommand` step
and said nothing while it did. Measured on one host: two consecutive lines
**5m01s apart**, with nothing in between to say the launch was alive, which
step it was on, or how long that step had been running. The same launch warm
was 21.6s in total, so the ratio between the two is about 16x and a reader had
no way to tell from the terminal which one they were in.

```
11:42:52 Pixi task (ui-install-locked): npm --prefix frontend ci
Still working: devpod has printed nothing for 30s.
Still working: devpod has printed nothing for 1m00s.
...
11:47:53 added 585 packages in 5m
```

**The measurement restarts at every line devpod writes**, so it is the age of
the step devpod is on and not the age of the launch, and its restarting is the
whole of what tells a long step from a wedged one. Neither is something `dl` can
diagnose; what it can do is stop them looking identical. A launch that is
talking never produces one of these, because devpod's own steps are seconds
apart, which keeps the line off a warm launch entirely and off most of a cold
one.

Worth being clear about attribution: **the five minutes are the repository's own
`postCreateCommand`, not devlaunch's work.** `dl` cannot make `npm ci` faster and
does not try.

Written as whole lines rather than one refreshed in place, deliberately. `dl`
echoes devpod's output but does not compose it, so a line rewritten with a
carriage return could not be relied on to be the last thing written and would
be overtyped by devpod's next; and a whole line is the only shape that also
survives stderr being a file, which is what it is in CI and in the log `aid`'s
background boot replays from.

### Changed

- **`devpod up` runs as a watched session rather than a plain passthrough**, which
is what makes the line above possible: only something sitting on the read can
time a silence, since a line sink that is not being called says nothing. The
trade is that `devpod up`'s stderr is a pipe now and no longer a terminal to
devpod. stdin and stdout are untouched, so the terminal devpod puts into raw
mode is exactly as it was.

**The process group is untouched too, and that is the part that mattered.** The
build still leads a group of its own, so a Ctrl-C (or `kill -INT`, or a closed
terminal) still takes it down with `dl` rather than orphaning it holding the
launch lock, which is concurrency review F3 and has its own suite. The
parent-side `setpgid` and the note the interrupt handler reads were one method's
private business and are now a helper both foreground methods take, so there is
one copy of it rather than a second that could be subtly wrong in whichever
method nobody was thinking about.

## [0.29.0] - 2026-09-02

### Added
Expand Down
47 changes: 47 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,53 @@ failing. A master that has gone away leaves a socket the next client unlinks, an
resident `devpod ssh --stdio` process and a `docker exec` per key, and `dl` must
not be the reason a container never goes idle.

## While the launch is still running

`DEVLAUNCH_TIMING` answers after the fact, which is exactly when you no longer
need it. What a cold launch needed was something during, because a cold launch
spends minutes on one step and says nothing while it does. Measured on one host,
streaming devpod's output, two consecutive lines **5m01s apart**:

```
11:42:52 Pixi task (ui-install-locked): npm --prefix frontend ci
11:47:53 added 585 packages in 5m
```

Nothing in between said the launch was alive, which step it was on, or how long
that step had been running. The same launch warm was 21.6s in total with that
step taking 5s, so the ratio between a warm and a cold launch of one branch is
about 16x and a reader had no way to tell from the terminal which one they were
in.

`dl` now times the gaps between devpod's own lines and says so every 30 seconds
while one lasts:

```
11:42:52 Pixi task (ui-install-locked): npm --prefix frontend ci
Still working: devpod has printed nothing for 30s.
Still working: devpod has printed nothing for 1m00s.
...
11:47:53 added 585 packages in 5m
```

Three things it does not claim. **It is not a diagnosis**: a step that is slow
and a step that is stuck both produce it, and the only thing separating them is
the number going up. **It is not devlaunch's time**: the five minutes above are
the repository's own `postCreateCommand`, `dl` cannot make `npm ci` faster and
does not try. And **it is not the age of the launch**: the measurement restarts
at every line devpod writes, so it is the age of the step devpod is on, and its
restarting is what tells a long step from a wedged one.

A launch that is talking never produces one, because devpod's own steps are
seconds apart. That is what keeps the line off a warm launch entirely and off
most of a cold one.

**What it costs.** `devpod up`'s stderr is a pipe now rather than the terminal,
so it is no longer a terminal to devpod and loses whatever devpod does with one.
stdin and stdout are untouched, and so is the process group the build leads: a
Ctrl-C still takes the build down with `dl` rather than orphaning it holding the
launch lock.

## Measuring launch time

Set `DEVLAUNCH_TIMING=1` and a `dl` command ends with one summary on stderr,
Expand Down
4 changes: 4 additions & 0 deletions rust/devlaunch-core/public-api.api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ pub devlaunch_core::api::LaunchNotice::StartingForDotfiles::workspace_id: alloc:
pub devlaunch_core::api::LaunchNotice::TerminalTitle(devlaunch_core::flows::launch::TerminalTitle)
pub devlaunch_core::api::LaunchNotice::TokenNotStaged
pub devlaunch_core::api::LaunchNotice::TokenNotStaged::reason: alloc::string::String
pub devlaunch_core::api::LaunchNotice::UpHasGoneQuiet
pub devlaunch_core::api::LaunchNotice::UpHasGoneQuiet::quiet: core::time::Duration
pub devlaunch_core::api::LaunchNotice::WaitingForSiblingLaunch
pub devlaunch_core::api::LaunchNotice::WaitingForSiblingLaunch::workspace_id: alloc::string::String
impl core::clone::Clone for devlaunch_core::flows::launch::LaunchNotice
Expand Down Expand Up @@ -563,6 +565,8 @@ pub devlaunch_core::flows::launch::LaunchNotice::StartingForDotfiles::workspace_
pub devlaunch_core::flows::launch::LaunchNotice::TerminalTitle(devlaunch_core::flows::launch::TerminalTitle)
pub devlaunch_core::flows::launch::LaunchNotice::TokenNotStaged
pub devlaunch_core::flows::launch::LaunchNotice::TokenNotStaged::reason: alloc::string::String
pub devlaunch_core::flows::launch::LaunchNotice::UpHasGoneQuiet
pub devlaunch_core::flows::launch::LaunchNotice::UpHasGoneQuiet::quiet: core::time::Duration
pub devlaunch_core::flows::launch::LaunchNotice::WaitingForSiblingLaunch
pub devlaunch_core::flows::launch::LaunchNotice::WaitingForSiblingLaunch::workspace_id: alloc::string::String
impl core::clone::Clone for devlaunch_core::flows::launch::LaunchNotice
Expand Down
46 changes: 45 additions & 1 deletion rust/devlaunch-core/src/clients/devpod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@

use crate::json::JsonKind;
use crate::runner::{
CapturedText, EnvSpec, Exit, Invocation, OsFailure, Outcome, Runner, SpawnSpec, StdinPlan,
CapturedText, EnvSpec, Exit, Invocation, OsFailure, Outcome, Runner, SessionOutput, SpawnSpec,
StdinPlan,
};
use crate::timing;

Expand Down Expand Up @@ -248,6 +249,49 @@
ran(runner.session(&call.spec(), &mut forward)).map(|(exit, ())| exit)
}

/// The same call, echoed as it arrives, with the silences between lines timed.
///
/// For the one call that goes quiet for minutes at a stretch and gives a reader
/// no way to tell that from a hang (devlaunch#576). The lines are written back to
/// stderr exactly as [`run_watching_stderr`] writes them, so nothing about what
/// devpod's output looks like changes; what is added is a report on the gaps,
/// which is the thing no line sink can produce because a callback that is not
/// being called says nothing.
///
/// `interval` is how often a continuing silence is reported and the measurement
/// restarts at every line, so what `on_quiet` is handed is the age of the step
/// devpod is on rather than the age of the call.
pub(crate) fn run_watching_silence(
runner: &dyn Runner,
call: &Call,
interval: Duration,
on_quiet: &mut dyn FnMut(Duration),
) -> Result<Exit, NotRun> {
let _span = timing::span(call.round_trip());
let mut watcher = Echoing { interval, on_quiet };
ran(runner.watched_session(&call.spec(), &mut watcher)).map(|(exit, ())| exit)
}

/// [`run_watching_silence`]'s watcher: echo the lines, hand the gaps over.
struct Echoing<'a> {
interval: Duration,
on_quiet: &'a mut dyn FnMut(Duration),
}

impl SessionOutput for Echoing<'_> {
fn line(&mut self, line: &str) {
eprintln!("{line}");
}

fn quiet_interval(&self) -> Option<Duration> {
Some(self.interval)
}

fn quiet(&mut self, quiet: Duration) {
(self.on_quiet)(quiet);
}

Check warning on line 292 in rust/devlaunch-core/src/clients/devpod.rs

View check run for this annotation

Codecov / codecov/patch

rust/devlaunch-core/src/clients/devpod.rs#L290-L292

Added lines #L290 - L292 were not covered by tests
}

/// Whether this is devpod saying it is blocked on a workspace's lock.
///
/// devpod's `initLock` is a *blocking* `flock` acquire with no deadline behind it,
Expand Down
35 changes: 34 additions & 1 deletion rust/devlaunch-core/src/flows/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@
/// the refresh is best-effort and the shell arrives either way.
pub(crate) const DOTFILES_ATTACH_TIMEOUT: Duration = Duration::from_secs(60);

/// How often a `devpod up` that has printed nothing is reported as still running.
///
/// Long enough that a launch which is talking never reaches it — devpod's own
/// steps are seconds apart, so most of a cold launch passes with nothing said
/// about the silence — and short enough that the five minutes devlaunch#576
/// measured reads as ten lines rather than as two. Not a knob: what a knob would
/// tune is how often a line nobody needs is printed, and the answer to not
/// needing it is that it does not appear.
pub(crate) const UP_QUIET_REPORT: Duration = Duration::from_secs(30);

/// How long a cached copy of devpod's context options is believed.
pub(crate) const CONTEXT_OPTIONS_TTL: Duration = Duration::from_secs(3600);

Expand Down Expand Up @@ -419,6 +429,15 @@
/// wait. Handed over *before* the blocking acquisition, which is the only
/// moment at which "this run is now waiting" can be reported at all.
WaitingForSiblingLaunch { workspace_id: String },
/// `devpod up` has printed nothing for this long and is still running.
///
/// Said every [`UP_QUIET_REPORT`] while the silence lasts, and the duration is
/// measured from the last thing devpod said rather than from the start of the
/// call — so it is the age of the step devpod is on, and it restarts when
/// devpod moves to the next one. That restart is the whole of what tells a
/// long step from a wedged one, and neither is a thing dl can diagnose: what
/// it can do is stop them looking identical (devlaunch#576).
UpHasGoneQuiet { quiet: Duration },
/// The launch lock could not be taken, so this `up` is unserialized. Nothing
/// worth failing a launch over: serialization guards a race that may not even
/// be happening.
Expand Down Expand Up @@ -1540,7 +1559,21 @@
// The build runs for minutes in the foreground; it leads a process group of
// its own so a Ctrl-C (or `kill -INT <pid>`) tears the whole build down with
// `dl` rather than orphaning it holding the launch lock.
let exit = devpod::run(context.runner(), &Call::new(args).leading_its_own_group())?;
//
// Watched rather than a plain passthrough, and the trade is stated where it is
// paid: devpod's stderr becomes a pipe, so it is no longer a terminal to
// devpod and loses whatever devpod does with one. What it buys is the only
// thing that tells a long step from a wedged one -- a measurement of the gaps
// between devpod's lines. `postCreateCommand` steps go quiet for minutes at a
// time and nothing said the launch was alive while they did (devlaunch#576).
// stdin and stdout are still this process's, so the terminal devpod puts into
// raw mode is untouched.
let exit = devpod::run_watching_silence(
context.runner(),
&Call::new(args).leading_its_own_group(),
UP_QUIET_REPORT,
&mut |quiet| notices.say(LaunchNotice::UpHasGoneQuiet { quiet }),

Check warning on line 1575 in rust/devlaunch-core/src/flows/launch.rs

View check run for this annotation

Codecov / codecov/patch

rust/devlaunch-core/src/flows/launch.rs#L1575

Added line #L1575 was not covered by tests
)?;
// `up` creates and starts workspaces, so any snapshot of `devpod list` taken
// before it is now out of date.
context.forget_workspaces();
Expand Down
16 changes: 16 additions & 0 deletions rust/devlaunch-runner/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ pub fn devlaunch_runner::Invocation::default() -> devlaunch_runner::Invocation
impl core::fmt::Debug for devlaunch_runner::Invocation
pub fn devlaunch_runner::Invocation::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_runner::Invocation
pub struct devlaunch_runner::Lines<F>(pub F)
impl<F: core::ops::function::FnMut(&str)> devlaunch_runner::SessionOutput for devlaunch_runner::Lines<F>
pub fn devlaunch_runner::Lines<F>::line(&mut self, &str)
pub fn devlaunch_runner::Lines<F>::quiet(&mut self, core::time::Duration)
pub fn devlaunch_runner::Lines<F>::quiet_interval(&self) -> core::option::Option<core::time::Duration>
pub struct devlaunch_runner::OsFailure
pub devlaunch_runner::OsFailure::errno: core::option::Option<i32>
pub devlaunch_runner::OsFailure::kind: core::io::error::ErrorKind
Expand Down Expand Up @@ -166,6 +171,7 @@ pub fn devlaunch_runner::ProcessRunner::capture(&self, &devlaunch_runner::SpawnS
pub fn devlaunch_runner::ProcessRunner::detach(&self, &devlaunch_runner::Invocation) -> devlaunch_runner::DetachOutcome
pub fn devlaunch_runner::ProcessRunner::passthrough(&self, &devlaunch_runner::SpawnSpec) -> devlaunch_runner::Outcome
pub fn devlaunch_runner::ProcessRunner::session(&self, &devlaunch_runner::SpawnSpec, &mut dyn core::ops::function::FnMut(&str)) -> devlaunch_runner::Outcome
pub fn devlaunch_runner::ProcessRunner::watched_session(&self, &devlaunch_runner::SpawnSpec, &mut dyn devlaunch_runner::SessionOutput) -> devlaunch_runner::Outcome
pub struct devlaunch_runner::SpawnSpec
pub devlaunch_runner::SpawnSpec::invocation: devlaunch_runner::Invocation
pub devlaunch_runner::SpawnSpec::own_group: bool
Expand Down Expand Up @@ -196,8 +202,18 @@ pub fn devlaunch_runner::Runner::capture(&self, &devlaunch_runner::SpawnSpec) ->
pub fn devlaunch_runner::Runner::detach(&self, &devlaunch_runner::Invocation) -> devlaunch_runner::DetachOutcome
pub fn devlaunch_runner::Runner::passthrough(&self, &devlaunch_runner::SpawnSpec) -> devlaunch_runner::Outcome
pub fn devlaunch_runner::Runner::session(&self, &devlaunch_runner::SpawnSpec, &mut dyn core::ops::function::FnMut(&str)) -> devlaunch_runner::Outcome
pub fn devlaunch_runner::Runner::watched_session(&self, &devlaunch_runner::SpawnSpec, &mut dyn devlaunch_runner::SessionOutput) -> devlaunch_runner::Outcome
impl devlaunch_runner::Runner for devlaunch_runner::ProcessRunner
pub fn devlaunch_runner::ProcessRunner::capture(&self, &devlaunch_runner::SpawnSpec) -> devlaunch_runner::Outcome<devlaunch_runner::CapturedText>
pub fn devlaunch_runner::ProcessRunner::detach(&self, &devlaunch_runner::Invocation) -> devlaunch_runner::DetachOutcome
pub fn devlaunch_runner::ProcessRunner::passthrough(&self, &devlaunch_runner::SpawnSpec) -> devlaunch_runner::Outcome
pub fn devlaunch_runner::ProcessRunner::session(&self, &devlaunch_runner::SpawnSpec, &mut dyn core::ops::function::FnMut(&str)) -> devlaunch_runner::Outcome
pub fn devlaunch_runner::ProcessRunner::watched_session(&self, &devlaunch_runner::SpawnSpec, &mut dyn devlaunch_runner::SessionOutput) -> devlaunch_runner::Outcome
pub trait devlaunch_runner::SessionOutput
pub fn devlaunch_runner::SessionOutput::line(&mut self, &str)
pub fn devlaunch_runner::SessionOutput::quiet(&mut self, core::time::Duration)
pub fn devlaunch_runner::SessionOutput::quiet_interval(&self) -> core::option::Option<core::time::Duration>
impl<F: core::ops::function::FnMut(&str)> devlaunch_runner::SessionOutput for devlaunch_runner::Lines<F>
pub fn devlaunch_runner::Lines<F>::line(&mut self, &str)
pub fn devlaunch_runner::Lines<F>::quiet(&mut self, core::time::Duration)
pub fn devlaunch_runner::Lines<F>::quiet_interval(&self) -> core::option::Option<core::time::Duration>
Loading
Loading