Files
Interlap e752a72898 perf: slim booted simulators offline and run launchctl transitions in parallel (#51)
* 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.
2026-09-24 14:17:41 +02:00

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.go holds a forbiddenLabels list and asserts none appear in any category; if you add labels, keep that test green.
  • Only managed labels are ever mutated. delta() is scoped to managedSet() 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 a simctl alias like all can 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-plan is strictly read-only. Durable storage (Documents, app bundles, user media) is measured but never deletable.

Conventions

  • CLI parsing uses github.com/urfave/cli/v3; the top command's live TUI uses github.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 in cmd/simslim/app.go (newApp); each subcommand's Action is a cmd* function in cmd/simslim/main.go that reads flags via cmd.Bool/String(...) and positionals via cmd.Args(). Flags may appear before or after positional args (e.g. simslim on <udid> --except search) — v3 parses flags anywhere by default. --set is a global flag (inherited by every subcommand) registered in its flag Action. main.go still owns version/help/no-args and the macOS-only guard before handing off to the tree, and routes every command error through fatal() for the stable simslim: <msg> (exit 1); unknown commands exit 2 with usage.
  • Two timeouts live in simctl.go: ShutdownTimeout (30s, a const) and BootTimeout (10min, because a first slim reconfigure boots twice). BootTimeout is a package var, not a const, so the CLI's global --boot-timeout flag (env SIMSLIM_BOOT_TIMEOUT, wired in app.go like --set) can raise it for slow CI runners where the per-daemon launchctl transitions would otherwise blow the deadline mid-reconfigure.
  • Progress for multi-minute operations goes to stderr via the Reporter callback; 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.