064: iOS shared hosts: ios boxes as macOS users with their own device, many to a Mac
Status: accepted, 26 October 2026, with the owner’s answers to its open questions under The owner’s answers. Revised the same day after an independent review, answered finding by finding under Review answers. Built on the research note on several iOS Simulators in one macOS VM, which this note calls the research. Changes 063, where each ios box is a Tart VM of its own. Measurements has every number this note gives that the research and 063 do not.
Context
An ios box today is a Tart VM with one booted device (063). Apple allows two macOS VMs on a Mac, so a Mac holds two ios boxes, or one beside a macOS box. The owner’s direction: one simulator per VM is wasteful.
The research measured three ways to put several devices in one VM. It recommended a macOS user per box in a shared VM, as the SSH pool lends a physical Mac’s users (047), and found the video encoder, not CPU or memory, limits how many devices can be watched at once.
The lead made five decisions before this note:
- A macOS user per box in a shared host is the default for ios boxes: its
own device set, files, keychain, DerivedData and idb socket. A VM of its
own stays as an opt-in,
dedicated: true, for sudo,brew install, its own ports and its own process list. - Streams follow the viewer’s size, not the device’s, because the encoder is the limit.
- A shared Tart VM has 8 cores, 32 GiB and 8 slots, with 1 to 2 made ahead. Apple’s two-VM limit counts shared VMs too.
- The device server in a slot runs as a LaunchDaemon with
UserName. - While a shared VM is drained for an image update, ios boxes go to VMs of their own if Apple’s limit allows, or wait with words.
The owner then set four more, while this note was written:
- No VM is held for iOS while nobody uses it. A shared Tart host is made when an ios box needs a slot and no host has one, and is stopped and deleted when its last box is released. Slots are made ahead only while a host is up for other boxes.
- The pool’s physical Mac is a second kind of shared host, with no two-VM limit, no start, and the Mac’s own encoder.
- Namespace Macs are a third, made and deleted as Tart hosts are.
- Placement prefers a free slot on an always-on pool Mac, then a
running Tart or Namespace host, then a new Tart host, then a new
Namespace host, then a VM of its own. And the note says whether a host
should lend
macosboxes too.
This note argues with decision 3, and finds decision 5 overtaken by decisions 6 and 9; see Where this differs from the brief.
Decisions at a glance
| Decision | Settled by | |
|---|---|---|
| 1 | A device host lends slots. A slot is one macOS user with one device; a box in a slot is an ios box. A host is a pool Mac, a Tart VM or a Namespace Mac | the research’s option B; the owner’s decisions 6 to 8 |
| 2 | A pool Mac is always on, and lends slots only while it is not lent whole. A Tart or Namespace host is made for a box, joinable while it starts, and deleted when its last box is released, decided under the host’s own lock | the owner; review findings 1 and 2 |
| 3 | Each backend that lends slots answers with a graded tier; the registry chooses across backends under one lock for device placement, in the owner’s order, and asks again when a claim loses its race | review finding 1; Placing a box |
| 4 | A slot’s device is made by simctl create in its user, and made slim by seeding launchd_sim’s store of disabled services before its first boot: 14.5 to 28.5 s in a VM, 10.4 to 12.4 s on the pool Mac, against 155 to 410 s for the list applied twice |
M1 to M4, M13, POOL |
| 5 | The ios image carries two slots in a UID range of their own, so a new host’s first box waits for a cold boot, not a device’s making | M13, M16 |
| 6 | A slot is never lent twice. Release removes its user at once; queued sweeps remove every file its UID owns; UIDs come from ranges the image never uses | M6, M7, M17 |
| 7 | The device server and BB’s daemon in a slot are LaunchDaemons with UserName. A slot box enrols as a BB machine with BB_INSTALL_SKIP_SERVICE=1 and the helper’s daemon |
M14, M15, M16 |
| 8 | Each slot has a port range, and the host holds the common dev ports, so a box cannot reach another’s server by mistake | the research: every device reaches every box’s 127.0.0.1 |
| 9 | Streams are sent at a step of the device’s size, the smallest that covers what the viewer draws; input is in the device’s pixels; idb’s stream runs only while wanted. Android keeps full size | M8, M9 |
| 10 | The encoder is the Mac’s, shared by every VM on it: about 320 full-size iPhone frames a second on the Tart Mac, 375 on the pool’s. A ledger per Mac caps streams, counting measured rates, giving room first to the person in control; at most two at full size | E1, M8, M11, POOL |
| 11 | dedicated: true asks for a VM of its own; a size, a project’s ios image, a stop, a restart, a resize and the host’s desktop need it. shared: true asks for a slot for a macos box |
M12; review findings 11 and 12 |
| 12 | Shared becomes the ios default only once the ledger exists and bb-box’s own enrolment of a slot has passed | review findings 4 and 16 |
| 13 | A host that drains for a new image still lends while no host on the new image can start | review finding 7 |
| 14 | Namespace hosts stay off: a Namespace Mac’s VM has no H.264 encoder, so idb’s stream sends nothing | M18 |
The data shape
The types go in src/core/device-host.ts, beside 063’s src/core/device.ts.
Hosts
interface HostSettings { // a snapshot of the backend's ios-hosts setting, taken when the host is made readonly most: number; // slots making, ready or held, at most readonly ahead: number; // ready slots kept while the host holds a box readonly resettingMost: number; // slots resetting at once, at most, apart from most}
interface HostCommon { readonly id: string; // the pool machine's, the Tart VM's or the Namespace instance's name readonly runtime: Runtime; // 063's iOS runtime: Xcode, iOS build, idb_companion readonly cores: number; readonly memoryGiB: number; readonly settings: HostSettings;}
type DeviceHost = | (HostCommon & { readonly on: "pool-mac"; readonly machine: string; readonly state: PoolMacState }) | (HostCommon & { readonly on: "tart"; readonly mac: string; readonly vm: string; readonly state: HostState }) | (HostCommon & { readonly on: "namespace"; readonly instance: string; readonly endsAt: number; readonly state: HostState });
type HostState = | { readonly is: "starting"; readonly promised: readonly string[]; readonly since: number } // boxes that wait for it | { readonly is: "serving" } | { readonly is: "draining"; readonly why: "image" | "ending" | "operator"; readonly lends: boolean; readonly since: number } | { readonly is: "stopped"; readonly since: number } // its VM is not running, as after the Mac restarted | { readonly is: "deleting"; readonly since: number } // the helper's marker: every claim is refused | { readonly is: "failed"; readonly error: { readonly code: HostFailure; readonly words: string }; readonly since: number };
type PoolMacState = | { readonly is: "free" } // no box, no slot in any state | { readonly is: "whole"; readonly box: string } // lent whole, as 047 | { readonly is: "resetting-whole"; readonly since: number } // 047's reset of a whole box | { readonly is: "slots" } // has a slot in any state; not lent whole until none is left | { readonly is: "out-of-service"; readonly why: string };
type HostFailure = "start-failed" | "helper-failed" | "no-slot-made";What the shapes rule out:
- A pool Mac is never deleted or started, and lent whole or holding
slots is one field, so it cannot do both. A Mac with any slot, even one
resetting, is
slots; it isfreeonly when none is left. - A host that is starting can be joined.
promisedlists every box waiting for it, up tomost, so a burst of acquires fills one host instead of starting several (Placing a box). - A host being deleted lends nothing.
deletingis written by the host’s helper, under its lock, in the same step that finds the last slot released (Release and reset), so no claim can land on it. - A host whose VM is not running says so.
stoppedis what the Tart backend finds after a Mac restarts; it starts the VM again as it does a box’s (The host’s life). - Only a Namespace host has an end,
endsAt, which box_status reads for every box in it, in place of the box’s own. - Settings are a snapshot. A host keeps the
most,aheadandresettingMostit was made with. A change to the setting applies to hosts made after it; the pool Mac, which is never made, reads it at each sweep.
Slots
interface Slot { readonly host: string; // DeviceHost.id readonly generation: number; // up by one for each slot the host makes; never reused while the host lives readonly carried: boolean; // one of the image's two slots readonly state: SlotState;}
const slotUser = (slot: Slot) => slot.carried ? `bbd-i${slot.generation}` : `bbd-${slot.generation}`;const slotUid = (slot: Slot) => slot.carried ? 79990 + slot.generation : 80000 + (slot.generation % 10000);
type SlotState = | { readonly is: "making"; readonly step: "user" | "device" | "first-boot" | "daemons"; readonly for: string | null; readonly since: number } | { readonly is: "ready"; readonly device: SlotDevice; readonly booted: boolean } | { readonly is: "held"; readonly box: string; readonly for: "ios"; readonly device: SlotDevice; readonly since: number } | { readonly is: "held"; readonly box: string; readonly for: "macos"; readonly since: number } | { readonly is: "releasing"; readonly box: string; readonly since: number } | { readonly is: "resetting"; readonly step: "processes" | "user" | "sweep"; readonly since: number };
interface SlotDevice { readonly model: string; readonly udid: string }
type Placement = | { readonly on: "vm" } // dedicated: a VM or Mac of its own, as today | { readonly on: "slot"; readonly host: string; readonly generation: number; readonly carried: boolean };- A slot’s user and UID come from its generation, by one function each,
so a record cannot hold
bbd-7with UID 80009. The image’s carried slots have a range of their own, 79991 and 79992, so the image’s check can forbid every file in the 80000 range and exempt exactly those two users by name and UID (Slots in the image). - A slot being made can be promised.
fornames the box it is made for, so two acquires never both decide to make “the” next slot. - A slot has one box at most, and is never lent again: after
resettingit is removed, and a new slot, with a new generation, takes its place. mostcounts making, ready and held slots. A slot leavesmostonce its user is removed, about 1.5 s after the release; its sweep counts only againstresettingMost, since its UID is never given again and nothing of it can meet a later user (review finding 10).- A
macosbox in a slot has no device. Its slot’s device is deleted at the claim. - A box does not move, and its placement is held once: in the record’s
guest.data, written by the backend at the claim. The record’splacementis a getter over it, not a second field. - What a box can do is on the box. The record gains
capabilities, written at placement from the slot or VM it got: displays, computer use, root, enrolment and sizes. box_list, box_status, the panel’s Device and Desktop views and 030’s desktop commands read the box’s, not the offer’s.
The lifecycle of a slot:
making -> ready -> held -> releasing -> resetting -> (removed) \ ^ `--- making failed --------------------'Where each part lives
| Data | Lives in | Written by | Read by |
|---|---|---|---|
| A host’s state, its slots and their claims | on the host, /var/db/bb-box/host/: a state file, a folder a slot, each claim a marker made with ln, all changed under the helper’s flock |
the host’s helper, as root | its backend, through the helper’s status |
| A Tart host’s VM, image build and settings snapshot | the Mac’s owner file for the VM, ~/.bb-box/tart/owners/<vm>, with role=device-host, as every Tart VM has one; its state there is a cache of the helper’s |
the Tart backend | the Tart backend at start and in each sweep; the doctor |
| A Namespace host | the instance’s labels, as Namespace boxes have today, with bbbox-role device-host |
the Namespace backend | the Namespace backend; the doctor |
| A pool Mac’s mode | the pool helper’s state, /var/db/bb-box: its owner marker when lent whole, a slots marker while it has slots, each made with ln, so one wins |
the pool’s helper, as root | the pool backend, through the helper’s status |
| Where a box is, and what it can do | the box record: guest.data (host, generation, carried) and capabilities |
the registry, from the backend’s acquire, once | every tool and the panel |
| Hosts’ settings | bb-box’s configuration: each backend’s ios-hosts (below) |
the operator | each backend |
| Device placement’s lock | the plugin’s memory: one lock for every ios placement, across backends | the registry | the registry |
| The encoder’s ledger | the plugin’s memory, per Mac, rebuilt from viewers and recordings at start | the instance that holds the boxes’ leases (063) | the budget |
| A stream’s step | the device server’s memory: the viewers’ wishes and the plugin’s cap | each viewer; the plugin | the server, which alone opens idb’s stream |
Backend storage holds no host or slot state: each host holds its own, so a bb-box that lost its storage, or a second instance during a reload (045), finds the same hosts and slots. Two instances that race on one host meet at its helper’s lock.
The ios-hosts setting
Per backend that lends slots:
| Key | Default | What |
|---|---|---|
on |
true on Tart and the pool; false on Namespace, whose VMs cannot stream a device (M18) | whether this backend lends slots |
most |
Tart 8, pool Mac 12, Namespace 3 | slots making, ready or held |
ahead |
1 | ready slots kept while the host holds a box (the pool Mac: always) |
resetting-most |
4 | slots sweeping at once |
cores, memory-gib |
8 and 32 | a Tart host’s size |
per-mac |
1 | Tart hosts a Mac may run |
disk-reserve-gib |
40 | below this much free disk a host lends no new slot |
Turning on off lends no new slot; boxes already in slots keep them, and
the host goes when the last is released.
Placing a box
The registry today asks each offer in turn and takes the first with room
(#place in src/core/boxes.ts). An ios box needs a choice across
backends, so each backend that lends slots answers one question with a
graded answer, and the registry chooses:
type SlotTier = | { readonly tier: "ready"; readonly host: string; readonly waitMs: number } // a ready slot | { readonly tier: "join"; readonly host: string; readonly waitMs: number } // a slot being made, or a starting host with room | { readonly tier: "make"; readonly host: string; readonly waitMs: number } // a slot to make on a host with room | { readonly tier: "new-host"; readonly where: string; readonly waitMs: number; readonly cost: "vm-slot" | "namespace-mac" } | { readonly tier: "own-vm"; readonly where: string; readonly waitMs: number } | { readonly tier: "wait"; readonly words: string };
interface SlotLender { offer(kind: "ios" | "macos", model: string, signal: AbortSignal): Promise<SlotTier>; take(choice: SlotTier, box: BoxIdentity, signal: AbortSignal): Promise<Guest | { readonly lost: string }>;}- One lock for device placement. The registry takes one lock for
every ios placement, across threads and backends, asks each lender for
its best tier, picks the best by the order below, and asks that backend
to
takeit. The lock is held from the asks to the backend’s answer totake: a claim’sln, a promise written into a starting host’spromised, or the backend’s own start of a host, which it records asstartingwith this box promised before it releases the lock. So the next acquire sees the starting host and joins it. - A lost race is asked again. The helper’s
lnis the last word. Atakethat loses it, as when another bb-box instance claimed the slot during a reload, or the host went todeleting, answerslostwith its words, and the registry asks the lenders again, three times at most, then waits with words. A lost race never fails an acquire. - The order, owner’s decision 9, with what the box waits for. Times are to the device’s first frame and the box being a BB machine, on one basis (M13, H3):
| Order | Tier | Where | What the box waits for |
|---|---|---|---|
| 1 | ready | a pool Mac that is free or has slots |
a claim: marker, SSH key, the server’s hello. Seconds (H1) |
| 2 | ready | a serving Tart or Namespace host |
the same |
| 3 | join | a starting host, or a slot being made, with room | what is left of its start or making |
| 4 | make | a pool Mac or running host with room in most |
a slot made: 24 to 43 s on Tart, under 30 s on the pool Mac (M3, M13, POOL) |
| 5 | new-host | Tart, on the Mac with a free VM slot and the fewest watched streams | about 50 to 60 s: the VM ready, 35 to 41 s; a carried slot’s cold boot, 5 to 8 s; the first frame, 2 to 3 s; BB’s daemon, 5 to 6 s (M13, H3). H1 measures a first box on a new host |
| 6 | new-host | Namespace, only once on, which it is not: its VMs cannot stream a device (M18) |
the Mac ready in 29.7 s, idb_companion 0.6 s, a template’s first boot 27.1 s (M18) |
| 7 | own-vm | where a backend has room for one | the same basis: 35 to 41 s, a cold boot of 5 to 6 s, enrolment 8 to 10 s: about 50 to 57 s (M13) |
| 8 | wait | words per cause, below |
A new Tart host’s first box and a VM of its own take about as long. The host is chosen first because one VM slot then serves 8 boxes, not 1.
- Words when it must wait, one per cause: “Every shared iOS host is full, and every Mac runs two VMs.” “Both of this Mac’s VMs are macOS boxes, and no shared iOS host exists.” “The shared iOS host is updating and no new one can start.” Each ends with what frees first, from the hosts’ and boxes’ ages: “The next slot is likely free when a box on the pool’s Mac is released.”
- Another model than the slots’. A ready slot holds the kind’s default
model. A box that asks for another claims a ready slot, then the server
switches as 063’s switch does, making the model in the slot by
simctl createand seeding: a first boot, 14.5 to 28.5 s. - Apple’s two-VM limit counts a Tart host as one macOS VM whether it
lends 1 slot or 8. A Mac with a host has one VM slot left, for a macOS
box, a dedicated ios box or, where
per-macallows, a second host. dedicated: true, a size, or a project with its own ios image asks only for tierown-vm, as today.- A
macosbox asks for slots only withshared: true(macosboxes in slots). - Ready slots are the spares. 063’s suspended ios spare stays for dedicated boxes only. Slots are made ahead only on a host that holds a box, and on the pool Mac, which is always on.
- Starting a host warns the Mac’s viewers. A VM starting on the Mac took 8 half-size streams to 36 to 43 fps (E1). For about 30 s from a new host’s start, viewers of devices on that Mac see “A VM is starting on this Mac; video may slow for about 30 s.”
Making a slot
Each host runs a helper as root: the pool’s machine-mac.sh gains slot
commands; Tart and Namespace hosts run device-host, the same commands,
installed in the ios image or at the host’s start. Each step and its time,
in a Tart VM at 8 cores and 32 GiB (M3):
| Step | What | Time |
|---|---|---|
| 1. The user | sysadminctl -addUser bbd-<generation> -UID <uid>, a standard user, hidden, in _developer, its home from the pool’s home template (the login PATH, which M12 found missing) and made 700 |
1.8 to 3.4 s |
| 2. CoreSimulator’s defaults | the user’s first simctl makes 19 default devices: deleted |
2.2 to 15.5 s |
| 3. The device | simctl create of the kind’s default model, in the user’s own set |
0.3 to 1.5 s |
| 4. Slim | the runtime’s disabled.plist copied to /private/var/tmp/com.apple.CoreSimulator.SimDevice.<udid>/, the user’s, before the first boot |
under 0.1 s |
| 5. First boot | simctl boot, bootstatus |
14.5 to 28.5 s |
| 6. The device server | its daemon loaded; it attaches to the booted device and starts its companion | listening in 0.3 s (M15); idb_companion’s socket in 0.1 to 0.2 s, a first frame 1.8 to 2.6 s later (M13) |
| 7. At the claim | the thread’s SSH key; BB’s command run with BB_INSTALL_SKIP_SERVICE=1, then the helper’s daemon |
the installer 5 s, then connected 0.5 to 0.7 s after the daemon loaded (M14) |
A slot took 24.1 to 26.1 s from a new user to its device’s first frame in a VM just booted (M13), 38.7 s to a booted device in a VM that had run for a while, and 43.4 s while four watched devices streamed beside it, which kept them at 55 fps (M3). A ready slot keeps its device booted and its server running, about 0.01 of a core and 0.3 GiB (the research), so a claim boots nothing.
Slots in the image
A Tart host is a clone of the ios image, and lives only while it holds
boxes, so its first box always meets a new host. The image therefore
carries two slots, bbd-i1 and bbd-i2, UIDs 79991 and 79992, each with
its device made, seeded, first-booted and shut down, as the image’s own
devices are (063 unit 3.2), and their daemons written but not loaded. A
clone of the image gets them clean every time, as nothing ever ran in them.
Every clone has the same two UDIDs; each is in its own VM’s device set, so
they never meet.
- At the host’s start nothing boots. The first box’s claim takes
bbd-i1and loads its daemons: a cold boot of a device booted before, 5.0 to 5.8 s for the image’s own device (M13), 7.7 s for a slot’s after a restart (M16), instead of a device’s making. Then the host bootsbbd-i2’s device in the background, as its ready slot. - The UID check. The image’s check fails on any file owned by a UID from 80000 to 89999, and on any owned by 79990 to 79999 but the two carried users’ own, by name and UID. The sweep accepts both ranges.
- The first slot made in a running host is
bbd-1, UID 80001. - Cost: the two devices’ first-boot data, about 1.6 GiB each (063 E24), once on the Mac’s disk, as clones share it.
Why simctl create, and seeded
- The store is found. launchd_sim keeps a device’s disabled services in
/private/var/tmp/com.apple.CoreSimulator.SimDevice.<udid>/disabled.plist, outside the device’s folder, keyed by its UDID, in a folder only its user can open (M1), in a Tart VM and on the pool’s physical Mac alike. This explains every earlier finding: the list outlivedsimctl eraseand was lost bysimctl clone(the slimming note); a folder copied with its UDID met that folder owned by another user, and hung booting (the research); a Tart clone of the image kept it (063 results). - Seeding it makes a device slim at its first boot: 112 of 112 labels
off, from a clone in the same user (M2) and from
simctl createin a new user (M3). - A device cloned from another user’s set does not work. It booted slim, but no app came to the front (M4), as the research found with a copied folder. So each slot makes its own device.
- The store is undocumented. The device server’s boot check (063 unit
4.2: it counts the list’s labels in
print-disabledat each boot, 0.3 s) stays the guard. If a later Xcode moves the store, the check finds the device stock and applies the list, as now, and making a slot takes about 2 minutes longer. The image build keeps each runtime’sdisabled.plistbeside its list in/usr/local/share/bb-box/ios/.
The device server, and BB’s daemon, in a slot
Both are LaunchDaemons with UserName set to the slot’s user, written and
removed by the host’s helper. A user with no console session has no gui
domain, so a LaunchAgent cannot load (the research; M14 for BB’s own).
- The device server,
/Library/LaunchDaemons/app.bb-box.device.<user>.plist, runsbb-box-device.sh servefrom~/.bb-box/device/currentin the slot’s home, where 063’s delivery puts each version, withKeepAliveandThrottleInterval1. Measured (M15): listening 0.3 s after the daemon loaded; it booted the slot’s own seeded device (first boot 18.5 s, all 112 services off) and started its companion in 169 ms. Killed three times, it was listening again in 0.32, 1.34 and 1.28 s. A new version folder withcurrentpointed at it, then the server’s exit: the new one listening in 1.67 s. So delivery works as 063’s does, with the restart made by the server exiting, since the slot’s user cannot kickstart a system daemon. bb-box-device connectwaits for the socket rather than starting the service. If none comes within 10 s it says so, and the panel shows it: “The device server for this box has not started. bb-box has asked the host to start it again.”- BB’s daemon. BB’s installer puts its daemon in the
guidomain, and in a slot it fails: “launchctl: Bootstrap failed: 125: Domain does not support specified action” (M14). WithBB_INSTALL_SKIP_SERVICE=1, which the installer already honours, it enrols the machine and runs the daemon under its own--supervise. bb-box’s enrolment of a slot box runs BB’s command so, then asks the helperdaemon <generation>, which stops the installer’s process and loads a LaunchDaemon with the same command, port and data folder. Measured on a fresh BB in the host (M14): two slots enrolled, each on a port of its own the installer chose (38889, 38890), each connected 0.5 to 0.7 s after its daemon loaded, and BB ran a terminal on each as its own user. After a cold restart of the VM, both daemons started with it and reconnected 10.9 s after BB’s server came back (M16). - Sockets for both servers and idb’s are in the user’s home, which is
700.
Release and reset
The release is the helper’s release <generation>, run under the helper’s
lock. Measured with three users in a Tart VM, each with a booted device
(M6, M17):
| Step | What | Time |
|---|---|---|
| 1. Stop | the BB machine removed, as every release does; the slot’s daemons booted out; launchctl bootout user/<uid>; every process of the UID ended |
0.6 s |
| 2. The user | sysadminctl -deleteUser and the home: device set, keychain, DerivedData, caches. The slot leaves most here |
0.2 to 0.8 s for homes of 193 to 513 MiB |
| 3. Sweep, queued | every file and folder the UIDs in the queue own on the Data volume, in one find -xdev with one -uid each, at background priority, removed with the pool’s sweep; the UIDs’ System V IPC objects removed, as the pool does |
one pass: 31.7 to 36.8 s for one UID, 31.8 s for three (M17) |
The release answers after step 2, about 1.5 s. The sweep queue runs one
pass at a time for every UID waiting, at most resetting-most of them. The
check, a second find that must find nothing (31.6 s, M17), is the
doctor’s, run each night and after each restart, not on the release path:
the UID is never given again, so nothing left can meet a later user. What
the sweep found of each user: its folder in /private/var/folders; its
device’s /private/var/tmp/com.apple.CoreSimulator.SimDevice.<udid>, with
the disabled list, launchd_sim’s log and its syslog socket;
/private/var/tmp/com.apple.launchd.*; and folders named for its UUID
under /private/var/protected.
The last release deletes the host
Decided on the host, where the claims are (review finding 2):
- The helper’s
release, holding the lock, removes the claim and looks for any other claim, promise or slot being made for a box. If there is none, it writes thedeletingmarker before it lets go of the lock, and answerslast. - From then every
claimandpromiseis refused: “This host is being deleted; ask again.” The registry treats that as a lost race and asks the lenders again (see Placing a box). - The backend then deletes the host under its own chain: on Tart
tart stopandtart delete, as a box’s VM today; on Namespace the instance. Its sweep is skipped: deleting the VM removes everything. - If the instance that began a deletion dies, the Tart backend’s upkeep
finishes any
deletinghost by itssince, and the owner file, a cache of the helper’s state, is removed with the VM.
A Namespace host does the same; the pool Mac is never deleted, and goes
back to free when its last slot’s user is removed.
When a step is cut short
make, release and reset are written to be run again, as the pool’s
helper is, and each records its step before it takes it. The helper has a
LaunchDaemon of its own that runs resume at every boot.
| Found | Who acts | What happens | What a person sees |
|---|---|---|---|
a slot making, by the helper at boot or on a new call |
the helper | carried on from its step; a step that fails twice sends the slot to resetting |
the box it was made for keeps waiting, or is placed again |
a slot releasing or resetting |
the helper | carried on from its step | nothing |
a host starting whose instance died |
the backend’s upkeep | after 5 minutes with no boxes promised, deleted | its promised boxes are placed again |
a host deleting |
the backend’s upkeep | deleted | nothing |
a host failed |
the backend’s upkeep | deleted once it holds no box | its boxes see “The device’s host failed” in box_status |
| a slot held by a box the registry no longer has | the backend’s sweep, as for a box’s VM today | released | nothing |
A UID is never given again
macOS gave a new user the UID a removed one had just had (M7). In the ios
image, /Applications/Google Chrome.app is owned by UID 502, so a sweep of
a box user that macOS had given 502 removed Chrome (M7). So, as the pool’s
helper does (047):
- Slot users take UIDs from 80000 to 89999, and the image’s two carried slots 79991 and 79992, so a file left by any slot can never belong to a later one, even before its sweep ends.
- The sweep refuses a UID outside those ranges. The ios image’s check, and the pool’s setup, fail if any file is owned by a UID in them, but the carried users’ own (see Slots in the image).
- Slot users are standard users, never
admin(047: that is what makes removing one a reset), in_developerso the debugger and Instruments need no password (DevToolsSecurity), and incom.apple.access_sshwhere Remote Login is limited to that group.
What can pass between boxes, and how it is checked
Boxes are not a security boundary, as in the pool (047). Between two boxes in one host, from the research’s isolation run, with this note’s changes:
| Between two boxes in one host | |
|---|---|
Device, simctl, idb’s socket, the device server’s socket |
apart: another user’s set and sockets are refused |
| Home, keychain, DerivedData, Library | apart: the home is 700 (the research found drwxr-x--- let others list its top level) |
| Processes | seen by all, with command lines and device UDIDs; not signalled |
| Loopback and ports | shared: a server on 127.0.0.1 is reached by every box and every device; a port held by one is refused to another. Each box has a range of its own (below) |
/tmp, /Users/Shared |
shared: what a box leaves readable there, others can read, until its reset |
| The host’s own user | its home 700, so no box reads bb-box’s key or the host’s state |
| After a reset | nothing: no process, no file of the UID on the Data volume, no IPC object |
H7, in unit 3, plants as one box: files in /tmp, /Users/Shared,
/private/var/tmp and its home; a LaunchAgent, a crontab and a keychain
item; a second simulator device; a process that ignores SIGTERM; a server
on 127.0.0.1. Another box, at the same time, must not reach the home,
keychain, device or sockets. After the release the next slot’s user must
find none of them, and find -uid nothing, as test/ssh-pool-live.ts does
for the pool’s Macs.
Ports: a range for each box
Every box and every device in a host shares one loopback, and the research found every device’s Safari reached a server another user ran on 127.0.0.1. So two React Native boxes both starting Metro on 8081 would not fail loudly: the second cannot bind, and its app loads the first box’s bundle. A test that passes against another box’s server is a false pass.
- Each slot has a range, 100 ports from
BB_PORT_BASE(20000 plus 100 times its place among the host’s slots), inBB_PORT_BASEandBB_PORT_COUNTin its environment, in box_status, and in the skill: “Servers on localhost in a shared iOS box are reachable from every box on its host. Start yours on your box’s ports, from BB_PORT_BASE: for Metro,--port $BB_PORT_BASE.” - The common ports are held, so a mistake is an error, not a wrong connection. The host’s helper listens on 127.0.0.1 at 3000, 4200, 5000, 5173, 8000, 8080, 8081 and 19000, and answers each request with HTTP 421 and “This port is shared by every box on this iOS host. Use your box’s ports, from BB_PORT_BASE.” A box that tries to bind one is refused with “Address already in use”. A dedicated box has none of this.
What one box can take from the others
macOS has no cgroups: nothing holds a box to a share of the cores or memory. On a VM of its own a runaway build hurts its own thread; on a host it hurts the others, so bb-box bounds what it can and shows the rest.
| Bound | Shown | |
|---|---|---|
| Devices | one booted device a slot. The device server checks every 5 s; a second booted device is left for 10 minutes for a test that needs two, then shut down, with words in box_status | |
| Disk | the host lends no new slot below disk-reserve-gib free (40) |
the helper’s status gives free disk, and each slot’s home size, read at background priority every 10 minutes |
| Memory | the host lends no new slot while memory_pressure gives under 15% free |
each slot’s resident memory |
| CPU | the helper’s own work (making, sweeps) runs at background priority with taskpolicy -b; a box’s own work does not |
each slot’s cores, from ps every 10 s |
| The encoder | the ledger caps bb-box’s own streams (below); a box’s own ffmpeg or Xcode previews it cannot see |
What a person sees: box_status for a box in a slot names whoever is
using most, when it is not this box: “This host is busy: slot 5 is using 6
of 8 cores and 9 GiB.” The doctor lists each host’s slots by load. A person
who needs the cores asks for dedicated.
What a box gives up in a slot
| A box in a slot (default) | dedicated: true |
|
|---|---|---|
| Ready | seconds from a ready slot; about 50 to 60 s on a new Tart host, as a BB machine (M13, M14) | about 50 to 57 s, as a BB machine (M13), or a spare (063) |
| Its device, files, keychain, input | its own | its own |
sudo, brew install, system settings |
no | yes |
| Cores and memory | shared with the host’s other boxes; a 49 s build took 95 s beside 4 streaming devices (the research) | its own, at a size it chooses |
| Ports and loopback | shared; a range of its own, and the common ports refused | its own |
| Process list | others see its processes | its own |
| The host’s desktop, for Xcode’s window | none: a slot’s user has no console session | yes |
| Watching its device | the same while the Mac’s encoder has room; at a lower step when many are watched | the same rule: the encoder is the Mac’s (E1) |
| A restart of its host | stops every box in the host | its own only |
| A project’s ios image (049) | no: its script runs as root | yes |
box_resize, box_grant |
no | yes |
Enrolment as a BB machine, box_thread |
yes (M14) | yes |
| On Namespace | ends with the host, at most the plan’s longest life after the host was made, not after the box was | at most the plan’s longest life after the box was made |
What each box operation does for a box in a slot (review finding 12):
| Operation | A box in a slot | dedicated: true |
|---|---|---|
box_run, box_copy, box_job, box_device, box_record, the computer tools |
as on any ios box | the same |
box_stop, box_start |
refused: “A shared ios box cannot be stopped on its own: it shares its host with other boxes. Release it, or ask for dedicated.” | a Tart suspend and resume, as today |
A restart of the box (box_resize with restart) |
refused, in the same words; box_device reboot reboots its device |
the VM restarts |
box_resize |
refused: no size of its own | as today (061) |
box_grant |
refused: no console session to grant to | as today (058) |
box_image_try, box_image_draft |
refused: a project’s image runs as root | as today (049) |
| The upkeep’s stop of an idle box | never: the host’s other boxes keep it up; an idle slot box is released only by its thread’s end, as today | as today |
| A spare’s claim | ready slots are the spares | 063’s suspended spare |
| 063’s “When things stop and start”, the box’s own row | its device and server restart as there; a host restart is below | as 063 |
box_list, the ios kind’s line: “Shared by default: a macOS user on a Mac or VM shared with other ios boxes, with its own device, files and keychain, but no sudo, and cores and processes shared. Servers on localhost are reachable from every box on the host: use your box’s ports, from BB_PORT_BASE. Ask for dedicated for a VM of its own.”
box_acquire gains dedicated: “For an ios box: a macOS VM of its own,
with sudo, brew install, its own cores, ports and desktop, instead of a
macOS user on a shared host. It takes one of the Mac’s two VMs, so it may
wait for one.” A size, cores or memory_gib for an ios box without it
is refused: “A shared ios box has no size of its own. Ask for dedicated:
true with a size.”
box_status says where it is and what is near it: “On a shared iOS host (a Tart VM), slot 3 of 8. Root: no. Ports 20300 to 20399.” with the host’s load when another slot is busy, or “A VM of its own.”
The New box form, for the ios kind, a choice before the device step:
- Shared (default): “Ready in seconds. No sudo or Homebrew installs. Other iOS boxes on the host share its cores and ports, and see its processes.”
- A VM of its own: “sudo, Homebrew, its own cores, ports and desktop. Takes one of the Mac’s two VMs, so it can wait for one.” The size step shows only with this choice.
The skill says when to ask for dedicated: a build that needs the
cores, brew install or sudo, an app that needs fixed ports, Xcode’s
window, or a project with its own ios image; and that a shared box’s
servers go on its own ports, from BB_PORT_BASE.
macos boxes in slots
Measured as a slot’s user with no console session, over SSH as
box_run reaches a box, in a Tart VM (M12):
| Works | Fails |
|---|---|
| a Swift package built (4.5 s) and signed ad hoc | brew install: Homebrew belongs to the image’s user |
a keychain of its own, made with security; no login keychain is unlocked |
sudo |
simctl, a booted device, an iOS app built for testing (6.2 s) and an XCUITest run, which passed (18.5 s) |
open -a TextEdit: “Launch failed” |
| npm into the home, a Python venv and pip | AppleScript to System Events: error -10810 |
| a server on 127.0.0.1, which the host’s other users reached too | screencapture: “could not create image from display” |
So a macos box in a slot is a pool Mac’s box (047) without the console:
commands, builds and the simulator, but no window of any app, no macOS UI
tests, no screenshots and no computer use. Against a VM of its own it gives
up all of that, and sudo, Homebrew, its own cores, its own ports and a size.
A macos box today has a console session even though its desktop is not
shown, so its desktop commands (030) run apps; in a slot they cannot.
Recommendation: a host lends macos boxes only when asked, with
shared: true on a macos acquire, and in the New box form as a choice
for the macos kind with the same words as the ios kind’s. Without it a
macos box is a VM of its own, as today, and waits with words when none
can be had. A first draft made the slot a silent fallback; the review
showed why not (finding 11): an agent that asked for macos to build gets
a user with no sudo, Homebrew or app window, and may run brew before it
reads the words. A macos box’s work is builds, which want the cores a
shared host splits (the research: a 49 s build took 95 s beside 4
streaming devices), and often sudo, Homebrew or a window. shared: true
on a macos box is for commands and builds that need none of them, and it
is ready in seconds.
A macos slot is an ios slot with its device deleted at the claim, so a
host’s ready slots serve either kind, and a host with a macos box in it
stays up for it as for an ios box.
The host’s life
Pool Macs
A pool Mac (047) is always on: no start, no deletion, no two-VM limit, and the Mac’s own encoder, shared with no VM (POOL).
- Whole or slots, never both. A pool Mac is
free, lentwholeto one box as today, or lendingslots. A whole box takes the console, all the cores and, at its reset on macOS 27, a restart of userspace, which would stop every slot’s device. So a whole claim needs afreeMac, and a slot claim turns afreeMac toslots. The Mac goes back tofreewhen its last slot’s user is removed; its sweeps finish in the background, and a whole claim waits for them. The pool’s next box user (047) stays signed in at the console meanwhile, idle. - What whole-Mac boxes give up. While any slot is held there, the pool
Mac is not free for a
macosormacos-desktopbox. Those go to their other offers, or wait with words: “The pool’s Mac is lending iOS slots (3 held). It is lent whole again when they are released.” An operator who wants the Mac kept whole sets itsslotsto 0. - Its helper,
machine-mac.sh, gainsslot make,claim,release,daemonandstatus, and the pool’s setup installs idb_companion from its pin and bb-box’s lists and seeds in/usr/local/share/bb-box/ios/. Slot users take UIDs from 80000, apart from the whole boxes’ 70000 range; they are never at the console. The sweep is the pool’s own, which removes protected folders where the Mac has Full Disk Access or SIP off (058). - Slots ahead. One ready slot even while the Mac is
free, since the Mac is up anyway and holds no VM for iOS. A whole claim removes ready slots first, about 1.5 s each, their sweeps in the background. - Size. 12 slots on this pool’s Mac, 18 cores and 36 GiB. Twelve busy devices took about 6.4 of its 18 cores and left 54 to 59% of its memory free; a seeded device first-booted in 10.4 to 12.4 s beside them (POOL).
- The encoder: about 375 full-size iPhone frames a second, in two halves of about 188. Four watched at full size ran at 59.3 to 59.5 fps with a tap shown in 50 ms at the median; twelve at half size at 58.7 to 59.2 fps and 49 ms (POOL).
Tart hosts
| What happens | The host | Its boxes |
|---|---|---|
| An ios box finds no slot, and a Mac has a free VM slot | cloned from the ios image as bb-box-host-<id> and booted, starting with the box promised; the carried bbd-i1 claimed for it, bbd-i2 booted after as the ready slot; more made one at a time while it holds a box |
the first waits about 50 to 60 s (M13, M14); boxes that came meanwhile join it |
| Its last box is released | the helper writes deleting; the backend stops and deletes it; the Mac’s VM slot is free again |
|
| A slot’s device or CoreSimulatorService crashes | launchd starts that user’s CoreSimulatorService again within 5 s (the research) | that box only: its server boots the device again, about 5 s (063); the others went on at 55.5 and 56.9 fps through it (the research) |
| A slot’s idb_companion dies | that box only: a new companion in about 0.1 s, a stall under 1 s (063 results) | |
| A restart of its VM, by the operator or a crash | the helper’s resume, every slot’s device server and BB’s daemon start with the VM, with no one signed in |
every box stops, then comes back by itself: in M16 a slot’s device server was up as the VM booted, its device booted cold in 7.7 s and streamed 25 s after, and BB’s daemons reconnected 10.9 s after BB’s server answered. With 8 slots, 8 cold boots took 62 s (the research). box_status says “The device’s host restarted; back in about 2 minutes” |
| The Mac restarts | stopped: its VM does not start by itself. The Tart backend starts it at its own start and at each sweep, as it starts a held box’s VM |
as a restart of the VM, from when the backend starts it. box_status says “The host is starting again” |
Full: most slots making, ready or held |
lends no more | new boxes placed as above |
| The ios image changes | drains, softly: it still lends its ready slots while no host on the new image can start on that Mac, and stops lending as soon as one can; it is deleted when its last box goes, as always | new boxes go to a host on the new image where one can start, and otherwise to the old host’s slots, with box_status naming its build. The doctor says why a host on an old build still lends |
| It fails to start, or to make a slot three times running | failed, with words; deleted by the upkeep once it holds no box |
new boxes go on down the order |
Draining is soft (review finding 7). The lead’s decision 5 had new boxes wait while a host drained. With one host a Mac and the Mac’s other VM slot taken by a macOS box, a long-lived box in the old host would make every new ios box wait, or take a Namespace Mac, while ready slots sat on an image a day older. An image a day older is still an image every box of the last month ran on, so the old host lends until a new one can start.
Namespace hosts
A Namespace host is a Namespace Mac made by the Namespace backend for an ios box when no tier above it in placing a box answered, and deleted at once when its last box is released, as a Tart host is. What is different:
- One Mac at a time, at most 6 cores and 14 GB, in this workspace
(
machines: 1,largest-machine: 6x14). A Namespace host takes that one Mac, so while it lives nomacosormacos-desktopbox can be made on Namespace, and the reverse. - A life of at most 4 hours, the plan’s longest (
longest-life-hours: 4), from the host’s creation, not each box’s. Every box in it ends with it. So a Namespace host lends no new slot in its last hour (draining,ending), every box placed there has at least an hour, and box_acquire says when it ends: “This box ends at 19:40, when its Namespace host does.” A thread that needs a box for longer asks fordedicated, or gets a Tart host or the pool Mac by waiting; a Namespace host is the last shared choice in the order for this reason. - Its image is Namespace’s: macOS 26.3.1 on an M4 Pro, Xcode 26.1.1,
with iOS runtimes 17.5, 18.6 and 26.0 to 26.2 (M18), not bb-box’s ios
image. At the host’s start the helper installs idb_companion from 063’s
pin, checked by SHA-256 (0.6 s, M18), and bb-box’s lists and seeds in
/usr/local/share/bb-box/ios/, and 063’s record names the runtime it uses, so box_status says which iOS a Namespace box tests on. - Slimming works there. The iOS 27.0 list applied to an iPhone 17 Pro on iOS 26.2 turned off all 112 of its labels, and the store was where it is on Tart and the pool Mac (M18). Each runtime still gets its list checked, as 063’s image check does.
- It cannot stream a device’s video. Its VM has no H.264 encoder: VideoToolbox could not make a compression session (-12903), and idb’s video stream answered “VideoToolbox encode error: OSStatus -12915” for every frame and sent none, while taps reached the devices (M18). VideoToolbox’s software encoder gave 42 frames a second of one full-size iPhone, on the CPU. idb’s screenshots worked, at 509 ms at the median and 2.2 s at worst, too slow for 063’s stills.
- And four busy devices took all of it: 6 cores at 0.6% idle, 11% of its memory free and 2 GiB swapped (M18).
So Namespace hosts stay off (on: false), and Namespace leaves the
order until a device on it can stream: by 063’s Android path, raw frames
into bb-box-h264, if its CPU can afford it beside the devices, which M18
suggests it cannot for more than one or two; or by Namespace offering Macs
whose VMs have the encoder. Unit 7 waits for one of them. The rest of this
section stands for when it does.
Streams at the viewer’s size
How a viewer’s size reaches the server
- The viewer says what it draws. After
START_VIDEO, and whenever what it draws changes and stays changed for 500 ms (a resize of the panel, zoom, Fit, Pixel for pixel, Show frame, a move to a screen of another pixel ratio), the viewer sendsBB_DEVICE_VIEW {"width": w, "height": h}on its viewer connection: the device screen’s drawn size in the browser’s device pixels, CSS size timesdevicePixelRatio, upright. A hidden tab sendsSTOP_VIDEO, as now. - The relay passes it. Its list of viewer verbs (
VIDEO_VERBSinsrc/core/selkies.ts) gainsBB_DEVICE_VIEW; a server of an older version ignores the verb, so it is never dropped as unknown. - The server picks a step. Its wish is the largest drawn size among the
viewers watching, as a share of the device’s own pixels. The step is the
smallest of a third, a half, two thirds and full that is at least that
share less 5%. It changes step only after the wish has held for 1 s, by
restarting idb’s stream with
scale_factorset to the step: a key frame in 50 to 106 ms (063 E5). - Input stays in the device’s pixels. The viewer turns its pointer into the device’s own pixels by the video size in each frame’s header, so a touch made while the step changes lands where it was drawn. A step change is not a geometry change: it does not move the generation, which 063 moves at every change of video size. So an agent’s screenshot and references stay good when a person resizes the panel.
- The picture is drawn at the frame’s size, scaled up by the browser. At a lower step it is softer, not smaller.
This is a new version of the device server, with a new protocol version for the verb and the input’s space. A viewer of an older protocol gets full-size video and sends video pixels, as now.
Android keeps full size in this change. Its frames are encoded by
bb-box-h264 in its own VM, with no Mac encoder to share, so the steps
would save that box’s CPU only, a later decision. Its input moves to device
pixels too, as the server is one program; with full-size video, device
pixels and video pixels are the same, so nothing an Android viewer sends
changes. Unit 1’s live check runs 063’s E8 cases on Android as well as iOS.
A window dragged between screens of two pixel ratios sends two wishes; with the 1 s hold the stream restarts at most twice, each a stall of a key frame’s time, 50 to 106 ms (063 E5). H4 measures it.
What the panel would draw, the iPhone 18 Pro fitted to the panel’s height. These are estimates from the frame’s fit rule (063 unit 6.1), not measurements; H4 measures the steps through BB:
| Screen | Device drawn, in device pixels | Step |
|---|---|---|
| A laptop at 2x, panel about 800 px tall | about 1,300 tall | a half |
| A desktop monitor at 1x, panel about 1,000 px tall | about 850 tall | a third |
| A 5K monitor at 2x, panel about 1,300 px tall | about 2,100 tall | full |
| Pixel for pixel | 2,622 tall | full |
What runs the stream
Today the iOS driver opens idb’s stream when the device attaches, and keeps it open, viewers or not. Frames come only when the screen changes, so an unwatched busy device, such as one running a UI test, takes the encoder for nothing. Drawing with no stream takes none: with 8 devices busy and none watched, the encoder gave the same 318 frames a second (M9).
So the server opens idb’s stream only while one of these wants it, at the largest step any asks, and closes it 10 s after the last:
| Who | Step | Why |
|---|---|---|
| A viewer watching | its drawn size | |
| A recording (059, 063) | two thirds | the clip is at most 1,600 px on its long edge; two thirds of the iPhone 18 Pro is 1,748 px tall, of the iPad Pro 13-inch 1,835 |
The agent’s tools waiting for the screen to settle (063 unit 7.1, the server’s settle read) |
a third | they count frames, not read them |
Geometry no longer comes from frames when none flow: the server reads the orientation from idb every few seconds while still, as it does now.
A full-size screenshot does not use the stream. computer_screenshot
and the toolbar’s Screenshot take idb’s screenshot, a PNG of the
framebuffer. With the encoder flat out it took 47 ms at the median against
38 ms with it idle (M10). Screenshots stay at the device’s full size.
The encoder budget
Per Mac, not per VM
The research’s first open question, settled by E1: two VMs on one Mac share one encoder.
| The Mac’s other VM | This VM: two encoders flat out, in all | This VM: 4 watched at full size | This VM: 8 watched at half size | The other VM’s own encoders |
|---|---|---|---|---|
| running, idle | 319.6 to 319.8 | 57.8 to 58.2 fps, 66 ms | 55.8 to 56.7 fps, 65 ms | |
| two encoders flat out | 159.3 to 159.4 | 52.6 fps, 149 ms | 57.0 to 57.3 fps, 68 ms | 112 each |
| four paced at 60 fps, as four watched iPhones | 105.9 to 106.1 | 52.4 fps, 146 ms | 56.7 to 57.5 fps, 69 ms | 52.4 to 52.5 each, not 60 |
Frames a second are each device’s; times are from a tap to the frame that
shows it, at the median. So a second VM’s watched devices take from the
first’s, and the other way round: a second host on a Mac adds slots, not
watched streams. One host a Mac is the default for this reason
(ios-hosts.per-mac, 1).
Twice, another VM starting on the Mac while devices streamed (once ours, once another thread’s) took 8 half-size streams down to 36 to 43 fps, with taps at 206 and 74 ms at the median and 370 and 404 ms at the 95th percentile (E1). Whether that was the Mac’s CPU during the boot or the new VM’s device streaming its first frames is not separated; H8 does.
Two halves
The encoder works as two halves (M11). One stream alone used both, 288 frames a second (M8). With two or more, each was placed on one half when it started and stayed there, and the streams on a half shared it equally. Four full-size streams flat out split two and two, about 80 frames a second each, in 8 of 12 tries, and one and three in 4: the one at 105 to 160, the three at 46 to 53. This explains the research’s slow state, “three at 52.9 fps and 145 ms, one at 66 ms”, and its six devices all at 52.4 to 52.7 fps: three on each half, and 160 / 3 is 53. The pool Mac’s encoder splits the same way, into halves of about 188 (POOL).
So a half carries two full-size streams at 60 frames a second, and a third brings all three on it to 53. bb-box cannot choose the half. So at most two streams on a Mac are at full size at once; others are capped at two thirds, which a half carries four of at 60 (M8).
What a frame costs
| A frame of | Pixels | Frames a second, the whole encoder, Tart Mac | Cost against a full iPhone frame |
|---|---|---|---|
| iPad Pro 13-inch, full | 2064x2752 | 183 | 1.75 |
| iPhone 18 Pro, full | 1206x2622 | 294 to 320 | 1 |
| two thirds | 804x1748 | 518 | 0.57 |
| a half | 604x1312 | 885 to 931 | 0.33 |
| a third | 402x874 | 1,604 | 0.18 |
On the pool Mac, a full-size iPhone frame: 374 to 377 a second; a half: 1,310 to 1,329, so 0.28 of a full one (POOL).
The ledger
- A budget per Mac, in full-size iPhone frames a second, from the chip: 320 for an M4 Max running VMs and 375 for an M5 Max (E1, POOL), in a table in the code, read with the Mac’s other facts. A chip not in the table is measured by a 15 s probe, run only while the Mac’s ledger is empty, so it neither takes the encoder from a stream nor reads low because of one; two encoders, not four, so the halves cannot split it one and three (M11). A Namespace Mac has no hardware encoder (M18).
- What it counts: every stream bb-box makes on that Mac, each at its frames a second over the last 10 s, with a floor of 20, times its frame’s cost from the table: watched devices, recordings, and the desktops bb-box streams there, a macOS desktop from a Tart VM (060) and a Windows desktop encoded on the Mac (046). So a panel open on a still device counts a third of a busy one’s, and a device that becomes busy is counted as busy within 10 s.
- The cap. The plugin keeps the sum within three quarters of the budget: 240 on the Tart Mac, 280 on the pool’s. That is four watched iPhones at full size, which ran at 57.8 to 58.2 fps and 66 ms with the other VM idle (E1), where six did not (the research). At most two streams a Mac are at full size (M11).
- Who gets room, in order: the stream of the person holding control of a device, then recordings, then other viewers, then the tools’ waits. Over the cap, the plugin lowers the step of the lowest in that order first, largest first within it; never below a third, and a recording never below a half. Pixel for pixel for the person holding control always gets a full-size place, by stepping passive viewers down. When room comes back, steps rise in the same order after 10 s, so they do not flap.
- What that gives, at a busy device’s 60 frames a second: on the Tart Mac 2 at full size and 3 at two thirds, or 12 at a half, or 21 at a third; on the pool Mac 2 at full size and about 4 at two thirds, or 16 at a half, or about 25 at a third. Still devices cost a third of that.
- What a person sees, in words that name boxes, not frames: under the toolbar while their stream is lowered, “Shown at a lower resolution: this Mac’s video encoder is busy with 3 other devices (box ios-2, box ios-5, box ios-7).” box_status and the doctor: “On this Mac: 4 of about 5 full-size streams’ room in use.”
- Beyond the cap, when every stream is at its floor and the sum still exceeds it: frames slow for every stream alike, as the encoder shares, and the toolbar says “This Mac’s video encoder is full, so frames come more slowly.” Nothing is refused.
- New hosts go where streams are fewest. Tier
new-hostpicks the Mac with a free VM slot and the smallest ledger, and warns that Mac’s viewers while the VM starts (see Placing a box).
Measurements
Measured on 26 October 2026, on two Macs:
- The Tart Mac, an M4 Max: ios boxes from the ios image build.20261026-091450-8505, macOS 27.0, Xcode 27.0 (27A266a), iOS 27.0 (24A434), idb_companion 1.7.7, each at 8 cores and 32 GiB, a host’s size, but for E1’s second VM at 6 and 16. Each box’s own device server was stopped.
- The pool’s Mac, an M5 Max with 18 cores and 36 GiB, macOS 27.0.1, the same iOS 27.0 runtime, as a pool box’s user, with no root, idb_companion 1.7.7 from its pin in the home.
Every device was an iPhone 18 Pro. Streams were read in the box from idb’s
socket, with the research’s harness,
multi.ts, and its decoded-pixel
tap analysis, analyse.py: BB’s
relay, the network and the browser are not in them. The encoder was loaded with ffmpeg’s h264_videotoolbox, hardware
only, 8 Mbit/s, from a raw clip in memory, so nothing but the encoder
limited it. The scripts and summaries are in
064-ios-shared-hosts.
During M1 to M11, another thread’s ios box ran in the Tart Mac’s other VM slot, with whatever its work gave the encoder. E1 had both slots.
M1: where launchd_sim keeps disabled services
A label that does not exist was disabled in a booted device with simctl spawn <udid> launchctl disable system/<label>, and every file changed
after it was listed with find -newer, as root, across /private/var,
/Library, /private/etc and the user’s home. One file held it:
/private/var/tmp/com.apple.CoreSimulator.SimDevice.<udid>/disabled.plist,
a binary property list of 124 labels, each true: the image’s 112 and
others iOS disables itself. Its folder is the user’s, 700, and holds
launchd_sim’s log and syslog socket too. Nothing in the device’s own folder
held the label but LaunchServices’ database, which names every label it
has seen.
M2: a clone, seeded
| Time | Disabled of the 112 | Processes under launchd_sim, 15 s after boot | |
|---|---|---|---|
simctl clone of the image’s slim iPhone, same user |
2.1 s | ||
disabled.plist copied into the clone’s folder, then boot |
1.7 s | 112 | 60, against the template’s 62 |
| 8 more clones, each seeded | 17.6 s for all 8 | ||
| The 8 booted one after another, each opening a page in Safari | 1.5 to 10.5 s each |
M3: a slot, made in a new user
| Run | User | First simctl |
simctl create |
First boot, seeded | Disabled of the 112 | Whole slot |
|---|---|---|---|---|---|---|
| 1 | 1.9 s | 1.5 s | 28.5 s | 112 | ||
| 2 | 2.7 s | 2.2 s | 0.3 s | 24.8 s | 112 | 38.7 s |
| 3, while 4 devices streamed | 112 | 43.4 s | ||||
| 4 to 6, in a VM just booted (M13) | 1.9 to 2.0 s | 5.4 to 6.5 s | 0.2 s | 14.5 to 15.6 s | 112 | 24.1 to 26.1 s, to the first frame |
In run 1, Safari opened a page and came to the front, seen in a screenshot. The 4 devices beside run 3, each busy and watched at full size, tapped once a second:
| Frames a second | Worst gap | Tap to frame: median, p95, worst | |
|---|---|---|---|
| Before, 30 s | 55.2 to 55.6 | 89 to 121 ms | 72, 144, 317 ms |
| While the slot was made, 40 s | 55.4 to 56.3 | 114 to 135 ms | 71, 148, 244 ms |
A new user’s first simctl made 19 default devices, every model of every
runtime, in 15.5 s and 192 MiB the first time and 2.2 s the second. A
device_set.plist written before it, with an empty DefaultDevices for
each runtime, did not stop it.
M4: a device cloned across users
A template was cloned into a set in /Users/Shared readable by all, and a
new user cloned it into its own set with simctl --set <shared set> clone <udid> slot <its own set>. The clone took 2.3 s and booted in 1.7 s, with
112 of 112 labels off once seeded. But no app came to the front: simctl openurl answered success and simctl launch gave Safari a process, yet
launchd listed no UIKitApplication for Safari, and a screenshot showed the
Home Screen. A device made by simctl create in a user (M3), and a clone
within the same user (M2), each listed Safari and showed its page.
M5: what a new user’s first simctl costs
See M3: 19 default devices, 2.2 to 15.5 s, 192 MiB. Making a slot deletes them.
M6: removing a user, and what is left
Three users, each with a booted device, removed in turn:
| User’s home | Processes ended | User and home removed | find -xdev -uid over the Data volume |
Paths found | After the sweep | Sweep and check |
|---|---|---|---|---|---|---|
| 193 MiB | 0.6 s | 0.2 s | 31.7 s | 6 | 0 | 71.1 s |
| 513 MiB | 0.6 s | 0.8 s | 36.8 s | 9 | 0 | 75.1 s |
| 328 MiB | 0.6 s | 0.7 s | 34.8 s | 9 | 0 | 71.0 s |
The paths: the user’s folder in /private/var/folders; four folders named
for its UUID in /private/var/protected/trustd/private and
/private/var/protected/sfanalytics; three /private/var/tmp/com.apple.launchd.*
folders; and its device’s /private/var/tmp/com.apple.CoreSimulator.SimDevice.<udid>.
An earlier removal without the sweep left 19 to 49 files of each user.
M7: UIDs given again
After users 502 to 504 were removed, macOS gave the next new user 502. In
the image, /Applications/Google Chrome.app is owned by UID 502, and the
sweep of that user’s UID removed it.
M8: the encoder, by frame size
Four encoders flat out for 15 s, frames a second each over the last 10 s:
| Size | Each | All four |
|---|---|---|
| 2064x2752 | 45.5 to 45.9 | 182.6 |
| 1206x2622 | 73.3 to 73.7 | 294.0 |
| 804x1748 | 129.4 to 129.7 | 518.0 |
| 604x1312 | 221.1 to 221.4 | 884.9 |
| 402x874 | 400.1 to 402.0 | 1,604.2 |
One encoder alone at 1206x2622 gave 288 frames a second; two, 160 each.
M9: does drawing take the encoder?
Four encoders at 1206x2622 flat out gave 318.0 frames a second in all while 8 devices drew Safari’s page every frame, unwatched, and 242.3 and 289.9 with every device shut down. Drawing does not take the encoder; what moved the total is in M11 and E1.
M10: a screenshot under a full encoder
15 PNG screenshots by idb, 200 ms apart, of a busy device: 38 ms at the median and 164 ms at worst with the encoder idle; 47 ms and 68 ms with four encoders flat out.
M11: the encoder’s two halves
Four encoders at 1206x2622 flat out, 12 runs: six started at once, six 1.5 s apart.
| Started | Two and two: about 80 each | One and three |
|---|---|---|
| At once | 5 of 6 | 1: 160.2 and 52.6 to 53.1 |
| 1.5 s apart | 3 of 6 | 3: 105.2 to 160.3, and 46.2 to 53.2 |
The total was 317.3 to 319.0 in 10 runs, and 245.0 and 222.6 in two, with nothing else in the VM using the encoder. The same split explains the devices: 6 watched at full size ran at 51.5 to 51.7 fps, with taps at 139 to 149 ms at the median, all six alike, as three on each half; 8 at half size ran at 55.9 to 56.6 fps and 66 ms.
M12: what a slot’s user can do
A slot’s user, made as in M3, with no console session, reached over SSH
from the VM’s own user, as box_run reaches a box. Each command, its exit
and its time:
| Command | Result |
|---|---|
sudo -n true |
refused: a password is required |
brew install tree |
refused: Homebrew’s folders are the image user’s, and it asks for sudo chown |
npm install -g --prefix ~/.npm-global, a venv and pip install |
worked: 0.6 s and 2.5 s |
swift package init, swift build -c release |
worked: 17.4 s with the init; the build 4.5 s |
codesign -s - of the build |
worked |
security list-keychains |
only the System keychain: no login keychain |
security create-keychain in the home |
worked |
open -a TextEdit |
failed: “Launch failed” |
osascript to System Events |
failed: error -10810 |
screencapture -x |
failed: “could not create image from display” |
simctl list devices booted |
worked: its own device |
xcodebuild build-for-testing of 063’s probe app |
worked: 6.2 s |
xcodebuild test-without-building, the probe’s XCUITest, on its own device |
passed: 18.5 s |
| a server on 127.0.0.1:8799 | answered; the VM’s own user reached it too |
The new user’s PATH had neither Homebrew nor Node: a slot’s home needs the home template the pool’s users get (047).
M13: a new host
Two Tart boxes of the ios image, from box_acquire’s timings, to SSH: place
4.3 and 4.6 s, clone 4.5 s, start 5.5 and 5.6 s, network 7.3 and 9.4 s,
SSH 4.2 and 6.5 s: 25.8 and 30.6 s; to ready, with a box’s identify, name
and grants steps, 34.8 and 40.7 s. The image’s own device, booted
once in the image, then booted cold in 5.0 and 5.8 s.
In the first, just booted, three slots were made one after another by a
script run as root, with UIDs 80001 to 80003 given to sysadminctl, and
idb_companion started for each by a LaunchDaemon with UserName:
| Slot | User | Defaults deleted | simctl create |
First boot | Companion’s socket | First frame after connecting | Whole |
|---|---|---|---|---|---|---|---|
| 1 | 2.0 s | 6.5 s | 0.2 s | 14.5 s | 0.2 s | 2.6 s | 26.1 s |
| 2 | 1.9 s | 5.4 s | 0.2 s | 14.5 s | 0.1 s | 1.8 s | 24.1 s |
| 3 | 1.9 s | 5.4 s | 0.2 s | 15.6 s | 0.1 s | 2.5 s | 26.0 s |
Each had 112 labels off.
M14: a slot enrolled as a BB machine (H3)
A fresh BB, npx bb-app with no account, ran in the ios box’s own user at
127.0.0.1:4999, with its machines’ server address set to that URL and its
default access to direct. bb machine create --provider manual gave a
one-time enrolment command for each slot, which the slot’s user ran over
SSH, as bb-box’s enrolment runs BB’s command in a box. Both slot users were
made as in M13, UIDs 80001 and 80002, with no console session.
| Slot 1, as BB’s command is | Slot 2, with BB_INSTALL_SKIP_SERVICE=1 |
|
|---|---|---|
| The installer | downloaded and installed the server’s bb-app, 5 s; enrolled the machine; then failed: “Could not register the bb host-daemon launch agent … launchctl: Bootstrap failed: 125: Domain does not support specified action” | the same, then ran the daemon under its own --supervise; connected |
| The daemon’s port, chosen by the installer | 38889 | 38890 (the VM’s own user’s daemon had 38888) |
| Then | its LaunchAgent’s plist copied to /Library/LaunchDaemons with UserName added, and loaded: connected 0.7 s after |
the installer’s process stopped, and a LaunchDaemon written with the same command, port and data folder: connected 0.5 s after |
| A terminal made through BB on the machine | ran as bbd-1, UID 80001, and simctl saw its own devices |
ran as bbd-2, UID 80002 |
M15: the device server under a LaunchDaemon
The device server’s version 13, as the plugin had delivered it to the VM’s
own user, copied to ~/.bb-box/device/server/13 in slot 1’s home, owned by
the slot’s user, with current pointing at it, and run by a LaunchDaemon
with UserName, KeepAlive and ThrottleInterval 1:
| Time | |
|---|---|
| Listening on its socket, from the daemon’s load | 0.3 s |
| The slot’s own iPhone 18 Pro, seeded: first boot | 18.5 s; all 112 services off |
| Its companion listening | 169 ms after its start |
| Killed (SIGTERM), three times: listening again | 0.32, 1.34, 1.28 s |
A new version folder, current pointed at it, the server’s exit: listening again |
1.67 s |
M16: a cold restart of the host’s VM
The VM restarted by a resize with restart, which took 69.2 s, with slots 1 and 2 enrolled and slot 1’s device server loaded. Nobody signed in. As the VM came up, both slots’ BB daemons and slot 1’s device server were running. The device server booted its device cold in 7.7 s and had its companion listening and the device booted 25 s after launchd started it. The fresh BB, which ran as the VM’s own user by hand and so did not restart, was started again; both slots’ machines were connected 10.9 s after it answered.
M17: one sweep for several UIDs
Three slot users, each with a file in /tmp, a folder in /Users/Shared
and CoreSimulator’s defaults, removed; then one find -xdev over the Data
volume for one UID took 31.8 s, and one for all three, with -uid a -o -uid b -o -uid c and -prune, 31.8 s, finding 18 top paths. After the
sweep, a check of all three found nothing, in 31.6 s.
M18: a Namespace Mac (H10)
The workspace’s one Mac, at its largest shape, 6 cores and 14 GiB, taken when it was free: ready in 29.7 s (create 0.1 s, ready 19.2 s, SSH 2.4 s, tailnet 7.5 s). macOS 26.3.1 on an Apple M4 Pro (virtual), Xcode 26.1.1, iOS runtimes 17.5, 18.6, 26.0, 26.1 and 26.2.
| Result | |
|---|---|
| idb_companion 1.7.7 from its pin, checked by SHA-256 | installed and ran in 0.6 s |
| An iPhone 17 Pro (1206x2622) on iOS 26.2: first boot | 27.1 s |
The iOS 27.0 list applied (this Xcode’s simctl has no reboot) |
112 of 112 labels off; the store at /private/var/tmp/com.apple.CoreSimulator.SimDevice.<udid>/disabled.plist |
| Six seeded clones, booted one after another, each opening the busy page | 16.0 to 25.5 s each |
ffmpeg’s h264_videotoolbox, hardware only |
“cannot create compression session: -12903” |
| The same with software allowed | 42 frames a second at 1206x2622, on the CPU |
| idb’s video stream, 2 devices watched | no frames: “VideoToolbox encode error: OSStatus -12915” for each; taps sent, 29 a device |
| 4 devices booted and busy | 0.6% of 6 cores idle; 11% of memory free; 2,068 MiB of swap |
| 10 idb screenshots of a busy device | 509 ms at the median, 2,187 ms at worst |
The research’s harness starts each companion with nohup, which could
not detach in this session; the second run started them without it.
POOL: the pool’s Mac
Four encoders flat out for 15 s, twice: 373.7 and 375.3 frames a second in all at 1206x2622, 1,309.7 and 1,329.0 at 604x1312, the latter split one and three (660 and about 218 each). Two encoders: 187.2 to 188.5 each.
12 slim devices, cloned and seeded in the box user’s set, each busy in Safari, each with its own idb_companion, watched at the step given and tapped once a second for 30 s:
| Watched | Step | Frames a second | Worst gap | Tap to frame: median, p95, worst |
|---|---|---|---|---|
| 4 | full | 59.3 to 59.5 | 34 to 46 ms | 50, 63, 77 ms |
| 8 | full | 47.1 | 31 to 32 ms | 148, 157, 167 ms |
| 12 | full | 30.5 to 30.9 | 270 to 765 ms | 207, 229, 1,030 ms |
| 8 | half | 59.2 to 59.4 | 36 to 49 ms | 49, 64, 76 ms |
| 12 | half | 58.7 to 59.2 | 36 to 48 ms | 49, 63, 74 ms |
| 12 | a third | 59.1 to 59.4 | 37 to 49 ms | 47, 61, 66 ms |
With all 12 booted and busy, top gave 62.8 to 68.5% of the Mac idle,
about 6.4 of its 18 cores used, and memory_pressure 54 to 59% free. The
template’s list took 80 s to apply twice; 12 seeded clones took 26 s and
booted in 1.7 to 2.1 s each. A seeded simctl create then first-booted in
10.4 and 12.4 s beside the 12 busy devices, with 112 labels off. The store
was in the same place as in the VM (M1).
E1: is the encoder the VM’s or the Mac’s?
Two Tart VMs on the Mac, the Mac’s only two: VM A at 8 cores and 32 GiB with 8 slim devices booted and busy, VM B at 6 cores and 16 GiB with its device shut down. A ran the same phase under each of B’s states: two encoders flat out for 20 s, twice; 4 devices watched at full size for 30 s; 8 at half size for 30 s; two encoders again. B ran its load for 250 s from a few seconds before A’s phase began.
| B | A’s two encoders, in all, three times | A: 4 at full size | A: 8 at half size | B’s encoders |
|---|---|---|---|---|
| idle | 319.6, 319.6, 319.8 | 57.8 to 58.2 fps; 66, 79, 140 ms | 55.8 to 56.7 fps; 65, 75, 109 ms | |
| two flat out | 159.4, 159.3, 159.4 | 52.6 fps; 149, 154, 157 ms | 57.0 to 57.3 fps; 68, 82, 92 ms | 112.0 each over its 250 s |
| four paced at 60 fps | 106.1, 105.9, 106.1 | 52.4 fps; 146, 160, 168 ms | 56.7 to 57.5 fps; 69, 84, 93 ms | 52.4 to 52.5 each |
Tap times are median, 95th percentile and worst. The phase was also run with another thread’s ios box in B’s place, before B was taken: 319.7 to 320.2 in all, and 4 at full size at 58.1 to 58.5 fps and 65 ms; its 8 at half size overlapped B’s boot and fell to 35.7 to 37.0 fps, 206 ms. After B was released, a run meant to be alone overlapped another thread’s ios box starting in the freed slot: 317.5 to 320.1 in all, but 4 at full size at 53.0 to 53.3 fps and 72 ms, with a 95th percentile of 190 ms, and 8 at half size at 42.7 to 43.4 fps, 74 ms, 95th percentile 404 ms.
Build units
Each unit is one builder’s PR, built and checked on a Mac box: a dedicated ios box where it needs root in the VM, the pool’s Mac where it changes the pool. Each ends in a live check through BB, on a separate BB in a box with bb-box from the unit’s branch, with what it measured. Each brings the reference, the skill’s text, settings keys, doctor checks, conformance cases and guides for what it adds.
| Unit | Builds | Live check | After |
|---|---|---|---|
| 1. Streams at the viewer’s size, and only while wanted | BB_DEVICE_VIEW from the viewer, added to the relay’s VIDEO_VERBS; the server’s steps, wishes and cap; input in device pixels, iOS and Android; a step change that keeps the generation; idb’s stream opened only for a viewer, a recording or a tool’s wait, closed 10 s after; a recording at two thirds; Android at full size; the server’s new version and protocol |
on a dedicated ios box and an android box: the panel at three sizes and Pixel for pixel, the step following within 2 s; a tap at each step landing where drawn; 063’s E8 cases on both; an agent’s element reference still good after a person resizes the panel; no frames encoded 10 s after the last viewer leaves; a recording’s clip at 1,600 px; H4 | |
2. The data model, dedicated and shared |
src/core/device-host.ts with its validators and the compile-time cases that must fail; capabilities and the placement in guest.data on the box record, read by every tool; dedicated for ios and shared for macos on box_acquire and bb box acquire; the refusals of box operations; box_list’s and box_status’s words; the New box form’s choice, Shared off with “No shared iOS host is set up” until unit 4; the skill’s text |
dedicated: true gives today’s box; a size without it refused in its words; box_list and box_status read back; the form at 360 px and by keyboard |
|
| 3. The host’s helper | device-host: make, claim, promise, release (with last and deleting under its lock), reset with its sweep queue, daemon, resume and status, each re-runnable from its recorded step; UIDs and users derived from the generation; devices by simctl create, seeded; the device server’s and BB’s daemons; homes 700; the port range and the held common ports; the per-slot device bound and load in status. The ios image gains it, its two carried slots, each runtime’s disabled.plist, and its UID check. Tested first on Linux with stub macOS tools, as machine-mac.sh is |
in a dedicated ios box, as root: 8 slots made, each timed; a slot enrolled with bb-box’s own enrolment and a terminal run on it (H3 through the plugin, after M14 on a fresh BB); a new device server version delivered to a slot and listening within 2 s (M15); a cold restart with slots held (M16); H7; a CoreSimulatorService killed in one slot while three others stream; a release cut short and run again |
|
| 4. Tart hosts, shared still opt-in | the ios-hosts setting; SlotLender on the Tart backend; the registry’s device placement lock and tiers; hosts made for a box, joinable while starting, deleted at the helper’s last; carried slots; soft draining; stopped and the start of a held host’s VM; the upkeep’s rows for starting, deleting and failed; transports as the slot’s user through the Mac; every slot stream capped at two thirds until unit 6; the doctor’s page of hosts, slots, their load and the Mac’s ledger; conformance cases |
on a separate BB, with shared: true asked for: three ios boxes acquired at once make one host, not three; 8 boxes, each its own device, streamed and tapped; a ninth placed with its words; a release racing a claim, the claim placed again; the last release deleting the host and freeing the VM slot; H1 for a ready slot and for a first box on a new host; H5, H6, H9 |
2, 3 |
| 5. Pool Mac slots | machine-mac.sh’s slot commands; free, whole, resetting-whole and slots; a whole claim refused while slots are held, with words; SlotLender on the pool backend; setup installs idb_companion, the lists and seeds; the pool’s guide; conformance cases |
on the pool’s Mac: 12 slots made and held; a macos-desktop box for the pool refused with its words, then lent whole once they end; a reset leaves nothing; H1, H11 |
2, 3 |
| 6. The encoder ledger, then shared by default | the per-Mac ledger in the plugin with measured rates and a floor; budgets by chip, the probe only on an empty ledger; the order of who gets room; caps sent to each server; at most two at full size; the words naming boxes; new hosts placed on the Mac with the fewest streams, and the warning while one starts. Then the ios kind’s default becomes shared | 10 watched devices across a host and a dedicated ios box on one Mac: caps step down passive viewers first, the person in control keeps Pixel for pixel, every stream at 55 fps or more, the words seen; H8; then an ios box acquired with no options lands in a slot | 1, 4, 5 |
| 7. Namespace hosts, when a device can stream there | SlotLender on the Namespace backend; a Namespace Mac made as a host, idb_companion, lists and seeds installed from their pins, its runtime checked and named; ending an hour before the plan’s longest life; a stream that does not need VideoToolbox, by 063’s Android path, or Namespace Macs with an encoder; the Namespace guide’s note that a host takes the workspace’s only Mac |
a Namespace host streaming 2 devices through BB, their frame rate and its CPU; then on |
4 |
8. macos boxes in slots |
shared: true on a macos acquire takes a slot, its device deleted at the claim; the New box form’s choice for macos |
a macos box with shared: true placed in a slot in seconds, its words read back; one without it waits for a VM with its words |
4 |
Units 1, 2 and 3 run side by side; then 4 and 5; then 6, which makes
shared the default, and 7 and 8. Shared becomes the default only in unit 6:
after bb-box’s own enrolment of a slot passes in unit 3 (review finding 4)
and the ledger exists (finding 16). Until then a box in a slot is opt-in,
with shared: true, and its streams are capped at two thirds.
Conformance cases, for any backend that lends slots: two boxes of the kind share a host; a release of one leaves the other streaming; the last release deletes an elastic host; a slot box refuses a size, a stop and a grant with words; two acquires at once share one new host.
Experiments
| What | Settles | Unit | |
|---|---|---|---|
| H1 | Ten claims of a ready slot through BB, and five first boxes on a new host: box_acquire to the device’s first frame in the panel and to a connected BB machine | the ready times in Placing a box | 4, 5 |
| H3 | bb-box’s own enrolment of a slot, through the plugin | a slot box as a BB machine, which M14 showed on a fresh BB | 3 |
| H4 | Tap to screen through BB at each step, 100 taps, and the stall when the step changes | the steps’ cost to a person | 1 |
| H5 | A Tart host restarted with 8 held slots: time until every device streams and every BB machine is connected | the restart row of Tart hosts | 4 |
| H6 | An image change with 3 slots held: the old host lends while no new one can start, then stops lending and is deleted at its last release | soft draining | 4 |
| H7 | The plant-and-check of what can pass between boxes, with the held ports | isolation and reset | 3 |
| H8 | 10 watched devices on one Mac, in a host and a dedicated box, with the ledger on and off, and another VM starting meanwhile | the ledger, and what a VM’s start costs streams (E1) | 6 |
| H9 | 8 held slots idle, each with its device, server and BB’s daemon: the host’s CPU and memory | the Tart host’s size | 4 |
| H10 | Done (M18): a Namespace Mac’s start, Xcode and runtimes, slimming, and its encoder: none in hardware, and idb’s stream fails | Namespace stays off | 7 |
| H11 | A slot’s reset on the pool’s Mac, with its Full Disk Access: the sweep’s time, and what is left | the pool Mac’s reset | 5 |
M12 did what the first draft’s H2 asked: a slot’s user builds an iOS app and passes an XCUITest on its own device. M14 to M16 did the review’s asks of H3 and finding 4 on a fresh BB: enrolment, the device server’s delivery and restart under a daemon, and a cold restart.
Where this differs from the brief
- Slots made ahead: one, and two in the image (decision 3). Making a
slot now takes 24 to 43 s, not the research’s 155 to 410 s, and making
one beside four watched devices left them at 55 fps (M3, M13). So a host
keeps one ready while it holds a box, and
aheadmay be 2 where boxes come in bursts. The ios image carries two, so a new host’s first box does not wait for one to be made. - Draining is soft (decision 5). With hosts deleted at their last release (the owner’s decision 6), an image change needs no replacement step. Decision 5 had new boxes wait while a host drained; this note lets the old host lend while no host on the new image can start, as the review asked (finding 7), since a box held for days would otherwise make every new ios box on that Mac wait. They still wait, with words, when nothing in the order can be had.
- Shared becomes the default later (decision 1): in unit 6, after bb-box’s own enrolment of a slot passes and the ledger exists, not when hosts first work (review findings 4 and 16).
- Streams follow the viewer’s size by steps (decision 2): a third, a half, two thirds or full, so a panel resized by a few pixels does not restart the stream. A recording asks for two thirds, not full size; at most two streams on a Mac are at full size (M11); screenshots stay full size and do not touch the encoder (M10).
- A VM of its own comes last in the owner’s order. On a Tart Mac it can
be reached only when no host could start, since both need a free VM slot;
in practice a VM of its own is for
dedicated, a size, or a project’s ios image. macosboxes in slots only when asked, withshared: true, not as a fallback (review finding 11).- Namespace leaves the order for now (the owner’s decisions 8 and 9). Its Macs’ VMs have no H.264 encoder, so idb sends a device no frames (M18). The design for Namespace hosts stands for when a device can stream there.
- Against the research: the encoder is the Mac’s, not the VM’s (E1),
and its slow state is the encoder’s two halves, not the other VM (M11);
a device made by
simctl createand seeded, not cloned or copied; a slot’s UID from a range of its own. - Changes to 063: a step change is not a geometry change; input is in device pixels on both platforms; idb’s stream runs only while wanted; the device server in a slot is a daemon, restarted by exiting; a box in a slot has no host desktop; 063’s suspended ios spare stays for dedicated boxes only.
Open questions for the owner
- The pool’s Mac: slots before whole boxes? Default: an ios box takes
a slot there whenever the Mac is not lent whole, and a
macosormacos-desktopbox for the pool then goes elsewhere or waits until its slots end. The other way would keep the pool’s Mac whole whenever a whole box might come, and send ios boxes to Tart hosts first. - A size without
dedicated: refused, or a VM of its own? Default: refused, in words that namededicated, so an agent never takes a whole VM slot without asking for one. - The held dev ports. Default: the host’s helper holds 3000, 4200, 5000, 5173, 8000, 8080, 8081 and 19000 on 127.0.0.1 and answers with words, so a box that forgets its range fails loudly. The other way leaves them free, first come first served, with the risk of a test passing against another box’s server.
The owner’s answers
Given on 26 October 2026, to the open questions above. Each is the default:
- The pool’s Mac lends slots before whole boxes. A
macosormacos-desktopbox for the pool goes elsewhere or waits while slots are held. - A size without
dedicatedis refused, in words that namededicated. - The host holds the common dev ports listed above, with words.
Rejected
- One user with several devices (the research’s option A). One CoreSimulatorService crash stops every box’s device, and any box can drive, read or erase another’s.
- Copying or cloning a device into a slot’s user. A copied folder hung or booted with no app able to come to the front (the research); a clone from a shared set did the same (M4).
- Applying the list at making: 155 to 410 s against a seeded first boot of 10 to 28 s (M3, M13, POOL).
- Reusing a slot’s user for the next box, with its device erased. Its keychain, caches, DerivedData and files left elsewhere would pass on. A new user per box is the reset, as the pool’s (047).
- UIDs that macOS chooses. It gives them again, and the image owns files by one of them (M7).
- A host kept up for spares. It holds a VM slot for iOS while nobody uses one (the owner’s decision 6). The ios image’s two slots give a new host’s first box most of what a spare would.
- Two hosts on a Mac by default. They share one encoder (E1), so the second adds slots but no watched streams, and takes the VM slot macOS boxes need.
- A pool Mac lent whole and lending slots at once. A whole box expects the Mac’s cores, and its reset restarts userspace on macOS 27, which would stop every slot’s device.
- Full-size streams for every viewer. Four watched iPhones at full size fill most of a Mac’s encoder, two VMs’ together (E1, M8).
- An encoder arbiter in each host. It could not see the other VM’s streams, which share the encoder (E1). The plugin sees every stream on a Mac.
- Holding a box to its share of the cores. macOS has no cgroups;
taskpolicyonly lowers a process’s priority (the research). Making and sweeping slots run at background priority; boxes do not. - A
macosbox in a slot as a silent fallback. An agent that asked formacosto build would get no sudo, Homebrew or window (review finding 11). - A check pass on every release. A second walk of the volume for each slot doubles the sweep’s cost on the host; the UID is never given again, so the check is the doctor’s.
Consequences
- A Tart Mac holds up to 8 ios boxes in one VM slot, and a macOS box or a dedicated ios box in the other; the pool’s Mac up to 12 while it is not lent whole. Namespace holds none until its VMs can stream a device.
- Watched streams are limited per Mac, not per host: on the Tart Mac about 12 busy at half size or 21 at a third, and on the pool’s Mac about 16 at half size, before any is lowered; at most two at full size.
- No VM is held for iOS while no ios box runs. The first ios box on a Tart Mac waits about 50 to 60 s for a host and its enrolment, about as long as a dedicated box today; the next ones seconds.
- A box in a slot has no sudo, no Homebrew installs, no host desktop and no app windows, shares cores and its process list, and has ports of its own on a shared loopback. A restart of its host stops every box in it, and they come back by themselves.
- While the pool’s Mac lends slots it is not lent whole.
- Namespace Macs cannot host ios boxes today: their VMs have no H.264 encoder for idb’s stream (M18).
- Slimming rests on an undocumented store; the device server’s boot check
stays the guard, at the cost of about 2 minutes to make a slot if a later
Xcode moves it. Enrolment rests on BB’s installer honouring
BB_INSTALL_SKIP_SERVICEon macOS, which it does today. - The ios image grows by two slots’ first-boot data, about 3.2 GiB, once on the Mac’s disk.
- The device server’s protocol gains the viewer’s size and input in device pixels.
Review answers
An independent review by Claude Fable 5.1 read the first draft and said to build after changes. Every finding changed the note.
| Finding | Answer | |
|---|---|---|
| 1 | The order cannot be run by the registry; a starting host cannot be joined; bursts make several hosts | Changed. Each lender answers with a graded SlotTier; the registry chooses under one lock for device placement and holds it until the claim, promise or starting record is written; starting has promised, making has for; a lost ln race is asked again, three times: Placing a box. Unit 4’s live check acquires three at once and wants one host |
| 2 | The last release’s delete races a claim; two writers of host state | Changed. The helper’s release decides last under its lock and writes deleting, which refuses every later claim; the backend deletes after; the owner file is a cache; the upkeep finishes a deleting host: The last release deletes the host |
| 3 | The image’s carried slots break its own UID check; both boot at every start | Changed. Carried slots are bbd-i1 and bbd-i2, UIDs 79991 and 79992, exempted by name and UID; nothing boots at the host’s start; the first claim boots one, then the other boots as the ready slot: Slots in the image |
| 4 | Shared becomes the default before enrolment and the server-as-daemon are proven | Measured, and the flip moved. M14: two slots enrolled on a fresh BB and ran terminals through it; M15: the delivered server under a daemon, restarted in 0.3 to 1.3 s and from a new version in 1.7 s; M16: both back after a cold restart. The default flips in unit 6, after bb-box’s own enrolment passes in unit 3; connect’s words when no server comes |
| 5 | Nothing bounds one box’s load or shows whose it is | Changed. A booted-device bound, a disk reserve, a memory floor for lending, background priority for the helper’s work, and each slot’s cores, memory and disk in the helper’s status; box_status names the busiest slot: What one box can take from the others. The encoder used by a box’s own tools stays unseen, and the note says so |
| 6 | Shared loopback gives false passes against another box’s server | Changed. A port range per slot in its environment, box_status and the skill; the common dev ports held by the helper with words: Ports. Holding them is open question 3 |
| 7 | Draining is too strict; a Mac restart has no rule | Changed. Soft draining; stopped, with the backend starting a held host’s VM as it does a box’s; the restart measured (M16): Tart hosts |
| 8 | Half-made slots, cut sweeps and orphaned hosts | Changed. Every helper command re-runnable from its recorded step, resume at boot, and the upkeep’s rows for starting, deleting and failed: When a step is cut short |
| 9 | The ledger: the probe, the count, who gets room, the words | Changed. Budgets by chip, a probe only on an empty ledger with two encoders; measured rates with a floor; the person in control first, Pixel for pixel for them always; words that name boxes; new hosts on the Mac with the fewest streams, with a warning while one starts: The ledger |
| 10 | most counts resetting slots; a find per release |
Changed. most counts making, ready and held; resetting has its own bound; one queued find sweeps every waiting UID, measured as fast for three as for one (M17); the check is the doctor’s |
| 11 | macos in a slot as a silent fallback; capabilities per box |
Changed. shared: true only; capabilities on the box record, read by every tool: macos boxes in slots, The data shape |
| 12 | Box operations undefined for a slot box | Changed. A table, each refusal in words naming dedicated: What a box gives up in a slot |
| 13 | Illegal states in the shape | Changed. One placement, in guest.data; user and UID derived from the generation; promised and for; stopped; resetting-whole on the pool Mac and slots until the last slot is gone; settings as a snapshot; box_status reads a Namespace host’s endsAt |
| 14 | Row 4 and row 6 on different bases | Changed. Both to the first frame and a connected BB machine: about 50 to 60 s and 50 to 57 s; “about as long”, not “as long”; H1 measures first boxes on new hosts |
| 15 | Unit 1 changes Android’s protocol unsaid | Changed. Android keeps full size; its input moves to device pixels with no change in effect; unit 1 runs E8 on Android |
| 16 | Phases: the relay’s verbs, conformance, the setting’s shape, the flip before the ledger, a page for a person, docs | Changed. VIDEO_VERBS gains the verb; conformance cases listed; ios-hosts has keys and defaults; slot streams capped at two thirds until unit 6, which flips the default; the doctor’s page of hosts in unit 4; the pool’s and Namespace guides in units 5 and 7 |
The smaller points: the wait’s words are one per cause; slot users are
never admin, and why _developer; the pool’s setup and a Namespace
host’s start install the lists and seeds; a Namespace host is the last
shared choice, for its short life, and box_acquire says when a box there
ends; a window dragged between screens restarts the stream at most twice,
which H4 measures.
After the review, H10 ran on Namespace (M18) and took Namespace out of the order: its VMs cannot encode a device’s video.