diff --git a/README.md b/README.md index 8bf9314..67de5bb 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,25 @@ Live slimming only moves toward more-disabled. Managed daemons already disabled beyond the profile are left alone, because starting a daemon again live would need a per-label bootstrap; `simslim off` restores them with a reboot. +### Slimming parallel-testing clones + +`xcodebuild` parallel testing creates a fresh clone per worker in its own device +set, runs the tests, and deletes the clones when the run ends. The clones are +created stock, and disable overrides do not survive `simctl clone`, so there is +no device to slim ahead of time. `simslim watch` closes that gap: it polls the +device sets and slims each simulator no-reboot the moment it boots, then leaves +it alone. A clone slimmed a few seconds into a multi-minute run keeps its freed +memory for the rest of the run. + +```sh +simslim watch --except web,store & +xcodebuild test -scheme App -parallel-testing-enabled YES -parallel-testing-worker-count 4 ... +``` + +It takes the same `--profile`/`--except`/`--keep` selection as `on`, and scans +the default and `testing` sets plus any passed with `--set`. Point `--set` at a +custom parallel-testing device set if your Xcode uses one. It runs until Ctrl-C. + ## Disk cleanup Disk cleanup is permanent and separate from service slimming. `disk-plan` is diff --git a/cmd/simslim/app.go b/cmd/simslim/app.go index c0a2225..de78f78 100644 --- a/cmd/simslim/app.go +++ b/cmd/simslim/app.go @@ -66,6 +66,12 @@ func newApp() *cli.Command { {Name: "off", Flags: []cli.Flag{ preserveBootStateFlag("return an initially shutdown simulator to shutdown after reconfiguration"), }, Action: cmdOff}, + {Name: "watch", Flags: []cli.Flag{ + &cli.StringFlag{Name: "profile", Usage: "apply a JSON profile file (mutually exclusive with --except/--keep)"}, + &cli.StringFlag{Name: "except", Usage: "comma-separated category IDs to leave fully enabled (see `simslim profiles`)"}, + &cli.StringFlag{Name: "keep", Usage: "comma-separated launchd labels to keep running"}, + &cli.DurationFlag{Name: "interval", Usage: "how often to rescan for booting simulators", Value: simslim.DefaultWatchInterval}, + }, Action: cmdWatch}, } onUsageError := func(_ context.Context, _ *cli.Command, err error, _ bool) error { diff --git a/cmd/simslim/main.go b/cmd/simslim/main.go index 2d3a459..33bc47f 100644 --- a/cmd/simslim/main.go +++ b/cmd/simslim/main.go @@ -822,6 +822,21 @@ func onNoReboot(ctx context.Context, device simslim.Device, p simslim.Profile, r return nil } +// cmdWatch slims every simulator no-reboot as it boots, until Ctrl-C. It is the +// hook for xcodebuild parallel-testing clones, which are created stock and +// deleted per run: point --set at the parallel-testing device set and run this +// alongside the test invocation. +func cmdWatch(ctx context.Context, cmd *cli.Command) error { + p, err := simslim.BuildProfile(cmd.String("profile"), cmd.String("except"), cmd.String("keep")) + if err != nil { + return err + } + report := simslim.Reporter(func(msg string) { fmt.Fprintln(os.Stderr, msg) }) + sets := append([]string{"default", "testing"}, simslim.ExtraDeviceSetTokens()...) + fmt.Fprintf(os.Stderr, "Watching device sets [%s]; slimming each simulator no-reboot as it boots. Ctrl-C to stop.\n", strings.Join(sets, ", ")) + return simslim.Watch(ctx, p, cmd.Duration("interval"), report) +} + func cmdOff(ctx context.Context, cmd *cli.Command) error { preserveBootState := cmd.Bool("preserve-boot-state") udid, err := oneUDID(cmd.Args().Slice()) @@ -955,6 +970,15 @@ COMMANDS off Restore a simulator to stock (re-enable + reboot) --preserve-boot-state Return an initially shutdown simulator to shutdown + watch Slim every simulator no-reboot as it boots, until Ctrl-C. + The hook for xcodebuild parallel-testing clones, which are + created stock and deleted per run; point --set at the + parallel-testing device set + --profile path Apply a committed JSON profile (see below); mutually + exclusive with --except/--keep + --except ids Leave these categories enabled (comma-separated) + --keep labels Keep these individual daemons running (comma-separated) + --interval dur How often to rescan for booting simulators (default 3s) status Report how slim a booted simulator is --dropped Also list the disabled daemons grouped by category verify Check that a booted simulator's disable overrides still diff --git a/watch.go b/watch.go new file mode 100644 index 0000000..401200f --- /dev/null +++ b/watch.go @@ -0,0 +1,67 @@ +package simslim + +import ( + "context" + "fmt" + "sort" + "time" +) + +// DefaultWatchInterval is how often Watch rescans the device sets. +const DefaultWatchInterval = 3 * time.Second + +// slimStrategy is the per-device slimming applied by Watch. It is a field so +// tests can substitute a fake for EnableSlimNoReboot. +type slimStrategy func(ctx context.Context, set, udid string, p Profile, report Reporter) (bool, error) + +// Watch slims each simulator no-reboot as it boots, then leaves it alone. +// xcodebuild creates parallel-testing clones stock and deletes them per run, so +// no device exists to pre-slim: the only hook is to catch each clone once it +// boots. A clone slimmed a few seconds into a multi-minute run frees its +// background daemons for the rest of the run. Watch runs until ctx is cancelled +// (Ctrl-C), scanning the default, testing, and any --set device sets. It slims +// each device in its own goroutine so one slow reconfigure does not delay +// catching the next clone. +func Watch(ctx context.Context, p Profile, interval time.Duration, report Reporter) error { + return watch(ctx, p, interval, report, EnableSlimNoReboot) +} + +func watch(ctx context.Context, p Profile, interval time.Duration, report Reporter, slim slimStrategy) error { + if interval <= 0 { + interval = DefaultWatchInterval + } + seen := map[string]bool{} // UDIDs already slimmed or in flight; scan loop is single-threaded + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + scanAndSlim(ctx, p, report, slim, seen) + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + } +} + +func scanAndSlim(ctx context.Context, p Profile, report Reporter, slim slimStrategy, seen map[string]bool) { + devices, err := ListDevices(ctx) + if err != nil { + report.report(fmt.Sprintf("watch: list devices: %v", err)) + return + } + sort.Slice(devices, func(i, j int) bool { return devices[i].UDID < devices[j].UDID }) + for _, d := range devices { + if d.State != "Booted" || seen[d.UDID] { + continue + } + seen[d.UDID] = true + go func(d Device) { + report.report(fmt.Sprintf("watch: slimming %s (set %s)", d.UDID, d.Set)) + if _, err := slim(ctx, d.Set, d.UDID, p, nil); err != nil { + report.report(fmt.Sprintf("watch: %s: %v", d.UDID, err)) + return + } + report.report(fmt.Sprintf("watch: slimmed %s", d.UDID)) + }(d) + } +} diff --git a/watch_test.go b/watch_test.go new file mode 100644 index 0000000..f26d179 --- /dev/null +++ b/watch_test.go @@ -0,0 +1,66 @@ +package simslim + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// TestWatchSlimsEachBootedDeviceOnce verifies the scan slims a booted device +// exactly once and never touches a shutdown one, so a steady poll does not +// re-slim clones it already handled. +func TestWatchSlimsEachBootedDeviceOnce(t *testing.T) { + dir := t.TempDir() + xcrunPath := filepath.Join(dir, "xcrun") + script := `#!/bin/sh +if [ "$*" = "simctl list devices -j" ]; then + printf '%s\n' '{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-26-1":[{"udid":"AAAA","name":"clone-1","state":"Booted","isAvailable":true,"dataPath":"/tmp/a"},{"udid":"BBBB","name":"clone-2","state":"Shutdown","isAvailable":true,"dataPath":"/tmp/b"}]}}' + exit 0 +fi +exit 0 +` + if err := os.WriteFile(xcrunPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var mu sync.Mutex + slimmed := map[string]int{} + fake := func(_ context.Context, _, udid string, _ Profile, _ Reporter) (bool, error) { + mu.Lock() + slimmed[udid]++ + mu.Unlock() + return true, nil + } + + seen := map[string]bool{} + p := Profile{} + for i := 0; i < 3; i++ { // repeated scans must not re-slim + scanAndSlim(context.Background(), p, nil, fake, seen) + } + waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return slimmed["AAAA"] == 1 }) + + mu.Lock() + defer mu.Unlock() + if slimmed["AAAA"] != 1 { + t.Fatalf("booted device slimmed %d times, want 1", slimmed["AAAA"]) + } + if slimmed["BBBB"] != 0 { + t.Fatalf("shutdown device slimmed %d times, want 0", slimmed["BBBB"]) + } +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("condition not met before deadline") +}