Repository navigation
feat(sandbox): add a stop action, and queue lifecycle jobs - #241
Merged
Merged
Conversation
Create, reset and destroy shared one abort controller and one state slot, so beginJob aborted whatever was in flight regardless of which sandbox it belonged to. Deleting or resetting sandbox B part-way through sandbox A's image download silently abandoned that download: the cancelled create resolves undefined, which addSandbox treats as a clean cancel, so nothing was persisted and no error was raised. The UI only disabled actions for the sandbox holding the job, so every other sandbox's menu stayed live and could trigger it. Jobs now queue. They still run one at a time — create and reset stage images through one shared cache whose resumable-download bookkeeping assumes a single writer (image.ts removeStalePartials) — but each one waits its turn instead of killing the one ahead of it. Only a second job for the *same* sandbox supersedes the first, which is what the single-flight guard was for. Cancelling a job that is still queued returns immediately rather than waiting out the running job, and the one behind it does not jump ahead of the job actually running. State exposes `jobs: SandboxJob[]` (running first) in place of `job`, each with a `status`, so the app can tell "waiting behind another sandbox" from "working". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sandbox ran from app launch until it was deleted: initialize() started every persisted sandbox and the UI offered no way to shut one down, so three sandboxes meant three guests holding memory and CPU regardless of whether any work was happening in them. Adds stopSandbox: it cancels a start already in flight (the stale-attempt guard makes the pending start discard its instance), stops the running guest, and records `stopped` on the persisted config. initialize() skips those, so the decision survives a relaunch — a stop that a restart undoes would not address the problem. Only an explicit start clears the flag; the internal restarts (initialize, reset, a mount change) leave the user's intent alone, and resetting a stopped sandbox re-provisions it without bringing it up. Stop is offered while a sandbox is still starting as well as when it is ready: a start hanging on a slow image or a wedged VM is exactly when the user wants out of it. The Work side's empty state gets a stopped variant. It told the user "it appears here once it is ready", which is true of a sandbox that is starting or has failed but a false promise for one that is stopped and will never come up on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two sandbox lifecycle changes, plus a README that had gone stale.
1. Jobs no longer cancel each other across sandboxes
createSandboxControllerran create/reset/destroy through one abort controller and onejobstate slot.beginJobopened with an unconditionaljobAbort?.abort(), so starting a job on any sandbox aborted whatever job was in flight on any other sandbox.The failure is silent. A cancelled create resolves
undefined, whichaddSandboxtreats as a clean user cancel — nothing persisted, no error surfaced. So deleting sandbox B part-way through sandbox A's ~800 MB image download threw the download away and left A uncreated, with nothing to say why. The UI made it easy to hit:busy()only disabled the menu of the sandbox that held the job, so every other sandbox's actions stayed live.Jobs now queue. They still run one at a time, deliberately: create and reset stage guest images through one shared cache directory whose resumable-download bookkeeping explicitly assumes a single writer (
image.tsremoveStalePartials), so running them concurrently would have them delete each other's partials. The change is that a job now waits its turn rather than killing the job ahead of it.updateSandboxMountsandcancelJobnow consider queued jobs too.Wire shape:
SandboxesState.job: SandboxJob | null→jobs: SandboxJob[](running first, at most one per sandbox), each job carryingstatus: "running" | "queued". The renderer mirror moves with it and the three consumers read the list; a waiting sandbox says so rather than claiming to be working (sandbox.job.queued).2. Sandboxes can be stopped
initialize()started every persisted sandbox at launch and nothing could shut one down short of deleting it, so N sandboxes meant N guests holding memory and CPU whether or not anyone was working in them (vz defaults: 4 cpus / 4 GiB each).stopSandboxcancels a start already in flight (the stale-attempt guard makes the pending start discard its instance), stops the guest, and recordsstoppedon the persisted config.initialize()skips those.The flag persists on purpose. A stop that a relaunch undoes would not address the problem it exists for. Only an explicit start clears it — the internal restarts (initialize, reset, a mount change) leave the user's intent alone, and resetting a stopped sandbox re-provisions it without bringing it up. Persisted records written before this existed have no flag and keep starting, so nothing changes for an existing install until the user stops something.
Stop appears in the sandbox menu (Settings → Servers) when a sandbox is ready or still starting — a start hanging on a slow image or a wedged VM is exactly when you want out of it.
One knock-on: the Work side's empty state said "it appears here once it is ready", which is true for a starting or failed sandbox but a false promise for a stopped one. It now has a stopped variant pointing at where Start lives.
3. README
packages/codev-sandbox/README.mdwas a phase log that ended with "Still to come: the Windows managed-WSL provider, the desktop main-process wiring, and thepackages/appUI" — all three shipped, along with mounts, the WSL guest firewall, and per-sandbox resource limits, none of which it mentioned. Rewritten as a description of what the package is now: platform core, the three backends, the image pipeline, and where the desktop and app halves live. It keeps one honest known gap — image upgrades only happen if the user happens to hit Reset.Testing
packages/codev-sandbox: 129 pass / 2 skip. Seven new tests: a job on one sandbox neither aborts nor overtakes another's; cancelling a queued job returns at once without starting it or letting the next one jump the queue; a same-sandbox job still supersedes; stop keeps a sandbox down across a relaunch; start clears the flag; stopping a starting sandbox discards the instance it was waiting on; reset on a stopped sandbox re-provisions without starting.await tick(): a job now starts a microtask after admission rather than synchronously, so the fake provider's resolver isn't assigned until the turn begins.packages/app(663) andpackages/desktop(96) suites pass;bun typecheckclean in all three packages.DIVERGENCES rows for the sandbox and workspace i18n families updated with the new keys; two CHANGELOG entries added under Unreleased.
🤖 Generated with Claude Code