From 41c5c53f22f3c1d2afad09f5d16dd140f05debd6 Mon Sep 17 00:00:00 2001 From: Procopis Achilleos Date: Wed, 1 Apr 2026 14:47:03 -0400 Subject: [PATCH 1/8] feat: add --persist flag to keep WDA running between invocations When --persist is set, maestro-runner will: - On startup: check if WDA is already running on the expected port and reuse it (skipping build/start) if healthy - On exit: leave WDA running instead of killing it This enables running separate maestro-runner commands sequentially without the app restarting between them, which is critical for step-by-step test execution from an external orchestrator. Env var: MAESTRO_PERSIST=true Made-with: Cursor --- pkg/cli/cli.go | 5 +++ pkg/cli/ios.go | 89 ++++++++++++++++++++++++++++++++++++++-- pkg/cli/test.go | 4 ++ pkg/driver/wda/client.go | 6 +++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 9a63316..324747c 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -103,6 +103,11 @@ var GlobalFlags = []cli.Flag{ Value: 180, Usage: "Device boot timeout in seconds", }, + &cli.BoolFlag{ + Name: "persist", + Usage: "Keep WDA running between invocations. Reuses an existing WDA session on startup and leaves it running on exit.", + EnvVars: []string{"MAESTRO_PERSIST"}, + }, } // Execute runs the CLI. diff --git a/pkg/cli/ios.go b/pkg/cli/ios.go index 57d132e..8aebd0b 100644 --- a/pkg/cli/ios.go +++ b/pkg/cli/ios.go @@ -52,12 +52,25 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { logger.Info("Using specified iOS device: %s", udid) } - // Check if device port is already in use (another instance using this device) + // Check if device port is already in use port := wdadriver.PortFromUDID(udid) - if isPortInUse(port) { + portInUse := isPortInUse(port) + + // --persist: try to reuse an existing WDA server + if cfg.Persist && portInUse { + logger.Info("--persist: WDA port %d in use, attempting to reuse existing session", port) + client := wdadriver.NewClient(port) + if client.IsHealthy() { + printSetupSuccess(fmt.Sprintf("Reusing existing WDA on port %d", port)) + return createIOSDriverFromClient(cfg, udid, client, port, true) + } + logger.Info("--persist: existing WDA not healthy, starting fresh") + } + + if portInUse && !cfg.Persist { return nil, nil, fmt.Errorf("device %s is in use (port %d already bound)\n"+ "Another maestro-runner instance may be using this device.\n"+ - "Hint: Wait for it to finish or use a different device with --device ", udid, port) + "Hint: Wait for it to finish, use --persist to reuse WDA, or use a different device with --device ", udid, port) } // 0. Detect device type (simulator vs physical) @@ -157,8 +170,12 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { wdaDrv := wdadriver.NewDriver(client, platformInfo, udid) wdaDrv.SetAppFile(cfg.AppFile) - // Cleanup function + // Cleanup function — with --persist, leave WDA running cleanup := func() { + if cfg.Persist { + logger.Info("--persist: leaving WDA running on port %d for reuse", runner.Port()) + return + } runner.Cleanup() } @@ -180,6 +197,70 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { return driver, cleanup, nil } +// createIOSDriverFromClient creates an iOS driver from an existing WDA client (for --persist reuse). +func createIOSDriverFromClient(cfg *RunConfig, udid string, client *wdadriver.Client, port uint16, persist bool) (core.Driver, func(), error) { + isSimulator := isIOSSimulator(udid) + + // Install app if specified + if cfg.AppFile != "" && !cfg.NoAppInstall { + printSetupStep(fmt.Sprintf("Installing app: %s", cfg.AppFile)) + if err := installIOSApp(udid, cfg.AppFile, isSimulator); err != nil { + return nil, nil, fmt.Errorf("install app failed: %w", err) + } + printSetupSuccess("App installed") + } + + deviceInfo, err := getIOSDeviceInfo(udid) + if err != nil { + return nil, nil, fmt.Errorf("get device info: %w", err) + } + + appVersion := "" + if cfg.AppID != "" && isSimulator { + appVersion = getIOSAppVersion(udid, cfg.AppID) + } + + var screenW, screenH int + if w, h, err := client.WindowSize(); err == nil { + screenW, screenH = w, h + } + + platformInfo := &core.PlatformInfo{ + Platform: "ios", + OSVersion: deviceInfo.OSVersion, + DeviceName: deviceInfo.Name, + DeviceID: udid, + IsSimulator: deviceInfo.IsSimulator, + ScreenWidth: screenW, + ScreenHeight: screenH, + AppID: cfg.AppID, + AppVersion: appVersion, + } + + wdaDrv := wdadriver.NewDriver(client, platformInfo, udid) + wdaDrv.SetAppFile(cfg.AppFile) + + cleanup := func() { + logger.Info("--persist: leaving WDA running on port %d for reuse", port) + } + + var driver core.Driver = wdaDrv + + if !cfg.NoFlutterFallback && isSimulator { + fw := flutter.WrapIOS(wdaDrv, nil, udid, cfg.AppID) + driver = fw + origCleanup := cleanup + cleanup = func() { + if fd, ok := fw.(*flutter.FlutterDriver); ok { + fd.Close() + } + origCleanup() + } + } + + return driver, cleanup, nil +} + // findIOSDevice finds an available iOS device (booted simulator or connected physical device). // Prefers simulators over physical devices. func findIOSDevice() (string, error) { diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 6376322..2ab6a60 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -472,6 +472,9 @@ type RunConfig struct { // Flutter NoFlutterFallback bool // Disable automatic Flutter VM Service fallback + + // Session persistence + Persist bool // Keep WDA running between invocations; reuse existing WDA on startup } func hyperlink(url, text string) string { @@ -630,6 +633,7 @@ func runTest(c *cli.Context) error { NoAppInstall: getBool("no-app-install"), NoDriverInstall: getBool("no-driver-install"), NoFlutterFallback: getBool("no-flutter-fallback"), + Persist: getBool("persist"), } // Apply waitForIdleTimeout with priority: diff --git a/pkg/driver/wda/client.go b/pkg/driver/wda/client.go index 35274c5..a845525 100644 --- a/pkg/driver/wda/client.go +++ b/pkg/driver/wda/client.go @@ -116,6 +116,12 @@ func (c *Client) Status() (map[string]interface{}, error) { return c.get("/status") } +// IsHealthy checks if the WDA server is reachable and responding. +func (c *Client) IsHealthy() bool { + _, err := c.Status() + return err == nil +} + // App management // LaunchApp launches an app by bundle ID. From bed34c6c1d5d8a41687c843ddd3016c8a23daa5d Mon Sep 17 00:00:00 2001 From: Procopis Achilleos Date: Wed, 1 Apr 2026 15:17:23 -0400 Subject: [PATCH 2/8] fix: use /status endpoint for WDA session reuse and detach xcodebuild process The initial --persist implementation had three issues that caused the app to restart between invocations: 1. xcodebuild (WDA) was killed when maestro-runner exited because it shared the parent's process group. Fixed by setting Setpgid on the child process so it survives parent exit. 2. isPortInUse (net.Listen) was unreliable on macOS due to SO_REUSEADDR. Replaced with a direct HTTP health check to the WDA /status endpoint. 3. ReuseSession() called /sessions which Apple's WDA does not implement. Fixed by extracting sessionId from the /status response instead, preventing EnsureSession from creating a new session and relaunching the app. Made-with: Cursor --- pkg/cli/ios.go | 95 +++++++++++++++++++++++++++++++++++++--- pkg/driver/wda/client.go | 19 ++++++++ pkg/driver/wda/runner.go | 11 +++++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/pkg/cli/ios.go b/pkg/cli/ios.go index 8aebd0b..74663dc 100644 --- a/pkg/cli/ios.go +++ b/pkg/cli/ios.go @@ -35,8 +35,18 @@ type iosDeviceInfo struct { func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { udid := getFirstDevice(cfg) + // --persist: before normal device detection, check if a persisted WDA is + // available. findBootedSimulator() skips port-in-use devices to prevent + // conflicts, so we need to find the device ourselves first. + if cfg.Persist && udid == "" { + if reusedUDID, driver, cleanup, ok := tryReusePersistedWDA(cfg); ok { + return driver, cleanup, nil + } else if reusedUDID != "" { + udid = reusedUDID + } + } + if udid == "" { - // Try to find booted simulator or connected physical device printSetupStep("Finding iOS device...") logger.Info("Auto-detecting iOS device (simulator or physical)...") var err error @@ -54,20 +64,17 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { // Check if device port is already in use port := wdadriver.PortFromUDID(udid) - portInUse := isPortInUse(port) - // --persist: try to reuse an existing WDA server - if cfg.Persist && portInUse { - logger.Info("--persist: WDA port %d in use, attempting to reuse existing session", port) + // --persist with explicit --device: try to reuse via health check + if cfg.Persist { client := wdadriver.NewClient(port) if client.IsHealthy() { printSetupSuccess(fmt.Sprintf("Reusing existing WDA on port %d", port)) return createIOSDriverFromClient(cfg, udid, client, port, true) } - logger.Info("--persist: existing WDA not healthy, starting fresh") } - if portInUse && !cfg.Persist { + if isPortInUse(port) && !cfg.Persist { return nil, nil, fmt.Errorf("device %s is in use (port %d already bound)\n"+ "Another maestro-runner instance may be using this device.\n"+ "Hint: Wait for it to finish, use --persist to reuse WDA, or use a different device with --device ", udid, port) @@ -111,6 +118,9 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { printSetupStep("Building WDA...") logger.Info("Building WDA for device %s (team ID: %s)", udid, cfg.TeamID) runner := wdadriver.NewRunner(udid, cfg.TeamID) + if cfg.Persist { + runner.SetPersist(true) + } ctx := context.Background() if err := runner.Build(ctx); err != nil { @@ -201,6 +211,14 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { func createIOSDriverFromClient(cfg *RunConfig, udid string, client *wdadriver.Client, port uint16, persist bool) (core.Driver, func(), error) { isSimulator := isIOSSimulator(udid) + // Adopt the existing WDA session so EnsureSession won't create a new one + // (which would relaunch the app). + if !client.ReuseSession() { + logger.Info("--persist: no existing session found, will create one on first flow") + } else { + printSetupSuccess("Reusing existing WDA session (app stays alive)") + } + // Install app if specified if cfg.AppFile != "" && !cfg.NoAppInstall { printSetupStep(fmt.Sprintf("Installing app: %s", cfg.AppFile)) @@ -279,6 +297,69 @@ func findIOSDevice() (string, error) { return "", fmt.Errorf("no iOS device found (no booted simulator or connected physical device)") } +// tryReusePersistedWDA scans all booted iOS simulators (including those with +// port in use) for a healthy WDA session. Returns the UDID of the first match. +// If WDA is healthy, returns the full driver+cleanup. If the device is found but +// WDA isn't healthy, returns just the UDID so the caller can start fresh WDA on it. +func tryReusePersistedWDA(cfg *RunConfig) (udid string, driver core.Driver, cleanup func(), ok bool) { + printSetupStep("Checking for persisted WDA session...") + sims, err := findBootedSimulatorAll() + if err != nil || len(sims) == 0 { + return "", nil, nil, false + } + + for _, simUDID := range sims { + port := wdadriver.PortFromUDID(simUDID) + client := wdadriver.NewClient(port) + if client.IsHealthy() { + printSetupSuccess(fmt.Sprintf("Reusing persisted WDA on port %d (device %s)", port, simUDID)) + d, cl, err := createIOSDriverFromClient(cfg, simUDID, client, port, true) + if err == nil { + return simUDID, d, cl, true + } + logger.Info("--persist: failed to create driver from existing WDA: %v", err) + } + } + + return sims[0], nil, nil, false +} + +// findBootedSimulatorAll returns UDIDs of all booted iOS simulators, including +// those whose WDA port is in use. Used by --persist to find reusable sessions. +func findBootedSimulatorAll() ([]string, error) { + out, err := runCommand("xcrun", "simctl", "list", "devices", "booted", "-j") + if err != nil { + return nil, err + } + + var data map[string]interface{} + if err := json.Unmarshal([]byte(out), &data); err != nil { + return nil, err + } + + devices, ok := data["devices"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("no devices in simctl output") + } + + var udids []string + for runtime, deviceList := range devices { + if !strings.Contains(runtime, "iOS-") { + continue + } + if list, ok := deviceList.([]interface{}); ok { + for _, device := range list { + if deviceMap, ok := device.(map[string]interface{}); ok { + if udid, ok := deviceMap["udid"].(string); ok && udid != "" { + udids = append(udids, udid) + } + } + } + } + } + return udids, nil +} + // hasBootedSimulator returns true if any iOS simulator is currently booted. func hasBootedSimulator() bool { _, err := findBootedSimulator() diff --git a/pkg/driver/wda/client.go b/pkg/driver/wda/client.go index a845525..dd9ab0e 100644 --- a/pkg/driver/wda/client.go +++ b/pkg/driver/wda/client.go @@ -122,6 +122,25 @@ func (c *Client) IsHealthy() bool { return err == nil } +// ReuseSession queries WDA for an existing session and adopts it. +// WDA includes a top-level "sessionId" field in every response when a +// session is active. We use the /status endpoint to retrieve it because +// the W3C /sessions endpoint is not supported by Apple's WDA. +func (c *Client) ReuseSession() bool { + resp, err := c.get("/status") + if err != nil { + return false + } + + if id, ok := resp["sessionId"].(string); ok && id != "" { + c.sessionID = id + logger.Info("Reusing existing WDA session: %s", id) + return true + } + + return false +} + // App management // LaunchApp launches an app by bundle ID. diff --git a/pkg/driver/wda/runner.go b/pkg/driver/wda/runner.go index b75fc57..4059597 100644 --- a/pkg/driver/wda/runner.go +++ b/pkg/driver/wda/runner.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "syscall" "time" goios "github.com/danielpaulus/go-ios/ios" @@ -36,6 +37,13 @@ type Runner struct { logFile *os.File portForwardListener io.Closer // Port forwarding for physical devices (go-ios) isSimulatorCache bool // Cached device type + persist bool // Keep WDA alive after parent exits +} + +// SetPersist enables persist mode: the WDA process is detached into its own +// process group so it survives after maestro-runner exits. +func (r *Runner) SetPersist(enabled bool) { + r.persist = enabled } // NewRunner creates a new WDA runner. @@ -175,6 +183,9 @@ func (r *Runner) Start(ctx context.Context) error { "-destination", r.destination(), "-derivedDataPath", r.derivedDataPath(), ) + if r.persist { + r.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + } r.cmd.Stdout = r.logFile r.cmd.Stderr = r.logFile From ea80d72a45f592551f47ad94b79db580ccc75239 Mon Sep 17 00:00:00 2001 From: Procopis Achilleos Date: Thu, 2 Apr 2026 14:01:15 -0400 Subject: [PATCH 3/8] test: add tests for --persist flag and update CHANGELOG - TestIsHealthy: verifies healthy/unreachable WDA server cases - TestReuseSession: verifies session adoption, no-session, and unreachable cases - TestSetPersist: verifies persist flag default, enable, and disable - CHANGELOG.md: document --persist in [Unreleased] section Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 +++++ pkg/driver/wda/client_test.go | 73 +++++++++++++++++++++++++++++++++++ pkg/driver/wda/runner_test.go | 30 ++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0eed7f..f56d258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`--persist` flag** (`MAESTRO_PERSIST` env var) — keeps WebDriverAgent running between invocations for faster repeated test runs. On startup, reuses an existing healthy WDA session (leaving the app alive); on exit, leaves WDA running instead of tearing it down. The xcodebuild process is detached into its own process group so it survives `maestro-runner` exiting. + ```bash + # First run: builds and launches WDA normally + maestro-runner --persist --platform ios test flow.yaml + + # Subsequent runs: reuses existing WDA session (~30-60s faster) + maestro-runner --persist --platform ios test flow.yaml + ``` + ## [1.1.0] - 2026-03-25 ### Added diff --git a/pkg/driver/wda/client_test.go b/pkg/driver/wda/client_test.go index b064e57..3ce3f81 100644 --- a/pkg/driver/wda/client_test.go +++ b/pkg/driver/wda/client_test.go @@ -1235,3 +1235,76 @@ func TestElementAttributeInvalidResponse(t *testing.T) { t.Error("Expected error for non-string attribute value") } } + +// TestIsHealthy tests WDA health check + +func TestIsHealthy_Healthy(t *testing.T) { + server := mockWDAServer(func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]interface{}{ + "value": map[string]interface{}{"state": "idle"}, + }) + }) + defer server.Close() + + client := &Client{baseURL: server.URL, httpClient: http.DefaultClient} + + if !client.IsHealthy() { + t.Error("Expected IsHealthy to return true when /status responds") + } +} + +func TestIsHealthy_Unhealthy(t *testing.T) { + // Point at a closed server so the request fails + client := &Client{baseURL: "http://127.0.0.1:1", httpClient: http.DefaultClient} + + if client.IsHealthy() { + t.Error("Expected IsHealthy to return false when server is unreachable") + } +} + +// TestReuseSession tests adopting an existing WDA session + +func TestReuseSession_WithSession(t *testing.T) { + server := mockWDAServer(func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]interface{}{ + "sessionId": "existing-session-abc", + "value": map[string]interface{}{"state": "idle"}, + }) + }) + defer server.Close() + + client := &Client{baseURL: server.URL, httpClient: http.DefaultClient} + + if !client.ReuseSession() { + t.Error("Expected ReuseSession to return true when sessionId is present") + } + if client.sessionID != "existing-session-abc" { + t.Errorf("Expected sessionID 'existing-session-abc', got '%s'", client.sessionID) + } +} + +func TestReuseSession_NoSession(t *testing.T) { + server := mockWDAServer(func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]interface{}{ + "value": map[string]interface{}{"state": "idle"}, + }) + }) + defer server.Close() + + client := &Client{baseURL: server.URL, httpClient: http.DefaultClient} + + if client.ReuseSession() { + t.Error("Expected ReuseSession to return false when no sessionId in response") + } + if client.sessionID != "" { + t.Errorf("Expected sessionID to remain empty, got '%s'", client.sessionID) + } +} + +func TestReuseSession_ServerUnreachable(t *testing.T) { + client := &Client{baseURL: "http://127.0.0.1:1", httpClient: http.DefaultClient} + + if client.ReuseSession() { + t.Error("Expected ReuseSession to return false when server is unreachable") + } +} diff --git a/pkg/driver/wda/runner_test.go b/pkg/driver/wda/runner_test.go index 41fcc96..e701cb6 100644 --- a/pkg/driver/wda/runner_test.go +++ b/pkg/driver/wda/runner_test.go @@ -177,3 +177,33 @@ func TestRunner_Destination(t *testing.T) { t.Errorf("expected destination %q, got %q", expected, dest) } } + +// Tests for SetPersist + +func TestSetPersist_EnablesFlag(t *testing.T) { + runner := &Runner{} + + runner.SetPersist(true) + + if !runner.persist { + t.Error("Expected persist to be true after SetPersist(true)") + } +} + +func TestSetPersist_DisablesFlag(t *testing.T) { + runner := &Runner{persist: true} + + runner.SetPersist(false) + + if runner.persist { + t.Error("Expected persist to be false after SetPersist(false)") + } +} + +func TestSetPersist_DefaultFalse(t *testing.T) { + runner := NewRunner("test-udid", "TEAM123") + + if runner.persist { + t.Error("Expected persist to be false by default") + } +} From 2b2b9742115f696208e0e7fa6fb8defb943ef33f Mon Sep 17 00:00:00 2001 From: Procopis Achilleos Date: Thu, 2 Apr 2026 14:16:18 -0400 Subject: [PATCH 4/8] refactor: remove unused persist parameter from createIOSDriverFromClient The parameter was always passed as true and never read inside the function. Co-Authored-By: Claude Sonnet 4.6 --- pkg/cli/ios.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cli/ios.go b/pkg/cli/ios.go index 74663dc..54d58a9 100644 --- a/pkg/cli/ios.go +++ b/pkg/cli/ios.go @@ -70,7 +70,7 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { client := wdadriver.NewClient(port) if client.IsHealthy() { printSetupSuccess(fmt.Sprintf("Reusing existing WDA on port %d", port)) - return createIOSDriverFromClient(cfg, udid, client, port, true) + return createIOSDriverFromClient(cfg, udid, client, port) } } @@ -208,7 +208,7 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { } // createIOSDriverFromClient creates an iOS driver from an existing WDA client (for --persist reuse). -func createIOSDriverFromClient(cfg *RunConfig, udid string, client *wdadriver.Client, port uint16, persist bool) (core.Driver, func(), error) { +func createIOSDriverFromClient(cfg *RunConfig, udid string, client *wdadriver.Client, port uint16) (core.Driver, func(), error) { isSimulator := isIOSSimulator(udid) // Adopt the existing WDA session so EnsureSession won't create a new one @@ -313,7 +313,7 @@ func tryReusePersistedWDA(cfg *RunConfig) (udid string, driver core.Driver, clea client := wdadriver.NewClient(port) if client.IsHealthy() { printSetupSuccess(fmt.Sprintf("Reusing persisted WDA on port %d (device %s)", port, simUDID)) - d, cl, err := createIOSDriverFromClient(cfg, simUDID, client, port, true) + d, cl, err := createIOSDriverFromClient(cfg, simUDID, client, port) if err == nil { return simUDID, d, cl, true } From 6325c0b988e01a56bcebd8509fd221bdba5a236a Mon Sep 17 00:00:00 2001 From: Procopis Achilleos Date: Thu, 2 Apr 2026 15:56:53 -0400 Subject: [PATCH 5/8] refactor: deduplicate findBootedSimulator using findBootedSimulatorAll findBootedSimulator was duplicating the xcrun simctl JSON-parsing logic already present in findBootedSimulatorAll. Refactored to delegate to findBootedSimulatorAll and filter out port-in-use entries from the result, removing ~30 lines of duplicated code. Made-with: Cursor --- pkg/cli/ios.go | 41 ++++++++--------------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/pkg/cli/ios.go b/pkg/cli/ios.go index 54d58a9..5fbe70c 100644 --- a/pkg/cli/ios.go +++ b/pkg/cli/ios.go @@ -366,46 +366,21 @@ func hasBootedSimulator() bool { return err == nil } -// findBootedSimulator finds the UDID of a booted iOS simulator. +// findBootedSimulator finds the UDID of a booted iOS simulator whose WDA port +// is not already in use. Delegates to findBootedSimulatorAll for device enumeration. func findBootedSimulator() (string, error) { - out, err := runCommand("xcrun", "simctl", "list", "devices", "booted", "-j") + udids, err := findBootedSimulatorAll() if err != nil { return "", err } - - // Parse JSON to find booted device - var data map[string]interface{} - if err := json.Unmarshal([]byte(out), &data); err != nil { - return "", err - } - - devices, ok := data["devices"].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("no devices in simctl output") - } - - for runtime, deviceList := range devices { - // Only consider iOS simulators — skip tvOS, watchOS, visionOS - if !strings.Contains(runtime, "iOS-") { + for _, udid := range udids { + port := wdadriver.PortFromUDID(udid) + if isPortInUse(port) { + logger.Info("Skipping booted simulator %s: port %d in use", udid, port) continue } - if list, ok := deviceList.([]interface{}); ok { - for _, device := range list { - if deviceMap, ok := device.(map[string]interface{}); ok { - if udid, ok := deviceMap["udid"].(string); ok && udid != "" { - // Skip simulators whose WDA port is already in use - port := wdadriver.PortFromUDID(udid) - if isPortInUse(port) { - logger.Info("Skipping booted simulator %s: port %d in use", udid, port) - continue - } - return udid, nil - } - } - } - } + return udid, nil } - return "", fmt.Errorf("no available booted iOS simulator found") } From 505534d29781821eadff3edb443ff92a596c3be0 Mon Sep 17 00:00:00 2001 From: Procopis Achilleos Date: Thu, 2 Apr 2026 15:57:58 -0400 Subject: [PATCH 6/8] fix: kill stale WDA process on port before fresh start under --persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When --persist is active and IsHealthy() returns false, the stale WDA process may still be holding the port. Suppressing the port-in-use error alone is not enough — the fresh WDA would fail to bind. Added killProcessOnPort helper (lsof -ti tcp: | xargs kill -9) that evicts the stale process so the fresh WDA can claim the port cleanly. Called from CreateIOSDriver when persist+unhealthy+port-in-use is detected. Also added TestKillProcessOnPort_FreePort to verify no panic when called on a port with no process bound. Made-with: Cursor --- pkg/cli/cli_test.go | 17 +++++++++++++++++ pkg/cli/ios.go | 5 +++++ pkg/cli/test.go | 14 ++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index f8baf8d..47659c5 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -2234,3 +2234,20 @@ func TestIsSocketInUse_SocketAndPidWithDeadProcess(t *testing.T) { t.Error("expected socket with dead owner PID to not be in use") } } + +// ============================================================ +// Tests for killProcessOnPort +// ============================================================ + +func TestKillProcessOnPort_FreePort(t *testing.T) { + port := uint16(49200 + (time.Now().UnixNano() % 100)) + if isPortInUse(port) { + t.Skipf("port %d unexpectedly in use, skipping", port) + } + // Should not panic or error when nothing holds the port + killProcessOnPort(port) + // Port should still be free after the call + if isPortInUse(port) { + t.Errorf("port %d in use after killProcessOnPort on free port", port) + } +} diff --git a/pkg/cli/ios.go b/pkg/cli/ios.go index 5fbe70c..245cc42 100644 --- a/pkg/cli/ios.go +++ b/pkg/cli/ios.go @@ -72,6 +72,11 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { printSetupSuccess(fmt.Sprintf("Reusing existing WDA on port %d", port)) return createIOSDriverFromClient(cfg, udid, client, port) } + // WDA is unhealthy — kill the stale process so the fresh WDA can bind the port + if isPortInUse(port) { + logger.Info("--persist: stale WDA on port %d is unhealthy, killing process", port) + killProcessOnPort(port) + } } if isPortInUse(port) && !cfg.Persist { diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 2ab6a60..89dc964 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -1687,6 +1687,20 @@ func isPortInUse(port uint16) bool { return false } +// killProcessOnPort kills any process holding the given TCP port. +// Used by --persist to evict a stale, unhealthy WDA process so a fresh one +// can bind the port. Logs a warning on failure rather than returning an error +// because the caller will proceed with the fresh WDA start regardless. +func killProcessOnPort(port uint16) { + out, err := runCommand("sh", "-c", fmt.Sprintf("lsof -ti tcp:%d | xargs kill -9", port)) + if err != nil { + logger.Warn("--persist: failed to kill process on port %d: %v (output: %s)", port, err, out) + return + } + logger.Info("--persist: killed stale WDA process on port %d", port) + time.Sleep(200 * time.Millisecond) +} + // checkDeviceAvailable checks if a device is available and not in use. // Returns error if device is busy or not found. func checkDeviceAvailable(deviceID, platform string) error { From bb2331c9d07da053b12f941401f7465cd2012088 Mon Sep 17 00:00:00 2001 From: Procopis Achilleos Date: Thu, 2 Apr 2026 15:58:40 -0400 Subject: [PATCH 7/8] feat: extend --persist auto-detect to connected physical devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tryReusePersistedWDA was simulator-only. Since it is a discovery function (find whichever device already has a healthy WDA running), simulators and physical devices are peers in the search — neither is preferred. Refactored to collect all candidates (booted simulators + connected physical device) into a single slice and iterate once, returning the first with a live WDA session. If none is healthy, the first candidate is returned so the caller starts a fresh WDA on it. Also added: - TestGlobalFlags_PersistFlag: verifies --persist is registered as a global flag - TestRunConfig_PersistField: verifies Persist defaults to false and can be set Made-with: Cursor --- pkg/cli/cli_test.go | 27 +++++++++++++++++++++++++++ pkg/cli/ios.go | 32 ++++++++++++++++++++------------ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index 47659c5..a16e377 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -2239,6 +2239,33 @@ func TestIsSocketInUse_SocketAndPidWithDeadProcess(t *testing.T) { // Tests for killProcessOnPort // ============================================================ +// ============================================================ +// Tests for --persist flag and RunConfig.Persist field +// ============================================================ + +func TestGlobalFlags_PersistFlag(t *testing.T) { + flagNames := make(map[string]bool) + for _, f := range GlobalFlags { + for _, name := range f.Names() { + flagNames[name] = true + } + } + if !flagNames["persist"] { + t.Error("expected --persist flag to be defined in GlobalFlags") + } +} + +func TestRunConfig_PersistField(t *testing.T) { + cfg := &RunConfig{} + if cfg.Persist { + t.Error("expected Persist to default to false") + } + cfg.Persist = true + if !cfg.Persist { + t.Error("expected Persist to be true after setting") + } +} + func TestKillProcessOnPort_FreePort(t *testing.T) { port := uint16(49200 + (time.Now().UnixNano() % 100)) if isPortInUse(port) { diff --git a/pkg/cli/ios.go b/pkg/cli/ios.go index 245cc42..b6c4afe 100644 --- a/pkg/cli/ios.go +++ b/pkg/cli/ios.go @@ -302,31 +302,39 @@ func findIOSDevice() (string, error) { return "", fmt.Errorf("no iOS device found (no booted simulator or connected physical device)") } -// tryReusePersistedWDA scans all booted iOS simulators (including those with -// port in use) for a healthy WDA session. Returns the UDID of the first match. -// If WDA is healthy, returns the full driver+cleanup. If the device is found but -// WDA isn't healthy, returns just the UDID so the caller can start fresh WDA on it. +// tryReusePersistedWDA scans all available iOS devices — booted simulators and +// any connected physical device — for a healthy WDA session. Simulators and +// physical devices are peers in this search; the function returns the first +// candidate that has a live WDA session. If no healthy session is found, the +// first candidate is returned so the caller can start a fresh WDA on it. func tryReusePersistedWDA(cfg *RunConfig) (udid string, driver core.Driver, cleanup func(), ok bool) { printSetupStep("Checking for persisted WDA session...") - sims, err := findBootedSimulatorAll() - if err != nil || len(sims) == 0 { + + var candidates []string + if sims, err := findBootedSimulatorAll(); err == nil { + candidates = append(candidates, sims...) + } + if physUDID, err := findConnectedDevice(); err == nil { + candidates = append(candidates, physUDID) + } + if len(candidates) == 0 { return "", nil, nil, false } - for _, simUDID := range sims { - port := wdadriver.PortFromUDID(simUDID) + for _, candidateUDID := range candidates { + port := wdadriver.PortFromUDID(candidateUDID) client := wdadriver.NewClient(port) if client.IsHealthy() { - printSetupSuccess(fmt.Sprintf("Reusing persisted WDA on port %d (device %s)", port, simUDID)) - d, cl, err := createIOSDriverFromClient(cfg, simUDID, client, port) + printSetupSuccess(fmt.Sprintf("Reusing persisted WDA on port %d (device %s)", port, candidateUDID)) + d, cl, err := createIOSDriverFromClient(cfg, candidateUDID, client, port) if err == nil { - return simUDID, d, cl, true + return candidateUDID, d, cl, true } logger.Info("--persist: failed to create driver from existing WDA: %v", err) } } - return sims[0], nil, nil, false + return candidates[0], nil, nil, false } // findBootedSimulatorAll returns UDIDs of all booted iOS simulators, including From 321a40863dd4f2ec993972a78eb995e4ce1f340c Mon Sep 17 00:00:00 2001 From: Procopis Achilleos Date: Thu, 2 Apr 2026 16:56:06 -0400 Subject: [PATCH 8/8] fix: re-establish port forward before WDA health check on physical devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When --persist is active, the in-process USB port forwarder (go-ios) dies when maestro-runner exits, even though WDA on the device stays alive. This caused IsHealthy() to always fail on the next invocation, forcing a full WDA restart instead of reusing the live session. Fix: - Extract startPortForward into a new exported ResumePortForward(udid, port) function that creates a fresh USB tunnel without restarting xcodebuild - In tryReusePersistedWDA, call ResumePortForward for physical device candidates before the IsHealthy() check; skip the candidate if the tunnel cannot be established - In the explicit --device persist path in CreateIOSDriver, do the same - Wire the forwarder closer into the driver cleanup so the tunnel stays alive for the lifetime of the session and is torn down on exit Simulator flow is unchanged — WDA runs on localhost and needs no forwarding. Made-with: Cursor --- pkg/cli/ios.go | 65 ++++++++++++++++++++++++++++++++++++++-- pkg/driver/wda/runner.go | 27 ++++++++++++----- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/pkg/cli/ios.go b/pkg/cli/ios.go index b6c4afe..6551b0c 100644 --- a/pkg/cli/ios.go +++ b/pkg/cli/ios.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os/exec" "path/filepath" "strings" @@ -65,12 +66,41 @@ func CreateIOSDriver(cfg *RunConfig) (core.Driver, func(), error) { // Check if device port is already in use port := wdadriver.PortFromUDID(udid) - // --persist with explicit --device: try to reuse via health check + // --persist with explicit --device: try to reuse via health check. + // For physical devices the in-process port forward died when the previous + // invocation exited, so we re-establish it before checking health. if cfg.Persist { + var fwd io.Closer + if !isIOSSimulator(udid) { + var fwdErr error + fwd, fwdErr = wdadriver.ResumePortForward(udid, port) + if fwdErr != nil { + logger.Info("--persist: failed to resume port forward for %s: %v", udid, fwdErr) + } + } + client := wdadriver.NewClient(port) if client.IsHealthy() { printSetupSuccess(fmt.Sprintf("Reusing existing WDA on port %d", port)) - return createIOSDriverFromClient(cfg, udid, client, port) + d, cl, err := createIOSDriverFromClient(cfg, udid, client, port) + if err != nil { + if fwd != nil { + fwd.Close() + } + return nil, nil, err + } + if fwd != nil { + origCleanup := cl + cl = func() { + origCleanup() + fwd.Close() + } + } + return d, cl, nil + } + + if fwd != nil { + fwd.Close() } // WDA is unhealthy — kill the stale process so the fresh WDA can bind the port if isPortInUse(port) { @@ -307,12 +337,20 @@ func findIOSDevice() (string, error) { // physical devices are peers in this search; the function returns the first // candidate that has a live WDA session. If no healthy session is found, the // first candidate is returned so the caller can start a fresh WDA on it. +// +// For physical devices the port forward (USB tunnel) dies when the previous +// maestro-runner process exits, so it must be re-established before the health +// check. Simulator WDA runs directly on localhost and needs no forwarding. func tryReusePersistedWDA(cfg *RunConfig) (udid string, driver core.Driver, cleanup func(), ok bool) { printSetupStep("Checking for persisted WDA session...") var candidates []string + simSet := make(map[string]bool) if sims, err := findBootedSimulatorAll(); err == nil { candidates = append(candidates, sims...) + for _, s := range sims { + simSet[s] = true + } } if physUDID, err := findConnectedDevice(); err == nil { candidates = append(candidates, physUDID) @@ -323,15 +361,38 @@ func tryReusePersistedWDA(cfg *RunConfig) (udid string, driver core.Driver, clea for _, candidateUDID := range candidates { port := wdadriver.PortFromUDID(candidateUDID) + + // Physical devices need the port forward re-established before health check + var fwd io.Closer + if !simSet[candidateUDID] { + var fwdErr error + fwd, fwdErr = wdadriver.ResumePortForward(candidateUDID, port) + if fwdErr != nil { + logger.Info("--persist: failed to resume port forward for %s: %v", candidateUDID, fwdErr) + continue + } + } + client := wdadriver.NewClient(port) if client.IsHealthy() { printSetupSuccess(fmt.Sprintf("Reusing persisted WDA on port %d (device %s)", port, candidateUDID)) d, cl, err := createIOSDriverFromClient(cfg, candidateUDID, client, port) if err == nil { + if fwd != nil { + origCleanup := cl + cl = func() { + origCleanup() + fwd.Close() + } + } return candidateUDID, d, cl, true } logger.Info("--persist: failed to create driver from existing WDA: %v", err) } + + if fwd != nil { + fwd.Close() + } } return candidates[0], nil, nil, false diff --git a/pkg/driver/wda/runner.go b/pkg/driver/wda/runner.go index 4059597..6f42ba9 100644 --- a/pkg/driver/wda/runner.go +++ b/pkg/driver/wda/runner.go @@ -214,21 +214,34 @@ func (r *Runner) Start(ctx context.Context) error { // startPortForward uses go-ios to forward the WDA port from a physical device to localhost. func (r *Runner) startPortForward() error { - entry, err := goios.GetDevice(r.deviceUDID) + listener, err := ResumePortForward(r.deviceUDID, r.port) + if err != nil { + return err + } + r.portForwardListener = listener + return nil +} + +// ResumePortForward re-establishes a USB port forward from localhost: to the +// physical device's WDA port. Used by --persist to reconnect the tunnel after the +// previous maestro-runner process exited (taking its in-process forwarder with it). +// WDA on the device is still alive; only the Mac-side tunnel needs to be recreated. +// The returned closer must be held open for the lifetime of the session. +func ResumePortForward(deviceUDID string, port uint16) (io.Closer, error) { + entry, err := goios.GetDevice(deviceUDID) if err != nil { - return fmt.Errorf("device %s not found: %w", r.deviceUDID, err) + return nil, fmt.Errorf("device %s not found: %w", deviceUDID, err) } - listener, err := forward.Forward(entry, r.port, r.port) + listener, err := forward.Forward(entry, port, port) if err != nil { - return fmt.Errorf("port forward %d->%d failed: %w", r.port, r.port, err) + return nil, fmt.Errorf("port forward %d->%d failed: %w", port, port, err) } - r.portForwardListener = listener - // Give the forward a moment to establish + // Give the tunnel a moment to establish time.Sleep(500 * time.Millisecond) - return nil + return listener, nil } // injectPort writes USE_PORT into the xctestrun plist's EnvironmentVariables