Skip to content
Merged
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
8 changes: 8 additions & 0 deletions Docs/maintainer-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ relay. The next request for the same workspace waits for this boundary. An actio
with no cancellable Operation can take until natural completion; opaque native
Swift Task handles are not joined by this mechanism.

A successful `RunProject` keeps its launched application's Operation under the
native workspace's ownership rather than waiting for the app to exit. Only a
tracker whose launch-session PID matches the successful output is excluded;
other Operations and failed or cancelled requests still drain. After the native
stream ends, a stopped simulator app without a debugger is resumed only when its
local executable belongs to the launch session's simulator. Physical-device PIDs
are never used for local process signaling.

Shutdown stops admission and awaits owned tasks before closing workspace and
transport resources. In the native host, the main task owns shutdown and runs it
once, for whichever comes first: input end, input or output failure, or a failure
Expand Down
91 changes: 91 additions & 0 deletions Sources/XcodeMCPNativeRuntime/NativeApplicationLaunch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import ABIBridge
import Darwin
import Foundation
import XcodeMCPWire

@safe
struct NativeApplicationLaunch: Sendable {
var processIdentifier: Int32
var debuggerIdentifier: String?
var simulatorIdentifier: UUID?

static func processIdentifier(in outcome: NativeToolOutcome) -> Int32? {
guard case .succeeded(.object(let output)) = outcome,
case .array(let errors)? = output["buildErrors"], errors.isEmpty,
case .number(.int(let value))? = output["processIdentifier"],
let pid = Int32(exactly: value), pid > 0 else { return nil }
return pid
}

func resumeIfStopped() throws {
guard debuggerIdentifier?.isEmpty != false, let simulatorIdentifier,
let process = Self.process(processIdentifier),
process.canResume(in: simulatorIdentifier),
let current = Self.process(processIdentifier), current == process else { return }
if kill(processIdentifier, SIGCONT) != 0, errno != ESRCH {
throw NativeRuntimeError.invocation("Resuming the simulator app (pid \(processIdentifier)) failed: errno \(errno)")
}
}

struct ProcessSnapshot: Equatable, Sendable {
var executable: String
var startedSeconds: Int64
var startedMicroseconds: Int32
var stopped: Bool
var traced: Bool

func canResume(in simulator: UUID) -> Bool {
guard stopped, !traced else { return false }
let parts = URL(fileURLWithPath: executable).standardizedFileURL.pathComponents
guard let index = parts.firstIndex(where: { UUID(uuidString: $0) == simulator }) else { return false }
return Array(parts.dropFirst(index + 1).prefix(4)) == ["data", "Containers", "Bundle", "Application"]
}
}

private static func process(_ pid: Int32) -> ProcessSnapshot? {
var info = unsafe kinfo_proc()
var size = unsafe MemoryLayout<kinfo_proc>.stride
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
guard unsafe sysctl(&name, UInt32(name.count), &info, &size, nil, 0) == 0, size > 0 else { return nil }
var path = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN))
guard unsafe proc_pidpath(pid, &path, UInt32(path.count)) > 0 else { return nil }
return unsafe ProcessSnapshot(executable: String(cString: path),
startedSeconds: Int64(info.kp_proc.p_starttime.tv_sec),
startedMicroseconds: info.kp_proc.p_starttime.tv_usec,
stopped: Int32(info.kp_proc.p_stat) == SSTOP, traced: info.kp_proc.p_flag & P_TRACED != 0)
}
}

// Xcode assigns the launch session after creating its tracker. Read it only
// after the native producer has drained, on the actor that owns Xcode's models.
@safe
final class NativeExecutionTracker: @unchecked Sendable {
private let tracker: AnyObject

init(_ tracker: AnyObject) { self.tracker = tracker }

@MainActor func launch() throws -> NativeApplicationLaunch? {
guard let session = try Self.object(tracker, "launchSession") else { return nil }
let pid = try ABIRuntime.shared.object(session).resolveAndWait(.objc(.method("runnablePID", as: (() -> Int32).self)))
let processIdentifier = try unsafe pid.unsafeInvoke()
guard processIdentifier > 0 else { return nil }
let parameters = try Self.object(session, "launchParameters")
let debugger = try parameters.flatMap { try Self.object($0, "selectedDebuggerIdentifier") as? String }
var simulator: UUID?
if let destination = try Self.object(session, "runDestination"),
let sdk = try Self.object(destination, "targetSDK"),
let name = try Self.object(sdk, "canonicalName") as? String,
["iphonesimulator", "appletvsimulator", "watchsimulator", "xrsimulator"].contains(where: name.hasPrefix),
let device = try Self.object(destination, "targetDevice"),
let identifier = try Self.object(device, "identifier") as? String {
simulator = UUID(uuidString: identifier)
}
return NativeApplicationLaunch(processIdentifier: processIdentifier,
debuggerIdentifier: debugger, simulatorIdentifier: simulator)
}

@MainActor private static func object(_ receiver: AnyObject, _ selector: String) throws -> AnyObject? {
let get = try ABIRuntime.shared.object(receiver).resolveAndWait(.objc(.method(selector, as: (() -> AnyObject?).self)))
return try unsafe get.unsafeInvoke()
}
}
50 changes: 42 additions & 8 deletions Sources/XcodeMCPNativeRuntime/NativeExecutionOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ final class NativeExecutionOperations {
guard let tracker else { return }
guard let scope = Self.scope else { return }
if let operation = try unsafe callback.operation.unsafeInvoke(on: tracker) as? Operation {
scope.register(operation)
let tracker = NativeExecutionTracker(tracker)
scope.register(operation, launch: { try tracker.launch() })
} else {
scope.recordFailure(NativeRuntimeError.unsupportedContract("Xcode's execution tracker does not contain an Operation"))
}
Expand All @@ -37,17 +38,22 @@ final class NativeExecutionOperations {
// The native Objective-C initializer cannot await an actor. This scope only
// associates its caller's native operations with the existing request owner.
final class Scope: Sendable {
private struct TrackedOperation: Sendable {
var operation: Operation
var launch: @MainActor @Sendable () throws -> NativeApplicationLaunch?
}

private struct State {
var operations: [ObjectIdentifier: Operation] = [:]
var operations: [ObjectIdentifier: TrackedOperation] = [:]
var cancelled = false
var failure: (any Error)?
}

private let state = Mutex(State())

func register(_ operation: Operation) {
func register(_ operation: Operation, launch: @escaping @MainActor @Sendable () throws -> NativeApplicationLaunch? = { nil }) {
let cancelled = state.withLock { state in
state.operations[ObjectIdentifier(operation)] = operation
state.operations[ObjectIdentifier(operation)] = TrackedOperation(operation: operation, launch: launch)
return state.cancelled
}
if cancelled { operation.cancel() }
Expand All @@ -57,25 +63,53 @@ final class NativeExecutionOperations {
let operations = state.withLock { state -> [Operation] in
guard !state.cancelled else { return [] }
state.cancelled = true
return Array(state.operations.values)
return state.operations.values.map(\.operation)
}
for operation in operations { operation.cancel() }
}

// Joining the observed operations does not expose or join private Swift
// producer Tasks retained inside a native AsyncStream.
nonisolated(nonsending) func finish() async throws {
nonisolated(nonsending) func finish(continuingLaunchPID: Int32? = nil) async throws {
var finished: Set<ObjectIdentifier> = []
var continuing: [ObjectIdentifier: Operation] = [:]
while true {
let pending = state.withLock { state in
state.operations.filter { !finished.contains($0.key) }
}
guard !pending.isEmpty else { break }
for (identifier, operation) in pending {
await Self.waitForCompletion(of: operation)
for (identifier, tracked) in pending {
let launch: NativeApplicationLaunch?
do { launch = continuingLaunchPID == nil ? nil : try await tracked.launch() }
catch {
cancel()
recordFailure(error)
launch = nil
}
if let continuingLaunchPID, let launch, launch.processIdentifier == continuingLaunchPID,
!state.withLock({ $0.cancelled }) {
do { try launch.resumeIfStopped() }
catch {
cancel()
recordFailure(error)
}
continuing[identifier] = tracked.operation
} else {
await Self.waitForCompletion(of: tracked.operation)
}
finished.insert(identifier)
}
}
let cancelled = state.withLock { state in
guard !state.cancelled else { return true }
// A completed launch belongs to the workspace. Later cancellation
// of its finished request must not stop it without draining it.
for identifier in continuing.keys { state.operations[identifier] = nil }
return false
}
if cancelled {
for operation in continuing.values { await Self.waitForCompletion(of: operation) }
}
try checkFailure()
}

Expand Down
20 changes: 13 additions & 7 deletions Sources/XcodeMCPNativeRuntime/NativeMCPSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,28 +211,33 @@ package actor NativeMCPSession {
private func executeTool(params: JSONValue?, name: String, arguments: [String: JSONValue],
context: NativeToolContext) async throws -> JSONValue {
do {
let result = try await consumeTool(params: params, name: name, arguments: arguments, context: context)
do { try await backend.finishExecution(context: context) }
let completion = try await consumeTool(params: params, name: name, arguments: arguments, context: context)
do { try await backend.finishExecution(context: context, outcome: completion.outcome) }
catch { await NativeErrorPresentation.report(error as NSError) }
return result
return completion.result
} catch {
let original = error
do { try await backend.finishExecution(context: context) }
do { try await backend.finishExecution(context: context, outcome: .failed) }
catch {
throw NativeRuntimeError.invocation("\(original); workspace cleanup failed: \(error)")
}
throw original
}
}

private struct ToolCompletion {
var result: JSONValue
var outcome: NativeToolOutcome
}

private func consumeTool(params: JSONValue?, name: String, arguments: [String: JSONValue],
context: NativeToolContext) async throws -> JSONValue {
context: NativeToolContext) async throws -> ToolCompletion {
let updates: AsyncStream<Data>
do {
updates = try await backend.execute(name, arguments: arguments, context: context)
} catch let error as NativeToolExecutionError {
try Task.checkCancellation()
return try toolResult(.string(error.message), isError: true)
return try ToolCompletion(result: toolResult(.string(error.message), isError: true), outcome: .failed)
}
var completed: JSONValue?
var output: JSONValue?
Expand Down Expand Up @@ -260,6 +265,7 @@ package actor NativeMCPSession {
output = result
completed = try toolResult(result, isError: false)
case "error":
output = nil
completed = try toolResult(fields["data"] ?? .string("Native tool failed"), isError: true)
default:
throw NativeRuntimeError.unsupportedContract("Unsupported native event '\(type)'")
Expand All @@ -271,7 +277,7 @@ package actor NativeMCPSession {
}
let metadata = await backend.resultMetadata(for: name, output: output, context: context)
if !metadata.isEmpty { result["_meta"] = .object(metadata) }
return .object(result)
return ToolCompletion(result: .object(result), outcome: output.map(NativeToolOutcome.succeeded) ?? .failed)
}

private func withOrigin(_ fields: [String: JSONValue]) async -> JSONValue {
Expand Down
15 changes: 11 additions & 4 deletions Sources/XcodeMCPNativeRuntime/NativeToolBackend.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import Foundation
import XcodeMCPWire

package enum NativeToolOutcome: Sendable {
case succeeded(JSONValue)
case failed
}

package struct NativeToolContext: Sendable {
package let artifactsDirectory: URL
package let artifactsRoot: URL
Expand Down Expand Up @@ -31,7 +36,7 @@ package protocol NativeToolBackend: AnyObject, Sendable {
func listTools() async throws -> [NativeTool]
func execute(_ name: String, arguments: [String: JSONValue], context: NativeToolContext) async throws -> AsyncStream<Data>
func executionResource(for name: String, arguments: [String: JSONValue]) async throws -> NativeExecutionResource
func finishExecution(context: NativeToolContext) async throws
func finishExecution(context: NativeToolContext, outcome: NativeToolOutcome) async throws
/// `_meta` fields for the result of `name`, which completed with `output`.
func resultMetadata(for name: String, output: JSONValue?, context: NativeToolContext) async -> [String: JSONValue]
/// A failure that leaves this process unable to serve further requests.
Expand All @@ -48,7 +53,7 @@ extension NativeToolBackend {
package func executionResource(for name: String, arguments: [String: JSONValue]) async throws -> NativeExecutionResource {
NativeExecutionResource()
}
package func finishExecution(context: NativeToolContext) async throws {}
package func finishExecution(context: NativeToolContext, outcome: NativeToolOutcome) async throws {}
package func resultMetadata(for name: String, output: JSONValue?,
context: NativeToolContext) async -> [String: JSONValue] { [:] }
}
Expand Down Expand Up @@ -321,7 +326,7 @@ package final class NativeXcodeBackend: NativeToolBackend {
previewCorrection.cancelPendingOperations()
}

package func finishExecution(context: NativeToolContext) async throws {
package func finishExecution(context: NativeToolContext, outcome: NativeToolOutcome) async throws {
defer {
workspaces.endExecution(request: context.artifactsDirectory)
if let request = requestWorkspaces.removeValue(forKey: context.artifactsDirectory), !request.finished {
Expand All @@ -334,7 +339,9 @@ package final class NativeXcodeBackend: NativeToolBackend {
await crashCorrection.finishExecution(request: context.artifactsDirectory)
await bridge.finishExecution(request: context.artifactsDirectory)
var failures: [String] = []
do { try await context.executionScope.finish() }
let launchedPID = requestWorkspaces[context.artifactsDirectory]?.tool == "RunProject"
? NativeApplicationLaunch.processIdentifier(in: outcome) : nil
do { try await context.executionScope.finish(continuingLaunchPID: launchedPID) }
catch { failures.append(String(describing: error)) }
do { try executionOperations?.checkFailure() }
catch { failures.append(String(describing: error)) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation
import Testing
@testable import XcodeMCPNativeRuntime
import XcodeMCPWire

@Suite
struct NativeApplicationLaunchTests {
@Test(arguments: [false, true]) func onlyASuccessfulLaunchCanContinue(failed: Bool) {
let output = JSONValue.object([
"buildErrors": .array(failed ? [.string("build failed")] : []),
"processIdentifier": .number(.int(42)),
])
#expect(NativeApplicationLaunch.processIdentifier(in: .succeeded(output)) == (failed ? nil : 42))
#expect(NativeApplicationLaunch.processIdentifier(in: .failed) == nil)
}

@Test(arguments: [Int64(0), -1, Int64(Int32.max) + 1]) func invalidLaunchPIDsDoNotChangeCleanup(pid: Int64) {
#expect(NativeApplicationLaunch.processIdentifier(in: .succeeded(.object([
"buildErrors": .array([]), "processIdentifier": .number(.int(pid)),
]))) == nil)
}

@Test func onlyAStoppedUntracedAppInTheSelectedSimulatorCanResume() {
let device = UUID()
var process = NativeApplicationLaunch.ProcessSnapshot(
executable: "/a/custom/device/set/\(device)/data/Containers/Bundle/Application/\(UUID())/Fixture.app/Fixture",
startedSeconds: 10, startedMicroseconds: 0, stopped: true, traced: false)
#expect(process.canResume(in: device))
#expect(!process.canResume(in: UUID()))
process.traced = true
#expect(!process.canResume(in: device))
process.traced = false
process.stopped = false
#expect(!process.canResume(in: device))
process.stopped = true
process.executable = "/Applications/Fixture.app/Contents/MacOS/Fixture"
#expect(!process.canResume(in: device))
}
}
Loading