diff --git a/docs/CLI.md b/docs/CLI.md index f091c4d..75cd8c1 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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 ` 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 `, `--set=`) — `simlock simctl` supplies the device set itself. A caller-supplied one would @@ -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 diff --git a/docs/internal/KNOWN-PITFALLS.md b/docs/internal/KNOWN-PITFALLS.md index cf35fff..e4be84f 100644 --- a/docs/internal/KNOWN-PITFALLS.md +++ b/docs/internal/KNOWN-PITFALLS.md @@ -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 diff --git a/src/drivers/ios/index.test.ts b/src/drivers/ios/index.test.ts index 22213e5..ce6210e 100644 --- a/src/drivers/ios/index.test.ts +++ b/src/drivers/ios/index.test.ts @@ -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("Build22H1", ""), + ); + 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"); + }); }); }); }); @@ -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 [ + '', + '', + "", + " MobileAssetProperties", + " ", + " __AssetDefaultGarbageCollectionBehaviorNeverCollected", + ` Build${build}`, + ` SimulatorVersion${simulatorVersion}`, + " ", + "", + "", + "", + ].join("\n"); +} diff --git a/src/drivers/ios/index.ts b/src/drivers/ios/index.ts index 85023d7..239af3a 100644 --- a/src/drivers/ios/index.ts +++ b/src/drivers/ios/index.ts @@ -53,6 +53,15 @@ const IOS_DOWNLOAD_FLOOR: readonly [number, number, number] = [16, 0, 0]; // with headroom) -- checked before `xcodebuild -downloadPlatform` ever starts, so a full disk // fails fast instead of filling up mid-download. const IOS_RUNTIME_MIN_FREE_BYTES = 8 * 1024 ** 3; +// Where macOS's own asset daemon keeps every simulator runtime it has ever downloaded, one +// `.asset` bundle per build. `simctl runtime delete` only unregisters a runtime from +// CoreSimulator; the bundle it was mounted from stays here, marked never-collected, spending +// the same free space `IOS_RUNTIME_MIN_FREE_BYTES` measures. Nothing Simlock may do can +// reclaim it (issue #79), so the driver only ever reads this path. +const IOS_RUNTIME_ASSET_ROOT = "/System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime"; +// Observed size of one downloaded runtime bundle, for an advisory that must not stat the +// store: a `du` over three bundles is tens of gigabytes of directory walking per doctor run. +const IOS_RUNTIME_ASSET_APPROX_SIZE = "roughly 7-8 GiB"; // A cold `simctl boot` to `bootstatus` measures roughly 30s on a fast, idle machine and up to // a minute on a loaded or slower one. The upper end is the estimate, deliberately: this number // is what a waiting requester is quoted, and quoting 30s to someone who then waits 60s is the @@ -262,11 +271,25 @@ interface Runtime { readonly identifier: string; readonly name: string; readonly version: string; + /** + * The exact build simctl reports for this runtime (`buildversion`), when it reports one. + * Two runtimes can share a marketing `version`, so this is what identifies which download + * a runtime is mounted from -- and it is optional because an older simctl omits the key. + */ + readonly build?: string; readonly isAvailable: boolean; /** Device type identifiers this runtime pairs with -- authoritative once the runtime is installed. */ readonly supportedDeviceTypeIds: ReadonlySet; } +/** One downloaded runtime bundle in the OS asset store, identified by its own metadata. */ +interface RuntimeAsset { + /** `MobileAssetProperties.SimulatorVersion` -- the marketing version, for the operator. */ + readonly version: string; + /** `MobileAssetProperties.Build` -- what says which runtime this bundle actually holds. */ + readonly build: string; +} + interface SimctlCatalog { readonly deviceTypes: readonly DeviceType[]; readonly runtimes: readonly Runtime[]; @@ -1185,6 +1208,106 @@ export class IosSimctlDriver implements Driver { } } + /** + * Everything only this driver can see about a standing condition of the machine it runs on: + * the slim-mode runtime gate, and downloaded runtimes whose disk nothing can reclaim. Both + * are read-only -- at most a `simctl list` runs, no boot, no download, no mutation -- + * matching `listCatalog`'s own contract. + */ + async advisories(): Promise { + return [...(await this.#unreclaimableCacheAdvisories()), ...(await this.#slimAdvisories())]; + } + + /** + * Issue #79: a runtime download outlives the runtime. `simctl runtime delete` unregisters + * the runtime and leaves its ~7.5 GiB bundle in the OS asset store, from which CoreSimulator + * can re-register it later -- so an operator running `downloads.policy` on a long-lived host + * loses disk to bundles no Simlock command, and no `simctl` verb, can reclaim, while the + * download preflight silently measures the space they already spent. Reports one + * `runtime-cache-unreclaimable` advisory naming every downloaded runtime the catalog no + * longer installs, and points at the one supported way to reclaim them. + * + * Read-only, and quiet on anything it cannot read: the store belongs to macOS, is absent on + * a machine that never downloaded a runtime, and is a system directory this driver has no + * business failing `doctor` over. It reads each bundle's own metadata and never its size -- + * measuring the store means walking tens of gigabytes on every `doctor` run. + */ + async #unreclaimableCacheAdvisories(): Promise { + const assets = await this.#downloadedRuntimeAssets(); + + if (assets.length === 0) { + return []; + } + + const installed = (await this.#loadCatalog()).runtimes.filter((runtime) => runtime.isAvailable); + const orphans = assets.filter( + (asset) => !installed.some((runtime) => runtimeMountsAsset(runtime, asset)), + ); + + if (orphans.length === 0) { + return []; + } + + // Oldest first, by version and then by build, so a store that has been collecting for + // months reads in the order the downloads happened rather than as a lexicographic jumble. + const described = [ + ...new Map( + orphans.map((asset) => [`${asset.version} (${asset.build})`, asset] as const), + ).entries(), + ] + .sort( + ([, left], [, right]) => + compareVersions(left.version, right.version) || left.build.localeCompare(right.build), + ) + .map(([label]) => label); + const plural = described.length > 1; + return [ + { + code: "runtime-cache-unreclaimable", + message: + `iOS ${described.join(", ")} ${plural ? "are" : "is"} no longer installed, but ` + + `${plural ? "their downloads" : "its download"} (${IOS_RUNTIME_ASSET_APPROX_SIZE} each) ` + + `still ${plural ? "occupy" : "occupies"} ${IOS_RUNTIME_ASSET_ROOT}; ` + + "`simctl runtime delete` does not reclaim that space and neither can Simlock -- " + + "remove the platform in Xcode's Settings -> Platforms to get it back", + }, + ]; + } + + /** + * Every `*.asset` bundle in the store whose metadata says which runtime build it holds. + * A bundle whose `Info.plist` is missing, unreadable, or shaped differently than the ones + * this parses is left out rather than guessed at: an advisory that names a runtime the + * operator still has installed is worse than one that names one bundle too few. + */ + async #downloadedRuntimeAssets(): Promise { + let bundles: readonly string[]; + try { + bundles = await this.#filesystem.readdir(IOS_RUNTIME_ASSET_ROOT); + } catch { + return []; + } + + const assets: RuntimeAsset[] = []; + for (const bundle of bundles.filter((name) => name.endsWith(".asset"))) { + let contents: string; + try { + contents = await this.#filesystem.readFile( + join(IOS_RUNTIME_ASSET_ROOT, bundle, "Info.plist"), + ); + } catch { + continue; + } + + const asset = parseRuntimeAsset(contents); + if (asset !== undefined) { + assets.push(asset); + } + } + + return assets; + } + /** * ADR point 4 (issue #87): slim mode is silent about the runtime gate everywhere except * `makeReady`'s per-boot `SlimSkippedFact` -- an operator who never leases a device on an @@ -1200,7 +1323,7 @@ export class IosSimctlDriver implements Driver { * runtime qualifies. Read-only: only `#loadCatalog` (a `simctl list`) runs, no boot, no * download, no mutation -- matching `listCatalog`'s own contract. */ - async advisories(): Promise { + async #slimAdvisories(): Promise { if (this.#slim === undefined || !this.#slim.enabled) { return []; } @@ -1750,6 +1873,7 @@ function parseRuntime(value: unknown): readonly Runtime[] { return [ { + ...(typeof value.buildversion === "string" ? { build: value.buildversion } : {}), identifier: value.identifier, isAvailable: value.isAvailable, name: value.name, @@ -1759,6 +1883,36 @@ function parseRuntime(value: unknown): readonly Runtime[] { ]; } +/** + * Whether an installed runtime is the one this downloaded bundle holds -- the test for + * "deleting this bundle would delete a runtime the operator still has". Builds decide it + * whenever simctl reports one, because two runtimes can share a marketing version and a + * version match would then call an orphaned bundle installed. Marketing version is the + * fallback for a simctl that reports no build at all: it can only over-match, which costs an + * advisory that is not shown, never one that names a runtime still in use. + */ +function runtimeMountsAsset(runtime: Runtime, asset: RuntimeAsset): boolean { + return runtime.build === undefined + ? runtime.version === asset.version + : runtime.build === asset.build; +} + +/** + * The build and marketing version out of an asset bundle's `Info.plist`. Read as text rather + * than through a plist parser: these are two flat string values in a file this driver must + * never write, and shelling out to `plutil` once per bundle would make a `doctor` run pay for + * every runtime ever downloaded. Both keys are read from `MobileAssetProperties` onwards, so a + * same-named key in the surrounding envelope cannot be mistaken for the asset's own. + */ +function parseRuntimeAsset(plist: string): RuntimeAsset | undefined { + const propertiesAt = plist.indexOf("MobileAssetProperties"); + const properties = propertiesAt === -1 ? plist : plist.slice(propertiesAt); + const build = /Build<\/key>\s*([^<]+)<\/string>/.exec(properties)?.[1]; + const version = /SimulatorVersion<\/key>\s*([^<]+)<\/string>/.exec(properties)?.[1]; + + return build === undefined || version === undefined ? undefined : { build, version }; +} + function versionIntOr(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; }