Repository navigation
feat(sandbox): share one memory budget across running macOS sandboxes - #259
Closed
quickbeard wants to merge 3 commits into
Closed
quickbeard wants to merge 3 commits into
quickbeard wants to merge 3 commits into
Conversation
Every sandbox got 4 GiB whatever the machine — thin for the office toolchain on a 32 GiB Mac, heavy-handed on an 8 GiB one. A sandbox that sets no Memory of its own now gets half the host's RAM, rounded down to a whole GiB and capped at 8 GiB: unchanged at 4 GiB on an 8 GiB Mac, 8 GiB from 16 GiB up. That is the rule Windows applies to the WSL utility VM, and the cap is what makes it safe to borrow. WSL's ceiling is one shared budget for every distro; here each sandbox is its own VM holding its own reservation for as long as it runs, so an uncapped half would let two sandboxes claim a whole machine. The Add dialog quotes the defaults in its field placeholders, so it can no longer keep the copy it held in src/sandbox/resources.ts — a mirror of a value derived from the host's RAM is a guess. The main process resolves them and the dialog reads them over a new sandbox-defaults channel, rendering the resources block once they arrive; the query is fired when the sandbox context mounts, so it has resolved long before the dialog opens. The ranges stay mirrored, since those are genuinely fixed. vz/provider.test.ts no longer pins 4096: with no per-sandbox and no provider limit the answer depends on the machine running the test, so it asserts against sandboxDefaultMemoryMiB() and the rule itself is covered directly against hosts this one is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit gave a sandbox half the Mac's RAM, capped at 8 GiB, and left the cap as the only protection against several of them at once. On a 16 GiB Mac that made one sandbox half the machine and two of them all of it. Sandboxes now share one budget — half the host's RAM — rather than each claiming it. A sandbox that names no memory of its own gets an even share of that budget across the sandboxes that would then be running, still capped at 8 GiB per sandbox and now floored at 2 GiB, because a guest too small to run the office toolchain is the worse outcome. On a 16 GiB Mac: 8 GiB alone, 4 GiB each for two, 2 GiB each for three. The share is resolved at start rather than at create, since what else is up changes in between, and it can only ever shape the sandbox being started: a VM's memory is fixed at boot, so a second sandbox makes room by taking less itself, never by shrinking the one already running. That puts the decision in the controller, the only thing that knows what is up — it passes the resolved number to provider.start() as defaultMemoryMiB, where a per-sandbox or provider-wide limit still outranks it, and answers defaultResources() for the Add dialog with the share the sandbox about to be created would get. initialize() now marks every sandbox it is about to start as starting before starting any of them. Otherwise a boot with two sandboxes handed the whole budget to whichever came first in the persisted file and squeezed the other, which is no basis for the decision. The renderer refetches the defaults when the set of live sandboxes changes, but not on the other state events — those include every download-progress tick. CPUs are deliberately left alone: they are time-sliced, so four each oversubscribes nothing. Memory is reserved, which is why this exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ad count Dividing the budget by the number of sandboxes assumed each one holds an even share, and none of them do. A sandbox that started alone took the whole budget and cannot give it back, so the next one was sized as though half were free: on a 16 GiB Mac, delete the small sandbox and add another and it took 4 GiB while the survivor already held all 8 — 12 GiB of VM on a 16 GiB machine. A hand-typed limit was invisible to the count for the same reason. The share is now the lesser of an even share and what is genuinely left, so both failure modes go: neither measure is right alone, because what-is-left on its own would floor the second sandbox even when the first holds very little. What is left is counted from what running sandboxes report reserving rather than from what the controller offered them: RunningSandbox gains an optional memoryMiB that the vz backend fills in with the figure the VM was really built with, whichever of its four sources won. Bubblewrap and WSL leave it absent — neither reserves anything a sandbox owns — so their sandboxes correctly count for nothing against the budget. Sandboxes still starting are absent from the running map and count for nothing either, which is what lets a boot divide the budget evenly between the sandboxes coming up together. Consequence worth stating: a second sandbox added beside one that started alone now gets the 2 GiB floor rather than 4 GiB. That is the budget being enforced rather than exceeded — restarting the app re-sizes both and splits it evenly, and an explicit Memory value overrides all of it. The renderer refetches the defaults on any change in the sandboxes' runtime kinds rather than on a change in how many are active: a sandbox reaching ready is when its memory is really taken, and that leaves the active count alone. 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.
What
A macOS sandbox used to get a flat 4 GiB regardless of the machine or of what else was running. Sandboxes now share one budget — half the host's RAM. A sandbox that names no Memory of its own gets an even share of that budget or whatever is genuinely left of it, whichever is smaller, capped at 8 GiB each and floored at 2 GiB.
Both measures are needed. An even share alone ignores that a sandbox which started alone took the whole budget and cannot give it back — on a 16 GiB Mac that put 12 GiB of VM on the machine. What-is-left alone would floor the second sandbox even when the first holds very little.
On a 16 GiB Mac:
The budget is the rule Windows applies to the WSL utility VM. The sharing is what makes it safe to borrow: WSL's ceiling covers every distro at once, while each sandbox here is its own VM holding its own reservation for as long as it runs.
Counted from what was reserved, not what was offered
RunningSandboxgains an optionalmemoryMiBthat the vz backend fills in with the figure the VM was really built with — whichever of its four sources won, including a hand-typed limit the controller never chose. Bubblewrap and WSL leave it absent: neither reserves anything the sandbox owns (bubblewrap shares host memory like any process; WSL's limits belong to a utility VM the sandbox does not own), so their sandboxes correctly count for nothing.Resolved at start, and only for the newcomer
A VM's memory is fixed at boot, so a second sandbox makes room by taking less itself, never by shrinking the one already up. The decision therefore belongs at start time — what else is running changes between create and start — and to the controller, the only thing that knows what is up:
startSandboxInternalresolves the share after marking the sandbox starting, so it counts itself, and passes it toprovider.start()asdefaultMemoryMiB. A per-sandbox limit or a provider-wide default still outranks it.defaultResources()answers the Add dialog withactiveSandboxes() + 1and the same committed total, so the dialog quotes exactly what the start path will hand out.initialize()marks every sandbox it is about to start as starting before starting any of them. Otherwise a boot with two sandboxes handed the whole budget to whichever came first in the persisted file. Sandboxes still starting are absent from the running map and count nothing as committed, which is what lets a boot divide the budget evenly between them.Stopping or deleting a sandbox hands its reservation straight back to the next start.
Two deliberate choices
The dialog can no longer mirror the default
It quotes the defaults in its field placeholders, and
app/must not import the node-flavoured@codev/sandbox, so it kept its ownSANDBOX_DEFAULT_MEMORY_GIB = 4. A copy of a number that depends on the machine and on what is running on it is a guess, so it now reads them over a newsandbox-defaultsIPC channel. TheSANDBOX_*_RANGEmirrors stay: those are genuinely fixed.Verification
packages/codev-sandbox— 156 pass / 2 skip / 0 fail. The rule is pinned against fixed host sizes (the split across 1–8 sandboxes; the cap still applying to a share on a large machine; the floor beating the budget; a committed total that exceeds the budget, an over-sized hand-typed sandbox, and a zero count all producing sane answers). The wiring is pinned in the controller against whatever the test machine has: sandboxes booting together get the same share rather than first-come-takes-all, a third joining two gets a third,defaultResources()agrees with what the start path hands out, a stopped sandbox holds no share until started, and a sandbox's reservation is freed by both delete and stop. vz covers the controller's share being used when a sandbox names none and losing when it does.packages/app25 pass,packages/desktop108 pass. All three packages typecheck clean; prettier clean.Not exercised: an actual VM boot at a shared size. The value only reaches
VZVirtualMachineConfiguration, which the Swift helper already clamps to Apple's allowed min/max.Docs
CHANGELOG.md— Unreleased ▸ Sandbox, including why a second sandbox gets 2 GB and that a relaunch re-splits evenly.DIVERGENCES.md— the renderer-slice row now says the ranges come from the mirrored constants and the defaults from thesandbox-defaultschannel.packages/codev-sandbox/README.md— the rule onprovider.ts; oncontroller.ts, why the boot marks everything starting up front and why the total comes from the backends.🤖 Generated with Claude Code