Skip to content

feat(sandbox): share one memory budget across running macOS sandboxes - #259

Closed
quickbeard wants to merge 3 commits into
mainfrom
feat/sandbox-memory-default
Closed

quickbeard wants to merge 3 commits into
mainfrom
feat/sandbox-memory-default

Conversation

@quickbeard

@quickbeard quickbeard commented Aug 17, 2026 •

Copy link
Copy Markdown
Owner

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.

export function sandboxDefaultMemoryMiB(opts?: { sandboxes?: number; committedMiB?: number; totalBytes?: number }) {
  const budgetGiB = Math.floor((opts?.totalBytes ?? totalmem()) / 2 / 1024 ** 3)
  const shareGiB = Math.floor(budgetGiB / Math.max(opts?.sandboxes ?? 1, 1))
  const freeGiB = Math.floor((budgetGiB * 1024 - (opts?.committedMiB ?? 0)) / 1024)
  return Math.min(
    Math.max(Math.min(shareGiB, freeGiB) * 1024, SANDBOX_DEFAULT_MEMORY_FLOOR_MIB),
    SANDBOX_DEFAULT_MEMORY_CAP_MIB,
  )
}

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:

Situation New sandbox gets
Nothing else running 8 GiB
Two starting together (app launch) 4 GiB each
Added beside one holding 8 GiB 2 GiB (floor — the budget is spent)
Added beside one holding 2 GiB 4 GiB (even share binds, 6 GiB free)

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

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, 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:

  • startSandboxInternal resolves the share after marking the sandbox starting, so it counts itself, and passes it to provider.start() as defaultMemoryMiB. A per-sandbox limit or a provider-wide default still outranks it.
  • defaultResources() answers the Add dialog with activeSandboxes() + 1 and 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.
  • The renderer refetches the defaults on any change in the sandboxes' runtime kinds, not 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 unchanged. Download-progress ticks carry no such change and are ignored.

Stopping or deleting a sandbox hands its reservation straight back to the next start.

Two deliberate choices

  • CPUs are not shared out the same way. They are time-sliced, so four each oversubscribes nothing; memory is reserved, which is the whole reason this exists.
  • The 2 GiB floor beats the budget. A second sandbox beside one that started alone gets the floor rather than nothing, because a guest too small to run the office toolchain is the worse outcome — and the user can still type a value. It sits above the validator's 1 GiB floor, which is what a deliberately-typed value may go down to.

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 own SANDBOX_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 new sandbox-defaults IPC channel. The SANDBOX_*_RANGE mirrors 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/app 25 pass, packages/desktop 108 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 the sandbox-defaults channel.
  • packages/codev-sandbox/README.md — the rule on provider.ts; on controller.ts, why the boot marks everything starting up front and why the total comes from the backends.

🤖 Generated with Claude Code

quickbeard and others added 2 commits August 17, 2026 21:53
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>
@quickbeard quickbeard changed the title feat(sandbox): size a new macOS sandbox's memory to the Mac feat(sandbox): share one memory budget across running macOS sandboxes Aug 17, 2026
…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>
@quickbeard quickbeard closed this Aug 18, 2026
@quickbeard
quickbeard deleted the feat/sandbox-memory-default branch August 18, 2026 01:53
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