Skip to content

feat: a cold launch says it is alive while devpod has stopped saying anything - #579

Draft
blooop wants to merge 2 commits into
mainfrom
feat/576-live-launch-progress
Draft

feat: a cold launch says it is alive while devpod has stopped saying anything#579
blooop wants to merge 2 commits into
mainfrom
feat/576-live-launch-progress

Conversation

@blooop

@blooop blooop commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Closes #576.

The line

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 ticket's measurement was 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 measurement restarts at every line devpod writes. That is the part that answers the ticket's title rather than only its first half: the number is the age of the step devpod is on, 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, because devpod's own steps are seconds apart. A warm launch never sees one at all, and most of a cold one does not either.

Attribution, as the ticket asks: the five minutes are the repository's own postCreateCommand. dl cannot make npm ci faster and does not try.

The two decisions the ticket left open

Whole lines, not a line refreshed in place. The ticket suggested postCreate · npm ci · 2m10s refreshed in place, and I did not build that. dl echoes devpod's output but does not compose it, so a line rewritten with \r could not be relied on to still be the last thing written and would be overtyped by devpod's next. A whole line is also the only shape that survives stderr being a file, which is what it is in CI and in the log aid's background boot replays from. Since the tick only fires while devpod is silent, consecutive ticks are adjacent lines and the step they are about is directly above them.

No phase name in the text, for the same reason: the last devpod line is the line above it. Naming it again would be quoting the screen back at itself.

How

Only something sitting on the read can time a silence: a line sink that is not being called says nothing. So devpod up runs as a watched session rather than a plain passthrough, and Runner grows one defaulted method that reports the gaps as well as the lines.

Defaulted so it is not a second mechanism: every fake and wrapper in the tree keeps implementing session alone and simply never ticks, and ProcessRunner expresses session in terms of the new one, so there is one loop and session is the narrower view of it. The snapshot diff is purely additive, with no signature changed and nothing removed.

What it costs, stated where it is paid

devpod up's stderr is a pipe now and no longer a terminal to devpod, so it loses whatever devpod does with one. stdin and stdout are untouched, so the terminal devpod puts into raw mode is exactly as it was. devpod ssh and devpod delete already ran this way, so this is the established trade rather than a new one.

The part that mattered: the interrupt contract

session hardcoded OwnGroup::No, and devpod up is spawned leading a group of its own on purpose (concurrency review F3): a Ctrl-C has to killpg the build so it comes down with dl instead of outliving it holding the launch lock. Moving up onto session naively would have dropped that silently.

So the parent-side setpgid and the note the interrupt handler reads are no longer one method's private business: start_foreground is a helper both foreground methods take, and ForegroundNote withdraws the note when the child is reaped. One copy of the dance rather than a second that could be subtly wrong in whichever method nobody was thinking about.

rust/dl/tests/interrupt.rs is the suite for that contract and it is unchanged and green against a rebuilt release binary (it spawns target/release/dl, so a stale build would have proved nothing):

test a_ctrl_c_mid_up_removes_the_token_file_and_kills_the_up ... ok
test a_kill_mid_up_removes_the_token_file_and_kills_the_up ... ok
test closing_the_terminal_mid_up_removes_the_token_file_and_kills_the_up ... ok
test an_inherited_ignore_wins_for_the_two_signals_that_mean_it_and_loses_for_ctrl_c ... ok
test a_ctrl_c_that_reaches_dl_mid_session_leaves_a_throwaway_workspace_standing ... ok

Verification

  • Four new runner tests against real processes, which is where this can actually be pinned: a silence reported between two lines and never after the line that ended it; a silence measured from the last line rather than from the start of the call; a watcher that asks for no interval never told about one (the default every existing caller keeps); and a timeout that still kills a watched child rather than being pushed back by the ticks.
  • Two render tests for the words and the 1m00s / 5m01s formatting.
  • Run end to end through the release binary on the launch_scenario.py world with a devpod whose up prints, sleeps and prints again. One tick landed in a 40s gap, devpod's own lines passed through verbatim and in order around it.
  • cargo test --workspace: 32 suites green. cargo clippy --locked --all-targets -- -D warnings and cargo fmt --check: clean. pytest test: 700 passed, 6 skipped.
  • Snapshots regenerated. The runner file gains the trait, the adapter and the defaulted method; public-api.api.txt gains the one notice variant.

Two things in the ticket I did not build, and one correction

The --remote-control half. A blocking AskUserQuestion rendering somewhere other than the terminal aid was invoked in is a different mechanism with a different fix, and a line about devpod up cannot say "waiting for you, elsewhere" about something that is not devpod up.

A correction to the reported-rather-than-measured half. The ticket relays that in aid's interactive flow the terminal shows nothing after the prompt is accepted, flagged as inferred from the binary's strings. Reading the code, that is not so: interactive::BootChild::finish relays the boot log to stderr as it lands, byte for byte, and aid's main calls it before dl runs. What is genuinely silent is the editor window itself, which is the user's own typing time and is silent by design. The ticks land in that log and are relayed with the rest, so aid gets this for free.

Summary by Sourcery

Make quiet devpod up steps visibly report ongoing progress without changing their output or interrupt behavior.

New Features:

  • Report when devpod up has stopped producing output, including the elapsed silence since its last line.

Bug Fixes:

  • Keep watched devpod up processes in their own process group so interrupts and termination continue to cleanly stop the entire build.

Enhancements:

  • Extend runner sessions to optionally observe output gaps while preserving existing line-only callers and timeout behavior.
  • Centralize foreground process-group lifecycle handling for passthrough and watched sessions.
  • Document the new cold-launch progress reporting and its terminal I/O trade-off.

Documentation:

  • Add user-facing changelog and performance documentation for quiet launch reporting.

Tests:

  • Add runner and rendering coverage for silence timing, formatting, disabled reporting, output ordering, and timeout handling.

Chores:

  • Update public API snapshots for the additive runner and launch-notice changes.

…anything

A cold launch spends minutes on one `postCreateCommand` step and said nothing
while it did. Measured on one host, streaming devpod's output, 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 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.

    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.

Whole lines rather than one refreshed in place, and that is a decision rather
than a shortcut: 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.

Five minutes of it are the repository's own `postCreateCommand`. dl cannot make
`npm ci` faster and does not try.

# How

Only something sitting on the read can time a silence: a line sink that is not
being called says nothing. So `devpod up` runs as a watched session rather than
a plain passthrough, and `Runner` grows one defaulted method that reports the
gaps as well as the lines. Defaulted so it is not a second mechanism -- every
fake and wrapper in the tree keeps implementing `session` alone and simply never
ticks -- and `ProcessRunner` expresses `session` in terms of it, so there is one
loop and `session` is the narrower view of it.

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, a `kill -INT` or a closed terminal
still takes it down with dl rather than orphaning it holding the launch lock.
That is concurrency review F3 and `dl/tests/interrupt.rs` is its suite, run
against the rebuilt binary. `session` used to hardcode "no group of its own" and
would have dropped the contract silently, so the parent-side `setpgid` and the
note the interrupt handler reads are now a helper both foreground methods take
rather than one method's private business.

Closes #576

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 8 hours and 56 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR makes devpod up run through a watched stderr session that periodically reports ongoing silence while preserving verbatim output, resetting elapsed time after each devpod line, and retaining timeout and process-group interrupt semantics. It adds the runner abstraction and rendering, comprehensive process-level tests, API/snapshot updates, and user-facing documentation of the behavior and terminal trade-off.

Sequence diagram for reporting silent devpod up steps

sequenceDiagram
    participant DL as dl
    participant Runner
    participant Devpod
    participant Notice as LaunchNotice

    DL->>Runner: watched_session(spec, SessionOutput)
    Runner->>Devpod: start devpod up with stderr pipe
    Devpod-->>Runner: line
    Runner->>DL: SessionOutput.line(line)
    Note over Runner: Reset silence measurement
    loop Every 30 seconds while silent
        Runner->>DL: SessionOutput.quiet(quiet)
        DL->>Notice: UpHasGoneQuiet { quiet }
    end
    Devpod-->>Runner: next line
    Runner->>DL: SessionOutput.line(line)
    Note over Runner: Reset silence measurement
Loading

File-Level Changes

Change Details Files
Adds periodic progress notices when devpod up stops emitting stderr lines, measuring silence from the most recent line and resetting the timer whenever output resumes.
  • Introduces a 30-second quiet interval and UpHasGoneQuiet launch notice.
  • Watches and echoes devpod output while reporting elapsed silent intervals.
  • Formats notices as durable whole lines with minute/second elapsed-time rendering.
rust/devlaunch-core/src/clients/devpod.rs
rust/devlaunch-core/src/flows/launch.rs
rust/dl/src/render.rs
rust/devlaunch-runner/src/lib.rs
Extends the runner abstraction with optional silence monitoring while preserving existing session callers and timeout behavior.
  • Adds SessionOutput and defaulted Runner::watched_session APIs.
  • Implements production timed reads that coordinate quiet ticks with process deadlines.
  • Keeps plain session behavior through the Lines adapter without enabling a clock.
rust/devlaunch-runner/src/lib.rs
rust/devlaunch-runner/public-api.txt
rust/devlaunch-core/public-api.api.txt
Refactors foreground child process-group lifecycle handling so watched and passthrough sessions retain the interrupt contract.
  • Centralizes process-group setup and parent-side setpgid handling in start_foreground.
  • Uses an RAII note to clear the interrupt target after the child is reaped.
  • Preserves Ctrl-C, signal, terminal-close, and launch-lock cleanup semantics.
rust/devlaunch-runner/src/lib.rs
rust/dl/src/flows/launch.rs
rust/dl/tests/interrupt.rs
Adds coverage for timing, rendering, timeout, and end-to-end behavior and documents the user-visible behavior and stderr trade-off.
  • Tests silence ordering, reset semantics, disabled watching, and timeout precedence against real processes.
  • Adds notice and duration-format tests and regenerates snapshots/API baselines.
  • Documents attribution, whole-line output, and the loss of a terminal stderr for devpod up.
rust/devlaunch-runner/src/tests.rs
rust/dl/src/render.rs
CHANGELOG.md
docs/performance.md
rust/dl/tests/interrupt.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#576 Provide live progress feedback during long periods when devpod up emits no output, including the elapsed duration of the current silent step, so a cold launch is distinguishable from a hang.
#576 Ensure the launch feedback also covers remote-control sessions waiting for user input elsewhere, so the invoking terminal can distinguish a launch waiting on a human from a stalled launch. The PR explicitly states that it does not implement the --remote-control case. Its silence reporting only observes devpod up output and cannot report a blocking AskUserQuestion rendered in another terminal or session.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.82759% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.72%. Comparing base (79457e9) to head (d2231b8).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
rust/devlaunch-core/src/clients/devpod.rs 84.21% 3 Missing ⚠️
rust/devlaunch-runner/src/lib.rs 96.87% 2 Missing ⚠️
rust/devlaunch-core/src/flows/launch.rs 80.00% 1 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 94.99% <94.82%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 94.99% <94.82%> (+<0.01%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@blooop
blooop marked this pull request as draft September 4, 2026 12:32
@blooop

blooop commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

The watched stream is the wrong one

devpod up writes its progress log to stdout. This watches stderr, and
devpod up's stderr is empty.

Measured against the devpod this repo pins, v0.26.1
(.pixi/envs/default/bin/devpod), across three runs: stdout piped, stdout on a pty
via script, and a run with a postCreateCommand. stderr received 0 bytes every
time.
The lines #576 quotes came out on stdout:

=== STDOUT (22 lines) ===
12:24:32 info creating devcontainer                       up.go:581
12:24:37 info running postCreateCommands lifecycle hook:  tunnelserver.go:426
=== STDERR (0 lines) ===

devpod's stream logger routes info/done/debug to stdout and only
warn/error/fatal to stderr. devpod provider add reproduces the split in a
second.

What that does to the notice

let mut last_spoke = Instant::now(); (devlaunch-runner/src/lib.rs:673) is set
once at spawn and never restarts, because no line ever arrives on the watched
stream. So a chatty six-minute cold launch prints

Still working: devpod has printed nothing for 30s.
Still working: devpod has printed nothing for 1m00s.
...
Still working: devpod has printed nothing for 6m00s.

with devpod's own lines scrolling past directly above them. The number is the age
of the whole up, not the age of the current step.

That is the PR's central claim inverted -- "the measurement restarts at every
line", "a launch that is talking never produces one" -- and it is #576's actual
requirement too: tell a long step from a wedged one. As built, the counter looks
identical either way, which is the one thing it exists not to do.

Why the tests are green

All four new runner tests drive sh -c 'echo ... >&2'
(devlaunch-runner/src/tests.rs:552+). They pin the mechanism correctly and assume
the premise. The end-to-end run used a fake devpod that also prints to stderr, so it
confirmed the fake. The branch nothing exercises is exactly "devpod prints, and none
of it lands on the watched stream".

Also worth a look, separately

The cost paragraph buys nothing as written: "devpod's stderr becomes a pipe, so it
is no longer a terminal to devpod" is a price paid for a stream carrying no output.

And the precedent this leans on may never have fired either. says_it_is_blocked
(devlaunch-core/src/clients/devpod.rs:258) matches "Trying to lock workspace" on
stderr, and that reads like an info-level stdout line by the same routing rule.
Worth checking on its own rather than as part of this.

The shape of the fix

Watch stdout, or both. Small in lines, but it changes what stdout means for
devpod up -- today it is passed through untouched -- so it is a decision rather
than a patch, which is why this is a comment and not a commit.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A cold launch is silent for minutes at a time, and a hang looks identical to progress

1 participant