mirror of
https://github.com/MobAI-App/simslim.git
synced 2026-10-11 02:49:02 +02:00
* 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.
261 lines
7.9 KiB
Go
261 lines
7.9 KiB
Go
package simslim
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestNormalizeSimulatorName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
wantError bool
|
|
}{
|
|
{name: "ordinary", input: "QA iPhone", want: "QA iPhone"},
|
|
{name: "trims whitespace", input: " QA iPhone ", want: "QA iPhone"},
|
|
{name: "unicode", input: "Démo 📱", want: "Démo 📱"},
|
|
{name: "empty", input: " ", wantError: true},
|
|
{name: "embedded control", input: "QA\nPhone", wantError: true},
|
|
{name: "too long", input: strings.Repeat("a", 129), wantError: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := NormalizeSimulatorName(tt.input)
|
|
if (err != nil) != tt.wantError {
|
|
t.Fatalf("NormalizeSimulatorName() error = %v, wantError %v", err, tt.wantError)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("NormalizeSimulatorName() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatListError(t *testing.T) {
|
|
exit72 := errors.New("exit status 72")
|
|
tests := []struct {
|
|
name string
|
|
stderr string
|
|
want []string
|
|
wantNot []string
|
|
}{
|
|
{
|
|
name: "no stderr keeps the bare error",
|
|
stderr: "",
|
|
want: []string{"simctl list: exit status 72"},
|
|
wantNot: []string{"xcode-select"},
|
|
},
|
|
{
|
|
name: "whitespace-only stderr keeps the bare error",
|
|
stderr: " \n",
|
|
want: []string{"simctl list: exit status 72"},
|
|
wantNot: []string{"xcode-select"},
|
|
},
|
|
{
|
|
name: "stderr is appended",
|
|
stderr: "An error was encountered processing the command\n",
|
|
want: []string{"simctl list: exit status 72: An error was encountered processing the command"},
|
|
},
|
|
{
|
|
name: "missing simctl adds the xcode-select hint",
|
|
stderr: `xcrun: error: unable to find utility "simctl", not a developer tool or in PATH`,
|
|
want: []string{
|
|
`unable to find utility "simctl"`,
|
|
"sudo xcode-select -s",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := formatListError(exit72, tt.stderr).Error()
|
|
for _, want := range tt.want {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("formatListError() = %q, missing %q", got, want)
|
|
}
|
|
}
|
|
for _, wantNot := range tt.wantNot {
|
|
if strings.Contains(got, wantNot) {
|
|
t.Errorf("formatListError() = %q, should not contain %q", got, wantNot)
|
|
}
|
|
}
|
|
if !errors.Is(formatListError(exit72, tt.stderr), exit72) {
|
|
t.Errorf("formatListError() does not wrap the original error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseClonedUDID(t *testing.T) {
|
|
const udid = "00000000-0000-0000-0000-000000000001"
|
|
got, err := parseClonedUDID([]byte("\n" + udid + "\n"))
|
|
if err != nil {
|
|
t.Fatalf("parseClonedUDID() error = %v", err)
|
|
}
|
|
if got != udid {
|
|
t.Errorf("parseClonedUDID() = %q, want %q", got, udid)
|
|
}
|
|
|
|
for _, invalid := range []string{"", "not-a-udid", "00000000-0000-0000-0000-00000000000Z"} {
|
|
if _, err := parseClonedUDID([]byte(invalid)); err == nil {
|
|
t.Errorf("parseClonedUDID(%q) unexpectedly succeeded", invalid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResetClonedLaunchServicesAt(t *testing.T) {
|
|
dataDirectory := filepath.Join(t.TempDir(), "data")
|
|
lsdDirectory := filepath.Join(dataDirectory, "var", "db", "lsd")
|
|
if err := os.MkdirAll(lsdDirectory, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
removed := []string{
|
|
"com.apple.LaunchServices-20971544-v2.csstore",
|
|
"com.apple.LaunchServices-20971544-v2.csstore-shm",
|
|
"com.apple.LaunchServices-20971544-v2.csstore-wal",
|
|
}
|
|
preserved := []string{
|
|
"SystemDataOnly-com.apple.LaunchServices-20971544-v2.csstore",
|
|
"com.apple.LaunchServicesAppProtectionStore.plist",
|
|
"unrelated.db",
|
|
}
|
|
for _, name := range append(append([]string{}, removed...), preserved...) {
|
|
if err := os.WriteFile(filepath.Join(lsdDirectory, name), []byte("test"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if err := resetClonedLaunchServicesAt(dataDirectory); err != nil {
|
|
t.Fatalf("resetClonedLaunchServicesAt() error = %v", err)
|
|
}
|
|
for _, name := range removed {
|
|
if _, err := os.Stat(filepath.Join(lsdDirectory, name)); !os.IsNotExist(err) {
|
|
t.Errorf("generated store %q still exists; stat error = %v", name, err)
|
|
}
|
|
}
|
|
for _, name := range preserved {
|
|
if _, err := os.Stat(filepath.Join(lsdDirectory, name)); err != nil {
|
|
t.Errorf("unrelated file %q was not preserved: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResetClonedLaunchServicesAtAllowsMissingStore(t *testing.T) {
|
|
dataDirectory := filepath.Join(t.TempDir(), "data")
|
|
if err := os.MkdirAll(dataDirectory, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := resetClonedLaunchServicesAt(dataDirectory); err != nil {
|
|
t.Fatalf("resetClonedLaunchServicesAt() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResetClonedLaunchServicesAtRejectsSymlink(t *testing.T) {
|
|
root := t.TempDir()
|
|
dataDirectory := filepath.Join(root, "data")
|
|
externalDirectory := filepath.Join(root, "external")
|
|
if err := os.MkdirAll(filepath.Join(dataDirectory, "var", "db"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(externalDirectory, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(externalDirectory, filepath.Join(dataDirectory, "var", "db", "lsd")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := resetClonedLaunchServicesAt(dataDirectory)
|
|
if err == nil || !strings.Contains(err.Error(), "not a real directory") {
|
|
t.Fatalf("resetClonedLaunchServicesAt() error = %v, want symlink rejection", err)
|
|
}
|
|
}
|
|
|
|
// TestApplyDeltaRunsTransitionsConcurrentlyButBounded drives applyDelta
|
|
// against a fake xcrun that records how many spawns are in flight while it
|
|
// works. The peak proves both halves of the contract: transitions really do
|
|
// overlap (the whole point of the pool), and never more than spawnWorkers of
|
|
// them at once, so a simulator still settling after a first boot is not
|
|
// hammered.
|
|
func TestApplyDeltaRunsTransitionsConcurrentlyButBounded(t *testing.T) {
|
|
dir := t.TempDir()
|
|
inFlight := filepath.Join(dir, "inflight")
|
|
peaks := filepath.Join(dir, "peaks")
|
|
if err := os.Mkdir(inFlight, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Each spawn logs its arguments, marks itself present, records how many
|
|
// peers it sees, holds long enough for the rest of its wave to arrive,
|
|
// then leaves.
|
|
script := `#!/bin/sh
|
|
printf '%s\n' "$*" >> "$SIMSLIM_XCRUN_LOG"
|
|
: > "$SIMSLIM_INFLIGHT/$$"
|
|
ls "$SIMSLIM_INFLIGHT" | wc -l >> "$SIMSLIM_PEAKS"
|
|
sleep 0.2
|
|
rm -f "$SIMSLIM_INFLIGHT/$$"
|
|
exit 0
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, "xcrun"), []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv("SIMSLIM_INFLIGHT", inFlight)
|
|
t.Setenv("SIMSLIM_PEAKS", peaks)
|
|
logPath := filepath.Join(dir, "xcrun.log")
|
|
t.Setenv("SIMSLIM_XCRUN_LOG", logPath)
|
|
|
|
labels := make([]string, 3*spawnWorkers)
|
|
for i := range labels {
|
|
labels[i] = fmt.Sprintf("com.apple.probe%d", i)
|
|
}
|
|
if err := applyDelta(context.Background(), "default", "UDID", labels, nil, "disable", nil); err != nil {
|
|
t.Fatalf("applyDelta: %v", err)
|
|
}
|
|
|
|
log, err := os.ReadFile(peaks)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
peak := 0
|
|
for _, line := range strings.Fields(string(log)) {
|
|
n, err := strconv.Atoi(line)
|
|
if err != nil {
|
|
t.Fatalf("unparsable peak %q: %v", line, err)
|
|
}
|
|
peak = max(peak, n)
|
|
}
|
|
if peak <= 1 {
|
|
t.Errorf("peak concurrency = %d, want transitions to overlap", peak)
|
|
}
|
|
if peak > spawnWorkers {
|
|
t.Errorf("peak concurrency = %d, want at most spawnWorkers (%d)", peak, spawnWorkers)
|
|
}
|
|
|
|
// Bounding the pool must not cost coverage: every label gets exactly the
|
|
// one transition it was queued for, whatever order the workers ran in.
|
|
want := make(map[string]bool, len(labels))
|
|
for _, l := range labels {
|
|
want["simctl spawn UDID launchctl disable system/"+l] = true
|
|
}
|
|
got := map[string]int{}
|
|
for _, call := range xcrunCalls(t, logPath) {
|
|
got[call]++
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Errorf("saw %d distinct transitions, want %d", len(got), len(want))
|
|
}
|
|
for call := range want {
|
|
if got[call] != 1 {
|
|
t.Errorf("transition %q ran %d times, want exactly 1", call, got[call])
|
|
}
|
|
}
|
|
}
|