Skip to content

perf: the setup pass runs its stages in subshells, not a bash each - #678

Merged
blooop merged 11 commits into
mainfrom
perf/launch-time
Oct 16, 2026
Merged

blooop merged 11 commits into
mainfrom
perf/launch-time

Conversation

@blooop

@blooop blooop commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

A cold launch of a large ROS repository on a new branch takes about 100s. This PR removes about 4.2s of that. The 4.2s is in the part that dl owns: the setup trip.

What this changes

  • The setup pass runs each stage in a subshell of the pass, not in a new bash. The new helper in_subshell in rust/devlaunch-core/src/flows/provision.rs writes a stage as ( eval '<script>' ). Before, it wrote bash -c '<script>'. Six stages use it: session-name, zellij, codex, title, onboarding and trust.
  • Each new non-interactive bash reads $BASH_ENV. That image sets BASH_ENV to the user's ~/.bashrc inside a devpod ssh session, and that file loads the full ROS environment. One read costs about 1.1s. A default cold launch ran four stages in a new bash, so it read the file five times. Now it reads the file one time.
  • eval keeps the isolation that the nested bash gave each stage:
    • The script parses when the stage runs, so a script that does not parse fails only its own stage.
    • An exit, a set -u or a set -C stops at the closing parenthesis.
  • The stage gets the environment that the pass already loaded. This is the environment that the stages read before.
  • New tests:
    • the_pass_reads_the_images_bash_env_once_however_many_stages_it_runs (core).
    • rust/dl/tests/setup_pass_shells.rs (the dl binary, a cold launch through a devpod that runs ssh --command for real).
    • a_stage_that_will_not_parse_fails_alone_and_the_pass_goes_on.
    • a_stage_that_sets_u_and_c_keeps_them_to_itself.
    • a_stage_that_calls_exit_ends_only_itself.
    • the_setup_pass_records_trust_for_the_workspace_it_stands_in. This test runs the trust stage's Python merge through the real pass.
  • The golden snippets and the three word-shape tests now expect ( eval … ). docs/performance.md has a new section "One shell for the setup pass". CHANGELOG.md has an entry.

Where this fits

The script that the setup trip sends, for one stage:

 devpod ssh <ws> --command "bash -lc '<setup script>'"     # one bash: reads BASH_ENV once
-  if bash -c '<stage script>'; then                       # one more bash per stage: reads BASH_ENV again
+  if ( eval '<stage script>' ); then                      # a subshell: reads nothing
     echo "devlaunch-probe stage <name> ok"
   else
     echo "devlaunch-probe stage <name> failed $?"
   fi
 <probe script>

How I checked it

How I found it. I timed four cold launches of that repository with DEVLAUNCH_TIMING=json (95s to 127s). Most of the time is in devpod up: the image's on_create script (about 23s) and the dotfiles step (about 34s). dl does not own those. In the part that dl owns, the setup trip took 10.85s to 10.91s every time. A devpod ssh <ws> --command true trip takes 4.4s. Inside the container:

  • bash -c true takes 0.49s with docker exec, and 1.07s inside a devpod ssh session.
  • bash -lc true takes 1.05s with docker exec, and 2.2s inside a devpod ssh session.
  • The cause is BASH_ENV, set to the user's ~/.bashrc, in the session environment.

Prototype. I replayed the exact setup command that dl sent, on the same container, with a shim that changes bash -c to ( eval … ). The output was the same, byte for byte. The trip time went from 10.95s, 11.03s and 11.41s to 6.56s, 6.76s and 6.78s.

Before and after, end to end. I ran six cold launches. Each launch used a new branch. The runs alternated between the released dl 0.59.4 (the same commit as this PR's base) and a release build of this branch:

run build setup trip total
1 0.59.4 10.91s 119.8s
2 this PR 6.73s 114.0s
3 0.59.4 11.14s 107.2s
4 this PR 6.66s 98.5s
5 0.59.4 10.90s 105.6s
6 this PR 6.90s 96.8s

The lend trip and the attach trip changed a lot from run to run (6.5s to 14.0s), so the totals are noisy. The setup trip is not noisy. In a container that this PR's build made, the hostname, the session name, the title and the PATH line were all in place.

Tests.

  • The new core test failed before the fix with BASH_ENV was read 5 times. It passes now.
  • The new binary test failed with the setup pass read BASH_ENV 5 times when I put bash -c back in in_subshell. It passes with the fix.
  • A review of mutants killed all four: a brace group, a bare eval, the script pasted without eval, and bash -c.
  • I ran cargo test --workspace --no-fail-fast, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check, prek on the changed files, and the doc guards (test_docs_prose.py, test_bench_doc.py, test_citations_resolve.py).
  • Five tests fail on this host. They also fail on a clean origin/main, so this PR does not cause them. All five are about commits pushed by URL, or a remote that cannot be reached. Four are in flows::lifecycle, and one is in rust/dl/tests/lifecycle.rs:
    • a_host_that_prunes_on_fetch_does_not_turn_a_merged_branch_into_unpushed_work
    • a_remote_that_cannot_be_reached_leaves_the_refusal_standing
    • a_site_pushed_by_url_does_not_stop_a_plain_rm
    • commits_pushed_by_url_do_not_stop_a_plain_rm
    • a_commit_pushed_by_url_does_not_stop_rm

I did not test an image with no $BASH_ENV. There, the saving is the cost of a bash start, which is small. A warm launch makes no setup trip, so this PR does not change it.

Merge danger

Door: two-way. A plain revert puts bash -c back.

Blast radius: small. If the change is wrong, a cold launch can set the hostname, title, session name or Claude onboarding incorrectly in a new container. dl <ws> recreate runs the pass again. The stage outcomes stay in the same probe protocol, so a stage that fails still says so.

Review notes

  • A function, alias, shell option or plain variable that the login profile sets now reaches the stages. A nested bash did not get them. For example, a profile set -u now applies to each stage, and a readonly profile variable fails a stage that assigns it. No current stage breaks: each stage assigns a variable before it reads it, or reads it as ${X:-}. For example, a profile zellij() wrapper with no zellij binary makes command -v zellij succeed, and the zellij stage then skips its install. The review judged this unlikely. The in_subshell doc comment records these rules. Functions from $BASH_ENV reached the stages before this change too.
  • Other parts of the cold launch are larger, but other repos own them. They are not in this PR: the image's on_create script runs pixi global sync (17.5s), and the dotfiles bootstrap runs pixi global sync again (13.5s) plus chezmoi apply (14s). Both pixi global sync runs report "unchanged".

🤖 Generated with Claude Code

Summary by Sourcery

Reduce cold-launch overhead by running setup stages in isolated subshells instead of starting a new Bash process for each stage.

Enhancements:

  • Run setup stages in isolated subshells within the existing setup shell to avoid repeatedly loading the image's BASH_ENV and reduce cold-launch setup time.

Documentation:

  • Document the one-shell setup pass and its measured cold-launch performance improvement.

Tests:

  • Add coverage confirming BASH_ENV is read once per setup pass and that stage failures, exits, and shell options remain isolated.

Chores:

  • Record the setup-pass shell optimization in the changelog.

Every new non-interactive bash reads $BASH_ENV, and kinisi_ros's image
points it at its whole ROS bashrc: about 1.1s a read inside a devpod ssh
session. A cold launch's setup trip started one bash per stage on top of
its own, so the trip took about 11s for 6.7s of work.

The stages now run as `( eval '<script>' )`. eval keeps what the nested
bash gave each stage: the script parses when the stage runs, so a parse
error fails its own stage only, and exit, set -u and set -C end at the
closing parenthesis. The stage inherits the environment the pass already
loaded, which is the one it was reading anyway.

Measured over six cold kinisi_ros launches on fresh branches, alternating
builds: the setup trip went from 10.90s-11.14s to 6.66s-6.90s.
@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR changes setup-stage execution from one bash -c per stage to ( eval … ) subshells within the existing setup shell, retaining per-stage error and option isolation while eliminating repeated $BASH_ENV reads. It adds unit, golden, and end-to-end coverage and documents an approximately 4.2-second cold-launch improvement.

Sequence diagram for the optimized setup pass

sequenceDiagram
    participant Devpod
    participant PassShell
    participant Stage
    participant BashEnv

    Devpod->>PassShell: devpod ssh --command setup script
    PassShell->>BashEnv: Read BASH_ENV once
    loop Each setup stage
        PassShell->>Stage: ( eval '<stage script>' )
        Stage-->>PassShell: Stage outcome
    end
    PassShell-->>Devpod: Probe results
Loading

File-Level Changes

Change Details Files
Replaces per-stage nested Bash processes with quoted eval commands inside subshells, preserving stage isolation while avoiding repeated $BASH_ENV loading.
  • Adds in_subshell to generate ( eval '<stage script>' ) commands.
  • Migrates session-name, zellij, codex, title, onboarding, and trust stages to the helper.
  • Documents the environment-inheritance tradeoff and retained failure semantics.
rust/devlaunch-core/src/flows/provision.rs
Adds coverage for shell startup counts, stage failure isolation, and shell-option containment.
  • Verifies the setup pass reads $BASH_ENV exactly once regardless of stage count.
  • Checks syntax errors fail only their own stage and later stages continue.
  • Checks set -u and set -C do not leak between stages.
  • Updates golden snippets and word-shape assertions for the new command form.
rust/devlaunch-core/src/flows/provision.rs
Adds an end-to-end cold-launch regression test using the real dl binary and a devpod shim.
  • Executes the setup command handed to devpod ssh --command under a counting BASH_ENV.
  • Verifies the complete setup pass runs and $HOME/.claude.json is created.
  • Asserts one Bash environment read for the entire pass.
rust/dl/tests/setup_pass_shells.rs
Documents and announces the cold-launch performance improvement.
  • Records measured setup-trip reductions of roughly 10.9–11.1 seconds to 6.7–6.9 seconds.
  • Explains why $BASH_ENV makes nested Bash startup expensive and notes image/warm-launch scope.
  • Adds the change to the unreleased changelog.
docs/performance.md
CHANGELOG.md

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

@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.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="rust/devlaunch-core/src/flows/provision.rs" line_range="1910" />
<code_context>
+/// parenthesis. `the_pass_reads_the_images_bash_env_once_however_many_stages_it_runs`
+/// pins the count.
+fn in_subshell(script: &str) -> String {
+    format!("( eval {} )", quote(script))
+}
+
</code_context>
<issue_to_address>
**Unexported config path gets seeded**

When a login profile assigns `CLAUDE_CONFIG_DIR` without exporting it, the subshell inherits the login shell’s non-exported `CLAUDE_CONFIG_DIR`, so `onboarding_script` and `trust_script` write to that directory. Claude Code runs as a child process and does not inherit the unexported variable, so it reads its default directory instead; the user sees onboarding or trust prompts despite the stages reporting success.

Make the stages use only the exported `CLAUDE_CONFIG_DIR` value, matching the environment Claude Code receives.

Also at `rust/devlaunch-core/src/flows/provision.rs:2081`, `rust/devlaunch-core/src/flows/provision.rs:2092`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

/// parenthesis. `the_pass_reads_the_images_bash_env_once_however_many_stages_it_runs`
/// pins the count.
fn in_subshell(script: &str) -> String {
format!("( eval {} )", quote(script))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Unexported config path gets seeded

When a login profile assigns CLAUDE_CONFIG_DIR without exporting it, the subshell inherits the login shell’s non-exported CLAUDE_CONFIG_DIR, so onboarding_script and trust_script write to that directory. Claude Code runs as a child process and does not inherit the unexported variable, so it reads its default directory instead; the user sees onboarding or trust prompts despite the stages reporting success.

Make the stages use only the exported CLAUDE_CONFIG_DIR value, matching the environment Claude Code receives.

Also at rust/devlaunch-core/src/flows/provision.rs:2081, rust/devlaunch-core/src/flows/provision.rs:2092.

Prompt for AI agents
In `rust/devlaunch-core/src/flows/provision.rs` at line 1910:

**Unexported config path gets seeded**

When a login profile assigns `CLAUDE_CONFIG_DIR` without exporting it, the subshell inherits the login shell’s non-exported `CLAUDE_CONFIG_DIR`, so `onboarding_script` and `trust_script` write to that directory. Claude Code runs as a child process and does not inherit the unexported variable, so it reads its default directory instead; the user sees onboarding or trust prompts despite the stages reporting success.

Make the stages use only the exported `CLAUDE_CONFIG_DIR` value, matching the environment Claude Code receives.

Also at `rust/devlaunch-core/src/flows/provision.rs:2081`, `rust/devlaunch-core/src/flows/provision.rs:2092`.

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.04433% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.39%. Comparing base (34fd58a) to head (4981e02).

Files with missing lines Patch % Lines
rust/devlaunch-core/src/flows/provision.rs 97.04% 6 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 94.61% <97.04%> (+<0.01%) ⬆️

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

Components Coverage Δ
shipped code (rust) 94.61% <97.04%> (+<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 merged commit f3053c5 into main Oct 16, 2026
15 checks passed
@blooop
blooop deleted the perf/launch-time branch October 16, 2026 12:47
@blooop blooop mentioned this pull request Oct 16, 2026
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