Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,9 @@ Refused, all exit 2 with `USAGE` and a message naming what to run instead:
interrupted lease spends its recovery budget rebooting; one that runs out
ends as `lease_lost`. `shutdown <udid>` of a single device is allowed.
- `runtime delete` — it deletes a runtime shared with Xcode, and Simlock will
not download one back. Delete it through Xcode if that is what you mean.
not download one back. It does not free the disk the runtime's download
takes either (see `doctor`'s `runtime-cache-unreclaimable` finding). Delete
it through Xcode if that is what you mean.
- `--set` and `--profiles`, wherever they appear *before* the subcommand and
however they are spelled (`-set`, `--set <path>`, `--set=<path>`) —
`simlock simctl` supplies the device set itself. A caller-supplied one would
Expand Down Expand Up @@ -997,6 +999,18 @@ makes that visible. It is advisory only — there is no `--fix` for it, since
the fix is either upgrading the runtime or narrowing `ios.slim` to the
runtimes that support it.

On macOS, `doctor` also reports a `driver-advisory` finding (code
`runtime-cache-unreclaimable`) for each iOS runtime that was downloaded to
this machine and is no longer installed. Deleting a simulator runtime only
unregisters it: the download it was installed from — roughly 7-8 GiB of it
— stays in the operating system's own asset store, where it keeps spending
the same free space the download preflight measures, and where nothing
Simlock runs can reclaim it. On a host that has been leasing with downloads
enabled for a while, several of these can accumulate unnoticed. The finding
names each one and the one supported way to get the space back: remove the
platform in Xcode's Settings → Platforms. Advisory only, like the finding
above — reclaiming this space is outside anything `--fix` may do.

## `simlock nuke [--delete-devices] [--yes]`

Emergency reset: force-release all leases, kill emulator/simulator processes
Expand Down
34 changes: 34 additions & 0 deletions docs/internal/KNOWN-PITFALLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,40 @@ built in stage 4. `component.install-started`'s payload already carries
enough (`platform`, `componentId`) that a future pass wiring this through
would mostly be plumbing, not new information to invent.

## An iOS runtime download outlives the runtime, and only Xcode can reclaim it (#79)

`xcrun simctl runtime delete` unregisters a runtime from CoreSimulator and
stops there. The download it was installed from — one `.asset` bundle of
roughly 7-8 GiB, marked never-collected — stays in macOS's own asset store at
`/System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime`, on the
same volume as the device root. CoreSimulator can re-register a runtime from
what is still sitting there, which is why a deleted runtime sometimes
reappears as installed.

**The pitfall:** on a host running `downloads.policy` with agents pinning
different `--os` versions, these bundles accumulate for as long as the host
lives, and nothing Simlock does reclaims any of them. The iOS driver's
`IOS_RUNTIME_MIN_FREE_BYTES` preflight then measures free space this cache has
already spent — it refuses a download for want of room the machine could get
back, and cannot say so.

**Why it is accepted:** deleting a bundle is root surgery on a system
directory, and destroying anything that is not a device in Simlock's own
registry is exactly what the safety rules forbid; Apple exposes no supported
way to evict a store entry for a runtime that is no longer registered. So the
driver reports instead of acting: `advisories()` compares the store's bundles
against the installed catalog by build and reports the leftovers as
`doctor`'s `driver-advisory` / `runtime-cache-unreclaimable` finding, naming
Xcode's Settings → Platforms as the way to reclaim them. It reads each
bundle's `Info.plist` and never its size — measuring the store means walking
tens of gigabytes on every `doctor` run — and stays silent when the store is
absent or unreadable, which is the normal state on a machine that has never
downloaded a runtime.

**The knob:** `downloads.policy` decides whether the host downloads runtimes
at all; short of that, the reclaim is manual and periodic, driven by what
`doctor` reports.

## True cancellation during provisioning is not implemented (ADR 0003 §10)

`simlock/client`'s `requestLease` takes an `AbortSignal`. When device work is
Expand Down
182 changes: 182 additions & 0 deletions src/drivers/ios/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2648,6 +2648,159 @@ describe("IosSimctlDriver", () => {

await expect(driver.advisories()).resolves.toEqual([]);
});

it("reports downloaded runtime assets whose runtime is not in the catalog, and only those (#79)", async () => {
// `simctl runtime delete` only unregisters a runtime from CoreSimulator: its ~7.5 GiB
// asset bundle stays in mobileassetd's store, tagged `NeverCollected`, on the same
// volume the download preflight measures. So the bytes stay spent and CoreSimulator can
// re-register the runtime from them later. The catalog here installs 18.4 and 26.5 while
// the store still holds two builds nobody deleted the assets for.
const filesystem = new MemoryFilesystem();
for (const [bundle, simulatorVersion, build] of [
["a1.asset", "18.4", "22E238"],
["b2.asset", "26.5", "23F79"],
["c3.asset", "18.6", "22G86"],
["d4.asset", "26.3", "23D60"],
] as const) {
await filesystem.mkdirp(`${IOS_RUNTIME_ASSET_ROOT}/${bundle}`);
await filesystem.writeFileAtomic(
`${IOS_RUNTIME_ASSET_ROOT}/${bundle}/Info.plist`,
assetInfoPlist(simulatorVersion, build),
);
}
const driver = await createDriver(scriptedListRunner(), new FakeClock(), filesystem);

// The advisory code and message shape are a proposal (see the triage report), so this
// pins only what any fix must do: name both orphan builds, name neither installed one.
const advisories = await driver.advisories();
const text = advisories.map((advisory) => advisory.message).join("\n");
expect(advisories.map((advisory) => advisory.code)).toContain(
"runtime-cache-unreclaimable",
);
expect(text).toContain("18.6");
expect(text).toContain("26.3");
expect(text).not.toContain("18.4");
expect(text).not.toContain("26.5");
});

it("tells two downloads of one marketing version apart by build, and reports only the uninstalled one (#79)", async () => {
// simctl reports `buildversion`, and a version match alone would call this bundle
// installed: 18.4 is in the catalog, but not from this download. Deleting the runtime
// and re-downloading the same version leaves exactly this -- a stale build nobody can
// reclaim, sitting behind an installed runtime of the same name.
const catalog = JSON.parse(listFixture) as {
devicetypes: unknown;
runtimes: { version: string }[];
};
const withBuilds = JSON.stringify({
devicetypes: catalog.devicetypes,
runtimes: catalog.runtimes.map((runtime) => ({
...runtime,
buildversion: runtime.version === "18.4" ? "22E238" : "23F79",
})),
});
const filesystem = new MemoryFilesystem();
for (const [bundle, build] of [
["a1.asset", "22E238"],
["stale.asset", "22E247"],
] as const) {
await filesystem.mkdirp(`${IOS_RUNTIME_ASSET_ROOT}/${bundle}`);
await filesystem.writeFileAtomic(
`${IOS_RUNTIME_ASSET_ROOT}/${bundle}/Info.plist`,
assetInfoPlist("18.4", build),
);
}
const runner = new ScriptedProcessRunner([
{ match: listInvocation, result: { code: 0, stderr: "", stdout: withBuilds } },
]);
const driver = await createDriver(runner, new FakeClock(), filesystem);

const advisories = await driver.advisories();

expect(advisories).toEqual([
{
code: "runtime-cache-unreclaimable",
message: expect.stringContaining("22E247"),
},
]);
expect(advisories[0]?.message).not.toContain("22E238");
});

it("names orphaned runtimes oldest first, not in string order (#79)", async () => {
// A store that has been collecting for months is read by a human deciding what to
// reclaim; "iOS 9.3, iOS 18.6" is that list, and a plain string sort inverts it.
const filesystem = new MemoryFilesystem();
for (const [bundle, simulatorVersion, build] of [
["new.asset", "18.6", "22G86"],
["old.asset", "9.3", "13E233"],
] as const) {
await filesystem.mkdirp(`${IOS_RUNTIME_ASSET_ROOT}/${bundle}`);
await filesystem.writeFileAtomic(
`${IOS_RUNTIME_ASSET_ROOT}/${bundle}/Info.plist`,
assetInfoPlist(simulatorVersion, build),
);
}
const driver = await createDriver(scriptedListRunner(), new FakeClock(), filesystem);

const message = (await driver.advisories())[0]?.message ?? "";

expect(message.indexOf("9.3")).toBeLessThan(message.indexOf("18.6"));
});

it("reports nothing when every downloaded asset belongs to an installed runtime (#79)", async () => {
const filesystem = new MemoryFilesystem();
await filesystem.mkdirp(`${IOS_RUNTIME_ASSET_ROOT}/a1.asset`);
await filesystem.writeFileAtomic(
`${IOS_RUNTIME_ASSET_ROOT}/a1.asset/Info.plist`,
assetInfoPlist("18.4", "22E238"),
);
const driver = await createDriver(scriptedListRunner(), new FakeClock(), filesystem);

await expect(driver.advisories()).resolves.toEqual([]);
});

it("stays quiet, and asks simctl nothing, when the asset store cannot be read (#79)", async () => {
// The store is macOS's, not Simlock's: absent on a machine that never downloaded a
// runtime, and readable only to whoever the OS says. Neither is a `doctor` failure --
// and with nothing to report, the `simctl list` that would classify the bundles is
// never worth running, which is why the runner below scripts no invocation at all.
const filesystem = new MemoryFilesystem();
filesystem.defineFailure(IOS_RUNTIME_ASSET_ROOT, "EACCES");
const runner = new ScriptedProcessRunner([]);
const driver = await createDriver(runner, new FakeClock(), filesystem);

await expect(driver.advisories()).resolves.toEqual([]);
expect(runner.calls).toEqual([]);
});

it("skips an asset bundle it cannot identify by build rather than naming it (#79)", async () => {
// The build is what decides whether a bundle's runtime is installed, so a bundle
// without one cannot be classified -- and guessing would send the operator to delete
// a runtime they are still using. `missing-build.asset` below names iOS 19.9 and
// nothing else; the store around it is still reported.
const filesystem = new MemoryFilesystem();
await filesystem.mkdirp(`${IOS_RUNTIME_ASSET_ROOT}/missing-build.asset`);
await filesystem.writeFileAtomic(
`${IOS_RUNTIME_ASSET_ROOT}/missing-build.asset/Info.plist`,
assetInfoPlist("19.9", "22H1").replace("<key>Build</key><string>22H1</string>", ""),
);
await filesystem.mkdirp(`${IOS_RUNTIME_ASSET_ROOT}/orphan.asset`);
await filesystem.writeFileAtomic(
`${IOS_RUNTIME_ASSET_ROOT}/orphan.asset/Info.plist`,
assetInfoPlist("18.6", "22G86"),
);
const driver = await createDriver(scriptedListRunner(), new FakeClock(), filesystem);

const advisories = await driver.advisories();

expect(advisories).toEqual([
{
code: "runtime-cache-unreclaimable",
message: expect.stringContaining("18.6"),
},
]);
expect(advisories[0]?.message).not.toContain("19.9");
});
});
});
});
Expand Down Expand Up @@ -2790,3 +2943,32 @@ function scriptedListRunner(): ScriptedProcessRunner {
},
]);
}

/**
* Where macOS keeps the simulator runtimes `xcodebuild -downloadPlatform` fetches. Spelled out
* here rather than imported: no driver code knows about this path yet, and a test that failed to
* compile would prove nothing (#79).
*/
const IOS_RUNTIME_ASSET_ROOT = "/System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime";

/**
* mobileassetd's own metadata for one downloaded simulator runtime, cut down to the keys that
* identify it. `NeverCollected` is verbatim from a real bundle on macOS 26.6.1 / Xcode 27: the
* store is told not to reclaim these, which is why deleting the runtime does not shrink it.
*/
function assetInfoPlist(simulatorVersion: string, build: string): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<plist version="1.0">',
"<dict>",
" <key>MobileAssetProperties</key>",
" <dict>",
" <key>__AssetDefaultGarbageCollectionBehavior</key><string>NeverCollected</string>",
` <key>Build</key><string>${build}</string>`,
` <key>SimulatorVersion</key><string>${simulatorVersion}</string>`,
" </dict>",
"</dict>",
"</plist>",
"",
].join("\n");
}
Loading