From bef8a1a3e3454a5e8f9e3fe27131d991543c5e15 Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Thu, 3 Sep 2026 13:57:05 +0300 Subject: [PATCH 1/9] [Fix] Retry simctl against the testing device set on Invalid device xcodebuild parallel testing creates simulator clones in a separate device set (~/Library/Developer/XCTestDevices) that plain simctl cannot see, so uninstall/privacy commands silently failed for those clones. Capture stderr (where simctl reports Invalid device) and retry the command with --set testing before giving up. --- Sources/MusselServer/ServerManager.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Sources/MusselServer/ServerManager.swift b/Sources/MusselServer/ServerManager.swift index 07e4a2a..ea40873 100644 --- a/Sources/MusselServer/ServerManager.swift +++ b/Sources/MusselServer/ServerManager.swift @@ -179,11 +179,27 @@ class ServerManager { } @discardableResult func run(command: String) -> String { + var result = launch(command: command) + // xcodebuild's parallel-testing simulator clones live in a separate device set + // that plain simctl cannot see ("Invalid device") — retry against the testing set. + if result.contains("Invalid device"), command.hasPrefix("xcrun simctl ") { + let testingSetCommand = command.replacingOccurrences( + of: "xcrun simctl ", + with: "xcrun simctl --set testing " + ) + print("Retrying with testing device set: \(testingSetCommand)") + result = launch(command: testingSetCommand) + } + return result + } + + private func launch(command: String) -> String { let pipe = Pipe() let task = Process() task.launchPath = "/bin/sh" task.arguments = ["-c", String(format: "%@", command)] task.standardOutput = pipe + task.standardError = pipe let file = pipe.fileHandleForReading task.launch() if let result = NSString(data: file.readDataToEndOfFile(), encoding: String.Encoding.utf8.rawValue) { From 4baec878a9afb999e892aa2434d3a8c44089278e Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Fri, 4 Sep 2026 10:59:46 +0300 Subject: [PATCH 2/9] [Fix] Cache which simulators live in the testing device set The Invalid-device retry doubled every simctl call for parallel-testing clones. Remember simulators that resolved via --set testing and build their commands against that set right away, so only the first command per clone pays the extra attempt. --- Sources/MusselServer/ServerManager.swift | 57 +++++++++++++++++------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/Sources/MusselServer/ServerManager.swift b/Sources/MusselServer/ServerManager.swift index ea40873..6a5354b 100644 --- a/Sources/MusselServer/ServerManager.swift +++ b/Sources/MusselServer/ServerManager.swift @@ -43,7 +43,7 @@ class ServerManager { if let pushFileUrl = self?.createTemporaryPushFile(payload: payload) { let command = "xcrun simctl push \(simId) \(appBundleId) \(pushFileUrl.path)" - self?.run(command: command) + self?.run(command: command, simulatorId: simId) do { try FileManager.default.removeItem(at: pushFileUrl) @@ -51,7 +51,7 @@ class ServerManager { print("Error removing file!") } - let result = self?.run(command: command) + let result = self?.run(command: command, simulatorId: simId) let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) @@ -74,7 +74,7 @@ class ServerManager { } let command = "xcrun simctl openurl \(simId) \"\(universalLink)\"" - let result = self?.run(command: command) + let result = self?.run(command: command, simulatorId: simId) let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) @@ -95,7 +95,7 @@ class ServerManager { } let command = "xcrun simctl privacy \(simulatorId) reset \(permission) \(appBundleId)" - let result = self?.run(command: command) + let result = self?.run(command: command, simulatorId: simulatorId) let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) @@ -115,7 +115,7 @@ class ServerManager { } let command = "xcrun simctl addmedia \(simId) \(path)" - let result = self?.run(command: command) + let result = self?.run(command: command, simulatorId: simId) let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) @@ -134,7 +134,7 @@ class ServerManager { } let command = "xcrun simctl status_bar \(simId) override --time 2007-01-09T09:41:00+01:00" - let result = self?.run(command: command) + let result = self?.run(command: command, simulatorId: simId) let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) @@ -154,7 +154,7 @@ class ServerManager { } let command = "xcrun simctl uninstall \(simId) \(appBundleId)" - let result = self?.run(command: command) + let result = self?.run(command: command, simulatorId: simId) let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) @@ -178,21 +178,48 @@ class ServerManager { return temporaryFileURL } - @discardableResult func run(command: String) -> String { - var result = launch(command: command) + private var testingSetSimulatorIds = Set() + private let testingSetLock = NSLock() + + @discardableResult func run(command: String, simulatorId: String? = nil) -> String { + var effectiveCommand = command + if let simulatorId, isInTestingSet(simulatorId) { + effectiveCommand = testingSetVariant(of: command) + } + + var result = launch(command: effectiveCommand) // xcodebuild's parallel-testing simulator clones live in a separate device set - // that plain simctl cannot see ("Invalid device") — retry against the testing set. - if result.contains("Invalid device"), command.hasPrefix("xcrun simctl ") { - let testingSetCommand = command.replacingOccurrences( - of: "xcrun simctl ", - with: "xcrun simctl --set testing " - ) + // that plain simctl cannot see ("Invalid device") — retry against the testing set + // and remember the simulator so its next commands skip the failing attempt. + if result.contains("Invalid device"), + effectiveCommand.hasPrefix("xcrun simctl "), + !effectiveCommand.contains("--set testing") { + let testingSetCommand = testingSetVariant(of: command) print("Retrying with testing device set: \(testingSetCommand)") result = launch(command: testingSetCommand) + if let simulatorId, !result.contains("Invalid device") { + remember(testingSetSimulatorId: simulatorId) + } } return result } + private func testingSetVariant(of command: String) -> String { + command.replacingOccurrences(of: "xcrun simctl ", with: "xcrun simctl --set testing ") + } + + private func isInTestingSet(_ simulatorId: String) -> Bool { + testingSetLock.lock() + defer { testingSetLock.unlock() } + return testingSetSimulatorIds.contains(simulatorId) + } + + private func remember(testingSetSimulatorId: String) { + testingSetLock.lock() + defer { testingSetLock.unlock() } + testingSetSimulatorIds.insert(testingSetSimulatorId) + } + private func launch(command: String) -> String { let pipe = Pipe() let task = Process() From 8b8428deac530b2782c3e76cd7ea0f8a84fa869e Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Mon, 7 Sep 2026 09:44:18 +0300 Subject: [PATCH 3/9] Address review: argv execution and single push run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands are built as argv arrays and executed via /usr/bin/env instead of sh -c, so values from request JSON can never inject shell commands; the testing-set retry inserts --set testing into the argv. The push endpoint also ran simctl push twice — the second time after deleting the payload file — now it runs once and reports that result. --- Sources/MusselServer/ServerManager.swift | 48 ++++++++++++------------ 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/Sources/MusselServer/ServerManager.swift b/Sources/MusselServer/ServerManager.swift index 6a5354b..dedc7b8 100644 --- a/Sources/MusselServer/ServerManager.swift +++ b/Sources/MusselServer/ServerManager.swift @@ -42,8 +42,8 @@ class ServerManager { } if let pushFileUrl = self?.createTemporaryPushFile(payload: payload) { - let command = "xcrun simctl push \(simId) \(appBundleId) \(pushFileUrl.path)" - self?.run(command: command, simulatorId: simId) + let command = ["xcrun", "simctl", "push", simId, appBundleId, pushFileUrl.path] + let result = self?.run(command: command, simulatorId: simId) do { try FileManager.default.removeItem(at: pushFileUrl) @@ -51,8 +51,7 @@ class ServerManager { print("Error removing file!") } - let result = self?.run(command: command, simulatorId: simId) - let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" + let responseInfo = "Ran command: \(command.joined(separator: " ")) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) } else { @@ -73,9 +72,9 @@ class ServerManager { return HttpResponse.badRequest(nil) } - let command = "xcrun simctl openurl \(simId) \"\(universalLink)\"" + let command = ["xcrun", "simctl", "openurl", simId, universalLink] let result = self?.run(command: command, simulatorId: simId) - let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" + let responseInfo = "Ran command: \(command.joined(separator: " ")) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) } @@ -94,9 +93,9 @@ class ServerManager { return HttpResponse.badRequest(nil) } - let command = "xcrun simctl privacy \(simulatorId) reset \(permission) \(appBundleId)" + let command = ["xcrun", "simctl", "privacy", simulatorId, "reset", permission, appBundleId] let result = self?.run(command: command, simulatorId: simulatorId) - let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" + let responseInfo = "Ran command: \(command.joined(separator: " ")) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) } @@ -114,9 +113,9 @@ class ServerManager { return HttpResponse.badRequest(nil) } - let command = "xcrun simctl addmedia \(simId) \(path)" + let command = ["xcrun", "simctl", "addmedia", simId, path] let result = self?.run(command: command, simulatorId: simId) - let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" + let responseInfo = "Ran command: \(command.joined(separator: " ")) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) } @@ -133,9 +132,9 @@ class ServerManager { return HttpResponse.badRequest(nil) } - let command = "xcrun simctl status_bar \(simId) override --time 2007-01-09T09:41:00+01:00" + let command = ["xcrun", "simctl", "status_bar", simId, "override", "--time", "2007-01-09T09:41:00+01:00"] let result = self?.run(command: command, simulatorId: simId) - let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" + let responseInfo = "Ran command: \(command.joined(separator: " ")) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) } @@ -153,9 +152,9 @@ class ServerManager { return HttpResponse.badRequest(nil) } - let command = "xcrun simctl uninstall \(simId) \(appBundleId)" + let command = ["xcrun", "simctl", "uninstall", simId, appBundleId] let result = self?.run(command: command, simulatorId: simId) - let responseInfo = "Ran command: \(command) \n Result:\n \(result ?? "Empty result")" + let responseInfo = "Ran command: \(command.joined(separator: " ")) \n Result:\n \(result ?? "Empty result")" print(responseInfo) return .ok(.text(responseInfo)) } @@ -181,7 +180,7 @@ class ServerManager { private var testingSetSimulatorIds = Set() private let testingSetLock = NSLock() - @discardableResult func run(command: String, simulatorId: String? = nil) -> String { + @discardableResult func run(command: [String], simulatorId: String? = nil) -> String { var effectiveCommand = command if let simulatorId, isInTestingSet(simulatorId) { effectiveCommand = testingSetVariant(of: command) @@ -192,10 +191,9 @@ class ServerManager { // that plain simctl cannot see ("Invalid device") — retry against the testing set // and remember the simulator so its next commands skip the failing attempt. if result.contains("Invalid device"), - effectiveCommand.hasPrefix("xcrun simctl "), - !effectiveCommand.contains("--set testing") { + !effectiveCommand.contains("--set") { let testingSetCommand = testingSetVariant(of: command) - print("Retrying with testing device set: \(testingSetCommand)") + print("Retrying with testing device set: \(testingSetCommand.joined(separator: " "))") result = launch(command: testingSetCommand) if let simulatorId, !result.contains("Invalid device") { remember(testingSetSimulatorId: simulatorId) @@ -204,8 +202,11 @@ class ServerManager { return result } - private func testingSetVariant(of command: String) -> String { - command.replacingOccurrences(of: "xcrun simctl ", with: "xcrun simctl --set testing ") + private func testingSetVariant(of command: [String]) -> [String] { + guard command.count >= 2, command[0] == "xcrun", command[1] == "simctl" else { return command } + var variant = command + variant.insert(contentsOf: ["--set", "testing"], at: 2) + return variant } private func isInTestingSet(_ simulatorId: String) -> Bool { @@ -220,11 +221,12 @@ class ServerManager { testingSetSimulatorIds.insert(testingSetSimulatorId) } - private func launch(command: String) -> String { + private func launch(command: [String]) -> String { let pipe = Pipe() let task = Process() - task.launchPath = "/bin/sh" - task.arguments = ["-c", String(format: "%@", command)] + // argv execution — request values never pass through a shell, so they can't inject commands + task.launchPath = "/usr/bin/env" + task.arguments = command task.standardOutput = pipe task.standardError = pipe let file = pipe.fileHandleForReading From c2e19233c85821d0a5fc80159d32d8cce8eec165 Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Mon, 7 Sep 2026 09:56:14 +0300 Subject: [PATCH 4/9] Address review: call xcrun by absolute path /usr/bin/env resolved xcrun through the server's PATH, which can break under a minimal launch environment; simctl commands now execute /usr/bin/xcrun directly, with env kept as a fallback for anything else. --- Sources/MusselServer/ServerManager.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/MusselServer/ServerManager.swift b/Sources/MusselServer/ServerManager.swift index dedc7b8..c8fd2de 100644 --- a/Sources/MusselServer/ServerManager.swift +++ b/Sources/MusselServer/ServerManager.swift @@ -224,9 +224,15 @@ class ServerManager { private func launch(command: [String]) -> String { let pipe = Pipe() let task = Process() - // argv execution — request values never pass through a shell, so they can't inject commands - task.launchPath = "/usr/bin/env" - task.arguments = command + // argv execution — request values never pass through a shell, so they can't inject + // commands. xcrun is addressed by absolute path so a minimal launch PATH can't break it. + if command.first == "xcrun" { + task.launchPath = "/usr/bin/xcrun" + task.arguments = Array(command.dropFirst()) + } else { + task.launchPath = "/usr/bin/env" + task.arguments = command + } task.standardOutput = pipe task.standardError = pipe let file = pipe.fileHandleForReading From aee7256858a0d688ec8559879d95d2ab59aa8f5c Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Mon, 7 Sep 2026 10:14:02 +0300 Subject: [PATCH 5/9] Address review: surface non-zero exit status when a command has no output --- Sources/MusselServer/ServerManager.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/MusselServer/ServerManager.swift b/Sources/MusselServer/ServerManager.swift index c8fd2de..18848c1 100644 --- a/Sources/MusselServer/ServerManager.swift +++ b/Sources/MusselServer/ServerManager.swift @@ -237,13 +237,19 @@ class ServerManager { task.standardError = pipe let file = pipe.fileHandleForReading task.launch() - if let result = NSString(data: file.readDataToEndOfFile(), encoding: String.Encoding.utf8.rawValue) { - print(result as String) - return result as String - } else { + let output = NSString(data: file.readDataToEndOfFile(), encoding: String.Encoding.utf8.rawValue) as String? + task.waitUntilExit() + + guard var result = output else { let errorString = "--- Error running command - Unable to initialize string from file data ---" print(errorString) return errorString } + // A failing command with empty output would otherwise look like success to callers + if task.terminationStatus != 0, result.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + result = "--- Command failed with exit status \(task.terminationStatus) and no output ---" + } + print(result) + return result } } From 5e89ff5bba8ec8604d2a82396d2002178acea377 Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Mon, 7 Sep 2026 10:21:12 +0300 Subject: [PATCH 6/9] Address review: modern Process API with startup error handling --- Sources/MusselServer/ServerManager.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/MusselServer/ServerManager.swift b/Sources/MusselServer/ServerManager.swift index 18848c1..5dc3e17 100644 --- a/Sources/MusselServer/ServerManager.swift +++ b/Sources/MusselServer/ServerManager.swift @@ -227,16 +227,22 @@ class ServerManager { // argv execution — request values never pass through a shell, so they can't inject // commands. xcrun is addressed by absolute path so a minimal launch PATH can't break it. if command.first == "xcrun" { - task.launchPath = "/usr/bin/xcrun" + task.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") task.arguments = Array(command.dropFirst()) } else { - task.launchPath = "/usr/bin/env" + task.executableURL = URL(fileURLWithPath: "/usr/bin/env") task.arguments = command } task.standardOutput = pipe task.standardError = pipe let file = pipe.fileHandleForReading - task.launch() + do { + try task.run() + } catch { + let errorString = "--- Failed to start command: \(error.localizedDescription) ---" + print(errorString) + return errorString + } let output = NSString(data: file.readDataToEndOfFile(), encoding: String.Encoding.utf8.rawValue) as String? task.waitUntilExit() From 2e5599e31838ee21a5c97731025cb9ec6d7e4dbb Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Mon, 7 Sep 2026 10:27:31 +0300 Subject: [PATCH 7/9] Address review: never insert a duplicate --set into simctl argv --- Sources/MusselServer/ServerManager.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/MusselServer/ServerManager.swift b/Sources/MusselServer/ServerManager.swift index 5dc3e17..80d31bf 100644 --- a/Sources/MusselServer/ServerManager.swift +++ b/Sources/MusselServer/ServerManager.swift @@ -203,7 +203,9 @@ class ServerManager { } private func testingSetVariant(of command: [String]) -> [String] { - guard command.count >= 2, command[0] == "xcrun", command[1] == "simctl" else { return command } + guard command.count >= 2, command[0] == "xcrun", command[1] == "simctl", + !command.contains("--set") + else { return command } var variant = command variant.insert(contentsOf: ["--set", "testing"], at: 2) return variant From 2cf70e6296c99be908106f2e2d515995c3515b80 Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Mon, 7 Sep 2026 10:34:42 +0300 Subject: [PATCH 8/9] Address review: cap the device-set cache, surface non-zero exit status always --- Sources/MusselServer/ServerManager.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/MusselServer/ServerManager.swift b/Sources/MusselServer/ServerManager.swift index 80d31bf..61fbc17 100644 --- a/Sources/MusselServer/ServerManager.swift +++ b/Sources/MusselServer/ServerManager.swift @@ -220,6 +220,11 @@ class ServerManager { private func remember(testingSetSimulatorId: String) { testingSetLock.lock() defer { testingSetLock.unlock() } + // Ephemeral clone UDIDs accumulate in long-lived servers; a stale entry only costs + // one extra retry, so resetting is safe. + if testingSetSimulatorIds.count >= 512 { + testingSetSimulatorIds.removeAll() + } testingSetSimulatorIds.insert(testingSetSimulatorId) } @@ -253,9 +258,11 @@ class ServerManager { print(errorString) return errorString } - // A failing command with empty output would otherwise look like success to callers - if task.terminationStatus != 0, result.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - result = "--- Command failed with exit status \(task.terminationStatus) and no output ---" + // A failing command would otherwise look like success to callers that don't parse the text + if task.terminationStatus != 0 { + let failure = "--- Command failed with exit status \(task.terminationStatus) ---" + let hasOutput = !result.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + result = hasOutput ? "\(result)\n\(failure)" : failure } print(result) return result From 5c1c1ee4da9fa57ea91c21c54b0b4c38662a28a0 Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Mon, 7 Sep 2026 10:58:48 +0300 Subject: [PATCH 9/9] Address review: positional device-set detection --- Sources/MusselServer/ServerManager.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/MusselServer/ServerManager.swift b/Sources/MusselServer/ServerManager.swift index 61fbc17..c4b784c 100644 --- a/Sources/MusselServer/ServerManager.swift +++ b/Sources/MusselServer/ServerManager.swift @@ -191,7 +191,7 @@ class ServerManager { // that plain simctl cannot see ("Invalid device") — retry against the testing set // and remember the simulator so its next commands skip the failing attempt. if result.contains("Invalid device"), - !effectiveCommand.contains("--set") { + !usesDeviceSet(effectiveCommand) { let testingSetCommand = testingSetVariant(of: command) print("Retrying with testing device set: \(testingSetCommand.joined(separator: " "))") result = launch(command: testingSetCommand) @@ -202,9 +202,15 @@ class ServerManager { return result } + // Positional check: a device set can only appear right after `xcrun simctl`, so argument + // VALUES that happen to equal "--set" can't be mistaken for the flag. + private func usesDeviceSet(_ command: [String]) -> Bool { + command.count >= 3 && command[0] == "xcrun" && command[1] == "simctl" && command[2] == "--set" + } + private func testingSetVariant(of command: [String]) -> [String] { guard command.count >= 2, command[0] == "xcrun", command[1] == "simctl", - !command.contains("--set") + !usesDeviceSet(command) else { return command } var variant = command variant.insert(contentsOf: ["--set", "testing"], at: 2)