Files
simslim-mirror/cmd/simslim/app.go
Margulan Daribayev e52d064905 feat: watch slims parallel-testing clones as they boot (#43)
* feat: watch slims parallel-testing clones as they boot

xcodebuild parallel testing creates a stock clone per worker in its own
device set and deletes them per run; disable overrides do not survive
simctl clone, so there is no device to pre-slim. watch polls the device
sets and applies a no-reboot slim to each simulator once it boots, then
leaves it alone, reusing EnableSlimNoReboot from #42.

Answers the ephemeral-clone case raised in #41.

* fix: watch re-slims rebooted clones and retries failed slims

Review fixes on the watch scan loop:

- Drop a UDID from seen when a scan finds it non-Booted. Below iOS 18.5 the
  no-reboot slim ends with the boot session, so a clone that booted again came
  back stock and was skipped for the rest of the run.
- Drop a UDID from seen when its slim fails, so the next scan retries it
  instead of leaving that clone stock behind one stderr line.
- Wait for the in-flight slim goroutines before Watch returns, so an importer
  holds no goroutine past the call.
- Pass the slim a Reporter that prefixes each line with the UDID, so the
  per-device progress reaches the caller.

seen is now mutated by the scan loop and by the slim goroutines, so it moves
into a watcher behind a mutex. go test -race is green.
2026-09-16 22:28:54 +02:00

146 lines
6.4 KiB
Go

package main
import (
"context"
"fmt"
"os"
"strings"
"time"
cli "github.com/urfave/cli/v3"
"github.com/mobai-app/simslim"
)
// newApp builds the command tree. It handles subcommand dispatch, the global
// --set flag, and per-command flag parsing.
func newApp() *cli.Command {
jsonFlag := func() cli.Flag {
return &cli.BoolFlag{Name: "json", Usage: "print machine-readable JSON", OnlyOnce: true}
}
preserveBootStateFlag := func(usage string) cli.Flag {
return &cli.BoolFlag{Name: "preserve-boot-state", Usage: usage}
}
commands := []*cli.Command{
{Name: "list", Flags: []cli.Flag{jsonFlag(), &cli.BoolFlag{Name: "booted", Usage: "only list booted simulators"}}, Action: cmdList},
{Name: "profiles", Flags: []cli.Flag{jsonFlag()}, Action: cmdProfiles},
{Name: "profile", Action: cmdNewProfile},
{Name: "status", Flags: []cli.Flag{jsonFlag(), &cli.BoolFlag{Name: "dropped", Usage: "list the disabled launchd labels grouped by category"}}, Action: cmdStatus},
{Name: "verify", Flags: []cli.Flag{
jsonFlag(),
&cli.StringFlag{Name: "profile", Usage: "verify against a JSON profile file (mutually exclusive with --except/--keep)"},
&cli.StringFlag{Name: "except", Usage: "comma-separated category IDs the profile leaves fully enabled (see `simslim profiles`)"},
&cli.StringFlag{Name: "keep", Usage: "comma-separated launchd labels the profile keeps running"},
}, Action: cmdVerify},
{Name: "doctor", Flags: []cli.Flag{
jsonFlag(),
&cli.StringFlag{Name: "requires", Usage: "comma-separated feature IDs the simulator must support (see `simslim doctor --list`)"},
&cli.BoolFlag{Name: "list", Usage: "list every checkable feature and its backing daemons"},
}, Action: cmdDoctor},
{Name: "measure", Flags: []cli.Flag{jsonFlag()}, Action: cmdMeasure},
{Name: "top", Flags: []cli.Flag{jsonFlag()}, Action: cmdTop},
{Name: "size", Flags: []cli.Flag{jsonFlag()}, Action: cmdSize},
{Name: "disk-categories", Flags: []cli.Flag{jsonFlag()}, Action: cmdDiskCategories},
{Name: "disk-plan", Flags: []cli.Flag{jsonFlag()}, Action: cmdDiskPlan},
{Name: "disk-clean", Flags: []cli.Flag{
jsonFlag(),
&cli.StringFlag{Name: "categories", Usage: "comma-separated cleanable disk category IDs"},
&cli.BoolFlag{Name: "confirm", Usage: "confirm permanent deletion"},
preserveBootStateFlag("reboot a simulator that was booted before cleanup"),
}, Action: cmdDiskClean},
{Name: "clone", Flags: []cli.Flag{jsonFlag()}, Action: cmdClone},
{Name: "repair-clone", Flags: []cli.Flag{jsonFlag()}, Action: cmdRepairClone},
{Name: "rename", Flags: []cli.Flag{jsonFlag()}, Action: cmdRename},
{Name: "boot", Flags: []cli.Flag{jsonFlag()}, Action: cmdBoot},
{Name: "shutdown", Flags: []cli.Flag{jsonFlag()}, Action: cmdShutdown},
{Name: "erase", Flags: []cli.Flag{jsonFlag()}, Action: cmdErase},
{Name: "delete", Flags: []cli.Flag{jsonFlag()}, Action: cmdDelete},
{Name: "on", 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.BoolFlag{Name: "no-reboot", Usage: "stop the daemons in the current boot session without a reboot (works on every runtime; lost at the next reboot on iOS < 18.5)"},
preserveBootStateFlag("return an initially shutdown simulator to shutdown after reconfiguration"),
}, Action: cmdOn},
{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 {
if err != nil && strings.Contains(err.Error(), "flag needs an argument: --set") {
return fmt.Errorf("--set requires a device set name (such as `testing`) or a path")
}
return err
}
for _, c := range commands {
c.OnUsageError = onUsageError
}
return &cli.Command{
Name: "simslim",
HideHelp: true, // root help/usage is handled by main's usage()
HideVersion: true, // version output is handled by main
OnUsageError: onUsageError,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "set",
Usage: "also scan these device sets (comma-separated name|path)",
Action: func(_ context.Context, _ *cli.Command, value string) error {
sets := simslim.SplitList(value)
if len(sets) == 0 {
return fmt.Errorf("--set requires a device set name (such as `testing`) or a path")
}
for _, set := range sets {
simslim.RegisterDeviceSet(set)
}
return nil
},
},
&cli.DurationFlag{
Name: "boot-timeout",
Usage: "max time for a simulator to boot and reconfigure (e.g. 10m, 15m); raise it for slow CI runners",
Sources: cli.EnvVars("SIMSLIM_BOOT_TIMEOUT"),
Value: simslim.BootTimeout,
Action: func(_ context.Context, _ *cli.Command, d time.Duration) error {
if d <= 0 {
return fmt.Errorf("--boot-timeout must be a positive duration (such as `15m`)")
}
simslim.BootTimeout = d
return nil
},
},
&cli.DurationFlag{
Name: "spawn-timeout",
Usage: "max time for a single launchctl transition inside the simulator (e.g. 2m, 5m); raise it for slow hosts",
Sources: cli.EnvVars("SIMSLIM_SPAWN_TIMEOUT"),
Value: simslim.SpawnTimeout,
Action: func(_ context.Context, _ *cli.Command, d time.Duration) error {
if d <= 0 {
return fmt.Errorf("--spawn-timeout must be a positive duration (such as `2m`)")
}
simslim.SpawnTimeout = d
return nil
},
},
},
Action: func(_ context.Context, cmd *cli.Command) error {
if args := cmd.Args().Slice(); len(args) > 0 {
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", args[0])
}
usage()
os.Exit(2)
return nil
},
ExitErrHandler: func(_ context.Context, _ *cli.Command, _ error) {},
Commands: commands,
}
}