Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import PackageDescription

let package = Package(
name: "XSim",
platforms: [.macOS(.v10_15)],
platforms: [.macOS(.v12)],
products: [
.executable(name: "xsim", targets: ["XSim"]),
],
Expand Down
129 changes: 65 additions & 64 deletions Sources/XSimCLI/Services/SimulatorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Dispatch
import Foundation

/// Service class for managing iOS Simulator operations through simctl
class SimulatorService {
final class SimulatorService: Sendable {
// MARK: - Private Properties

private let xcrunPath: String
Expand All @@ -17,21 +17,39 @@ class SimulatorService {
/// - Returns: The command output as Data
/// - Throws: SimulatorError if the command fails
private func executeSimctlCommand(arguments: [String], requiresJSON: Bool = false, timeoutSeconds: TimeInterval? = nil) throws -> Data {
let semaphore = DispatchSemaphore(value: 0)
var result: Result<Data, Error>!
Task.detached(priority: .userInitiated) { [arguments, requiresJSON, timeoutSeconds] in
do {
let data = try await self.executeSimctlCommandAsync(
arguments: arguments,
requiresJSON: requiresJSON,
timeoutSeconds: timeoutSeconds,
)
result = .success(data)
} catch {
result = .failure(error)
}
semaphore.signal()
}
semaphore.wait()
return try result.get()
}

/// Async Swift Concurrency implementation for simctl execution.
@discardableResult
private func executeSimctlCommandAsync(arguments: [String], requiresJSON: Bool = false, timeoutSeconds: TimeInterval? = nil) async throws -> Data {
let process = Process()
process.executableURL = URL(fileURLWithPath: xcrunPath)

var fullArguments = ["simctl"]
fullArguments.append(contentsOf: arguments)

// Add JSON output flag if required.
// simctl expects --json as an option to the 'list' subcommand, e.g.:
// xcrun simctl list --json devices
// not before the subcommand. Place it right after 'list' when present.
// Add JSON output flag if required right after 'list'
if requiresJSON, !arguments.contains("--json") {
if let listIndex = fullArguments.firstIndex(of: "list") {
fullArguments.insert("--json", at: listIndex + 1)
} else {
// Fallback: if 'list' isn't present for some reason, append at end
fullArguments.append("--json")
}
}
Expand All @@ -41,81 +59,71 @@ class SimulatorService {
// Debug
Env.debug("Executing command: \(xcrunPath) \(fullArguments.joined(separator: " "))")

// Pipes
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe

let outHandle = outputPipe.fileHandleForReading
let errHandle = errorPipe.fileHandleForReading

// Drain stdout/stderr on background threads using blocking reads.
// This avoids any dependency on run loops or readability handlers and
// prevents deadlocks when simctl outputs more than the pipe buffer.
var stdoutData = Data()
var stderrData = Data()
let ioLock = NSLock()
let readGroup = DispatchGroup()
readGroup.enter()
DispatchQueue.global(qos: .userInitiated).async {
let data = outHandle.readDataToEndOfFile()
ioLock.lock(); stdoutData = data; ioLock.unlock()
readGroup.leave()
}
readGroup.enter()
DispatchQueue.global(qos: .userInitiated).async {
let data = errHandle.readDataToEndOfFile()
ioLock.lock(); stderrData = data; ioLock.unlock()
readGroup.leave()
}

do {
try process.run()

// Drain both pipes using async read APIs (macOS 12+ guaranteed)
let stdoutTask = Task(priority: .userInitiated) { () -> Data in
await (try? outHandle.readToEnd()) ?? Data()
}
let stderrTask = Task(priority: .userInitiated) { () -> Data in
await (try? errHandle.readToEnd()) ?? Data()
}

// Await process exit or timeout
func waitForExitAsync(_ proc: Process) async {
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
DispatchQueue.global(qos: .userInitiated).async {
proc.waitUntilExit()
cont.resume()
}
}
}

var didTimeout = false
if let timeout = timeoutSeconds {
// Wait for process exit with timeout
let exitGroup = DispatchGroup()
exitGroup.enter()
DispatchQueue.global(qos: .userInitiated).async {
process.waitUntilExit()
exitGroup.leave()
let timeoutNanos = UInt64(max(0, timeout) * 1_000_000_000)

// Race process exit vs timeout sleep
let first = await withTaskGroup(of: Int.self) { group -> Int in
group.addTask { await waitForExitAsync(process); return 0 }
group.addTask { try? await Task.sleep(nanoseconds: timeoutNanos); return 1 }
return await group.next() ?? 0
Comment on lines +94 to +98

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reinstate non-blocking timeout race for simctl process

The new timeout logic uses withTaskGroup to race waitForExitAsync against Task.sleep, but the call returns only after all child tasks complete. Because waitForExitAsync never observes cancellation, the group will wait for the simulator process to exit even when the sleep finishes first, so the timeout path cannot fire until the command ends naturally. As a result, hung simctl invocations still block indefinitely and the termination/kill logic is never reached in time.

Useful? React with 👍 / 👎.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex fix comments

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Testing

View task →

}
let result = exitGroup.wait(timeout: .now() + timeout)
if result == .timedOut {

if first == 1 {
didTimeout = true
// Ask the process to terminate, then escalate if needed.
if process.isRunning { process.terminate() }
// Give it a brief grace period to exit and close pipes.
let graceDeadline = DispatchTime.now() + 1.5
if exitGroup.wait(timeout: graceDeadline) == .timedOut {
// Force kill if still running to unblock any readers.
try? await Task.sleep(nanoseconds: 1_500_000_000)
if process.isRunning {
let pid = process.processIdentifier
if pid > 0 { _ = Darwin.kill(pid, SIGKILL) }
_ = exitGroup.wait(timeout: .now() + 0.5)
}
// Close pipe readers to unblock background readers if needed.
outHandle.closeFile()
errHandle.closeFile()
} else {
// Ensure output is fully read
readGroup.wait()
}
} else {
// No timeout: wait to exit and drain all output
process.waitUntilExit()
readGroup.wait()
await waitForExitAsync(process)
}

let outData = await stdoutTask.value
let errData = await stderrTask.value

if didTimeout {
Env.debug("simctl timed out after \(timeoutSeconds ?? 0)s. args=\(fullArguments.joined(separator: " "))")
throw SimulatorError.operationTimeout
}

if process.terminationStatus != 0 {
let stderrText = String(data: stderrData, encoding: .utf8) ?? ""
let stdoutText = String(data: stdoutData, encoding: .utf8) ?? ""
let stderrText = String(data: errData, encoding: .utf8) ?? ""
let stdoutText = String(data: outData, encoding: .utf8) ?? ""

// Many simctl errors print to stdout instead of stderr; prefer stderr, fall back to stdout.
let primaryMessage: String = {
Expand All @@ -134,21 +142,14 @@ class SimulatorService {
// If JSON was requested and the tool likely doesn't support it, probe without --json
if requiresJSON {
let lower = primaryMessage.lowercased()
let mentionsJSONUnsupported = lower.contains("unrecognized") || lower.contains("unknown option") || lower
.contains("--json")
let mentionsJSONUnsupported = lower.contains("unrecognized") || lower.contains("unknown option") || lower.contains("--json")
if mentionsJSONUnsupported {
if let probe = try? executeSimctlCommand(arguments: arguments, requiresJSON: false),
let probePreview = String(data: probe.prefix(200), encoding: .utf8)
{
throw SimulatorError
.simctlCommandFailed(
"simctl's JSON output may not be supported by your Xcode. Please update Xcode (Xcode 9+). Raw output preview: \(probePreview)...",
)
throw SimulatorError.simctlCommandFailed("simctl's JSON output may not be supported by your Xcode. Please update Xcode (Xcode 9+). Raw output preview: \(probePreview)...")
} else {
throw SimulatorError
.simctlCommandFailed(
"simctl's JSON output may not be supported by your Xcode. Please update Xcode (Xcode 9+). Error: \(primaryMessage)",
)
throw SimulatorError.simctlCommandFailed("simctl's JSON output may not be supported by your Xcode. Please update Xcode (Xcode 9+). Error: \(primaryMessage)")
}
}
}
Expand All @@ -157,11 +158,11 @@ class SimulatorService {
}

if requiresJSON {
let preview = String(data: stdoutData.prefix(200), encoding: .utf8) ?? "<non-utf8>"
Env.debug("JSON bytes=\(stdoutData.count). preview=\(preview)")
let preview = String(data: outData.prefix(200), encoding: .utf8) ?? "<non-utf8>"
Env.debug("JSON bytes=\(outData.count). preview=\(preview)")
}

return stdoutData
return outData
} catch let error as SimulatorError {
throw error
} catch {
Expand Down
Loading