* perf: slim booted simulators offline and run launchctl transitions in parallel Three changes from the SwiftSimSlim report in #50, all in the Go backend and the SwiftUI front end. **The shell batching never ran.** `runBatch` spawned `/bin/sh -c` inside the simulator to walk a chunk of labels, but no iOS runtime ships a shell — `RuntimeRoot/bin` holds exactly `df` and `launchctl`, from iOS 18.3 through 27.1. Every spawn failed with LaunchdSimError 111, `parseBatchOK` returned nothing, and all 170 labels fell through to the serial per-label retry pass. Delete the script and its helpers; run the direct spawns through a pool of 8 instead. That is both less code and the actual speedup: `on --no-reboot` over 170 labels on a freshly erased iPhone 17 Pro / iOS 26.5 goes 2m14s -> 37.5s. **A booted device now takes the offline store too.** An override only takes effect at the next boot, so a booted device already owed a shutdown and a boot. `ensure` spends them up front and hands off to `ensureOffline`, which replaces every launchctl spawn for the same two state changes. Measured 16.8s to slim a booted simulator, 15.0s to restore one. It also reads the live state first and returns early when the profile already matches, so re-applying an unchanged profile costs one read and no reboot at all (0.7s). **The GUI no longer freezes on a global busy flag.** Batches reserve every device up front and run two at a time, and controls gate on the devices they would act on rather than on "anything is running", so work on one simulator leaves the others live. `BatchProgress` carries the names in flight instead of a single current name. * fix: address review findings on the concurrency rework Four defects found reviewing the previous commit. **Batch completion could erase a newer reservation.** `slim` and `restore` cleared their own reservation in a `defer`, and `runConcurrently` cleared it again when the result arrived. Those run in different MainActor tasks, so another operation could claim the device in the gap and have its reservation wiped by the batch's stale clear — leaving the device shown as free while work ran on it. The runner is now the sole owner. **Overlapping operations could lose their final refresh.** `refresh()` dropped any request made while one was in flight. That was safe when every mutation was globally exclusive; now that two devices finish independently, the second one's refresh could be dropped after the first had already read the device list, and its row would stay stale indefinitely. Requests made during a refresh are coalesced into a follow-up run, mirroring `diskReloadRequested`. **Batch controls were enabled but silently did nothing.** Relaxing the view gating left `runBatch`, `cleanDisk`, `analyzeDisk` and `runManagementBatch` guarding on `batchProgress == nil`, so Slim/Unslim/Erase/Delete/Clean on an idle device during a batch returned without doing anything. They now gate on `canStartBatchOnSelection` / `isBatchRunning`; per-device actions (boot, shutdown, clone, rename, measure) stay live as intended. **`ensure` read a booted device without waiting for boot.** `simctl list` reports Booted from the moment a boot starts, so `readDisabled` on a device that is still coming up would fail the whole command instead of waiting. The booted branch calls `BootAndWait` first; on a device that really is booted that costs ~0.2s. Also assert full transition coverage in the applyDelta concurrency test — it pinned the bound but would have passed an implementation that ran only the first `spawnWorkers` labels.
10 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
simslim runs many more iOS simulators on one Mac by disabling the background
daemons a simulator doesn't need, cutting each simulator's memory ~4x. It is a Go
CLI plus a SwiftUI macOS app that wraps it. Everything is driven through
xcrun simctl; the tool only ever touches the simulators you point it at, never
the host Mac. macOS-only.
Commands
go build ./cmd/simslim # build the CLI
go test ./... # run all tests (Makefile: make test)
go test -run TestName ./... # run a single test
make check # full CI gate — must pass before a PR
make format # gofmt + swift-format (run before committing)
make app # build build/SimSlim.app (Go + swiftc, macOS only)
open build/SimSlim.app
make check runs go test, go vet, swift-format lint --strict --recursive gui,
zsh -n scripts/build-app.sh, and plutil -lint gui/Info.plist. CI (.github/workflows/ci.yml)
runs exactly this on macos-26 / Xcode 26.6, then builds and verifies the app bundle.
Tests are pure unit tests (parsing, delta logic, allowlist invariants) — they do not boot real simulators, so they run anywhere. Only the app build and manual runs need Xcode + an iOS runtime.
Architecture
Two packages. The repo root is package simslim, an importable library holding
every piece of slimming logic and no external dependencies. cmd/simslim/
is package main, the CLI: main.go dispatches os.Args[1] to a cmd*
function per subcommand and enforces macOS-only up front.
Anything that talks to a terminal — printing, --json encoding via writeJSON,
the interactive wizard in wizard.go, fatal(), usage() — lives in
cmd/simslim/. The library never writes to stdout; it returns values and
reports progress through the Reporter callback the caller supplies.
Exported identifiers in the root package are the public API, so renaming one is a breaking change for importers as well as for the CLI.
The slimming model (the core idea). profiles.go defines Categories, an
allowlist of launchd daemon labels grouped by user-facing feature (siri, search,
icloud, …). Categories may overlap — a label lives in every category whose
feature needs it (e.g. the AMS payment-sheet daemons are in both store and
icloud), and Profile.Desired() keeps a label enabled when any excepted
category lists it. SlimmableSet() is the deduplicated union of every label in
Categories.
managedSet() adds each category's AlwaysEnabled compatibility services,
which simslim may only repair back to enabled; these are the only labels the
tool may ever disable or enable. Anything outside those sets is never touched.
service_descriptions.go supplies the short per-daemon explanations shown by
the GUI; its coverage and length are enforced in profiles_test.go.
profile_file.go loads a committed JSON profile (simslim on --profile <path>)
whose except/keep arrays mirror the flags of the same name, validates it
against the allowlist, and resolves it to a Profile. The dependency-free
profile command's interactive wizard lives in cmd/simslim/wizard.go.
features.go defines Features, a finer-grained catalog than Categories:
each feature (push, storekit, universal-links, …) names just the daemons one
testable capability needs. doctor reads a booted simulator's disabled labels
and reports any required feature whose daemons are down, exiting non-zero — a CI
preflight. features_test.go asserts every feature label is slimmable.
slim.go's ensure() rejects a non-empty slim profile on runtimes older than
iOS 18.5 before booting or mutating the device, then reads the currently disabled
labels, computes a delta against the desired set, and applies the changes with
launchctl disable/enable run inside the simulator via simctl spawn, a pool of
spawnWorkers (8) at a time. It reboots
and reads the state back before reporting persistence. on disables the profile;
off remains available on every runtime and re-enables the whole managed set.
EnableSlimNoReboot (on --no-reboot) skips the reboot: it runs launchctl disable + launchctl bootout per label so the daemon stops in the current boot
session, which is the only slimming possible on runtimes older than iOS 18.5.
The offline fast path. On a runtime with persistent overrides ensureOffline
takes over: disabled_store.go writes the overrides directly and boots the
device once, already slim, skipping every launchctl spawn and the reboot that
would apply them (measured 2m14s → 33s for 170 labels). A booted device gets
there too — an override only takes effect at the next boot, so ensure reads its
live state, returns early when the profile already matches, and otherwise spends
the shutdown the device already owed before handing off. There is no shell inside
any iOS runtime (RuntimeRoot/bin holds only df and launchctl), so batching
transitions through a spawned /bin/sh is not an option; the pool is. launchd_sim is a host process, so that store is not in the
device's data directory — it sits beside the device's launchd.log in
/private/var/tmp/com.apple.CoreSimulator.SimDevice.<UDID>/disabled.plist, keyed
only by UDID. It is an undocumented CoreSimulator detail, so treat it as
best-effort: writes merge (the runtime keeps its own explicit-enable entries
there, and unmanaged labels are never simslim's business), the booted device is
always read back with print-disabled rather than trusted, and anything that
does not work out returns errOfflineIneffective so ensure falls back to the
launchctl path. A store problem must never surface as a command failure.
simctl wrapper. simctl.go is the only place that shells out to
xcrun simctl (list/boot/shutdown/clone/erase/delete/spawn). measure.go sums
phys_footprint across the simulator's launchd process tree (via pgrep/ps/top)
— that's the memory figure that decides how many simulators fit. MeasureProcesses
keeps the per-process detail (footprint + cpu, from the same snapshot) for the
top drill-down. fleet.go's FleetSnapshot composes booted devices + slim
status + MeasureMany into the fleet view; the live TUI is cmd/simslim/top.go
(Bubble Tea), which also has a --json/non-TTY one-shot fallback. disk.go,
disk_cleanup.go, and disk_inventory.go handle disk measurement and the
separate, permanent disk-cleanup feature.
JSON is a contract. Every read-only and management command supports --json.
The structs in output.go (DeviceSummary, StatusOutput,
SimulatorMutationOutput, etc.) plus the category/plan structs are the stable
interface the SwiftUI app decodes. Changing a JSON field breaks the GUI in
gui/*.swift — keep them in sync. gui/Backend.swift invokes the bundled CLI
with --json and decodes these types; there is no other IPC.
The SwiftUI app (gui/) has no Xcode project. scripts/build-app.sh compiles
the Swift sources directly with swiftc, cross-builds the Go CLI, bundles it into
SimSlim.app/Contents/Resources/simslim, generates the icon, and ad-hoc codesigns.
The app is a thin front end that shells out to that bundled binary.
Safety invariants — preserve these
- Never add a deadlock-prone daemon to
Categories. A handful of daemons wedge a simulator when disabled.profiles_test.goholds aforbiddenLabelslist and asserts none appear in any category; if you add labels, keep that test green. - Only managed labels are ever mutated.
delta()is scoped tomanagedSet()on both sides. Compatibility labels are omitted from every desired slim state, so they can only be repaired to enabled. - Destructive/management commands resolve the exact UDID first (
findDevice/shutdownIfBooted) so asimctlalias likeallcan never fan out a boot, erase, delete, or filesystem path across every simulator. - Disk cleanup only removes allowlisted per-device directories, refuses to run
without
--confirm, and never touches the shared, signed iOS runtime (built-in apps, core OS language resources).disk-planis strictly read-only. Durable storage (Documents, app bundles, user media) is measured but never deletable.
Conventions
- CLI parsing uses
github.com/urfave/cli/v3; thetopcommand's live TUI usesgithub.com/charmbracelet/bubbletea+lipgloss. These are the CLI's only dependencies — the root library still has none, so importers never inherit a terminal stack. The command tree lives incmd/simslim/app.go(newApp); each subcommand'sActionis acmd*function incmd/simslim/main.gothat reads flags viacmd.Bool/String(...)and positionals viacmd.Args(). Flags may appear before or after positional args (e.g.simslim on <udid> --except search) — v3 parses flags anywhere by default.--setis a global flag (inherited by every subcommand) registered in its flagAction.main.gostill ownsversion/help/no-args and the macOS-only guard before handing off to the tree, and routes every command error throughfatal()for the stablesimslim: <msg>(exit 1); unknown commands exit 2 with usage. - Two timeouts live in
simctl.go:ShutdownTimeout(30s, a const) andBootTimeout(10min, because a first slim reconfigure boots twice).BootTimeoutis a package var, not a const, so the CLI's global--boot-timeoutflag (envSIMSLIM_BOOT_TIMEOUT, wired inapp.golike--set) can raise it for slow CI runners where the per-daemonlaunchctltransitions would otherwise blow the deadline mid-reconfigure. - Progress for multi-minute operations goes to stderr via the
Reportercallback; machine-readable JSON goes to stdout. Under--json, suppress the stderr chatter so consumers reading a combined stream still get clean JSON. - Memory estimates (
ApproxMemoryMB) are iOS-26.5 clean-boot medians and are not additive. Every category must have a positive measured estimate.
Git
Never add a Co-Authored-By: Claude trailer or any "Generated with Claude" footer
to commit messages.