Skip to content

Ask the listing's questions together - #540

Merged
blooop merged 11 commits into
mainfrom
claude/ls-concurrent-status
Sep 1, 2026
Merged

Ask the listing's questions together#540
blooop merged 11 commits into
mainfrom
claude/ls-concurrent-status

Conversation

@blooop

@blooop blooop commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Makes the per-workspace devpod status round trips of dl --ls --json overlap instead of running one at a time. Follow-up from the clean-room reconstruction (#539, merged).

Updated for 49501e1. The first two commits were mine and carried a number of defects the owner has since fixed, several of them claims this description asserted and the tree contradicts. They are corrected below rather than dropped, because the pattern is the useful part: every quantitative claim I made without measuring was optimistic, and four guards I wrote looked like they pinned properties and did not.

What it does, and which command it is

dl --ls --json, not dl --ls. I originally credited this to dl --ls and sized it at "about eighteen seconds" on a forty-workspace machine. Wrong. The human table has no state column, so dl --ls costs the single devpod list and nothing per row; enriched_listing has exactly one caller, render_json. The document is what carries a state per workspace, and devpod list does not answer that, so --json asks devpod status once per row. the_table_asks_devpod_for_the_list_and_nothing_else had pinned that distinction all along, in the file I was editing.

Those trips are required and none is removed. What the listing no longer does is wait for each answer before asking the next: they are independent, so they go out in batches of eight.

Measured, on real devpod

Ten workspaces, local docker provider, devpod 0.26.1, 2026-09-01. This closes the item I had flagged as unverifiable from the devcontainer, and it corrects two more of my claims, both optimistic in the same direction:

serial batched
mean trip 0.465s 0.641s (about 38% more)
devpod-up stage 4.656s 1.724s
whole command 5.132s 1.395s

The win is about 3.7x, not the 8x the width implies. I had written "five batches of 0.45s is therefore the figure to expect", which assumed a trip costs the same under contention — the one assumption a bounded pool exists to question. I had also written that the contention trade "has not been measured here" as though it were a neutral gap; it was the load-bearing unknown in my own headline number.

The barrier now has evidence rather than an assertion behind it: the first chunk's eight trips landed between 0.593s and 0.656s, and the second chunk's two took 0.443s and 1.066s, so one outlier is 62% of the stage. A work queue starting the ninth trip the moment any of the first eight returned would have spent the faster thread instead of idling it. Still unmeasured: where the curve turns, since nothing has run at four or sixteen.

Correctness held. Five runs byte-identical; ordering and attribution hold across the chunk boundary, with the single NotFound row landing at row 9, the first row of the second chunk, which is where a reordering or off-by-one would move it. Both timing claims held to three decimals.

Runner gains Sync, and it does reach the promised contract

Handing one &dyn Runner to several threads means the seam must promise it can be shared. That binds every future implementation: a runner holding a RefCell, Rc or Cell no longer compiles.

Correcting my earlier claim: the promised api tier is not untouched. public-api.api.txt promises CommandContext::new(&'r dyn Runner), ColdPath::new, Refresh::ask and Provision::provision_tools, and each now accepts only a Sync implementation. The rows are byte-identical, which is why no snapshot moved: cargo public-api prints the parameter the same either way, so the tightening reaches the promised surface with no row to diff and no guard able to see it. I wrote "the promised api tier is untouched" after finding those four signatures myself; it is true of the file and false of the contract.

Rest of the cost: production code nothing (ProcessRunner is a unit struct); three test wrappers took the RefCell to Mutex change a shared recorder wants anyway. No out-of-tree implementation breaks today — wayfinder does not link devlaunch-core at all, no devlaunch entry in its Cargo.lock — but that is a fact about today, not a guarantee.

State is paired by construction, not by position

container_states first returned states in input order and enriched_listing re-paired with .zip(), which permits a short vector truncating the listing at exit 0, and a completion-order vector giving each row a plausible state from a different workspace. Neither was live; neither was excluded by anything but a debug_assert_eq! compiled out of shipped builds. The worker now carries its workspace back out beside its answer.

Timing

One stage around the whole batch, not one per trip: the registry admits one owner per stage, so per-trip staging would have had one thread close devpod-up while its siblings ran. An empty listing returns before opening one. A worker's panic is carried with resume_unwind.

devpod-up stage seconds are now the batch loop's wall time, so the stage reports less than the spans inside it add up to — 1.724s against 6.554s measured. That is the one place the timing document stops being addition.

Where my own tests did not work

Four guards I wrote were inert or measured the wrong thing. Each is now verified against the defect it names:

  • the_status_trips_of_one_listing_overlap expressed its bar as STATUS_TRIPS_AT_ONCE, so forcing the width to 1 moved the bar with it and it passed against a deliberately serialised build.
  • a_batch_larger_than_the_width_still_answers_for_every_workspace_in_order used a rendezvous of one, so nothing ever overlapped and a completion-order collector slipped past it four runs in five. Widened to two chunks; then only the first chunk rendezvoused, so half was still inert.
  • an_empty_listing_asks_nothing_and_opens_no_stage could not fail against the defect it named.
  • Both new tests lacked timing::exclusive(), which every other fixture in the module holds, so they wrote into whatever document a concurrent measured test had installed: 12 failures in 15 beside launch's timing test under --test-threads=8, 0 in 25 with the guard.

test_status_width_agrees.py diffs the fan-out width against the prose, which was another second copy of a number nothing read.

Still open

The /dev/tty hazard is not closed, and my framing of it was mechanically wrong: I reached for SIGTTIN, but the children stay in dl's process group, so under a pty they are the foreground group. The real hazard is eight children legitimately reading one terminal with no timeout to escape. A docker-only host cannot reach it and this run did not: zero /dev/tty opens, no SIGTTIN. It needs a remote provider to close.

Not in this PR

  • Identity has accreted a stored layer under a derived design (reconcile.rs joins by path and never by id, plus a resumable two-phase migration). Already in flight.
  • The picker's identity is row text, which generates the collision-column machinery, while every_row_carries_its_own_index_or_marking_cannot_accumulate shows indices already exist.
  • Roughly thirty tests pin behaviour byte-for-byte against the retired Python build.

`dl --ls` reads the workspace list once and then asks `devpod status` about every
workspace in it, because the STATE column is reported for every row and `devpod
list` carries no state. That is one round trip per workspace and it cannot be
avoided. What it did not have to do was wait for each answer before asking the
next question: nothing devpod says about one workspace changes what is asked
about another.

At the 0.45s a trip costs (docs/performance.md), a forty workspace machine spent
about eighteen seconds in `dl --ls`. The trips now go out in batches of eight, so
the same forty cost about five rounds of one trip. The number of trips is
unchanged, which is why `the_listing_costs_one_list_and_one_status_per_workspace`
reads exactly as before; only the waiting overlaps.

**`Runner` gains `Sync`, which is the real decision here.** Handing one
`&dyn Runner` to several threads means the seam has to promise it can be shared.
It cost the production implementation nothing, `ProcessRunner` being a unit
struct, and cost three test wrappers the change from `RefCell` to `Mutex` that
any shared recorder needs anyway. The binding half is deliberate: no future
implementation may keep a `RefCell` inside it. A seam that can only be driven
from one thread makes every concurrent flow above it impossible, and putting the
bound at each call site instead would let an implementation exist that satisfies
some callers and not others. One row of `devlaunch-runner/public-api.txt` moves;
the promised `api` tier is untouched.

Two details that are not obvious from the diff:

The stage is opened once around the whole batch rather than once per trip. The
registry admits one owner per stage, so per-trip staging would have had whichever
thread opened `devpod-up` close it while its siblings were still running, and
their spans would have landed outside any stage. An empty listing returns before
opening one at all, because the serial version never reached the function that
opened it and an empty stage is a reported step that did not happen.

A worker's panic is carried rather than replaced, so a listing that panics still
says why.

`the_status_trips_of_one_listing_overlap` pins the property with a rendezvous
rather than a sleep: every trip announces itself and waits for one more, so
overlap returns at once and a serial build fails on the high-water mark. It
expects a literal 2 rather than the pool width, because expressing the bar in
terms of the constant under test is how an earlier version of it passed against a
build deliberately serialised to one.

@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 5 days and 4 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Reviewer's Guide

dl --ls now overlaps independent devpod status requests in bounded batches of eight, reducing wall-clock latency without changing request count, ordering, error/panic propagation, or timing-report behavior; the shared Runner contract and test fakes are updated for concurrent use, with focused concurrency and chunking tests.

Sequence diagram for concurrent workspace status listing

sequenceDiagram
    participant CLI as dl --ls
    participant Listing as enriched_listing
    participant Runner as Runner
    participant Devpod as devpod status

    CLI->>Listing: enriched_listing()
    Listing->>Listing: container_states()
    loop Batches of up to 8 workspaces
        par Independent status requests
            Listing->>Runner: capture(status workspace 1)
            Runner->>Devpod: status workspace 1
            Devpod-->>Runner: state 1
            Runner-->>Listing: answer 1
        and
            Listing->>Runner: capture(status workspace 2)
            Runner->>Devpod: status workspace 2
            Devpod-->>Runner: state 2
            Runner-->>Listing: answer 2
        end
        Listing->>Listing: Append answers in workspace order
    end
    Listing-->>CLI: Enriched listing
Loading

File-Level Changes

Change Details Files
Parallelize workspace status lookups while preserving bounded concurrency, result ordering, and timing semantics.
  • Collect one status result per workspace in batches of eight using scoped threads.
  • Open the devpod-up timing stage once around all non-empty batches and fail it if any status could not run.
  • Preserve empty-list behavior, panic causes, and row ordering while passing fetched state into row enrichment.
rust/devlaunch-core/src/flows/listing.rs
docs/performance.md
Make the runner abstraction safely shareable across concurrent status requests.
  • Add Sync as a supertrait bound to Runner and document the API tradeoff.
  • Update the generated public API snapshot.
rust/devlaunch-runner/src/lib.rs
rust/devlaunch-runner/public-api.txt
Adapt test doubles and shared test recorders to the new thread-safety contract.
  • Replace RefCell/Rc recorders with Mutex/Arc where shared access is possible.
  • Require fake callback effects to be Send + Sync.
rust/devlaunch-core/src/flows/agent_worktrees/tests.rs
rust/devlaunch-core/src/flows/repo_manager.rs
rust/devlaunch-core/src/flows/workspace_clone.rs
Add deterministic coverage for concurrency and bounded batch processing.
  • Use a condition-variable rendezvous to verify status calls overlap without sleep-based timing.
  • Verify a nine-workspace listing answers every workspace in original order across an eight-request batch boundary.
rust/devlaunch-core/src/flows/listing.rs

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

`rust-coverage` went red on the snapshot guard: it holds the Runner row by exact
string, and the row is now `pub trait devlaunch_runner::Runner:
core::marker::Sync`. My own fault for regenerating the snapshot after running the
workspace and not running it again, so the failure reached CI instead of this
machine.

Pinned as the whole row rather than loosened to a prefix match. The test's
subject is "the trait an implementer writes against", and a supertrait is part of
that in the same way a method is: `Sync` is what says a runner may be handed to
several threads at once, which is what lets the listing ask its status trips
together. Dropping it later would break every implementation that had come to
rely on being shareable, so it should cost a deliberate edit to this line, which
a prefix match would not have.
@codecov

codecov Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.27027% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.11%. Comparing base (741b935) to head (49501e1).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
rust/devlaunch-core/src/flows/listing.rs 93.71% 10 Missing ⚠️
rust/devlaunch-core/src/flows/workspace_clone.rs 63.63% 8 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.41% <90.27%> (-0.05%) ⬇️

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

Components Coverage Δ
shipped code (rust) 95.41% <90.27%> (-0.05%) ⬇️
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.

A fresh-context review of this branch found the two new tests writing into the
process-global timing registry without holding the exclusion every other fixture
in this module takes. Measured, not argued: run either of them beside
`launch`'s `a_warm_launch_reports_the_devpod_probe_and_the_attach_and_nothing_else`
under `--test-threads=8` and it failed 12 runs in 15. Zero in 25 with the guard.

`container_states` opens `devpod-up` on the global registry and every worker
records a span into it, so a listing built without the guard writes into whatever
document a concurrent measured test installed, and its stage guard closes a stage
that test opened. `FakeDevpodRealGit` already takes `timing::exclusive()` for
exactly this reason and its doc says so; `Overlapping` is a second runner in the
same module and skipped it. It also runs both ways: the listing's stage can be
the one a launch test's `stage_result` finds `AlreadyOpen`, after which the launch
document reports `devpod-up` seconds it never spent. That would have surfaced as
an unreproducible flake on a narrowed filter, since the full suite hides it.

**The order guard was weak.** With a rendezvous of one, no trip ever overlapped
another, so the test exercised the chunk boundary but never out-of-order
completion, which is the only way a reordering implementation manifests. The
review measured a completion-order collector slipping past it four runs in five.
It is now two full chunks at the full width, so every trip in a chunk is released
together; a reordering build fails on essentially every run, which was checked.

Also from the review:

- Deleting `container_state` left its doc block orphaned onto `unsaved_work_in`,
  which rustdoc rendered as one comment opening with two paragraphs about status
  parsing. Removed, and the part that is still true (every unreadable answer
  collapses to `None`) moved onto the function that now does it.
- `docs/performance.md` said forty workspaces cost "about five rounds of a single
  trip". `chunks` is a barrier, not a pool: a batch costs its slowest trip and the
  next does not start until the last returns. Says so now, along with what a work
  queue would buy, and no longer asserts a contention figure nobody measured.
  Also records that the `devpod-up` stage seconds shrink, since the stage is now
  the batch loop's wall time rather than the sum of the rows.
- The empty-listing early return had no test. It has one.
- `zip` would drop rows rather than fail if `container_states` ever came back
  short. `debug_assert_eq!` states the invariant where it is relied on.
- `repo_manager`'s new bound said "`Send + Sync` because `Runner` is". `Runner`
  gained `Sync` only.
- CHANGELOG entry, which a seam contract change had no business omitting.

One open question the review could not settle, now settled: no out-of-tree
implementation of `Runner` is broken by the supertrait, because wayfinder does not
link `devlaunch-core` at all. Its Cargo.lock holds no devlaunch entry; the only
mentions in its tree are issue references and fixture strings.

blooop commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

A fresh-context adversarial review of this branch found a defect in the two tests I added, plus six smaller things. All are addressed in 802f292. Recording what it found, because the top one would have been unpleasant to diagnose later.

The tests were corrupting other tests' timing documents

container_states opens devpod-up on the process-global timing registry and every worker records a span into it. Overlapping did not hold timing::exclusive(), which FakeDevpodRealGit in the same module already takes for precisely this reason, with a doc comment saying so.

Measured rather than reasoned about. Running either new test beside launch's a_warm_launch_reports_the_devpod_probe_and_the_attach_and_nothing_else under --test-threads=8:

failures
before the fix 12 / 15
after the fix 0 / 25

The full suite hides it, so this would have surfaced as an unreproducible flake on a narrowed filter or a re-run. It also runs in both directions: the listing's stage can be the one a launch test's stage_result finds AlreadyOpen, after which the launch document reports devpod-up seconds it never spent.

The order guard was weak

a_batch_larger_than_the_width_still_answers_for_every_workspace_in_order used a rendezvous of one, so no trip ever overlapped another. It exercised the chunk boundary but never out-of-order completion, which is the only way a reordering implementation actually manifests. A completion-order collector was measured slipping past it in roughly four runs out of five.

It is now two full chunks at the full width, so every trip in a chunk is released together. Verified that a build which reverses answers within a chunk fails it, and that the honest version passes.

The rest

  • Deleting container_state left its doc block orphaned onto unsaved_work_in, which rustdoc rendered as a single comment opening with two paragraphs about status parsing. Removed; the part still true (every unreadable answer collapses to None) moved onto the function that now does it.
  • docs/performance.md claimed forty workspaces cost "about five rounds of a single trip". chunks is a barrier, not a pool: a batch costs its slowest trip and the next does not start until the last returns. The section now says that, notes what a work queue would buy instead, and drops a contention figure nobody had measured. It also records that devpod-up stage seconds shrink for dl --ls, since that stage is now the batch loop's wall time rather than the sum of the rows.
  • The empty-listing early return had no test. It has one.
  • zip would silently drop rows rather than fail if container_states ever came back short; debug_assert_eq! now states the invariant where it is relied on.
  • The new bound in repo_manager.rs said "Send + Sync because Runner is". Runner gained Sync only.
  • Added the CHANGELOG entry, which a seam contract change had no business omitting.

One open question, now settled

The review could not determine whether any out-of-tree Runner implementation is broken by the supertrait. None is: wayfinder does not link devlaunch-core at all. Its Cargo.lock holds no devlaunch entry and its Cargo.toml no devlaunch dependency; the only mentions anywhere in its tree are issue references in comments and fixture strings. The frozen api tier is a prepared surface that wf has not yet consumed, so this break has no current consumer.

Still not verified

Real devpod under eight concurrent status calls. There is no devpod in this container. CI's e2e job passed on the previous head with real devpod and real containers, which is partial evidence, but if a provider can prompt on /dev/tty during a capture then eight children prompting one terminal at once is a failure mode nothing here has exercised. One manual DEVLAUNCH_TIMING=1 dl --ls against a remote provider before merging would close it.

Validation on 802f292: 1,560 pass (cargo test --workspace), clippy and fmt clean, Python suite 659 pass. The only failure is the pre-existing a_write_that_cannot_start_leaves_the_previous_file_readable, which relies on chmod 0o500 blocking a write and so cannot pass as root; it fails identically on an unmodified tree here.


Generated by Claude Code

`STATUS_TRIPS_AT_ONCE` is stated in prose five times on `docs/performance.md` --
"batches of eight", "a pool of eight permits", "eight is a conservative pick",
and the worked example "forty workspaces cost five batches" twice over. That is a
second hand-maintained copy of one number, which this repository allows only with
a test beside it that diffs the copies.

Nothing would have caught the drift. `test_bench_doc.py` reads this page for the
bench harness rows and `test_docs_prose.py` reads it for em dashes; neither holds
a sentence to being true. Tuning the constant is a one-character edit in a file no
doc guard reads, and the page would go on saying eight.

Measured rather than argued: set the constant to 16 and this guard fails twice,
once on the width and once on the arithmetic ("the page says 40 workspaces cost 5
batches, but at a width of 16 they cost 3"). The instrument is the sentences the
page already writes, so a rewrite that drops the claim fails here rather than
passing quietly, and `CHANGELOG.md` is out of scope for the same reason
`test_citations_resolve.py` exempts it.

The regexes match letters rather than `\w`, because the page also writes "Five
batches of 0.45s" and a `\w+` reads that duration as the width.

Claude-Session: https://claude.ai/code/session_011BUMqTboYui66dxqxfRCo1
…s not

"One row of `devlaunch-runner/public-api.txt` moves; the promised `api` tier is
untouched" is true of the *file* and false of the *contract*, which is the
opposite of what a reader of a breaking-change note needs.

`devlaunch-core/public-api.api.txt` promises `CommandContext::new(&'r dyn
Runner)` (line 283), `ColdPath::new`, `Refresh::ask` and
`Provision::provision_tools`. Every one of them names a `dyn Runner`, and every
one of them now accepts only a `Sync` implementation. The rendered rows are
byte-identical, which is exactly why the file did not move -- `cargo public-api`
renders the parameter as `&'r dyn devlaunch_runner::Runner` either way, so a
supertrait tightening reaches the promised surface with no row to diff and no
guard able to see it.

The sentence one paragraph earlier already gets this right ("Any out-of-tree
implementation holding a `RefCell`, `Rc` or `Cell` no longer compiles"), so this
is a wrong reassurance sitting beside a correct warning, which is worse than
either alone.

Not testable as written: the defect is that the snapshot cannot observe the
narrowing, so any guard I added here would assert on prose rather than on the
contract. Named in the review report as the design question it actually is.

Claude-Session: https://claude.ai/code/session_011BUMqTboYui66dxqxfRCo1
`container_states` returned `Vec<Option<ContainerState>>` in input order and
`enriched_listing` re-paired it with `.zip()`. Two things that shape permits,
both silent, and the batched fan-out is what puts them in reach:

- a short vector truncates the listing rather than failing, so `dl --ls --json`
  prints fewer workspaces than exist and exits 0;
- a vector in completion order rather than spawn order gives every row a
  plausible `state` belonging to a different workspace.

Neither was live. The point is that neither was excluded by anything except a
`debug_assert_eq!` that is compiled out of the shipped binary and a six-line
comment explaining what the positions mean, which is the invariant the type was
supposed to carry. The diff's own test comment records the second one being
measured slipping through four runs in five against an earlier shape.

The worker now carries its workspace back out beside its answer, so the pairing
is made where the trip is made and never re-derived. The compiler is the proof:
there is no longer a bare vector to zip, mis-pair, or truncate, and
`enriched_listing` maps the pairs straight through. The `debug_assert_eq!` and
its comment delete with the hazard they described.

`a_batch_larger_than_the_width_still_answers_for_every_workspace_in_order` now
checks each pair against itself -- the fake echoes the id it was asked about as
the state, so a row carrying another row's answer is visible inside the pair --
and keeps the order assertion separately. Verified by reversing each chunk's
answers: the order assertion fires and every per-pair assertion still passes,
which is the shape of the guarantee that was just added.

Claude-Session: https://claude.ai/code/session_011BUMqTboYui66dxqxfRCo1
…trips

`docs/performance.md` opened with "`dl --ls` is the one command whose cost grows
with the machine ... because the `STATE` column is reported for every row", and
sized it at "about eighteen seconds" on a forty-workspace machine. The CHANGELOG
entry and two comments in `listing.rs` said the same.

There is no `STATE` column. `dl --ls` renders the human table, whose header is
`WORKSPACE TYPE SOURCE LAST USED` (`dl/src/render.rs:125`), built by
`workspace_table` from the single `devpod list` and nothing else. `render_table`'s
own doc has said "Still one devpod round trip" since before this branch, and
`the_table_asks_devpod_for_the_list_and_nothing_else` has pinned it, one screen
below the tests this PR added: "the table has no state column, so `dl --ls` is one
devpod round trip where `dl --ls --json` is one per workspace."

`enriched_listing` has exactly one caller, `render_json` (`dl/src/commands.rs:273`).
So every per-workspace trip this PR parallelised belongs to `dl --ls --json`, and a
reader sizing `dl --ls` off this page was told to expect eighteen seconds for a
command that costs one round trip.

I wrote a fresh guard for this before finding the existing one, and deleted it
rather than leave a second copy: the repository's standing rule is that a second
hand-maintained copy of a fact needs a test diffing it against the first, and here
the test already existed and the prose simply contradicted it. It is cited by name
from the page now, so the two are read together.

Verified the guard is not vacuous rather than trusting it: making `workspace_table`
call `container_states` fails it with six devpod calls against the one it demands.

Also corrected here, from the same reading: the page and the CHANGELOG now say
that `devpod-up` stage seconds are smaller *than the spans inside the stage add up
to*, which is the counterintuitive half and the only place the timing document
stops being addition.

Claude-Session: https://claude.ai/code/session_011BUMqTboYui66dxqxfRCo1
`an_empty_listing_asks_nothing_and_opens_no_stage` asserted that no trips were
made and that the answers were empty. Neither can distinguish the early return
from its absence: `[].chunks(8)` yields no chunks, so with the `is_empty` guard
deleted the loop body never runs, `states` is still empty and `high_water` is
still 0. Measured, not argued -- deleting the guard and running the test passes.

The named property is "opens no stage", and the test never looked at a stage.
Worse, it could not have: `timing::stage` returns a guard with `stage: None`
while `RECORDING` is false, and no registry was installed, so the stage guard
under test was a no-op in the only run that exercised it.

It now installs a document registry the way `lifecycle`'s
`a_devpod_that_cannot_be_run_fails_the_stage_but_one_that_refuses_does_not`
does, emits, and asserts `devpod-up` is absent from the stages. With the early
return deleted it fails with "an empty listing reported a devpod-up stage it
never spent: [\"devpod-up\"]", which is the defect the test was written for.

Claude-Session: https://claude.ai/code/session_011BUMqTboYui66dxqxfRCo1
…t was inert

`Overlap::arrivals` counted arrivals ever and the rendezvous waited for it to
reach `want`. `chunks` releases one batch and then starts another, so by the time
the second chunk's first trip arrived the total was already 9, `9 < 8` was false,
and every trip in that chunk returned without waiting for anything.

That makes the comment on
`a_batch_larger_than_the_width_still_answers_for_every_workspace_in_order` false
for half its input. It says "Held to the width, every trip in a chunk is released
together, so a collector that reads completion order sees a shuffled chunk on
essentially every run", and by its own next clause -- "a trip that never overlaps
another cannot reorder anything" -- the second chunk was contributing no
reordering coverage at all. The test still caught the defect on chunk one, so this
is dead coverage behind a false claim rather than a dead test, which is the kind
that rots quietly: the sixteen ids read like twice the coverage of eight.

The barrier now re-arms. `waiting` resets when the batch trips it, so each chunk
rendezvouses on its own, and `released` counts the trips so the property is
asserted rather than described. `high_water` is asserted at exactly
`STATUS_TRIPS_AT_ONCE` too, which is deterministic here for the same reason: no
trip leaves before the eighth arrives, and the next chunk cannot start until this
one is joined.

Verified by stopping the barrier re-arming: `released` comes back 9 rather than 2
-- one real trip plus the eight that sailed past -- and the test fails. Ran the
fixed version eight times for the two new deterministic assertions; ok every time.

Claude-Session: https://claude.ai/code/session_011BUMqTboYui66dxqxfRCo1
…once

`FakeDevpodRealGit::new`'s doc had a stray break leaving `/// and` alone on a
line, so rustdoc rendered "...a concurrent measured test installed, and its stage
guard closes..." with a gap in the middle of the clause. Rewrapped, and the
"enriched listing built without the guard" phrasing dropped for "listing", since
the sentence was already carrying the qualifier twice.

`Overlapping::_serialized` then restated the same paragraph in full -- the
process-global registry, the span every worker records, the stage guard closing
another test's stage. It now cites `FakeDevpodRealGit::new` and keeps only what is
its own: the measurement, 12 failures in 15 without the field and 0 in 25 with it.

The other two statements of the staging argument are deliberately left. They are
not copies of this one or of each other: `container_states`'s doc block argues why
the stage is opened once around the whole batch, and the inline comment above the
early return argues why it is opened after the empty check rather than before.
Different claims about different lines.

Claude-Session: https://claude.ai/code/session_011BUMqTboYui66dxqxfRCo1
…enied

Verified against real devpod on a host, which the devcontainer cannot do. Ten
workspaces on the local docker provider, devpod 0.26.1, 2026-09-01. Correctness,
ordering and attribution across the chunk boundary all hold: five runs
byte-identical, and the one `NotFound` row sits at row 9, the first row of the
second chunk, which is where a reordering or an off-by-one would move it.

Two claims on `docs/performance.md` were wrong, both in the optimistic direction:

- "Five batches of 0.45s is therefore the figure to expect." A trip costs about
  38% more when eight are in flight: 0.465s asked alone against 0.641s in a
  batch. The whole command went 5.132s to 1.395s, which is about 3.7x and not the
  8x the width implies. The page and the CHANGELOG now carry the measured
  numbers.
- "How that trades against the extra overlap has not been measured here." It has
  now, at eight. What is still unmeasured is where the curve turns, since nothing
  has run at four or sixteen, and the page says that instead.

The barrier paragraph gets the evidence it was arguing from: the first chunk's
eight trips landed between 0.593s and 0.656s, and the second chunk's two took
0.443s and 1.066s, so one outlier is 62% of the stage. The work queue the page
names as the better shape would have saved the faster thread's wait, which is now
a figure rather than an assertion.

Both timing claims held exactly. Span count unchanged at 10; the serial stage
equals its span sum to three decimals (4.656s) while the concurrent stage reports
1.724s against 6.554s of spans.

`container_states` now records the one path nothing has exercised, with the
hazard stated correctly. SIGTTIN is not it: the children stay in dl's own process
group, so under a pty they are in the foreground group. The hazard is eight
children legitimately reading one terminal with no timeout to escape it. A
docker-only host cannot reach it, and this one did not: zero `/dev/tty` opens and
no SIGTTIN across the run. It needs a remote provider to close.

Claude-Session: https://claude.ai/code/session_011BUMqTboYui66dxqxfRCo1
@blooop
blooop merged commit c2b4ca7 into main Sep 1, 2026
15 checks passed
@blooop
blooop deleted the claude/ls-concurrent-status branch September 1, 2026 12:02
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.

1 participant