From 93575e56d520b4626fbf85a1ad4fcd4a8807ed8b Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 11:02:10 +0530 Subject: [PATCH] fix(sidecar): reap orphaned XCTest runner sessions at iOS init --- .../dev/sanderling/sidecar/DriverBackend.kt | 21 +++++++++++++++++++ .../sanderling/sidecar/DriverServiceTest.kt | 19 +++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 7203d7c..9b3451e 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -677,12 +677,33 @@ private fun pngHeight(bytes: ByteArray): Int { (bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF) } +internal const val IOS_XCTEST_RUNNER_BUNDLE_ID = "dev.mobile.maestro-driver-iosUITests.xctrunner" + +// reapOrphanIosRunners kills XCTest runner sessions left over from a prior +// run. A sidecar that died without its shutdown hook leaves its xcodebuild +// session alive; xcodebuild later restarts its dead runner, which terminates +// the active run's session and steals the simulator's gesture daemon. Returns +// true when an orphaned xcodebuild session was found and killed. +internal fun reapOrphanIosRunners(udid: String, execute: (List) -> Int): Boolean { + val killed = execute(listOf("pkill", "-f", "xcodebuild.*test-without-building.*$udid")) == 0 + execute(listOf("xcrun", "simctl", "terminate", udid, IOS_XCTEST_RUNNER_BUNDLE_ID)) + return killed +} + class IosDriverBackend(private val udid: String) : DriverBackend { private lateinit var driver: maestro.drivers.IOSDriver private lateinit var localDevice: ios.LocalIOSDevice private val reconnectLock = java.util.concurrent.locks.ReentrantLock() init { + val reaped = reapOrphanIosRunners(udid) { command -> + runCatching { ProcessBuilder(command).start().waitFor() }.getOrDefault(1) + } + if (reaped) { + println("terminated orphaned XCTest runner session for $udid") + // Give the killed session a beat to tear down before installing ours. + Thread.sleep(1000) + } val wdaPort = maestro.utils.SocketUtils.nextFreePort(22000, 23000) val tempFileHandler = maestro.utils.TempFileHandler() val simctlDevice = device.SimctlIOSDevice( diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt index 710908f..98538de 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt @@ -159,6 +159,25 @@ class DriverServiceTest { assertTrue(thrown.status.description.orEmpty().contains("only one gesture")) } + @Test fun reapOrphanIosRunnersKillsStrayXcodebuildAndRunnerApp() { + val commands = mutableListOf>() + val reaped = reapOrphanIosRunners("UDID-1234") { command -> + commands.add(command) + 0 + } + assertTrue(reaped) + assertEquals(2, commands.size) + assertEquals("pkill", commands[0][0]) + assertTrue(commands[0][2].contains("test-without-building")) + assertTrue(commands[0][2].contains("UDID-1234")) + assertEquals(listOf("xcrun", "simctl", "terminate", "UDID-1234", IOS_XCTEST_RUNNER_BUNDLE_ID), commands[1]) + } + + @Test fun reapOrphanIosRunnersReportsNothingFound() { + val reaped = reapOrphanIosRunners("UDID-1234") { 1 } + assertEquals(false, reaped) + } + @Test fun overlappedDoubleTapLandsTwoTaps() { val invocations = java.util.concurrent.atomic.AtomicInteger(0) overlappedDoubleTap { invocations.incrementAndGet() }