Skip to content

Stop paying for the same devpod answer twice - #78

Merged
blooop merged 1 commit into
mainfrom
fix/60-cut-devpod-shellouts
Aug 7, 2026
Merged

blooop merged 1 commit into
mainfrom
fix/60-cut-devpod-shellouts

Conversation

@blooop

@blooop blooop commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Resolves #60 on map #51.

devpod list --output json costs 0.440s on this machine and devpod status 0.559s, against 0.09s for all of dl's own work. So every removed round-trip is worth more than the entire Python startup this map once considered rewriting away.

Spawn counts — counted twice, two ways

Patched subprocess.run (argv[0] == devpod), then independently re-counted end-to-end with a logging devpod shim on PATH. Both methods agree.

command before after sequence after
dl --ls 1 1 list
dl <ws> existing + running 4 4 list, status, ssh hostname, ssh
dl <ws> -- cmd 4 3 list, status, ssh --command
dl --purge -y (2 workspaces) 4 3 list, delete, delete
dl <ws> existing + stopped 6 6 list, status, context options, up, ssh hostname, ssh

Wall time through a shim charging the measured 0.45s per call, in an env -i sandbox: dl <ws> -- echo hi 1.924s -> 1.463s, dl --purge -y 1.938s -> 1.463s.

What changed

list_workspaces() is memoized per command, not per process — main() invalidates at the start of every command, and workspace_up, workspace_stop, workspace_delete and purge's delete loop each invalidate after mutating. refresh=True is the documented bypass.

All seven call sites were audited; none needs a fresh read today, because no site reads the list after a mutation in the same process. The win is dl --purge, which read the same list twice back to back — and memoizing also makes the count the user confirms and the set actually deleted provably the same snapshot, which it previously was not.

Two safety properties are tested, not assumed: a failed or unparsable read is never cached, so a transient failure cannot be re-served as "no workspaces"; and a cached empty list cannot mask a missing binary — dl --ls with no devpod still exits 127 with one stderr line (#57).

The attach chain: one call dropped, four kept

devpod ssh --command sudo hostname is dropped for one-shot commands, kept for interactive. Keeping it interactively is load-bearing: bash resolves \h from the hostname read at shell startup, so it must be set before dl hands over the session, and devpod ssh (v0.26.1) exposes no hook inside that session. Folding it in as --command 'sudo hostname X; exec $SHELL -l' would swap devpod's interactive session path for its command path — different PTY, terminfo and window-size handling — which is not provably safe on the path users hit constantly.

devpod list, status, context options, up and the final ssh are all kept with reasons. Dropping status in particular would mean running devpod up (full devcontainer.json re-parse, seconds) on an already-running workspace — exactly what fast-attach exists to avoid.

Suspected but left alone

The devpod list for bare-name specs is genuinely replaceable by the devpod status we make anyway (devpod status <unknown> exits 1 with "workspace not found"), taking a running interactive attach from 4 to 3. Not done here: it reroutes validation for every bare-name spec and would need ~25 existing tests updated, in files that #58 and #64 are editing right now. Cheapest available follow-up.

The durable artifact

New test/test_devpod_spawn_counts.py (20 tests) asserts the exact ordered sequence of devpod invocations per command, so a future change that reintroduces a redundant call fails a test rather than quietly costing 450ms. A conftest fixture stops the snapshot leaking between tests.

Verification

534 passed, 8 deselected after rebasing onto current main. ruff, ruff format, pylint 10.00/10, ty clean. Red-before-green verified with 13 revert-one-thing checks — one of which initially reported a false green from stale bytecode (two reverts left dl.py the same size in the same second), so the harness now clears __pycache__.

Only user-visible change: dl <ws> -- cmd no longer sets the container hostname, since nothing in a one-shot command displays it.

Incidental finding, not acted on: in this repo's own container (runArgs: --network=host) setup_hostname silently fails and the hostname stays the host's — consistent with it being best-effort.

Summary by Sourcery

Memoize devpod workspace listing per command and adjust attach behaviour to avoid redundant devpod calls, improving performance and preserving correctness of workspace operations.

Enhancements:

  • Cache devpod workspace list results per command with explicit invalidation after workspace mutations and purge, preventing repeated list calls while keeping snapshots consistent.
  • Introduce an attach helper that conditionally sets the workspace hostname only for interactive sessions, removing an unnecessary devpod ssh round-trip for one-shot commands.
  • Ensure workspace lifecycle commands (up, stop, delete, purge) and main CLI entrypoints interact correctly with the new cache and attach helper, keeping behaviour consistent across multiple invocations.

Documentation:

  • Document reduced devpod round-trips and one-shot command hostname behaviour in the README.

Tests:

  • Add a comprehensive test suite that pins the exact sequence and count of devpod subprocess invocations per command and validates workspace list memoization safety, including cache invalidation, failure handling, and missing-binary scenarios.
  • Add a pytest fixture to ensure each test gets a fresh devpod workspace list cache snapshot.

@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 have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces a memoized cache for devpod list responses scoped per dl command, ensures cache invalidation after workspace mutations, and refactors the attach path so hostname setup is skipped for one-shot commands while preserving interactive behavior, backed by new tests that pin exact devpod spawn sequences and workspace-list caching semantics.

Sequence diagram for updated attach_workspace behavior

sequenceDiagram
    actor User
    participant dl_main as main/_run_cli
    participant attach_workspace
    participant setup_hostname
    participant workspace_ssh

    User->>dl_main: invoke dl with workspace spec
    dl_main->>attach_workspace: attach_workspace(workspace_id, shell_command)
    alt [interactive attach (shell_command is None)]
        attach_workspace->>setup_hostname: setup_hostname(workspace_id)
        setup_hostname-->>attach_workspace: hostname set
        attach_workspace->>workspace_ssh: workspace_ssh(workspace_id, None)
        workspace_ssh-->>attach_workspace: return code
    else [one_shot command (shell_command is not None)]
        attach_workspace->>workspace_ssh: workspace_ssh(workspace_id, shell_command)
        workspace_ssh-->>attach_workspace: return code
    end
    attach_workspace-->>dl_main: return code
    dl_main-->>User: exit status (hostname only set for interactive)
Loading

File-Level Changes

Change Details Files
Memoize devpod list results per command and keep cache consistent with workspace mutations.
  • Introduce a module-level workspace list cache with helpers to invalidate it and avoid confusing 'no read yet' with 'no workspaces'.
  • Update list_workspaces to optionally serve from cache, support a refresh flag, and only cache successful, parseable devpod outputs while returning defensive copies.
  • Invalidate the workspace cache at main entry and after up, stop, delete, and purge operations so post-mutation reads see fresh data, and ensure purge uses the same snapshot for counting and deleting workspaces.
devlaunch/dl.py
Refactor workspace attach flow to control when hostname setup runs and drop an unnecessary devpod ssh for one-shot commands.
  • Introduce attach_workspace to centralize hostname setup plus ssh behavior, calling setup_hostname only for interactive attaches (no shell command).
  • Replace direct setup_hostname + workspace_ssh call sites in the CLI with attach_workspace, ensuring one-shot commands skip hostname setup while interactive attaches retain it.
  • Clarify behavior in comments and README, documenting that one-shot commands avoid the extra devpod round-trip for hostname setting.
devlaunch/dl.py
README.md
Add tests that pin devpod spawn sequences and validate workspace list caching semantics and hostname behavior.
  • Add DevpodSpawns test helper and tests that assert the exact ordered devpod commands for hot dl paths like --ls, --purge -y, interactive attach, one-shot commands, and multiple main invocations.
  • Add tests to verify workspace list memoization behavior, including refresh, invalidation, mutation safety, and that failed or missing devpod executions are never cached as empty lists.
  • Add tests to ensure memoization does not hide a missing devpod binary and to verify attach_workspace only triggers hostname setup for interactive attaches.
  • Introduce a pytest fixture that automatically invalidates the workspace list cache before and after each test to avoid cross-test leakage of cached state.
test/test_devpod_spawn_counts.py
test/conftest.py

Assessment against linked issues

Issue Objective Addressed Explanation
#60 Memoize list_workspaces() so that each dl command reads the devpod workspace list at most once (e.g., eliminating the double devpod list in dl --purge -y), while preserving correct behavior and error handling.
#60 Reduce redundant devpod subprocess calls in the attach/ssh chain (especially around hostname setup), dropping or merging any calls that can be removed without changing observable behavior of dl <ws> and related commands.

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

devpod is where dl's runtime goes. On this machine `devpod list --output
json` takes 0.44s and `devpod status <ws> --output json` 0.56s, against
0.09s for the whole Python interpreter dl runs in. Removing one round-trip
is worth five times whatever the language costs, so the only thing that
makes dl faster is asking devpod fewer questions.

`dl --purge` asked the same question twice: once to print the workspace
count it wants confirmed, once again inside purge_all_data. list_workspaces
now memoizes for the length of one command, so both reads share a snapshot
-- and the count the user answered can no longer disagree with the set that
actually gets deleted. Six call sites read that list; whichever of them runs
first now pays for it and the rest are free.

Memoizing a value that describes mutable state needs a story for staleness,
and dl's is that it is the mutator. Every path that changes what devpod
would list -- up, stop, delete, and purge's delete loop -- drops the
snapshot, main() drops it at the start of each command so nothing driving
main() twice inherits the first command's view, and refresh=True bypasses it
for a caller that needs post-mutation truth regardless. A read that failed
is never remembered: only an answer devpod actually gave is cached, so a
transient failure cannot be served again as "this machine has no
workspaces", and a missing devpod still raises out of the first read.

The attach chain was audited call by call and almost all of it is
load-bearing. `devpod list` is what validates the spec and decides whether
the workspace already exists; `devpod status` is what makes fast-attach
possible at all, since skipping it would mean running the far slower `up` on
an already-running workspace. Both stay. The hostname round-trip -- a whole
extra `devpod ssh` in front of every attach -- cannot be folded into the
attach either: bash reads the hostname once when the shell starts, so it has
to be set before the session dl hands over, and `devpod ssh` offers no hook
inside that session. It is now skipped for a one-shot `dl <ws> -- cmd`,
which renders no prompt for a hostname to appear in, taking that path from
four devpod spawns to three. Its value and derivation are untouched.

devpod spawns per command, before -> after: `dl --ls` 1 -> 1, `dl <ws>` on a
running workspace 4 -> 4, `dl <ws> -- cmd` 4 -> 3, `dl --purge -y` with two
workspaces 4 -> 3. Measured at the subprocess boundary, and again end to end
against a devpod shim on PATH that charges 0.45s a call: purge 1.94s ->
1.46s, one-shot command 1.92s -> 1.46s. The counts are pinned by tests that
assert the exact sequence of devpod invocations, so a change that
reintroduces a redundant round-trip fails a test rather than quietly costing
half a second.

Closes #60
@blooop
blooop force-pushed the fix/60-cut-devpod-shellouts branch from 6f5a28a to 22afd52 Compare August 7, 2026 21:48
@blooop
blooop merged commit 3fb85f8 into main Aug 7, 2026
7 checks passed
@blooop
blooop deleted the fix/60-cut-devpod-shellouts branch August 7, 2026 21:49
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.66667% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 88.81%. Comparing base (53b9a43) to head (22afd52).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
devlaunch/dl.py 96.66% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #78      +/-   ##
==========================================
+ Coverage   88.64%   88.81%   +0.17%     
==========================================
  Files          11       11              
  Lines        1559     1574      +15     
==========================================
+ Hits         1382     1398      +16     
+ Misses        177      176       -1     
Files with missing lines Coverage Δ
devlaunch/dl.py 90.43% <96.66%> (+0.34%) ⬆️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

Build: cut redundant devpod shell-outs on hot paths

1 participant