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
1 change: 1 addition & 0 deletions Docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ flowchart LR
- Process-bound routing, upstream readiness, DocumentationSearch discovery, startup summaries, and permission-dialog automation read the same cached snapshot. Permission automation covers the known helper applications exposed by `NSWorkspace`; it does not claim an inventory of every OS process.
- The runtime does not run `pgrep`, enumerate all PIDs, or periodically rescan Xcode membership. Inventory membership changes only from the initial/KVO snapshots. Recovery may re-run route reconciliation against the monitor's cached snapshot, but it never re-reads OS process inventory.
- Every compatible Xcode PID in the cached inventory owns an independent route. As soon as membership is established, the runtime starts the route's primary bridge and sends that route's protocol `initialize` without waiting for another Xcode's response, permission decision, initialized notification, or catalog. `CanonicalHandshakeState` accepts concurrent proof-bound participants; the first participant to complete its initialized-notification and health commit publishes the canonical semantic result, and results that differ only in `serverInfo` join it. Each joined route loads its own catalog, after which workspace and tab ownership select the matching PID. A meaningful result mismatch terminates the current activation attempt for that route; the route may retry after its cooldown and must pass compatibility again.
- Client-facing `tools/list` availability does not wait for every route catalog to converge. It may return the union of the currently committed route catalogs as an available, non-canonical surface while missing routes continue under their activation and background-refresh owners. The canonical catalog is published only when every currently required route catalog is present; later route commits publish `notifications/tools/list_changed` when the client-visible surface changes.
- Canonical initialize support is bound to the exact upstream topology proof, including slot generation. Raw compatible initialize evidence is retained separately from current health eligibility. When the publishing proof is quarantined or exits, the exposed source and raw result rebind to an eligible compatible survivor. If every raw supporter is quarantined, the initialize result is hidden from clients while the retained semantic baseline still rejects incompatible offers. Only a validated health probe or validated `tools/list` response restores eligibility; ordinary request success does not. Detaching the last raw supporter clears the semantic baseline, and a delayed clear from an old slot generation cannot remove its replacement.
- Quarantine, detach, and slot replacement evict the catalog and abandon catalog-attempt resources owned by the affected exact proof. A recovered proof can re-expose its retained initialize result after validation, but it is not routable for tools until a fresh `tools/list` load commits a new catalog.
- If a downstream `initialize` is pending while only quarantined raw supporters remain, the initialize owner arms one generation-fenced timer for the earliest exact proof and quarantine deadline. The callback validates the topology proof, health-probe generation, deadline, and pending waiter before probing; failure re-arms from the current health state, while publication, removal of the last waiter, debug reset, and shutdown cancel the timer. This recovery reads existing topology and never rescans Xcode processes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1507,7 +1507,8 @@ final class ProcessControlPlaneAuthority: Sendable {
func beginCatalogAttempt(
routeID: ProcessRouteID,
preferredUpstreamProof: UpstreamTopologyProof,
nowUptimeNanoseconds: UInt64
nowUptimeNanoseconds: UInt64,
allowsConcurrentLoad: Bool = true
) -> (CatalogLease, ProcessControlPlaneTransition)? {
state.withLockedValue { state in
state.nowUptimeNs = max(state.nowUptimeNs, nowUptimeNanoseconds)
Expand All @@ -1518,6 +1519,9 @@ final class ProcessControlPlaneAuthority: Sendable {
) else { return nil }
if var attempt = record.attempt,
[.pending, .attaching, .initialized, .loadingCatalog].contains(attempt.phase) {
guard allowsConcurrentLoad || attempt.loads.isEmpty else {
return nil
}
let effects = attempt.readinessToken.map {
[ProcessControlPlaneEffect.cancelReadinessWaiter($0)]
} ?? []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,21 @@ extension RuntimeCoordinator {
guard uncachedExposures.isEmpty == false else {
throw UpstreamSlotScheduler.AcquisitionError.unavailable
}
let uncachedProcessIDs = Set(uncachedExposures.map(\.route.target.processID))
if let surface = currentSurface,
let sourceProof = surface.sourceProof {
refreshMissingProcessToolsCatalogsIfNeeded(
reason: "foreground_partial_catalog",
processIDs: uncachedProcessIDs
)
return CanonicalToolsCatalogLoadResult(
rawResult: surface.rawResult,
sourceProof: sourceProof,
durationMilliseconds: elapsedMilliseconds(
sinceUptimeNanoseconds: startedAt
)
)
}
let routes = uncachedExposures.compactMap { exposure -> AvailableToolsCatalogRoute? in
guard let preferred = exposure.usableUpstreamIDs.first,
let preferredProof = upstreamTopology.operationLease(for: preferred)?.proof,
Expand All @@ -254,14 +269,19 @@ extension RuntimeCoordinator {
throw UpstreamSlotScheduler.AcquisitionError.unavailable
}

return try await loadAvailableToolsCatalogsInBatch(
let result = try await loadAvailableToolsCatalogsInBatch(
routes,
requestTimeout: requestTimeout,
deadlineUptimeNs: deadlineUptimeNs,
startedAt: startedAt,
exposedProcessIDs: exposedProcessIDs,
returnAfterFirstSuccess: false
returnAfterFirstSuccess: true
)
refreshMissingProcessToolsCatalogsIfNeeded(
reason: "foreground_first_catalog",
processIDs: uncachedProcessIDs
)
return result
}

private func loadAvailableToolsCatalogsInBatch(
Expand Down Expand Up @@ -439,7 +459,8 @@ extension RuntimeCoordinator {
let preferredProof = upstreamTopology.operationLease(for: preferred)?.proof,
let (lease, transition) = beginProcessCatalogAttemptIfRunning(
routeID: exposure.route.id,
preferredUpstreamProof: preferredProof
preferredUpstreamProof: preferredProof,
allowsConcurrentLoad: false
) else { return nil }
applyProcessControlPlaneTransition(transition)
return AvailableToolsCatalogRoute(
Expand Down Expand Up @@ -480,15 +501,17 @@ extension RuntimeCoordinator {

func beginProcessCatalogAttemptIfRunning(
routeID: ProcessRouteID,
preferredUpstreamProof: UpstreamTopologyProof
preferredUpstreamProof: UpstreamTopologyProof,
allowsConcurrentLoad: Bool = true
) -> (CatalogLease, ProcessControlPlaneTransition)? {
let nowUptimeNanoseconds = nowUptimeNanoseconds()
var attempt: (CatalogLease, ProcessControlPlaneTransition)?
guard initializeManager.performIfRunning({
attempt = processControlPlane.beginCatalogAttempt(
routeID: routeID,
preferredUpstreamProof: preferredUpstreamProof,
nowUptimeNanoseconds: nowUptimeNanoseconds
nowUptimeNanoseconds: nowUptimeNanoseconds,
allowsConcurrentLoad: allowsConcurrentLoad
)
}) else {
return nil
Expand Down
27 changes: 27 additions & 0 deletions Tests/XcodeMCPProxyRuntimeTests/ControlPlaneAuthorityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,33 @@ struct ControlPlaneAuthorityTests {
#expect(toolNames(authority.canonicalToolsCatalogRaw()) == ["Foreground"])
}

@Test func backgroundCatalogAdmissionCoalescesWhileForegroundCanJoin() throws {
let target = xcodeProcessTarget(processID: 41050, xcodeVersion: "27.0")
let authority = makeAuthority([(target, [0])])
let route = try #require(authority.route(forProcessID: target.processID))
let (background, _) = try #require(authority.beginCatalogAttempt(
routeID: route.id,
preferredUpstreamProof: testTopologyProof(0),
nowUptimeNanoseconds: 1,
allowsConcurrentLoad: false
))

#expect(authority.beginCatalogAttempt(
routeID: route.id,
preferredUpstreamProof: testTopologyProof(0),
nowUptimeNanoseconds: 2,
allowsConcurrentLoad: false
) == nil)

let (foreground, _) = try #require(authority.beginCatalogAttempt(
routeID: route.id,
preferredUpstreamProof: testTopologyProof(0),
nowUptimeNanoseconds: 3
))
#expect(foreground.attempt == background.attempt)
#expect(foreground != background)
}

@Test func catalogCommitUsesActualFallbackResponseProof() throws {
let group = borrowSharedTestEventLoopGroup()
defer { shutdownAndWait(group) }
Expand Down
Loading