diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b57bad5c..701f541d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,10 +44,17 @@ jobs: - name: Test Gesture Capture Models run: bash Scripts/test-gesture-models.sh + - name: Test MessageAttribute + run: bash Scripts/test-message-attribute.sh + - name: Test Activation Package working-directory: Packages/LookInsideActivation run: swift test + - name: Test Host Core Package + working-directory: Packages/LookInsideHostCore + run: swift test + - name: Build LookInside run: | xcodebuild \ diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index f6531b67..3b944ea1 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -184,6 +184,7 @@ MCPBridge/MCPBridgeLiveDocumentLookup.swift, MCPBridge/MCPBridgeModificationService.swift, MCPBridge/MCPBridgeObjectIdentifierList.swift, + MCPBridge/MCPBridgeRecoveryHint.swift, MCPBridge/MCPBridgeRefreshService.swift, MCPBridge/MCPBridgeScreenshotService.swift, MCPBridge/MCPBridgeSearchQuery.swift, diff --git a/LookInside/Base/Message/MessageAttribute.swift b/LookInside/Base/Message/MessageAttribute.swift index 27b31b2a..3933311f 100644 --- a/LookInside/Base/Message/MessageAttribute.swift +++ b/LookInside/Base/Message/MessageAttribute.swift @@ -63,9 +63,27 @@ class MessageAttribute: NSObject { } } + /// Identifies a subscription by object identity, for the duplicate check. + /// An identity outlives its object, so a hit only counts when the stored + /// subscriber still points at the very same objects. + private struct SubscriptionKey: Hashable { + let target: ObjectIdentifier + let action: Selector + let relatedObject: ObjectIdentifier? + } + private(set) var currentValue: Any? + /// In subscription order, which is the order actions are sent in. private var subscribers: [Subscriber] = [] + /// The duplicate check: a linear scan of `subscribers` made subscribing n + /// targets O(n²), and the 3D preview subscribes each of its nodes to three + /// shared preference attributes, so opening a 6,000-node document spent + /// seconds in it. + private var subscribersByKey: [SubscriptionKey: Subscriber] = [:] + /// Subscriptions added since subscribers whose target went away were last + /// dropped. + private var subscriptionsSincePruning = 0 override init() { super.init() @@ -91,7 +109,7 @@ class MessageAttribute: NSObject { currentValue = value let ignored = ignoreSubscriber as AnyObject? - subscribers.removeAll { $0.target == nil } + pruneSubscribersWhoseTargetWentAway() for subscriber in subscribers { guard let target = subscriber.target, target !== ignored else { continue @@ -117,10 +135,21 @@ class MessageAttribute: NSObject { return } let related = relatedObject as AnyObject? - if subscribers.contains(where: { $0.matches(target: target, action: action, relatedObject: related) }) { + let key = SubscriptionKey(target: ObjectIdentifier(target), action: action, relatedObject: related.map { ObjectIdentifier($0) }) + if let existing = subscribersByKey[key], existing.matches(target: target, action: action, relatedObject: related) { return } - subscribers.append(Subscriber(target: target, action: action, relatedObject: related)) + let subscriber = Subscriber(target: target, action: action, relatedObject: related) + subscribers.append(subscriber) + subscribersByKey[key] = subscriber + + // Preview nodes go away with their document while the preference + // attributes they subscribed to live on, and only a value change used + // to drop them. + subscriptionsSincePruning += 1 + if subscriptionsSincePruning >= max(subscribers.count / 2, 1024) { + pruneSubscribersWhoseTargetWentAway() + } if sendAtOnce { let params = MessageActionParameters() @@ -129,6 +158,15 @@ class MessageAttribute: NSObject { NSApp.sendAction(action, to: target, from: params) } } + + private func pruneSubscribersWhoseTargetWentAway() { + subscriptionsSincePruning = 0 + guard subscribers.contains(where: { $0.target == nil }) else { + return + } + subscribers.removeAll { $0.target == nil } + subscribersByKey = subscribersByKey.filter { $0.value.target != nil } + } } final class DoubleMessageAttribute: MessageAttribute { diff --git a/LookInside/Connection/ConnectionManager.swift b/LookInside/Connection/ConnectionManager.swift index 4dfca6ee..3847b189 100644 --- a/LookInside/Connection/ConnectionManager.swift +++ b/LookInside/Connection/ConnectionManager.swift @@ -35,8 +35,9 @@ struct ConnectionPush { /// Connection state the manager keeps for one channel. @MainActor final class ServerChannelConnectionState { - /// Requests sent on this channel whose responses have not all arrived. - var activeRequests: [ConnectionRequest] = [] + /// Requests sent on this channel whose responses have not all arrived: + /// one of a type at a time, pings excepted. + let activeRequests = ActiveRequests(typesThatMayOverlap: [UInt32(LookinRequestTypePing)]) let licenseHandshake = LicenseHandshakeGate() /// Identifies this channel to the activation runtime's signing policy, /// which remembers a failed handshake per channel. @@ -50,7 +51,9 @@ final class ServerChannelConnectionState { } func remove(_ request: ConnectionRequest) { - activeRequests.removeAll { $0 === request } + // Every way a request ends goes through here. + request.endSignpostInterval() + activeRequests.remove(request) } } @@ -68,6 +71,7 @@ private final class ConnectionPort { } @Loggable(.internal, subsystem: "com.lookinside.app") +@Signpostable(subsystem: "com.lookinside.app", category: "RefreshPerformance") @MainActor final class ConnectionManager: NSObject { static let shared = ConnectionManager() @@ -253,8 +257,11 @@ final class ConnectionManager: NSObject { /// each response frame, then a failure or a completion. /// /// An older request of the same type still waiting on this channel fails - /// with `LookinErrCode_Discard` when this one is sent. - func request(type: UInt32, data: NSObject?, channel: ServerChannel, sink: ResponseSink) { + /// with `LookinErrCode_Discard` when this one is sent. With + /// `yieldsToRequestInFlight`, this one fails that way instead, unsent, + /// while one of its type is in flight. A request cancelled while its ping + /// runs (`cancelRequest(receivingEventsWith:channel:)`) is not sent. + func request(type: UInt32, data: NSObject?, channel: ServerChannel, yieldsToRequestInFlight: Bool = false, sink: ResponseSink) { // An app in the background keeps its channel but cannot answer; a // long ping timeout would hold up the launch window's app list. let pingTimeout: TimeInterval = type == UInt32(LookinRequestTypeApp) ? 0.5 : 2 @@ -268,11 +275,13 @@ final class ConnectionManager: NSObject { return } let sendRequest = { [weak self] in + guard !sink.isCancelled else { return } self?.sendFrame( type: type, channel: channel, data: data, timeoutInterval: Self.timeoutInterval(forRequestType: type), + yieldsToRequestInFlight: yieldsToRequestInFlight, sink: sink ) } @@ -292,18 +301,28 @@ final class ConnectionManager: NSObject { sendFrame(type: UInt32(LookinRequestTypePing), channel: channel, data: nil, timeoutInterval: pingTimeout, sink: pingSink) } - /// Ends the waiting request of `type` on `channel` and reports it as - /// completed. - func cancelRequest(type: UInt32, channel: ServerChannel?) { - guard let state = channel?.connectionState, - let request = state.activeRequests.first(where: { $0.type == type }) - else { - return + /// Ends the request whose events go to `sink` and reports it as + /// completed, at once: also while its ping runs, and then it is never + /// sent. Other requests of its type, another client's, go on. Returns + /// whether it was in flight on `channel`, that is whether the Server is + /// working on it. + @discardableResult + func cancelRequest(receivingEventsWith sink: ResponseSink, channel: ServerChannel?) -> Bool { + let state = channel?.connectionState + guard let request = state?.activeRequests.first(where: { $0.sink === sink }) else { + sink.cancel() + return false } request.endTimeoutCount() - state.remove(request) - request.sink.send(.completion) - #log(.default, "Lookin - request cancelled by the user, type:\(NSNumber(value: type), privacy: .public)") + state?.remove(request) + sink.cancel() + #log(.default, "Lookin - request cancelled, type:\(NSNumber(value: request.type), privacy: .public)") + return true + } + + /// Whether a request of `type` is in flight on `channel`. + func isRequestInFlight(type: UInt32, channel: ServerChannel?) -> Bool { + channel?.connectionState.activeRequests.containsRequest(ofType: type) ?? false } /// Response frames of one request, until it completes. The stream @@ -404,41 +423,71 @@ final class ConnectionManager: NSObject { } /// Sends one frame and tracks it until its last response, a failure or - /// its timeout. - func sendFrame(type: UInt32, channel: ServerChannel, data: NSObject?, timeoutInterval: TimeInterval, sink: ResponseSink) { + /// its timeout. With `yieldsToRequestInFlight`, a request of its type in + /// flight keeps the channel and this one fails as discarded, unsent. + func sendFrame( + type: UInt32, + channel: ServerChannel, + data: NSObject?, + timeoutInterval: TimeInterval, + yieldsToRequestInFlight: Bool = false, + sink: ResponseSink + ) { guard channel.isConnected else { sink.send(.failure(ConnectionError.noConnect)) return } let state = channel.connectionState - if type != UInt32(LookinRequestTypePing) { + let tag = UInt32(truncatingIfNeeded: Int64(Date().timeIntervalSince1970)) + let request = ConnectionRequest( + type: type, + tag: tag, + timeoutInterval: timeoutInterval, + sink: sink, + isResponseWaiting: { [weak channel] request in + // Answered in time, and waiting behind other frames here. + channel?.hasUndeliveredFrame(type: request.type, tag: request.tag) ?? false + }, + onTimeout: { [weak channel] request in + request.sink.send(.failure(ConnectionError.timeout)) + channel?.connectionState.remove(request) + } + ) + + // Track the request before the write: the write completion and the + // reply reach the main queue independently, and on loopback the + // reply can come first. A request registered only on completion + // refuses its own reply and then times out. + switch state.activeRequests.admit(request, yieldsToRequestInFlight: yieldsToRequestInFlight) { + case .refused: + #log(.default, "LookinClient - request yields to the one in flight, type:\(NSNumber(value: type), privacy: .public)") + sink.send(.failure(ConnectionError.discarded)) + return + case let .admitted(replacedRequests): // A newer request replaces an older one of the same type, which // fails. - for discarded in state.activeRequests where discarded.type == type { - discarded.sink.send(.failure(ConnectionError.discarded)) + for discarded in replacedRequests { discarded.endTimeoutCount() state.remove(discarded) + discarded.sink.send(.failure(ConnectionError.discarded)) #log(.default, "LookinClient - will discard request, type:\(NSNumber(value: discarded.type), privacy: .public), tag:\(NSNumber(value: discarded.tag), privacy: .public)") } } - let tag = UInt32(truncatingIfNeeded: Int64(Date().timeIntervalSince1970)) - let request = ConnectionRequest(type: type, tag: tag, timeoutInterval: timeoutInterval, sink: sink) { [weak channel] request in - request.sink.send(.failure(ConnectionError.timeout)) - channel?.connectionState.remove(request) - } - let attachment = ConnectionAttachment() attachment.data = data let payload = archivedPayload(rootObject: attachment) - // Track the request before the write: the write completion and the - // reply reach the main queue independently, and on loopback the - // reply can come first. A request registered only on completion - // refuses its own reply and then times out. - state.activeRequests.append(request) + switch Int(type) { + case LookinRequestTypeHierarchy: + request.signpostInterval = #signpost(.begin, "Hierarchy request") + case LookinRequestTypeHierarchyDetails: + request.signpostInterval = #signpost(.begin, "Detail request") + default: + break + } request.resetTimeoutCount() channel.send(type: type, tag: tag, payload: payload) { error in - guard error != nil, state.activeRequests.contains(where: { $0 === request }) else { return } + guard error != nil, state.activeRequests.contains(request) else { return } request.endTimeoutCount() state.remove(request) sink.send(.failure(ConnectionError.transport)) @@ -486,7 +535,10 @@ final class ConnectionManager: NSObject { // graph of Lookin models and Foundation collections. Secure coding // with NSObject as the allowed class warns on every frame, so the // receive path stays non-secure on purpose. - let attachment = Self.unarchiveResponse(data) + #signpost(.event, "Response frame", "type=\(type, privacy: .public) bytes=\(data.count, privacy: .public)") + let attachment = #signpostInterval("Unarchive response") { + Self.unarchiveResponse(data) + } if attachment?.appIsInBackground == true { request.endTimeoutCount() diff --git a/LookInside/Connection/ConnectionRequest.swift b/LookInside/Connection/ConnectionRequest.swift index 0c7517be..69209c35 100644 --- a/LookInside/Connection/ConnectionRequest.swift +++ b/LookInside/Connection/ConnectionRequest.swift @@ -6,6 +6,7 @@ // import Foundation +import FoundationToolbox import LookInsideHostCore /// What a request reports, in order: zero or more responses, then either a @@ -23,6 +24,9 @@ enum ResponseEvent { @MainActor final class ResponseSink { private var onEvent: ((ResponseEvent) -> Void)? + /// Set by `cancel()`. A request still waiting for its ping when it is + /// cancelled is not sent. + private(set) var isCancelled = false init(_ onEvent: @escaping (ResponseEvent) -> Void) { self.onEvent = onEvent @@ -32,6 +36,13 @@ final class ResponseSink { onEvent == nil } + /// Ends the delivery with a completion, the way a cancelled request + /// ends. + func cancel() { + isCancelled = true + send(.completion) + } + func send(_ event: ResponseEvent) { guard let onEvent else { return } switch event { @@ -48,50 +59,69 @@ final class ResponseSink { } } -/// A request in flight on one channel. The timeout uses -/// `-performSelector:withObject:afterDelay:` like the Objective-C class it -/// replaces, so it fires on the main run loop in the default mode only. +/// A request in flight on one channel. The timeout is a `ResponseCountdown`: +/// it fires on the main run loop in the default mode only, like the +/// Objective-C class this replaces, and it does not expire while a response +/// to the request waits to be handed over. @MainActor -final class ConnectionRequest: NSObject { +final class ConnectionRequest: NSObject, ChannelRequest { let type: UInt32 let tag: UInt32 let sink: ResponseSink var frameProgress = ResponseFrameProgress() + /// The signpost interval of a hierarchy or detail request, from the + /// send until the request leaves its channel; nil for other requests. + var signpostInterval: SignpostInterval? /// Must be positive. private let timeoutInterval: TimeInterval + private let isResponseWaiting: (ConnectionRequest) -> Bool private let onTimeout: (ConnectionRequest) -> Void + /// The running countdown. Only the run loop holds it, and through its + /// closures this request, while it runs: a request whose channel is gone + /// still times out and reports it, as the request kept alive by + /// `-performSelector:afterDelay:` did. + private weak var countdown: ResponseCountdown? + /// - Parameter isResponseWaiting: whether a response to the request was + /// read but not handed over yet; the countdown starts over then. init( type: UInt32, tag: UInt32, timeoutInterval: TimeInterval, sink: ResponseSink, + isResponseWaiting: @escaping (ConnectionRequest) -> Bool, onTimeout: @escaping (ConnectionRequest) -> Void ) { self.type = type self.tag = tag self.timeoutInterval = timeoutInterval self.sink = sink + self.isResponseWaiting = isResponseWaiting self.onTimeout = onTimeout } /// Starts or restarts the countdown. func resetTimeoutCount() { endTimeoutCount() - guard timeoutInterval > 0 else { - assertionFailure("timeoutInterval is 0") - return - } - perform(#selector(handleTimeout), with: nil, afterDelay: timeoutInterval) + let countdown = ResponseCountdown( + interval: timeoutInterval, + isResponseWaiting: { [self] in isResponseWaiting(self) }, + onExpire: { [self] in onTimeout(self) } + ) + self.countdown = countdown + countdown.restart() } - /// Stops the countdown. Called before the request is dropped. - func endTimeoutCount() { - NSObject.cancelPreviousPerformRequests(withTarget: self) + /// Closes `signpostInterval`, once. + func endSignpostInterval() { + guard let signpostInterval else { return } + self.signpostInterval = nil + #signpost(.end, signpostInterval) } - @objc private func handleTimeout() { - onTimeout(self) + /// Stops the countdown. Called before the request is dropped. + func endTimeoutCount() { + countdown?.stop() } } diff --git a/LookInside/Connection/InspectableApp.swift b/LookInside/Connection/InspectableApp.swift index b3db658d..4b99ad6f 100644 --- a/LookInside/Connection/InspectableApp.swift +++ b/LookInside/Connection/InspectableApp.swift @@ -33,13 +33,17 @@ final class InspectableApp: NSObject { /// Sends a request on `channel`. Each response's error becomes a /// failure, localized for the object-not-found and inner-error codes; /// otherwise its `data`, passed through `transform`, becomes a value. - /// The returned sink stops the delivery when detached. + /// The returned sink stops the delivery when detached, and names the + /// request to `cancelHierarchyDetailRequest(receivingEventsWith:)`. + /// With `yieldsToRequestInFlight`, a request of its type in flight + /// keeps the channel, and this one fails as discarded, unsent. @MainActor static func startRequest( on channel: ServerChannel, type: UInt32, data: NSObject?, transform: ((Any?) -> Any?)? = nil, + yieldsToRequestInFlight: Bool = false, onEvent: @escaping (AppResponseEvent) -> Void ) -> ResponseSink { var hasEnded = false @@ -62,7 +66,7 @@ final class InspectableApp: NSObject { onEvent(.completion) } } - ConnectionManager.shared.request(type: type, data: data, channel: channel, sink: sink) + ConnectionManager.shared.request(type: type, data: data, channel: channel, yieldsToRequestInFlight: yieldsToRequestInFlight, sink: sink) return sink } @@ -134,11 +138,10 @@ final class InspectableApp: NSObject { ConnectionManager.shared.push(type: type, data: data, channel: channel) } - /// Ends the waiting request of `type` and reports it as completed. + /// Whether a request of `type` is in flight on the channel. @MainActor - func cancelRequest(type: UInt32) { - guard let channel else { return } - ConnectionManager.shared.cancelRequest(type: type, channel: channel) + func isRequestInFlight(type: UInt32) -> Bool { + ConnectionManager.shared.isRequestInFlight(type: type, channel: channel) } // MARK: - Request payloads @@ -213,11 +216,15 @@ extension InspectableApp { typedResponses(type: UInt32(LookinRequestTypeHierarchyDetails), data: packages as NSArray, as: [DisplayItemDetail].self) } - /// Stops the running hierarchy details request: it completes, and the - /// Server is told to stop sending. + /// Stops the hierarchy details request whose events go to `sink`: it + /// completes, and when it was in flight the Server is told to stop + /// sending. The cancel push stops every detail request the Server works + /// on; while this one is in flight on the Host, any other is one the Host + /// already ended (one request of a type is in flight at a time), so + /// another client's request still waiting is never stopped. @MainActor - func cancelHierarchyDetailFetching() { - cancelRequest(type: UInt32(LookinRequestTypeHierarchyDetails)) + func cancelHierarchyDetailRequest(receivingEventsWith sink: ResponseSink) { + guard ConnectionManager.shared.cancelRequest(receivingEventsWith: sink, channel: channel) else { return } push(type: UInt32(LookinPush_CanceHierarchyDetails), data: nil) } diff --git a/LookInside/Connection/LiveDocument.swift b/LookInside/Connection/LiveDocument.swift index 4196d41c..40df5281 100644 --- a/LookInside/Connection/LiveDocument.swift +++ b/LookInside/Connection/LiveDocument.swift @@ -121,6 +121,18 @@ final class LiveDocument: NSDocument { saveOperation == .saveAsOperation ? ["com.lookin.lookin"] : [] } + /// The file cannot ask the app later: first fetch what the tree misses, + /// then show the save panel. + override func saveAs(_ sender: Any?) { + guard let asyncUpdateManager else { + super.saveAs(sender) + return + } + asyncUpdateManager.fetchWholeTree { + super.saveAs(sender) + } + } + override func data(ofType typeName: String) throws -> Data { guard typeName == "com.lookin.lookin", let info = hierarchyDataSource?.rawHierarchyInfo, diff --git a/LookInside/Connection/ServerChannel.swift b/LookInside/Connection/ServerChannel.swift index 06c8799b..eaad21e7 100644 --- a/LookInside/Connection/ServerChannel.swift +++ b/LookInside/Connection/ServerChannel.swift @@ -5,10 +5,13 @@ // One connection to an inspected app's Server: a FrameChannel on a // loopback TCP socket (Simulator and Mac apps) or a usbmuxd tunnel (USB // devices), plus the state the connection manager keeps for it. Frames, -// send completions and the end arrive on the main queue. +// send completions and the end arrive on the main queue; frames and the +// end reach the delegate a time slice per main-queue turn, so a long +// response does not hold the main thread (see PacedMainQueueDelivery). // import Foundation +import LookInsideHostCore /// Where a channel's Server runs. enum ServerChannelKind { @@ -39,6 +42,13 @@ final class ServerChannel: NSObject { let connectionState = ServerChannelConnectionState() private let frameChannel: FrameChannel + /// The frames and the end the frame channel reported, in order, on their + /// way to the delegate. Released once the end is delivered. + private var channelEvents: PacedMainQueueDelivery? + + /// What one main-queue turn spends handing frames to the delegate: about + /// half a 60 Hz frame, so the run loop keeps time for events and drawing. + private static let eventDeliveryTimeBudget: Duration = .milliseconds(8) /// True until the channel ends or is closed. var isConnected: Bool { @@ -62,20 +72,34 @@ final class ServerChannel: NSObject { super.init() // The callbacks hold the channel until it ends, as Peertalk's // dispatch I/O handlers did; the frame channel drops them after - // onEnd. + // onEnd, and the delivery lets go of the channel once the end is + // delivered. + let channelEvents = PacedMainQueueDelivery(timeBudget: Self.eventDeliveryTimeBudget) { [self, weak delegate] event in + switch event { + case let .frame(frame): + delegate?.channel(self, didReceiveFrameOfType: frame.type, tag: frame.tag, payload: frame.payload) + case .end: + self.channelEvents = nil + delegate?.channelDidEnd(self) + } + } + self.channelEvents = channelEvents + // A frame header is decided on as it is read, ahead of the frames + // still waiting for the delegate. A frame accepted for a request that + // ends before the frame is delivered is dropped there. frameChannel.shouldAcceptFrame = { [self, weak delegate] header in MainActor.assumeIsolated { delegate?.channel(self, shouldAcceptFrameOfType: header.type, tag: header.tag) ?? false } } - frameChannel.onFrame = { [self, weak delegate] frame in + frameChannel.onFrame = { frame in MainActor.assumeIsolated { - delegate?.channel(self, didReceiveFrameOfType: frame.type, tag: frame.tag, payload: frame.payload) + channelEvents.enqueue(.frame(frame)) } } - frameChannel.onEnd = { [self, weak delegate] _ in + frameChannel.onEnd = { _ in MainActor.assumeIsolated { - delegate?.channelDidEnd(self) + channelEvents.enqueue(.end) } } frameChannel.start() @@ -93,12 +117,35 @@ final class ServerChannel: NSObject { } } - /// Ends the channel now; queued writes are dropped. The delegate hears - /// `channelDidEnd` once. + /// Whether a frame of `type` with `tag` was read and still waits for its + /// turn to reach the delegate. A request whose response waits here has + /// been answered in time: its timeout starts over instead of expiring. + func hasUndeliveredFrame(type: UInt32, tag: UInt32) -> Bool { + channelEvents?.containsPending { event in + guard case let .frame(frame) = event else { return false } + return frame.type == type && frame.tag == tag + } ?? false + } + + /// Ends the channel now; queued writes are dropped, and so are the + /// frames read but not delivered yet, as the frame channel stops reading. + /// The delegate hears `channelDidEnd` once. func close() { + channelEvents?.removePending { event in + if case .frame = event { + return true + } + return false + } frameChannel.close() } + /// What the frame channel reported. + private enum ChannelEvent { + case frame(TransportFrame) + case end + } + override nonisolated var description: String { String(format: "", UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque()), port) } diff --git a/LookInside/DebugE2E/DebugE2EDump.swift b/LookInside/DebugE2E/DebugE2EDump.swift index 8659bb4c..3738ab12 100644 --- a/LookInside/DebugE2E/DebugE2EDump.swift +++ b/LookInside/DebugE2E/DebugE2EDump.swift @@ -1,6 +1,7 @@ #if DEBUG import AppKit import Foundation + import FoundationToolbox /// DEBUG-only end-to-end dump. When LOOKINSIDE_DEBUG_E2E_DUMP_DIR is set, /// the Host waits for the first inspectable app after launch (optionally @@ -32,6 +33,31 @@ /// windows is visible the dump satisfies the activation runtime's /// first-window gate itself (debugNoteFirstWindowShown()). /// + /// Every dump also writes timings.json: how long the hierarchy request, + /// the data source reload and the detail fetch took, and how many items + /// the hierarchy had. The detail time ends at the first idle tick of the + /// update manager, so it is accurate to tickInterval. Refresh benchmark + /// drivers read it. The prefetch that follows the displayed items is + /// waited for too, so a refresh never overlaps one. + /// + /// While details load, each tick is a "Driver tick" signpost event inside + /// a "Driver loading details" interval (subsystem com.lookinside.app, + /// category RefreshPerformance): a long gap between ticks is a time the + /// main thread kept its run loop waiting. + /// + /// LOOKINSIDE_DEBUG_E2E_REFRESH_COUNT= + /// After the first load, reload the hierarchy n more times the way + /// the toolbar button does, each once the previous one's details + /// are in, and add a `refreshes` entry per reload to timings.json. + /// The first load builds every preview node; a refresh in an open + /// window reuses them, so the two cost differently. + /// LOOKINSIDE_DEBUG_E2E_WHOLE_TREE=1 + /// After the last load's details, fetch what the whole tree misses + /// the way an export does first (`fetchWholeTree(then:)`), then + /// write: snapshot.json shows whether every node got its + /// attributes and screenshots, and timings.json gets + /// `wholeTreeMilliseconds`. + /// /// UI snapshots (Scripts/reborn-e2e/ui-snapshots.sh) add: /// /// LOOKINSIDE_DEBUG_E2E_START_FILE= @@ -43,6 +69,7 @@ /// against the loaded document instead of exiting; the Host exits /// when they are done. @MainActor + @Signpostable(subsystem: "com.lookinside.app", category: "RefreshPerformance") final class DebugE2EDump: NSObject { private static let directoryKey = "LOOKINSIDE_DEBUG_E2E_DUMP_DIR" private static let bundleIDKey = "LOOKINSIDE_DEBUG_E2E_BUNDLE_ID" @@ -50,6 +77,8 @@ private static let prepareLicenseCSRKey = "LOOKINSIDE_DEBUG_E2E_PREPARE_LICENSE_CSR" private static let expectLicensedKey = "LOOKINSIDE_DEBUG_E2E_EXPECT_LICENSED" private static let startFileKey = "LOOKINSIDE_DEBUG_E2E_START_FILE" + private static let refreshCountKey = "LOOKINSIDE_DEBUG_E2E_REFRESH_COUNT" + private static let wholeTreeKey = "LOOKINSIDE_DEBUG_E2E_WHOLE_TREE" /// Exit statuses, so the driver script can tell the failing step. private enum ExitStatus: Int32 { @@ -64,6 +93,7 @@ case waitingForApp case opening case loadingDetails + case fetchingWholeTree case settling case done } @@ -86,6 +116,8 @@ private let startFile: String? private let timeout: TimeInterval private let expectsLicensed: Bool + private let refreshCount: Int + private let fetchesWholeTree: Bool private var loggedStartWait = false private var loggedLicenseWait = false private var notedFirstWindow = false @@ -101,6 +133,22 @@ private var document: LiveDocument? private var sawUpdating = false private var idleSince: Date? + /// Phase boundaries for timings.json. + private var hierarchyRequestInstant: ContinuousClock.Instant? + private var hierarchyResponseInstant: ContinuousClock.Instant? + private var reloadEndInstant: ContinuousClock.Instant? + private var detailsIdleInstant: ContinuousClock.Instant? + /// The reload in progress after the first load, and those done. + private var refreshStartInstant: ContinuousClock.Instant? + private var refreshReloadedInstant: ContinuousClock.Instant? + private var refreshTimings: [[String: Double]] = [] + /// `detailsIdleInstant` of the first load, kept once refreshes start. + private var firstLoadDetailsIdleInstant: ContinuousClock.Instant? + /// The whole tree fetch of LOOKINSIDE_DEBUG_E2E_WHOLE_TREE. + private var wholeTreeStartInstant: ContinuousClock.Instant? + private var wholeTreeEndInstant: ContinuousClock.Instant? + /// Open while the phase is loadingDetails. + private var loadingDetailsSignpostInterval: SignpostInterval? private var settleUntil = Date() private init(directory: String, environment: [String: String]) { @@ -110,6 +158,8 @@ let timeout = (environment[Self.timeoutKey] as NSString?)?.doubleValue ?? 0 self.timeout = timeout > 0 ? timeout : 240 expectsLicensed = environment[Self.expectLicensedKey] == "1" + refreshCount = max(Int(environment[Self.refreshCountKey] ?? "") ?? 0, 0) + fetchesWholeTree = environment[Self.wholeTreeKey] == "1" super.init() } @@ -179,6 +229,12 @@ // MARK: - Steps private func enterPhase(_ phase: Phase) { + if phase != .loadingDetails, let loadingDetailsSignpostInterval { + #signpost(.end, loadingDetailsSignpostInterval) + self.loadingDetailsSignpostInterval = nil + } else if phase == .loadingDetails, loadingDetailsSignpostInterval == nil { + loadingDetailsSignpostInterval = #signpost(.begin, "Driver loading details") + } self.phase = phase deadline = Date(timeIntervalSinceNow: timeout) } @@ -187,6 +243,9 @@ if phase == .done { return } + if phase == .loadingDetails { + #signpost(.event, "Driver tick") + } if deadline.timeIntervalSinceNow < 0 { fail(.timeout, timeoutMessage()) return @@ -208,6 +267,8 @@ break case .loadingDetails: checkDetails() + case .fetchingWholeTree: + break case .settling: if settleUntil.timeIntervalSinceNow <= 0 { writeAndExit() @@ -230,6 +291,8 @@ case .loadingDetails: let updating = document?.asyncUpdateManager?.isUpdating() == true return "timed out waiting for the display-item details (update manager \(updating ? "still updating" : "never started"))" + case .fetchingWholeTree: + return "timed out fetching what the whole tree misses" default: return "timed out" } @@ -304,7 +367,9 @@ log("opening \(app.appInfo?.appBundleIdentifier ?? "(null)") (channel licensed: \(licensed))") self.app = app enterPhase(.opening) + hierarchyRequestInstant = .now DebugE2EDumpWriter.fetchHierarchy(for: app) { [self] info, error in + hierarchyResponseInstant = .now if let error { fail(.openFailed, "the hierarchy request failed: \(error as NSError)") return @@ -315,10 +380,13 @@ } let (document, alreadyOpen) = LiveDocumentController.shared.openLiveDocument(for: app) self.document = document - enterPhase(.loadingDetails) if !alreadyOpen { document.hierarchyDataSource?.reload(with: info, keepState: false) } + // After the reload, as for a refresh, so the details phase + // (and its signpost interval) holds no reload work. + enterPhase(.loadingDetails) + reloadEndInstant = .now NavigationManager.shared.closeLaunch() log("hierarchy loaded, \(document.hierarchyDataSource?.flatItems?.count ?? 0) items; waiting for details") } @@ -338,16 +406,91 @@ } guard let idleSince else { idleSince = Date() + detailsIdleInstant = .now return } if !sawUpdating || -idleSince.timeIntervalSinceNow < Self.idleInterval { return } + // The displayed items are in; the prefetch is waited for too. + if manager.isPrefetching() { + return + } + if let refreshStartInstant { + recordRefresh(startedAt: refreshStartInstant) + self.refreshStartInstant = nil + } + if refreshTimings.count < refreshCount { + if firstLoadDetailsIdleInstant == nil { + firstLoadDetailsIdleInstant = detailsIdleInstant + } + startRefresh() + return + } log("details loaded") + if fetchesWholeTree, wholeTreeStartInstant == nil { + startWholeTreeFetch(with: manager) + return + } settleUntil = Date(timeIntervalSinceNow: Self.settleInterval) enterPhase(.settling) } + /// What an export does before it writes the file. + private func startWholeTreeFetch(with manager: StaticAsyncUpdateManager) { + log("fetching what the whole tree misses") + enterPhase(.fetchingWholeTree) + wholeTreeStartInstant = .now + manager.fetchWholeTree { [self] in + wholeTreeEndInstant = .now + let milliseconds = Self.milliseconds(from: wholeTreeStartInstant, to: wholeTreeEndInstant) ?? 0 + log("whole tree fetched in \(String(format: "%.0f", milliseconds)) ms") + settleUntil = Date(timeIntervalSinceNow: Self.settleInterval) + enterPhase(.settling) + } + } + + /// Reloads the hierarchy through the window controller, the path the + /// toolbar button takes, then waits for the details as after the first + /// load. + private func startRefresh() { + guard let windowController = document?.staticWindowController else { + fail(.openFailed, "refresh \(refreshTimings.count + 1): the document has no inspector window") + return + } + log("refresh \(refreshTimings.count + 1) of \(refreshCount)") + enterPhase(.opening) + sawUpdating = false + idleSince = nil + refreshReloadedInstant = nil + refreshStartInstant = .now + Task { @MainActor [self] in + do { + _ = try await windowController.reloadHierarchy(initiator: .host) + } catch { + fail(.openFailed, "refresh \(refreshTimings.count + 1) failed: \(error as NSError)") + return + } + refreshReloadedInstant = .now + enterPhase(.loadingDetails) + } + } + + private func recordRefresh(startedAt startInstant: ContinuousClock.Instant) { + var timing: [String: Double] = [:] + timing["reloadMilliseconds"] = Self.milliseconds(from: startInstant, to: refreshReloadedInstant) + timing["detailsMilliseconds"] = Self.milliseconds(from: refreshReloadedInstant, to: detailsIdleInstant) + timing["totalMilliseconds"] = Self.milliseconds(from: startInstant, to: detailsIdleInstant) + refreshTimings.append(timing) + log("refresh \(refreshTimings.count) done in \(String(format: "%.0f", timing["totalMilliseconds"] ?? 0)) ms") + } + + private static func milliseconds(from start: ContinuousClock.Instant?, to end: ContinuousClock.Instant?) -> Double? { + guard let start, let end else { return nil } + let components = (end - start).components + return Double(components.seconds) * 1000 + Double(components.attoseconds) / 1_000_000_000_000_000 + } + private func writeAndExit() { phase = .done // AppKit swallows an exception raised in a timer callback, which @@ -372,6 +515,12 @@ fail(.writeFailed, "writing snapshot.json failed: \(error as NSError)") return } + do { + try writeTimings(itemCount: dataSource.flatItems?.count ?? 0, to: directoryURL.appendingPathComponent("timings.json")) + } catch { + fail(.writeFailed, "writing timings.json failed: \(error as NSError)") + return + } let window = document.windowControllers.first?.window let pngURL = directoryURL.appendingPathComponent("host-window.png") guard let png = DebugWindowImage.pngData(of: window), !png.isEmpty, (try? png.write(to: pngURL, options: .atomic)) != nil else { @@ -389,6 +538,21 @@ exit(ExitStatus.success.rawValue) } + private func writeTimings(itemCount: Int, to url: URL) throws { + var timings: [String: Any] = ["itemCount": itemCount] + timings["hierarchyRequestMilliseconds"] = Self.milliseconds(from: hierarchyRequestInstant, to: hierarchyResponseInstant) + timings["reloadMilliseconds"] = Self.milliseconds(from: hierarchyResponseInstant, to: reloadEndInstant) + timings["detailsMilliseconds"] = Self.milliseconds(from: reloadEndInstant, to: firstLoadDetailsIdleInstant ?? detailsIdleInstant) + timings["totalMilliseconds"] = Self.milliseconds(from: hierarchyRequestInstant, to: firstLoadDetailsIdleInstant ?? detailsIdleInstant) + if !refreshTimings.isEmpty { + timings["refreshes"] = refreshTimings + } + timings["wholeTreeMilliseconds"] = Self.milliseconds(from: wholeTreeStartInstant, to: wholeTreeEndInstant) + let data = try JSONSerialization.data(withJSONObject: timings, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url, options: .atomic) + log("timings: \(String(data: data, encoding: .utf8) ?? "")") + } + // MARK: - Output private func log(_ message: String) { diff --git a/LookInside/Hierarchy/HierarchyDataSource.swift b/LookInside/Hierarchy/HierarchyDataSource.swift index cfa28fc2..7daf4aa1 100644 --- a/LookInside/Hierarchy/HierarchyDataSource.swift +++ b/LookInside/Hierarchy/HierarchyDataSource.swift @@ -98,6 +98,7 @@ private func selfAndDescendants(of item: DisplayItem) -> [DisplayItem] { /// The hierarchy tree of a document: the flat rows, selection, hover, /// expansion, search and focus. Subclasses supply the preference manager. @Loggable(subsystem: "com.lookinside.app") +@Signpostable(subsystem: "com.lookinside.app", category: "RefreshPerformance") class HierarchyDataSource: NSObject { /// KVO-observable. @objc dynamic var state: HierarchyDataSourceState { @@ -223,6 +224,12 @@ class HierarchyDataSource: NSObject { // MARK: - Reload func reload(with info: HierarchyInfo?, keepState: Bool) { + #signpostInterval("Reload hierarchy") { + performReload(with: info, keepState: keepState) + } + } + + private func performReload(with info: HierarchyInfo?, keepState: Bool) { // Path identifiers of the old items resolve their root index against // the old root items, so take them before replacing the info. let previousRootItems = rawHierarchyInfo?.displayItems ?? [] diff --git a/LookInside/Hierarchy/HierarchyView.swift b/LookInside/Hierarchy/HierarchyView.swift index b9e89216..1c8cd64f 100644 --- a/LookInside/Hierarchy/HierarchyView.swift +++ b/LookInside/Hierarchy/HierarchyView.swift @@ -7,6 +7,7 @@ // import AppKit +import FoundationToolbox private let rowHeight: CGFloat = 28 @@ -62,6 +63,7 @@ protocol HierarchyViewDelegate: AnyObject { func hierarchyView(_ view: HierarchyView, needToShowPreviewOf item: DisplayItem) } +@Signpostable(subsystem: "com.lookinside.app", category: "RefreshPerformance") class HierarchyView: BaseView { let tableView: TableView /// The filter field under the rows. The DEBUG UI snapshots type into it. @@ -262,7 +264,9 @@ class HierarchyView: BaseView { displayItems = items minIndentLevel = items.map { $0.indentLevel() }.min() ?? 0 - tableView.reloadDataWithOffset() + #signpostInterval("Reload outline rows") { + tableView.reloadDataWithOffset() + } if items.isEmpty { let label: TextLabel diff --git a/LookInside/MCPBridge/MCPBridgeDetailsService.swift b/LookInside/MCPBridge/MCPBridgeDetailsService.swift index 97b04f49..9f75bdc9 100644 --- a/LookInside/MCPBridge/MCPBridgeDetailsService.swift +++ b/LookInside/MCPBridge/MCPBridgeDetailsService.swift @@ -21,8 +21,9 @@ // Scope: v0.5 only fetches attribute groups (no screenshots, no // frame/bounds/hidden/alpha, no subitems). Screenshots get their // own dedicated route; visual info is already in `get_hierarchy`; -// subtree changes are out of scope (agents follow up with -// `get_hierarchy` when they need to.) +// subtree changes are out of scope (agents call `refresh_hierarchy` +// when they need them -- `get_hierarchy` only reads the tree the host +// already has.) import AppKit import CoreGraphics @@ -155,7 +156,7 @@ final class MCPBridgeDetailsService { identifier: identifier, error: MCPBridgeErrorPayload( code: "details.objectNotFound", - message: "None of the requested object identifiers are present in this target's hierarchy. Refresh the hierarchy via get_hierarchy and retry." + message: "None of the requested object identifiers are present in this target's hierarchy. \(MCPBridgeRecoveryHint.identifierIsUnknown)" ) ) } @@ -321,7 +322,7 @@ final class MCPBridgeDetailsService { case LookinErrCode_ObjectNotFound: return MCPBridgeErrorPayload( code: "details.objectNotFound", - message: "The target app could not find one or more of the requested objects. They may have been deallocated; refresh via get_hierarchy and retry." + message: "The target app could not find one or more of the requested objects. They may have been deallocated. \(MCPBridgeRecoveryHint.objectIsGone)" ) case LookinErrCode_Inner: return MCPBridgeErrorPayload( diff --git a/LookInside/MCPBridge/MCPBridgeInspectionService.swift b/LookInside/MCPBridge/MCPBridgeInspectionService.swift index fa0a19f4..86ded6ad 100644 --- a/LookInside/MCPBridge/MCPBridgeInspectionService.swift +++ b/LookInside/MCPBridge/MCPBridgeInspectionService.swift @@ -294,8 +294,8 @@ final class MCPBridgeInspectionService { let payload = try encodeAsJSONValue(encodedGroups) // Annotate whether the host has actually fetched per-item // details for this view. v2 reads the cache as-is; agents that - // see an empty `groups` array should know to ask the user to - // open this view in the host inspector first. `secureContent` + // see an empty `groups` array fetch the details with + // `read_view_details` (the `details.read` route). `secureContent` // tells the agent why textual values may be `null` here. let hasCachedDetails = displayItem.attributesGroupList?.isEmpty == false return .success( diff --git a/LookInside/MCPBridge/MCPBridgeInvocationService.swift b/LookInside/MCPBridge/MCPBridgeInvocationService.swift index 5bff2ccf..c1b8cd8d 100644 --- a/LookInside/MCPBridge/MCPBridgeInvocationService.swift +++ b/LookInside/MCPBridge/MCPBridgeInvocationService.swift @@ -204,7 +204,7 @@ final class MCPBridgeInvocationService { case LookinErrCode_ObjectNotFound: return MCPBridgeErrorPayload( code: "invoke.objectNotFound", - message: "The target app could not find an object for this identifier or it does not respond to the selector. The object may have been deallocated; try reloading the inspector." + message: "The target app could not find an object for this identifier or it does not respond to the selector. The object may have been deallocated. \(MCPBridgeRecoveryHint.objectIsGone)" ) case LookinErrCode_Inner: return MCPBridgeErrorPayload( diff --git a/LookInside/MCPBridge/MCPBridgeModificationService.swift b/LookInside/MCPBridge/MCPBridgeModificationService.swift index bb6dd9cf..9f46d00c 100644 --- a/LookInside/MCPBridge/MCPBridgeModificationService.swift +++ b/LookInside/MCPBridge/MCPBridgeModificationService.swift @@ -104,7 +104,10 @@ final class MCPBridgeModificationService { identifier: identifier, error: MCPBridgeErrorPayload( code: "modify.attributeNotFound", - message: "Attribute \(attributeIdentifier) is not present in this view's cached detail. Open the view in the LookInside inspector (or call read_attributes first) to populate its detail, then retry." + message: MCPBridgeRecoveryHint.attributeNotFoundMessage( + attributeIdentifier: attributeIdentifier, + viewDetailsFetched: displayItem.attributesGroupList != nil + ) ) ) } @@ -302,7 +305,7 @@ final class MCPBridgeModificationService { case LookinErrCode_ObjectNotFound: return MCPBridgeErrorPayload( code: "modify.objectNotFound", - message: "The target app could not find an object for this identifier. The object may have been deallocated; try reloading the inspector." + message: "The target app could not find an object for this identifier. The object may have been deallocated. \(MCPBridgeRecoveryHint.objectIsGone)" ) case LookinErrCode_Inner: return MCPBridgeErrorPayload( diff --git a/LookInside/MCPBridge/MCPBridgeRecoveryHint.swift b/LookInside/MCPBridge/MCPBridgeRecoveryHint.swift new file mode 100644 index 00000000..143305cf --- /dev/null +++ b/LookInside/MCPBridge/MCPBridgeRecoveryHint.swift @@ -0,0 +1,44 @@ +// MCPBridgeRecoveryHint.swift +// +// What a bridge error tells the agent to do next. +// +// The agent acts on these sentences as written, so a hint that names a +// tool which cannot help costs it a call and the same error back. Every +// hint here once did: the stale-object errors said "refresh via +// get_hierarchy" and the missing-attribute error said "call +// read_attributes first", yet both tools only read what the host already +// holds. The tools that fetch from the app, `refresh_hierarchy` and +// `read_view_details`, landed after or alongside those messages, which +// were never updated. +// +// The hints name MCP tools (`refresh_hierarchy`, not the bridge route +// `hierarchy.refresh`) because the CLI hands error messages to the agent +// unchanged; that is also why they live here rather than in the generic +// wire schema of MCPBridgeFrame.swift. Foundation-only, so +// `Tests/MCPBridge/MCPBridgeRecoveryHintTests.swift` can compile it +// standalone, the same reason `MCPBridgeSearchQuery` was split out. + +import Foundation + +enum MCPBridgeRecoveryHint { + /// For an object the target app no longer has. The host's tree keeps + /// listing it until the hierarchy is fetched again, so reading that + /// tree with `get_hierarchy` or `find_views` finds the same stale entry. + static let objectIsGone = "Call refresh_hierarchy to fetch the current tree, then locate views again with find_views or get_hierarchy; identifiers from before the refresh may no longer resolve." + + /// For an identifier that is not in the host's current tree: it came + /// from an older tree, or the view appeared after the host last + /// fetched one. + static let identifierIsUnknown = "Locate views again with find_views or get_hierarchy; if the app's screen has changed since the host last fetched its tree, call refresh_hierarchy first." + + /// The message for a modification whose attribute is not among the + /// attributes the host holds for the view. The host fetches attributes + /// only for the rows its inspector has shown, and `read_attributes` + /// returns only what is already there. + static func attributeNotFoundMessage(attributeIdentifier: String, viewDetailsFetched: Bool) -> String { + if viewDetailsFetched { + return "Attribute \(attributeIdentifier) is not one of this view's attributes. Call read_attributes to list the ones it has." + } + return "Attribute \(attributeIdentifier) cannot be found because the host has not fetched this view's attributes yet. Call read_view_details with this object identifier to fetch them, then retry." + } +} diff --git a/LookInside/MCPBridge/MCPBridgeScreenshotService.swift b/LookInside/MCPBridge/MCPBridgeScreenshotService.swift index 3b01a017..832d155e 100644 --- a/LookInside/MCPBridge/MCPBridgeScreenshotService.swift +++ b/LookInside/MCPBridge/MCPBridgeScreenshotService.swift @@ -197,7 +197,7 @@ final class MCPBridgeScreenshotService { identifier: identifier, error: MCPBridgeErrorPayload( code: "screenshot.objectNotFound", - message: "Object identifier \(requestedObjectIdentifier) is not present in this target's hierarchy. Refresh via get_hierarchy and retry." + message: "Object identifier \(requestedObjectIdentifier) is not present in this target's hierarchy. \(MCPBridgeRecoveryHint.identifierIsUnknown)" ) ) } @@ -245,7 +245,7 @@ final class MCPBridgeScreenshotService { identifier: identifier, error: MCPBridgeErrorPayload( code: "screenshot.objectNotFound", - message: "The resolved display item has no live object behind it; it was probably deallocated. Refresh via get_hierarchy and retry." + message: "The resolved display item has no live object behind it; it was probably deallocated. \(MCPBridgeRecoveryHint.objectIsGone)" ) ) } @@ -288,7 +288,7 @@ final class MCPBridgeScreenshotService { identifier: identifier, error: MCPBridgeErrorPayload( code: "screenshot.objectNotFound", - message: "The target app could not find the requested object; it may have been deallocated. Refresh via get_hierarchy and retry." + message: "The target app could not find the requested object; it may have been deallocated. \(MCPBridgeRecoveryHint.objectIsGone)" ) ) } @@ -456,7 +456,7 @@ final class MCPBridgeScreenshotService { case LookinErrCode_ObjectNotFound: return MCPBridgeErrorPayload( code: "screenshot.objectNotFound", - message: "The target app could not find the requested object. It may have been deallocated; refresh via get_hierarchy and retry." + message: "The target app could not find the requested object. It may have been deallocated. \(MCPBridgeRecoveryHint.objectIsGone)" ) case LookinErrCode_Inner: return MCPBridgeErrorPayload( diff --git a/LookInside/Static/Preview/PreviewController.swift b/LookInside/Static/Preview/PreviewController.swift index 3023a34f..628c8335 100644 --- a/LookInside/Static/Preview/PreviewController.swift +++ b/LookInside/Static/Preview/PreviewController.swift @@ -149,10 +149,10 @@ final class PreviewController: BaseViewController, NSGestureRecognizerDelegate, } if let staticDataSource = dataSource as? StaticHierarchyDataSource { dataSourceSignals.observe(staticDataSource.itemDidChangeHiddenAlphaValue) { [weak self] in - self?.previewView.updateZPosition() + self?.previewView.setNeedsZPositionUpdate() } dataSourceSignals.observe(staticDataSource.itemsDidChangeFrame) { [weak self] in - self?.previewView.updateZPosition() + self?.previewView.setNeedsZPositionUpdate() } } diff --git a/LookInside/Static/Preview/PreviewView.swift b/LookInside/Static/Preview/PreviewView.swift index 1e2e45e8..cb0ebe79 100644 --- a/LookInside/Static/Preview/PreviewView.swift +++ b/LookInside/Static/Preview/PreviewView.swift @@ -18,6 +18,7 @@ import FoundationToolbox /// The 3D (or flat) preview of the hierarchy: one DisplayItemNode per /// item, stacked along the z axis. @Loggable(subsystem: "com.lookinside.app") +@Signpostable(subsystem: "com.lookinside.app", category: "RefreshPerformance") final class PreviewView: SCNView { private let dataSource: HierarchyDataSource? @@ -27,6 +28,8 @@ final class PreviewView: SCNView { private let leftLightNode = SCNNode() private var flatDisplayItems: [DisplayItem] = [] + /// A `setNeedsZPositionUpdate()` pass is waiting for the next main-queue turn. + private var hasScheduledZPositionUpdate = false private var displayItemNodes: [DisplayItemNode] = [] /// The frameToRoot point at the 3D origin: the centre of the largest @@ -186,6 +189,12 @@ final class PreviewView: SCNView { /// use are dropped; otherwise they are only removed from the scene and /// kept for later renders. func render(displayItems items: [DisplayItem], discardCache: Bool) { + #signpostInterval("Render preview") { + performRender(displayItems: items, discardCache: discardCache) + } + } + + private func performRender(displayItems items: [DisplayItem], discardCache: Bool) { #log(.default, "LKPreviewView - render \(NSNumber(value: items.count), privacy: .public) items") flatDisplayItems = items @@ -229,11 +238,62 @@ final class PreviewView: SCNView { /// Recomputes every item's z index and moves its plane along the z axis /// to match; folding and other states show or hide the planes. + /// + /// The rule is `PreviewZOrdering`'s. A pixelless overlay node (layout + /// guide, cell) marks out a region of the node that owns it, so it takes + /// its owner's z index rather than a plane of its own: it always overlaps + /// its super item, and the overlap rule would push every guide a full + /// plane forward and thicken the preview for nothing. The coplanar offset + /// in `PreviewGeometry.zPositions` then nudges it a hair in front, which + /// is exactly "drawn on its own view". func updateZPosition() { - for item in flatDisplayItems { - updateZIndex(for: item) + #signpostInterval("Update z positions") { + let items = flatDisplayItems + var indexByItem: [ObjectIdentifier: Int] = [:] + indexByItem.reserveCapacity(items.count) + for (index, item) in items.enumerated() { + indexByItem[ObjectIdentifier(item)] = index + } + let planes = items.map { item -> PreviewZOrdering.Plane in + let parent: PreviewZOrdering.Parent + if let superItem = item.super { + if let parentIndex = indexByItem[ObjectIdentifier(superItem)] { + parent = .plane(index: parentIndex) + } else { + parent = .outside(zIndex: superItem.previewZIndex) + } + } else { + // A top-level item (a folded UIWindowScene, say). + parent = .none + } + return PreviewZOrdering.Plane( + frameToRoot: item.calculateFrameToRoot(), + parent: parent, + isDisplayed: item.displayingInHierarchy, + isCoplanarOverlay: item.shouldRenderAsCoplanarPreviewOverlay(), + isInHiddenHierarchy: item.inHiddenHierarchy + ) + } + let zIndexes = PreviewZOrdering.zIndexes(of: planes, showsHiddenItems: showHiddenItems) + for (item, zIndex) in zip(items, zIndexes) { + item.previewZIndex = zIndex + } + updateZPositionByZIndex() + } + } + + /// `updateZPosition()` once on the next main-queue turn, however many + /// times this is called before then. A detail reply can change the + /// frame, hidden state or alpha of dozens of items, each of which asks + /// for a new pass. + func setNeedsZPositionUpdate() { + guard !hasScheduledZPositionUpdate else { return } + hasScheduledZPositionUpdate = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + hasScheduledZPositionUpdate = false + updateZPosition() } - updateZPositionByZIndex() } private func updateZPositionByZIndex() { @@ -252,65 +312,6 @@ final class PreviewView: SCNView { SCNTransaction.commit() } - private func updateZIndex(for item: DisplayItem) { - item.previewZIndex = -1 - // A pixelless overlay node (layout guide, cell) marks out a region of - // the node that owns it, so it belongs on that node's plane rather - // than on one of its own. It always overlaps its super item, so the - // rule below would push every guide a full plane forward and thicken - // the preview for nothing. Inheriting the super item's zIndex keeps - // them coplanar; the coplanar offset in PreviewGeometry.zPositions - // then nudges them a hair in front, which is exactly "drawn on its - // own view". - // - // flatDisplayItems runs parents before children, so the super item's - // zIndex read here was already computed in this same pass. - if item.shouldRenderAsCoplanarPreviewOverlay(), let superItem = item.super { - item.previewZIndex = max(superItem.previewZIndex, 0) - return - } - if item.displayingInHierarchy { - if let referenceItem = highestOverlappedItem(below: item) { - // Overlapping another item puts this one a level above it. - item.previewZIndex = referenceItem.previewZIndex + 1 - } else { - item.previewZIndex = 0 - } - } else if let superItem = item.super { - item.previewZIndex = superItem.previewZIndex - } else { - // A top-level item without a super item (a folded UIWindowScene). - item.previewZIndex = 0 - } - - if item.previewZIndex < 0 { - assertionFailure() - item.previewZIndex = 0 - } - } - - /// The item, among those visible in the preview and earlier in - /// flatDisplayItems (lower in the hierarchy) whose frameToRoot overlaps - /// `item`'s, with the highest z index; nil when there is none. - private func highestOverlappedItem(below item: DisplayItem) -> DisplayItem? { - guard let itemIndex = flatDisplayItems.firstIndex(where: { $0 == item }) else { - assertionFailure() - return nil - } - guard itemIndex > 0 else { return nil } - let itemFrameToRoot = item.calculateFrameToRoot() - var target: DisplayItem? - for other in flatDisplayItems[..() - /// Requests that received every reply. - private var succeededRequests: [DetailUpdateRequest] = [] + /// Tasks that requests answered in full since the last reload, so a task + /// answered without an image (a leaf has no solo screenshot) is not asked + /// for again each time the displayed items change. + private var answeredTasks = AnsweredTaskRecord() /// The request sent and not yet ended. private var ongoingRequest: DetailUpdateRequest? + /// An `updateForDisplayingItems()` is waiting for the next main-queue turn. + private var hasScheduledDisplayingUpdate = false + /// Whoever waits for `fetchWholeTree(then:)`. + private var wholeTreeWaiters: [@MainActor () -> Void] = [] + /// A look at whether another client's detail request still holds the + /// channel is scheduled. + private var isWaitingForChannel = false + + /// A prefetch package is answered in one main-queue turn of the + /// inspected app, and a cancellation only takes effect between packages, + /// so prefetch packages are small. + private static let prefetchPackageMaximumArea: Double = 500_000 + private static let prefetchPackageMaximumTaskCount = 20 + /// How often to look whether another client's detail request still holds + /// the channel: the connection tells only a request's sender that it + /// ended. + private static let channelCheckInterval: TimeInterval = 0.25 private let dataSourceSignals = SyncSubscriptionBag() @@ -55,8 +92,22 @@ final class StaticAsyncUpdateManager: NSObject { self.inspectableApp = inspectableApp super.init() dataSourceSignals.observe(hierarchyDataSource?.willReloadHierarchyInfo) { [weak self] in - self?.succeededRequests.removeAll() + guard let self else { return } + // Whatever runs was planned for the tree being replaced, a fetch + // of the displayed items too: the replies it got so far went to + // the old tree, so its tasks are still missing in the new one. + // A file waiting for the whole tree gets the new tree instead, + // so its waiters outlive the request ended here. + let waiters = wholeTreeWaiters + wholeTreeWaiters.removeAll() + endUpdating() + answeredTasks.startNewTree() + if !waiters.isEmpty { + wholeTreeWaiters = waiters + updateForDisplayingItems() + } } + PreferenceManager.shared.fastMode.subscribe(self, action: #selector(handleFastModeChange(_:)), relatedObject: nil) } override convenience init() { @@ -69,42 +120,73 @@ final class StaticAsyncUpdateManager: NSObject { // MARK: - Public - /// With fast mode off: after a reload, fetches the details of every item. - func updateAll() { - assert(!PreferenceManager.shared.fastMode.currentBOOLValue) - guard inspectableApp != nil, let dataSource, !(dataSource.flatItems ?? []).isEmpty else { - return - } - endUpdating() - let newTasks = makeMaximumTasks() - guard !newTasks.isEmpty else { return } - send(newTasks) - } - - /// With fast mode on: fetches the details of the visible items that do - /// not have them yet. Call it whenever the displayed items change. + /// Fetches what the displayed items miss, then prefetches the rest unless + /// fast mode is on. Call it whenever the displayed items change; calls + /// made before the next main-queue turn run once, after the reload or + /// expansion that made them is complete. func updateForDisplayingItems() { - assert(PreferenceManager.shared.fastMode.currentBOOLValue) - guard inspectableApp != nil else { return } - let items = dataSource?.displayingFlatItems ?? [] - guard !items.isEmpty else { return } - let newTasks = makeMinimumTasks(for: items) - guard !newTasks.isEmpty else { return } - send(newTasks) + guard !hasScheduledDisplayingUpdate else { return } + hasScheduledDisplayingUpdate = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + hasScheduledDisplayingUpdate = false + fetchForDisplayingItems() + } } - /// Stops the running fetch. + /// Stops this manager's running fetch, prefetch included; another + /// client's detail request goes on. func endUpdating() { - guard ongoingRequest != nil else { return } + guard let request = ongoingRequest, let sink = request.sink else { return } #log(.default, "AsyncUpdate - endUpdating") - // Completes the running details request synchronously, which runs - // its completion below and notifies the delegate. - inspectableApp?.cancelHierarchyDetailFetching() + // Completes the request synchronously, also while its ping runs, + // which runs its completion below and notifies the delegate. + if let inspectableApp { + inspectableApp.cancelHierarchyDetailRequest(receivingEventsWith: sink) + } else { + sink.cancel() + } } - /// A method rather than a property: Swift callers use `isUpdating()`. + /// Stops a running prefetch; a fetch the user waits for goes on. What + /// the prefetch did not reach is asked for on the next + /// `updateForDisplayingItems()`. + func stopPrefetching() { + guard ongoingRequest?.kind == .prefetch else { return } + endUpdating() + } + + /// Whether a fetch the user waits for is running, the displayed items' + /// or the whole tree's; a prefetch does not count. A method rather than + /// a property: Swift callers use `isUpdating()`. func isUpdating() -> Bool { - ongoingRequest != nil + ongoingRequest?.kind.isWaitedFor == true + } + + /// Whether a prefetch is running. + func isPrefetching() -> Bool { + ongoingRequest?.kind == .prefetch + } + + /// Fetches what the tree misses of every node's screenshots and + /// attributes, then runs `completion`, which writes a file of the tree: + /// an export, a Save As or a reader window cannot ask the app later. + /// The window shows the progress, and its reload button stops the fetch; + /// a hierarchy reload meanwhile carries it over to the new tree. + /// `completion` runs on a later main-queue turn: once the fetch ends, + /// whether it finished, was stopped or failed (the file then has what + /// arrived), or right away when no app is connected. + func fetchWholeTree(then completion: @escaping @MainActor () -> Void) { + guard inspectableApp?.channel?.isConnected == true, dataSource != nil else { + DispatchQueue.main.async { + completion() + } + return + } + wholeTreeWaiters.append(completion) + // A whole tree fetch that runs already serves this caller too. + guard ongoingRequest?.kind != .wholeTree else { return } + sendWholeTreeRequest() } /// Fetches the screenshots of `displayItem` (solo and group) and the @@ -156,138 +238,212 @@ final class StaticAsyncUpdateManager: NSObject { } } - /// Fetches one item's screenshots and attributes again, not its sub items. + /// Fetches one item's screenshots and attributes again, not its sub + /// items. The user asked for it: it does not yield to another client's + /// detail request. func reloadSingleDisplayItem(_ item: DisplayItem?) { let tasks = makeReloadSingleItemTasks(item) guard !tasks.isEmpty else { return } - send(tasks) + send(tasks, yieldsToOtherRequests: false) { [weak self] in + self?.startPrefetchIfNeeded() + } } - /// Fetches an item and its sub items again, then their details. + /// Fetches an item and its sub items again, then their details. The user + /// asked for it: it does not yield to another client's detail request. func reloadDisplayItemAndChildren(_ rootItem: DisplayItem?) { // First the root item's basis, attributes and sub items. let tasks = makeReloadItemAndChildrenTasks(rootItem) guard let rootItem, !tasks.isEmpty else { return } rootItem.enumerateSelfAndChildren { item in - // Drop the screenshots and the record of finished tasks: they are + // Drop the screenshots and the record of answered tasks: they are // fetched again below. The new children are new instances, but - // their oids (which task equality uses) are likely the same. + // their oids (which the record is keyed by) are likely the same. item.soloScreenshot = nil item.groupScreenshot = nil - for request in self.succeededRequests { - request.removeTasks(of: item) - } + let itemOids = Set(item.availableObjectOidsPreferView(false).map(\.uintValue)) + self.answeredTasks.forget { itemOids.contains($0.oid) } } - send(tasks) { [weak self] in - self?.updateAfterReloadingItemAndChildren(rootItem) + send(tasks, yieldsToOtherRequests: false) { [weak self] in + // The new children: the displayed ones now, the others prefetched. + self?.updateForDisplayingItems() } } - // MARK: - Tasks + // MARK: - Displayed items and prefetch - private func makeMaximumTasks() -> [StaticAsyncUpdateTask] { - guard let dataSource else { return [] } - // Order matters: earlier tasks are fetched first, so the visible - // items go first. - var tasks: [StaticAsyncUpdateTask] = (dataSource.displayingFlatItems ?? []).compactMap { item in - if item.isUserCustom() { - return nil - } - if item.doNotFetchScreenshotReason == .fetchScreenshotPermitted { - // Mirrors appropriateScreenshot(): a node showing its group - // screenshot must not have the solo one fetched first, or the - // preview waits on an image it will never display. - if item.isExpandable, item.isExpanded, item.hasPixelBearingSubitems() { - return task(from: item, type: .soloScreenshot) - } - return task(from: item, type: .groupScreenshot) + private func fetchForDisplayingItems() { + guard inspectableApp != nil, dataSource != nil else { return } + // A whole tree fetch brings what the displayed items miss too. + guard ongoingRequest?.kind != .wholeTree else { return } + if !wholeTreeWaiters.isEmpty { + // Another client's detail request held the channel when the + // whole tree was asked for. + sendWholeTreeRequest() + return + } + let newTasks = #signpostInterval("Make detail tasks") { + makeTasks(for: .foreground) + } + guard !newTasks.isEmpty else { + // Everything on screen is there: carry on with the prefetch. + if ongoingRequest == nil { + startPrefetchIfNeeded() } - return task(from: item, type: .noScreenshot) + return } + send(newTasks) { [weak self] in + self?.startPrefetchIfNeeded() + } + } - func appendIfNew(_ task: StaticAsyncUpdateTask?) { - if let task, !tasks.contains(task) { - tasks.append(task) - } + private func startPrefetchIfNeeded() { + guard !PreferenceManager.shared.fastMode.currentBOOLValue, ongoingRequest == nil, inspectableApp != nil else { return } + let newTasks = #signpostInterval("Make prefetch tasks") { + makeTasks(for: .prefetch) } - for item in dataSource.flatItems ?? [] where !item.isUserCustom() { - if item.doNotFetchScreenshotReason == .fetchScreenshotPermitted { - appendIfNew(task(from: item, type: .groupScreenshot)) - if item.isExpandable { - appendIfNew(task(from: item, type: .soloScreenshot)) - } - } else { - appendIfNew(task(from: item, type: .noScreenshot)) + guard !newTasks.isEmpty else { return } + send(newTasks, kind: .prefetch) + } + + private func sendWholeTreeRequest() { + let newTasks = #signpostInterval("Make whole tree tasks") { + makeTasks(for: .wholeTree) + } + guard !newTasks.isEmpty else { + finishWholeTree() + return + } + send(newTasks, kind: .wholeTree) + } + + /// Hands the tree to whoever waits for `fetchWholeTree(then:)`, on a + /// later main-queue turn: the request may have ended in the middle of a + /// reload. + private func finishWholeTree() { + let waiters = wholeTreeWaiters + wholeTreeWaiters.removeAll() + guard !waiters.isEmpty else { return } + DispatchQueue.main.async { + for waiter in waiters { + waiter() } } - return tasks } - private func makeMinimumTasks(for items: [DisplayItem]) -> [StaticAsyncUpdateTask] { - items.compactMap { item -> StaticAsyncUpdateTask? in - if item.isUserCustom() { - return nil - } - if item.appropriateScreenshot() != nil { - // Has its image, and so its attributes too. - return nil - } - let newTask: StaticAsyncUpdateTask? - if item.doNotFetchScreenshotReason == .fetchScreenshotPermitted { - // Should have an image but does not: fetch it. - if item.isExpandable, item.isExpanded { - newTask = task(from: item, type: .soloScreenshot) - } else { - newTask = task(from: item, type: .groupScreenshot) - } - } else if (item.attributesGroupList ?? []).isEmpty { - // No image by design; fetch the attributes once. - newTask = task(from: item, type: .noScreenshot) + /// Looks again shortly whether another client's detail request still + /// holds the channel, and once it is done asks for what is still missing. + private func resumeWhenChannelIsFree() { + guard !isWaitingForChannel else { return } + isWaitingForChannel = true + DispatchQueue.main.asyncAfter(deadline: .now() + Self.channelCheckInterval) { [weak self] in + guard let self else { return } + isWaitingForChannel = false + // A reload or an expand started a request meanwhile. + guard ongoingRequest == nil else { return } + if inspectableApp?.isRequestInFlight(type: UInt32(LookinRequestTypeHierarchyDetails)) == true { + resumeWhenChannelIsFree() } else { - return nil + fetchForDisplayingItems() } - guard let newTask else { return nil } + } + } + + @objc private func handleFastModeChange(_ parameters: MessageActionParameters) { + if parameters.boolValue { + stopPrefetching() + } else { + startPrefetchIfNeeded() + } + } - // attrRequest is supported since Client 1.0.7 and Server 1.2.7. - newTask.attrRequest = (item.attributesGroupList ?? []).isEmpty ? .need : .notNeed + // MARK: - Tasks - if succeededRequests.contains(where: { $0.contains(newTask) }) { - // Already fetched successfully. - return nil + /// The tasks of a request of `kind` (the displayed items', the + /// prefetch's or the whole tree's) as `DetailFetchPlanning` lists them, + /// without the tasks a request already answered since the last reload. + private func makeTasks(for kind: DetailRequestKind) -> [StaticAsyncUpdateTask] { + guard let dataSource else { return [] } + let items = dataSource.flatItems ?? [] + var indexByItem: [ObjectIdentifier: Int] = [:] + let nodes = #signpostInterval("Build planning nodes") { + indexByItem.reserveCapacity(items.count) + for (index, item) in items.enumerated() { + indexByItem[ObjectIdentifier(item)] = index + } + return items.map { item in + DetailFetchPlanning.Node( + childIndexes: (item.subitems ?? []).compactMap { indexByItem[ObjectIdentifier($0)] }, + isExpandable: item.isExpandable, + isExpanded: item.isExpanded, + hasPixelBearingSubitems: item.hasPixelBearingSubitems(), + mayFetchScreenshot: item.doNotFetchScreenshotReason == .fetchScreenshotPermitted, + isUserCustom: item.isUserCustom(), + hasSoloScreenshot: item.soloScreenshot != nil, + hasGroupScreenshot: item.groupScreenshot != nil, + hasAttributes: !(item.attributesGroupList ?? []).isEmpty + ) + } + } + let displayedIndexes = (dataSource.displayingFlatItems ?? []).compactMap { indexByItem[ObjectIdentifier($0)] } + let requests = #signpostInterval("Plan detail requests") { + // An explicit return: the macro's expansion does not take a lone + // switch for the closure's value. + return switch kind { + case .foreground: + DetailFetchPlanning.onScreenRequests(for: nodes, displayedIndexes: displayedIndexes) + case .prefetch: + DetailFetchPlanning.prefetchRequests(for: nodes, displayedIndexes: displayedIndexes) + case .wholeTree: + DetailFetchPlanning.wholeTreeRequests(for: nodes, displayedIndexes: displayedIndexes) } - return newTask } - } - private func updateAfterReloadingItemAndChildren(_ rootItem: DisplayItem) { - // The root item's and its children's screenshots and attributes. - if PreferenceManager.shared.fastMode.currentBOOLValue { - updateForDisplayingItems() - return + let context = TaskContext(appInfo: dataSource.rawHierarchyInfo?.appInfo) + let blockedIndexes = #signpostInterval("Check SwiftUI access") { + Self.itemIndexesBlockedBySwiftUIAccess(requests, items: items, context: context) } - var tasks: [StaticAsyncUpdateTask] = [] - rootItem.enumerateSelfAndChildren { item in - guard !item.isUserCustom() else { return } - if item.doNotFetchScreenshotReason == .fetchScreenshotPermitted { - if let groupTask = self.task(from: item, type: .groupScreenshot) { - tasks.append(groupTask) + return #signpostInterval("Make task objects") { + var tasks: [StaticAsyncUpdateTask] = [] + var taskKeys = Set() + for request in requests where !blockedIndexes.contains(request.nodeIndex) { + let taskType: LookinStaticAsyncUpdateTaskType = switch request.screenshot { + case .solo: .soloScreenshot + case .group: .groupScreenshot + case nil: .noScreenshot } - if item.isExpandable, let soloTask = self.task(from: item, type: .soloScreenshot) { - tasks.append(soloTask) - } - } else if let attrTask = self.task(from: item, type: .noScreenshot) { - tasks.append(attrTask) + guard let task = Self.task(from: items[request.nodeIndex], type: taskType, context: context) else { continue } + task.attrRequest = request.wantsAttributes ? .need : .notNeed + let key = DetailTaskKey(task) + guard !self.answeredTasks.contains(key), taskKeys.insert(key).inserted else { continue } + tasks.append(task) } + return tasks + } + } + + /// What every task of one request shares. + private struct TaskContext { + let isMacTarget: Bool + let clientReadableVersion: String + + init(appInfo: InspectedAppInfo?) { + isMacTarget = AppHelper.appInfoLooksLikeMacTarget(appInfo) + clientReadableVersion = AppHelper.readableVersion() } - send(tasks) } private func task(from item: DisplayItem, type: LookinStaticAsyncUpdateTaskType) -> StaticAsyncUpdateTask? { - let appInfo = dataSource?.rawHierarchyInfo?.appInfo - guard Self.allowsSwiftUISupportAccess(for: item, appInfo: appInfo) else { + let context = TaskContext(appInfo: dataSource?.rawHierarchyInfo?.appInfo) + if context.isMacTarget, item.isSwiftUISupportRelated(), !Self.swiftUISupportAccessAllowed() { return nil } + return Self.task(from: item, type: type, context: context) + } + /// The task, or nil for an item without an oid. The SwiftUI license + /// check is the caller's. + private static func task(from item: DisplayItem, type: LookinStaticAsyncUpdateTaskType, context: TaskContext) -> StaticAsyncUpdateTask? { var oid: UInt = 0 switch item.resolvedNodeKind() { case .window, .windowScene: @@ -308,7 +464,7 @@ final class StaticAsyncUpdateManager: NSObject { // layers on): then the view node has to use the view oid. let viewOid = item.viewObject?.oid ?? 0 let layerOid = item.layerObject?.oid ?? 0 - let prefersViewOid = AppHelper.appInfoLooksLikeMacTarget(appInfo) || item.ownsSeparateBackingLayerNode() + let prefersViewOid = context.isMacTarget || item.ownsSeparateBackingLayerNode() if prefersViewOid, viewOid != 0 { oid = viewOid } else if layerOid != 0 { @@ -324,20 +480,48 @@ final class StaticAsyncUpdateManager: NSObject { task.frameSize = item.frame.size task.taskType = type task.needBasisVisualInfo = true - task.clientReadableVersion = AppHelper.readableVersion() + task.clientReadableVersion = context.clientReadableVersion return task } - private static func allowsSwiftUISupportAccess(for item: DisplayItem, appInfo: InspectedAppInfo?) -> Bool { - guard AppHelper.appInfoLooksLikeMacTarget(appInfo), item.isSwiftUISupportRelated() else { - return true + /// The indexes of the requests' items a Mac app's SwiftUI license check + /// keeps out: its SwiftUI items, when the license does not cover them. + /// The gatekeeper is asked once, at the first SwiftUI item, as it may + /// show an alert; once it grants access no other item needs a look. + private static func itemIndexesBlockedBySwiftUIAccess( + _ requests: [DetailFetchPlanning.Request], + items: [DisplayItem], + context: TaskContext + ) -> Set { + guard context.isMacTarget else { return [] } + var checkedIndexes = Set() + var blockedIndexes = Set() + var accessAllowed: Bool? + for request in requests where checkedIndexes.insert(request.nodeIndex).inserted { + guard items[request.nodeIndex].isSwiftUISupportRelated() else { continue } + let allowed = accessAllowed ?? swiftUISupportAccessAllowed() + accessAllowed = allowed + if allowed { + return [] + } + blockedIndexes.insert(request.nodeIndex) } - return SwiftUISupportGatekeeper.sharedInstance().allowProtectedFeatureAccess(for: NSApplication.shared.keyWindow) + return blockedIndexes + } + + private static func swiftUISupportAccessAllowed() -> Bool { + SwiftUISupportGatekeeper.sharedInstance().allowProtectedFeatureAccess(for: NSApplication.shared.keyWindow) } - private static func packages(from tasks: [StaticAsyncUpdateTask]) -> [StaticAsyncUpdateTasksPackage] { + private static func packages(from tasks: [StaticAsyncUpdateTask], kind: DetailRequestKind) -> [StaticAsyncUpdateTasksPackage] { let areas = tasks.map { Double($0.frameSize.width * $0.frameSize.height) } - return DetailTaskPackaging.packageRanges(areas: areas).map { range in + let ranges = switch kind { + case .foreground, .wholeTree: + DetailTaskPackaging.packageRanges(areas: areas) + case .prefetch: + DetailTaskPackaging.packageRanges(areas: areas, maxArea: prefetchPackageMaximumArea, maxCount: prefetchPackageMaximumTaskCount) + } + return ranges.map { range in let package = StaticAsyncUpdateTasksPackage() package.tasks = Array(tasks[range]) return package @@ -348,18 +532,28 @@ final class StaticAsyncUpdateManager: NSObject { /// Sends a request whose events arrive synchronously on the main /// thread. `endUpdating()` relies on that: cancelling the details - /// request completes it before the next request starts. + /// request completes it before the next request starts. Returns the + /// sink that names the request to the connection, nil when there is no + /// channel (the failure arrived already). + @discardableResult private func startRequest( app: InspectableApp, type: Int, tasks: NSArray, + yieldsToRequestInFlight: Bool = false, onEvent: @escaping (AppResponseEvent) -> Void - ) { + ) -> ResponseSink? { guard let channel = app.channel else { onEvent(.failure(ConnectionError.noConnect)) - return + return nil } - _ = InspectableApp.startRequest(on: channel, type: UInt32(type), data: tasks, onEvent: onEvent) + return InspectableApp.startRequest( + on: channel, + type: UInt32(type), + data: tasks, + yieldsToRequestInFlight: yieldsToRequestInFlight, + onEvent: onEvent + ) } private func startRequest( @@ -371,34 +565,71 @@ final class StaticAsyncUpdateManager: NSObject { startRequest(app: app, type: type, tasks: tasks as NSArray, onEvent: onEvent) } - private func send(_ newTasks: [StaticAsyncUpdateTask], completion: (() -> Void)? = nil) { + /// Sends `newTasks`, replacing the running request of this manager. + /// `completion` runs once every task is answered, not when the request + /// is cancelled or fails. With `yieldsToOtherRequests`, another client's + /// detail request on the channel goes first: nothing is sent while it + /// runs, and what is still missing is asked for once it is done. + private func send( + _ newTasks: [StaticAsyncUpdateTask], + kind: DetailRequestKind = .foreground, + yieldsToOtherRequests: Bool = true, + completion: (() -> Void)? = nil + ) { guard let app = inspectableApp, !newTasks.isEmpty else { return } // The same request cannot run twice at once: cancel the previous one. endUpdating() + if yieldsToOtherRequests, app.isRequestInFlight(type: UInt32(LookinRequestTypeHierarchyDetails)) { + // This manager's ended above: the one in flight is another + // client's. + resumeWhenChannelIsFree() + return + } - let packages = Self.packages(from: newTasks) - let request = DetailUpdateRequest(packages: packages) + let request = DetailUpdateRequest( + kind: kind, + packages: Self.packages(from: newTasks, kind: kind), + treeVersion: answeredTasks.treeVersion + ) + request.signpostInterval = switch kind { + case .foreground: #signpost(.begin, "Fetch displayed items") + case .prefetch: #signpost(.begin, "Prefetch") + case .wholeTree: #signpost(.begin, "Fetch whole tree") + } ongoingRequest = request notifyTasksCountToDelegate() - #log(.default, "AsyncUpdate - Will send \(NSNumber(value: newTasks.count), privacy: .public) tasks.") + #log(.default, "AsyncUpdate - Will send \(NSNumber(value: newTasks.count), privacy: .public) tasks (\(kind.name, privacy: .public)).") - startRequest(app: app, type: LookinRequestTypeHierarchyDetails, tasks: packages as NSArray) { [weak self] event in + // The connection checks again as the request goes out, after its + // ping: another client's request may have started meanwhile. + request.sink = startRequest( + app: app, + type: LookinRequestTypeHierarchyDetails, + tasks: request.packages as NSArray, + yieldsToRequestInFlight: yieldsToOtherRequests + ) { [weak self] event in guard let self else { return } switch event { case let .value(value): - receive(details: value as? [DisplayItemDetail] ?? []) - case .failure: - fail() + #signpostInterval("Apply details") { + self.receive(details: value as? [DisplayItemDetail] ?? [], for: request) + } + case let .failure(error): + request.endSignpostInterval() + fail(request, error: error) case .completion: - // A request the user cancelled ends here too. - complete() - completion?() + request.endSignpostInterval() + // A request the user cancelled, or one a newer request + // replaced, ends here too. + if complete(request) { + completion?() + } } } } - private func receive(details: [DisplayItemDetail]) { + private func receive(details: [DisplayItemDetail], for request: DetailUpdateRequest) { for detail in details { if detail.failureCode != LookinDisplayItemDetailFailureCode.none.rawValue { // ObjectGone is routine: the inspected app released the @@ -409,7 +640,7 @@ final class StaticAsyncUpdateManager: NSObject { // transmit" alert. let objectIsGone = detail.failureCode == LookinDisplayItemDetailFailureCode.objectGone.rawValue if !objectIsGone { - ongoingRequest?.failedTasksCount += 1 + request.failedTasksCount += 1 } let failedItem = dataSource?.displayItem(withOid: detail.displayItemOid) let outcome: String = objectIsGone ? "skipped, object gone" : "failed" @@ -424,14 +655,38 @@ final class StaticAsyncUpdateManager: NSObject { dataSource?.modify(with: detail) } } - ongoingRequest?.finishedTasksCount += details.count - notifyTasksCountToDelegate() + request.finishedTasksCount += details.count + if request === ongoingRequest { + notifyTasksCountToDelegate() + } } - private func fail() { + private func fail(_ request: DetailUpdateRequest, error: NSError) { + guard request === ongoingRequest else { return } ongoingRequest = nil notifyTasksCountToDelegate() + if error.code == LookinErrCode_Discard { + // Another client's detail request took the channel, or held it + // as this one was about to go (the MCP bridge asks for + // screenshots and details this way). Nothing went wrong: what is + // still missing, a whole tree a file waits for included, is asked + // for again once that request is done. + #log(.default, "AsyncUpdate - \(request.kind.name, privacy: .public) request gave way to another client's detail request") + resumeWhenChannelIsFree() + return + } + if request.kind == .wholeTree { + // The file gets what arrived. + finishWholeTree() + } + guard request.kind.isWaitedFor else { + // A prefetch the user never waited for fails quietly; the items + // it did not reach are asked for when they are displayed. + #log(.default, "AsyncUpdate - prefetch failed: \(error.localizedDescription, privacy: .public)") + return + } + let title = NSLocalizedString("Request timeout, layer data transmission failed.", comment: "") var detail = NSLocalizedString( "Perhaps your iOS app is paused with breakpoint in Xcode, blocked by other tasks in main thread, or moved to background state.\nToo large screenshots may also lead to this error.", @@ -445,41 +700,47 @@ final class StaticAsyncUpdateManager: NSObject { comment: "" ) } - let error = StaticErrors.make(title: title, detail: detail) + let alertError = StaticErrors.make(title: title, detail: detail) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { // The static view controller may not be set up yet to show the // tip, so wait a little. - self.delegate?.detailUpdateReceivedError(error) + self.delegate?.detailUpdateReceivedError(alertError) } } - private func complete() { - if let request = ongoingRequest { - let userCancelled = request.tasksTotalCount > request.finishedTasksCount - if !userCancelled { - succeededRequests.append(request) - } - if request.failedTasksCount > 0 { - let error = StaticErrors.make( - title: NSLocalizedString("Some layer data failed to transmit.", comment: ""), - detail: NSLocalizedString( - "It may be due to changes in the layer structure within the iOS app. You can try reloading the entire structure in LookInside.", - comment: "" - ) + /// Ends `request`; true when every task was answered, false when it was + /// cancelled or replaced first. + private func complete(_ request: DetailUpdateRequest) -> Bool { + guard request === ongoingRequest else { return false } + let answeredEveryTask = request.finishedTasksCount >= request.tasksTotalCount + if answeredEveryTask { + answeredTasks.record(request.taskKeys, sentForTreeVersion: request.treeVersion) + } + if request.kind.isWaitedFor, request.failedTasksCount > 0 { + let error = StaticErrors.make( + title: NSLocalizedString("Some layer data failed to transmit.", comment: ""), + detail: NSLocalizedString( + "It may be due to changes in the layer structure within the iOS app. You can try reloading the entire structure in LookInside.", + comment: "" ) - delegate?.detailUpdateReceivedError(error) - } - ongoingRequest = nil - } else { - assertionFailure() + ) + delegate?.detailUpdateReceivedError(error) } + ongoingRequest = nil notifyTasksCountToDelegate() PerformanceReporter.sharedInstance().didComplete() + if request.kind == .wholeTree { + // Answered, or stopped: the file gets what arrived. + finishWholeTree() + } + return answeredEveryTask } + /// Reports the request the user waits for; a prefetch reports nothing. private func notifyTasksCountToDelegate() { - let totalCount = ongoingRequest?.tasksTotalCount ?? 0 - let finishedCount = ongoingRequest?.finishedTasksCount ?? 0 + let request = ongoingRequest?.kind.isWaitedFor == true ? ongoingRequest : nil + let totalCount = request?.tasksTotalCount ?? 0 + let finishedCount = request?.finishedTasksCount ?? 0 #log(.default, "AsyncUpdate - notify delagate: \(NSNumber(value: finishedCount), privacy: .public)/\(NSNumber(value: totalCount), privacy: .public)") delegate?.detailUpdateTasksTotalCount(UInt(totalCount), finishedCount: UInt(max(finishedCount, 0))) } @@ -567,31 +828,82 @@ extension StaticAsyncUpdateManager { // MARK: - DetailUpdateRequest +/// What a request fetches, and whether the user waits for it. +private enum DetailRequestKind { + /// The displayed items, or an item reloaded on request: the window shows + /// the progress, and its reload button stops it. + case foreground + /// What expanding the folded items, and collapsing the expanded ones, + /// would show: never shown, never in the way of a reload, cancelled + /// whenever anything else needs the channel. + case prefetch + /// Everything the tree misses, before a file of it is written; shown and + /// stopped like `foreground`. + case wholeTree + + /// Whether the window shows the request's progress. + var isWaitedFor: Bool { + self != .prefetch + } + + /// For the log. + var name: String { + switch self { + case .foreground: "foreground" + case .prefetch: "prefetch" + case .wholeTree: "whole tree" + } + } +} + +/// A task as the answered-task record knows it. +private struct DetailTaskKey: Hashable { + let oid: UInt + let taskType: Int + let requestsAttributes: Bool + + init(_ task: StaticAsyncUpdateTask) { + oid = task.oid + taskType = task.taskType.rawValue + requestsAttributes = task.attrRequest == .need + } +} + /// One details request: its packages and how many of their tasks replied. private final class DetailUpdateRequest { - private(set) var packages: [StaticAsyncUpdateTasksPackage] + let kind: DetailRequestKind + let packages: [StaticAsyncUpdateTasksPackage] + /// `AnsweredTaskRecord.treeVersion` when the request was sent. + let treeVersion: Int + /// Where the request's events go; it names the request when it is + /// cancelled. + var sink: ResponseSink? /// Tasks that received a reply, failed ones included. Which ones is not /// known, only how many. var finishedTasksCount = 0 var failedTasksCount = 0 + /// From the send to the completion or failure. + var signpostInterval: SignpostInterval? - init(packages: [StaticAsyncUpdateTasksPackage]) { + init(kind: DetailRequestKind, packages: [StaticAsyncUpdateTasksPackage], treeVersion: Int) { + self.kind = kind self.packages = packages + self.treeVersion = treeVersion } - var tasksTotalCount: Int { - packages.reduce(0) { $0 + ($1.tasks?.count ?? 0) } + /// Closes `signpostInterval`, once. + func endSignpostInterval() { + guard let signpostInterval else { return } + self.signpostInterval = nil + #signpost(.end, signpostInterval) } - func contains(_ task: StaticAsyncUpdateTask) -> Bool { - packages.contains { ($0.tasks ?? []).contains(task) } + var tasksTotalCount: Int { + packages.reduce(0) { $0 + ($1.tasks?.count ?? 0) } } - func removeTasks(of item: DisplayItem) { - let itemOids = Set(item.availableObjectOidsPreferView(false).map(\.uintValue)) - for package in packages { - package.tasks = (package.tasks ?? []).filter { !itemOids.contains($0.oid) } - } + var taskKeys: [DetailTaskKey] { + packages.flatMap { ($0.tasks ?? []).map(DetailTaskKey.init) } } } diff --git a/LookInside/Static/StaticHierarchyDataSource.swift b/LookInside/Static/StaticHierarchyDataSource.swift index 0154c300..a4fd6b11 100644 --- a/LookInside/Static/StaticHierarchyDataSource.swift +++ b/LookInside/Static/StaticHierarchyDataSource.swift @@ -22,11 +22,11 @@ class StaticHierarchyDataSource: HierarchyDataSource { /// `.itemDidChangeFrame`. final let itemsDidChangeFrame = SyncSignal() - /// Keeps a detail's subitems rebuild from starting a fast-mode update + private var storedAppInfo: InspectedAppInfo? + /// Keeps a detail's subitems rebuild from starting a detail update /// while the update task that delivered it is still running: update /// tasks must not overlap. - private var storedAppInfo: InspectedAppInfo? - private var shouldIgnoreFastModeAutoUpdate = false + private var shouldIgnoreAutomaticDetailUpdate = false private var isUsingDanceUI = false private var frameSubscription: SyncSubscription? @@ -66,12 +66,8 @@ class StaticHierarchyDataSource: HierarchyDataSource { } updateMessageStatus() - - if !PreferenceManager.shared.fastMode.currentBOOLValue { - MainActor.assumeIsolated { - asyncUpdateManager?.updateAll() - } - } + // The details follow from buildDisplayingFlatItems(), which the reload + // ran: the displayed items first, then the prefetch. } func modify(with detail: DisplayItemDetail?) { @@ -133,10 +129,10 @@ class StaticHierarchyDataSource: HierarchyDataSource { private func replaceSubitems(of item: DisplayItem, with subitems: [DisplayItem]) { // Without this flag the buildDisplayingFlatItems below would start a - // fast-mode update task while the current one is still running (it + // detail update task while the current one is still running (it // is still in its subscription, not yet completed), and update tasks // must not run concurrently. - shouldIgnoreFastModeAutoUpdate = true + shouldIgnoreAutomaticDetailUpdate = true // Leave search or focus first; keeping their state across the // rebuild is not worth it. @@ -170,12 +166,12 @@ class StaticHierarchyDataSource: HierarchyDataSource { } } buildDisplayingFlatItems() - shouldIgnoreFastModeAutoUpdate = false + shouldIgnoreAutomaticDetailUpdate = false } override func buildDisplayingFlatItems() { super.buildDisplayingFlatItems() - if PreferenceManager.shared.fastMode.currentBOOLValue, !shouldIgnoreFastModeAutoUpdate { + if !shouldIgnoreAutomaticDetailUpdate { MainActor.assumeIsolated { asyncUpdateManager?.updateForDisplayingItems() } diff --git a/LookInside/Static/StaticWindowController.swift b/LookInside/Static/StaticWindowController.swift index 706be4d7..97708343 100644 --- a/LookInside/Static/StaticWindowController.swift +++ b/LookInside/Static/StaticWindowController.swift @@ -386,6 +386,9 @@ final class StaticWindowController: WindowController, NSToolbarDelegate, @precon // relabels the reload that refused it. lastReloadInitiator = initiator isFetchingHierarchy = true + // The prefetch of the tree being replaced would only compete with + // the hierarchy request, in the inspected app and here. + asyncUpdateManager.stopPrefetching() viewController.progressView.animate(toProgress: InitialIndicatorProgressWhenFetchHierarchy) PerformanceReporter.sharedInstance().willStartReload() @@ -396,6 +399,8 @@ final class StaticWindowController: WindowController, NSToolbarDelegate, @precon } catch { self?.viewController.progressView.resetToZero() self?.isFetchingHierarchy = false + // The old tree stays: carry on with its prefetch. + self?.asyncUpdateManager.updateForDisplayingItems() completion(.failure(error as NSError)) return } @@ -421,6 +426,7 @@ final class StaticWindowController: WindowController, NSToolbarDelegate, @precon // button stays disabled and the caller never hears back. viewController.progressView.resetToZero() isFetchingHierarchy = false + asyncUpdateManager.updateForDisplayingItems() completion(.failure(ReloadError.make( ReloadError.noResponse, NSLocalizedString("The app finished the hierarchy request without returning a hierarchy.", comment: "") @@ -701,6 +707,16 @@ final class StaticWindowController: WindowController, NSToolbarDelegate, @precon } @objc func appMenuManagerDidSelectExport() { + guard hierarchyDataSource.rawHierarchyInfo != nil else { + return + } + // The file cannot ask the app later: first fetch what the tree misses. + asyncUpdateManager.fetchWholeTree { [weak self] in + self?.showExportPanel() + } + } + + private func showExportPanel() { guard let hierarchyInfo = hierarchyDataSource.rawHierarchyInfo else { return } @@ -749,11 +765,16 @@ final class StaticWindowController: WindowController, NSToolbarDelegate, @precon } @objc func appMenuManagerDidSelectOpenInNewWindow() { - let newHierarchyInfo = hierarchyDataSource.rawHierarchyInfo?.copy() as? HierarchyInfo - let file = HierarchyFile() - file.serverVersion = newHierarchyInfo?.serverVersion ?? 0 - file.hierarchyInfo = newHierarchyInfo - NavigationManager.shared.showReader(with: file, title: nil) + // The reader cannot ask the app later: first fetch what the tree + // misses. + asyncUpdateManager.fetchWholeTree { [weak self] in + guard let self else { return } + let newHierarchyInfo = hierarchyDataSource.rawHierarchyInfo?.copy() as? HierarchyInfo + let file = HierarchyFile() + file.serverVersion = newHierarchyInfo?.serverVersion ?? 0 + file.hierarchyInfo = newHierarchyInfo + NavigationManager.shared.showReader(with: file, title: nil) + } } @objc func appMenuManagerDidSelectFilter() { @@ -815,7 +836,8 @@ final class StaticWindowController: WindowController, NSToolbarDelegate, @precon let priorSwiftUIID = hierarchyDataSource.selectedItem?.customInfo?.swiftUIDisplayItemID let priorWasSwiftUI = SwiftUISelectionMigration.isSwiftUIID(priorSwiftUIID) - app.cancelHierarchyDetailFetching() + // This window's detail fetch only; the MCP bridge's request goes on. + asyncUpdateManager.endUpdating() viewController.progressView.animate(toProgress: InitialIndicatorProgressWhenFetchHierarchy) Task { [weak self] in do { diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ActiveRequests.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ActiveRequests.swift new file mode 100644 index 00000000..acc245e7 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ActiveRequests.swift @@ -0,0 +1,67 @@ +import Foundation + +/// A request a channel sent, as `ActiveRequests` sees it. +@MainActor +public protocol ChannelRequest: AnyObject { + var type: UInt32 { get } +} + +/// The requests one channel sent and has not heard the end of. +/// +/// One request of a type is in flight at a time: a response carries only its +/// request's type and tag, and the tag is the send time in seconds, so two +/// requests of a type in flight could not be told apart. A new request either +/// replaces the one of its type in flight, which the caller then fails as +/// discarded, or yields to it and is not sent. Upstream Lookin only +/// replaced; the inspector's own detail requests yield, so that resuming its +/// fetch never knocks out the one the MCP bridge sent. Requests of the types +/// in `typesThatMayOverlap` (pings) never replace one another. +@MainActor +public final class ActiveRequests { + public enum Admission { + /// The request is in flight now. `replacedRequests` were in flight + /// with its type and are not any more. + case admitted(replacedRequests: [Request]) + /// The request yields to one of its type in flight; it was not + /// added. + case refused + } + + public private(set) var requests: [Request] = [] + private let typesThatMayOverlap: Set + + public init(typesThatMayOverlap: Set) { + self.typesThatMayOverlap = typesThatMayOverlap + } + + /// Adds `request` unless it yields to one of its type in flight; + /// otherwise the ones of its type in flight make way. + public func admit(_ request: Request, yieldsToRequestInFlight: Bool) -> Admission { + var replacedRequests: [Request] = [] + if !typesThatMayOverlap.contains(request.type) { + replacedRequests = requests.filter { $0.type == request.type } + if yieldsToRequestInFlight, !replacedRequests.isEmpty { + return .refused + } + requests.removeAll { $0.type == request.type } + } + requests.append(request) + return .admitted(replacedRequests: replacedRequests) + } + + public func remove(_ request: Request) { + requests.removeAll { $0 === request } + } + + public func contains(_ request: Request) -> Bool { + requests.contains { $0 === request } + } + + public func containsRequest(ofType type: UInt32) -> Bool { + requests.contains { $0.type == type } + } + + public func first(where predicate: (Request) -> Bool) -> Request? { + requests.first(where: predicate) + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/AnsweredTaskRecord.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/AnsweredTaskRecord.swift new file mode 100644 index 00000000..062b5a34 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/AnsweredTaskRecord.swift @@ -0,0 +1,39 @@ +import Foundation + +/// The detail tasks that requests answered in full since the hierarchy was +/// last reloaded, so a task answered without an image (a leaf has no solo +/// screenshot) is not asked for again each time the displayed items change. +/// +/// A request notes `treeVersion` when it is sent. One sent before the latest +/// reload records nothing: part of its replies went to the tree the reload +/// replaced, so its tasks are still missing in the new one. +public struct AnsweredTaskRecord { + /// Goes up with each reload. + public private(set) var treeVersion = 0 + private var keys = Set() + + public init() {} + + public func contains(_ key: TaskKey) -> Bool { + keys.contains(key) + } + + /// The hierarchy was reloaded: no task is answered for the new tree yet. + public mutating func startNewTree() { + treeVersion += 1 + keys.removeAll() + } + + /// Records the tasks of a request that every task answered; nothing + /// when it was sent for an earlier tree. + public mutating func record(_ taskKeys: some Sequence, sentForTreeVersion: Int) { + guard sentForTreeVersion == treeVersion else { return } + keys.formUnion(taskKeys) + } + + /// Forgets the tasks `shouldForget` matches, so they are asked for + /// again. + public mutating func forget(where shouldForget: (TaskKey) -> Bool) { + keys = keys.filter { !shouldForget($0) } + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/DetailFetchPlanning.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/DetailFetchPlanning.swift new file mode 100644 index 00000000..bc855452 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/DetailFetchPlanning.swift @@ -0,0 +1,188 @@ +import Foundation + +/// Which details the inspector asks the inspected app for after a reload, +/// and in which order. +/// +/// A refresh used to ask for every node's group screenshot, every +/// expandable node's solo screenshot and every node's attributes at once. +/// With a 2,000-layer screen that was 6,594 tasks while 68 rows were on +/// screen; the attributes alone were two thirds of the bytes. Now the +/// inspector fetches in two kinds of request: +/// +/// - **On screen**: for each displayed node, the screenshot the preview +/// shows for it (`StaticAsyncUpdateManager` mirrors +/// `DisplayItem.appropriateScreenshot()`), and its attributes when it has +/// none yet. The window shows the progress. +/// - **Prefetch**: what expanding a folded node would show — its solo +/// screenshot and its children's group screenshots — breadth first from +/// the rows on screen, so the next expand finds them ready; then what +/// collapsing a displayed node would show, its group screenshot. Never +/// attributes: a node gets those when it is displayed. +/// - **Whole tree**: before a file of the hierarchy is written, everything +/// any node misses, attributes included. The window shows the progress. +public enum DetailFetchPlanning { + public enum Screenshot: Equatable, Sendable { + case solo + case group + } + + /// One node of the hierarchy, as planning sees it. + public struct Node: Equatable, Sendable { + /// Indexes of the children in the same list. + public var childIndexes: [Int] + public var isExpandable: Bool + public var isExpanded: Bool + public var hasPixelBearingSubitems: Bool + /// Not a pixelless node, not too large, not excluded by the app. + public var mayFetchScreenshot: Bool + /// A custom node: it has no object the inspected app can resolve. + public var isUserCustom: Bool + public var hasSoloScreenshot: Bool + public var hasGroupScreenshot: Bool + public var hasAttributes: Bool + + public init( + childIndexes: [Int], + isExpandable: Bool, + isExpanded: Bool, + hasPixelBearingSubitems: Bool, + mayFetchScreenshot: Bool, + isUserCustom: Bool, + hasSoloScreenshot: Bool, + hasGroupScreenshot: Bool, + hasAttributes: Bool + ) { + self.childIndexes = childIndexes + self.isExpandable = isExpandable + self.isExpanded = isExpanded + self.hasPixelBearingSubitems = hasPixelBearingSubitems + self.mayFetchScreenshot = mayFetchScreenshot + self.isUserCustom = isUserCustom + self.hasSoloScreenshot = hasSoloScreenshot + self.hasGroupScreenshot = hasGroupScreenshot + self.hasAttributes = hasAttributes + } + + /// The screenshot the preview shows for the node in its current + /// state: the solo one once its children that draw something are + /// shown on their own, the group one otherwise. + public var shownScreenshot: Screenshot { + isExpandable && isExpanded && hasPixelBearingSubitems ? .solo : .group + } + + func has(_ screenshot: Screenshot) -> Bool { + switch screenshot { + case .solo: + hasSoloScreenshot + case .group: + hasGroupScreenshot + } + } + } + + /// One thing to ask for: a node's screenshot, its attributes, or both. + public struct Request: Equatable, Sendable { + public var nodeIndex: Int + /// Nil for attributes alone. + public var screenshot: Screenshot? + public var wantsAttributes: Bool + + public init(nodeIndex: Int, screenshot: Screenshot?, wantsAttributes: Bool) { + self.nodeIndex = nodeIndex + self.screenshot = screenshot + self.wantsAttributes = wantsAttributes + } + } + + /// What the displayed nodes miss, in display order: the screenshot the + /// preview shows for each, and attributes for those that have none. + public static func onScreenRequests(for nodes: [Node], displayedIndexes: [Int]) -> [Request] { + displayedIndexes.compactMap { index in + let node = nodes[index] + guard !node.isUserCustom else { + return nil + } + var screenshot: Screenshot? + if node.mayFetchScreenshot, !node.has(node.shownScreenshot) { + screenshot = node.shownScreenshot + } + let wantsAttributes = !node.hasAttributes + guard screenshot != nil || wantsAttributes else { + return nil + } + return Request(nodeIndex: index, screenshot: screenshot, wantsAttributes: wantsAttributes) + } + } + + /// What expanding the folded nodes would show, breadth first from the + /// displayed ones: for each folded node, its solo screenshot (when it has + /// children that draw something) and its children's group screenshots; + /// then the same one level further down. Last, what collapsing would + /// show: the group screenshot of each displayed node that shows its solo + /// one now. Only screenshots the nodes do not have yet; never attributes. + public static func prefetchRequests(for nodes: [Node], displayedIndexes: [Int]) -> [Request] { + var requests: [Request] = [] + var foldedIndexes = displayedIndexes.filter { nodes[$0].isExpandable && !nodes[$0].isExpanded } + var visitedIndexes = Set(foldedIndexes) + while !foldedIndexes.isEmpty { + var nextFoldedIndexes: [Int] = [] + for index in foldedIndexes { + let node = nodes[index] + if !node.isUserCustom, node.mayFetchScreenshot, node.hasPixelBearingSubitems, !node.hasSoloScreenshot { + requests.append(Request(nodeIndex: index, screenshot: .solo, wantsAttributes: false)) + } + for childIndex in node.childIndexes { + let child = nodes[childIndex] + if !child.isUserCustom, child.mayFetchScreenshot, !child.hasGroupScreenshot { + requests.append(Request(nodeIndex: childIndex, screenshot: .group, wantsAttributes: false)) + } + if child.isExpandable, visitedIndexes.insert(childIndex).inserted { + nextFoldedIndexes.append(childIndex) + } + } + } + foldedIndexes = nextFoldedIndexes + } + for index in displayedIndexes { + let node = nodes[index] + if !node.isUserCustom, node.mayFetchScreenshot, node.shownScreenshot == .solo, !node.hasGroupScreenshot { + requests.append(Request(nodeIndex: index, screenshot: .group, wantsAttributes: false)) + } + } + return requests + } + + /// What the nodes miss for a file of the whole tree (an export, a Save + /// As, a reader window), which cannot ask the inspected app later: every + /// node's group screenshot, its solo screenshot when it has children that + /// draw something, and its attributes. The displayed nodes' requests come + /// first, as on screen; then the rest in list order, a node's attributes + /// with its first request. + public static func wholeTreeRequests(for nodes: [Node], displayedIndexes: [Int]) -> [Request] { + var requests = onScreenRequests(for: nodes, displayedIndexes: displayedIndexes) + // A displayed node's attributes and shown screenshot are asked for + // above already, when it misses them. + let displayedIndexSet = Set(displayedIndexes) + for (index, node) in nodes.enumerated() where !node.isUserCustom { + let isDisplayed = displayedIndexSet.contains(index) + var wantsAttributes = !node.hasAttributes && !isDisplayed + if node.mayFetchScreenshot { + var missingScreenshots: [Screenshot] = [] + if !node.hasGroupScreenshot { + missingScreenshots.append(.group) + } + if node.isExpandable, node.hasPixelBearingSubitems, !node.hasSoloScreenshot { + missingScreenshots.append(.solo) + } + for screenshot in missingScreenshots where !(isDisplayed && screenshot == node.shownScreenshot) { + requests.append(Request(nodeIndex: index, screenshot: screenshot, wantsAttributes: wantsAttributes)) + wantsAttributes = false + } + } + if wantsAttributes { + requests.append(Request(nodeIndex: index, screenshot: nil, wantsAttributes: true)) + } + } + return requests + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PacedMainQueueDelivery.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PacedMainQueueDelivery.swift new file mode 100644 index 00000000..86e59562 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PacedMainQueueDelivery.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Hands items to a handler on the main queue, in the order they were +/// enqueued, one time slice per main-queue turn. +/// +/// Work handed to the main thread in one block keeps the run loop from +/// events, timers and drawing until it ends. The Host reads its channels with +/// dispatch I/O on the main queue, and dispatch I/O calls its read handler on +/// a serial queue that targets the main queue: that queue goes on draining +/// for as long as more data arrives in time, so frames handled inside the +/// read handler hold the main thread for a whole multi-frame response (2.4 s +/// for a 7,000-task prefetch from a Mac app). Items enqueued here are handled +/// for up to `timeBudget`; the rest wait for a later main-queue turn, after +/// the run loop has had its go. +/// +/// - Each turn handles at least one item, however long it takes. +/// - An item the handler enqueues waits behind the ones already pending. +/// - Pending items keep the delivery, and what its handler holds, alive. +@MainActor +public final class PacedMainQueueDelivery { + private let timeBudget: Duration + private let handler: @MainActor (Item) -> Void + private let clock = ContinuousClock() + /// The pending items are `pendingItems[headIndex...]`; the slots before + /// `headIndex` were handled and hold nil. + private var pendingItems: [Item?] = [] + private var headIndex = 0 + private var isTurnScheduled = false + + /// - Parameters: + /// - timeBudget: how long one turn goes on handling items; the item + /// that runs past it ends the turn. + /// - handler: called on the main actor for each item, in order. + public init(timeBudget: Duration, handler: @escaping @MainActor (Item) -> Void) { + self.timeBudget = timeBudget + self.handler = handler + } + + /// The items not handled yet. + public var pendingCount: Int { + pendingItems.count - headIndex + } + + /// Queues `item` behind the pending ones; it is handled on a later + /// main-queue turn, never before this returns. + public func enqueue(_ item: Item) { + pendingItems.append(item) + scheduleTurnIfNeeded() + } + + /// Whether a pending item matches `predicate`. + public func containsPending(where predicate: (Item) -> Bool) -> Bool { + pendingItems[headIndex...].contains { item in + item.map(predicate) ?? false + } + } + + /// Drops the pending items `shouldRemove` matches. The handler may call + /// it for the items after its own. + public func removePending(where shouldRemove: (Item) -> Bool) { + var keptItems: [Item?] = [] + for case let item? in pendingItems[headIndex...] where !shouldRemove(item) { + keptItems.append(item) + } + pendingItems = keptItems + headIndex = 0 + } + + private func scheduleTurnIfNeeded() { + guard !isTurnScheduled, pendingCount > 0 else { return } + isTurnScheduled = true + // The main queue runs the blocks it held when its drain began; this + // block, queued during a turn, runs after the run loop's next pass. + DispatchQueue.main.async { [self] in + MainActor.assumeIsolated { + handleTurn() + } + } + } + + private func handleTurn() { + let deadline = clock.now.advanced(by: timeBudget) + while headIndex < pendingItems.count { + let item = pendingItems[headIndex] + pendingItems[headIndex] = nil + headIndex += 1 + if let item { + handler(item) + } + if clock.now >= deadline { + break + } + } + compactHandledSlots() + // Still set while the handler ran, so its enqueues did not schedule + // a second turn. + isTurnScheduled = false + scheduleTurnIfNeeded() + } + + private func compactHandledSlots() { + if headIndex == pendingItems.count { + pendingItems.removeAll(keepingCapacity: true) + headIndex = 0 + } else if headIndex >= 1024, headIndex * 2 >= pendingItems.count { + pendingItems.removeFirst(headIndex) + headIndex = 0 + } + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreviewZOrdering.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreviewZOrdering.swift new file mode 100644 index 00000000..9629368a --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreviewZOrdering.swift @@ -0,0 +1,99 @@ +import CoreGraphics +import Foundation + +/// How the 3D preview stacks its planes: the z index of every plane, from +/// the planes' frames and states. +/// +/// The rule (`PreviewView.updateZPosition()`): planes are visited in +/// hierarchy order, parents before children. +/// - A plane drawn on its owner's plane (a layout guide, a cell) takes the +/// owner's z index, at least 0. +/// - A plane shown in the hierarchy sits one level above the highest plane +/// it overlaps among those visited before it, or at 0 when it overlaps +/// none. Planes in a hidden part of the hierarchy count only when hidden +/// items are shown. +/// - A plane not shown (inside a folded node) takes its parent's z index, +/// or 0 without a parent. +/// +/// Every input frame is the plane's frame in root coordinates, computed +/// once per pass; the preview used to recompute it, up the whole ancestor +/// chain, for every pair of planes it compared. +public enum PreviewZOrdering { + /// Where a plane's parent is, for the planes that follow it. + public enum Parent: Equatable, Sendable { + /// A top-level plane. + case none + /// The parent is the plane at this index in the same list, earlier + /// in it. + case plane(index: Int) + /// The parent is not in the list (it has no preview of its own); + /// its last known z index. + case outside(zIndex: Int) + } + + public struct Plane: Equatable, Sendable { + public var frameToRoot: CGRect + public var parent: Parent + /// Shown in the hierarchy (not inside a folded node). + public var isDisplayed: Bool + /// Drawn on its owner's plane rather than on one of its own. + public var isCoplanarOverlay: Bool + public var isInHiddenHierarchy: Bool + + public init(frameToRoot: CGRect, parent: Parent, isDisplayed: Bool, isCoplanarOverlay: Bool, isInHiddenHierarchy: Bool) { + self.frameToRoot = frameToRoot + self.parent = parent + self.isDisplayed = isDisplayed + self.isCoplanarOverlay = isCoplanarOverlay + self.isInHiddenHierarchy = isInHiddenHierarchy + } + } + + /// The z index of each plane, in order. + public static func zIndexes(of planes: [Plane], showsHiddenItems: Bool) -> [Int] { + var zIndexes = [Int](repeating: 0, count: planes.count) + for (index, plane) in planes.enumerated() { + let zIndex: Int + if plane.isCoplanarOverlay, let parentZIndex = parentZIndex(of: plane, in: zIndexes) { + zIndex = max(parentZIndex, 0) + } else if plane.isDisplayed { + zIndex = highestOverlappedZIndex(before: index, in: planes, zIndexes: zIndexes, showsHiddenItems: showsHiddenItems).map { $0 + 1 } ?? 0 + } else { + zIndex = parentZIndex(of: plane, in: zIndexes) ?? 0 + } + zIndexes[index] = max(zIndex, 0) + } + return zIndexes + } + + private static func parentZIndex(of plane: Plane, in zIndexes: [Int]) -> Int? { + switch plane.parent { + case .none: + nil + case let .plane(parentIndex): + zIndexes[parentIndex] + case let .outside(zIndex): + zIndex + } + } + + /// The highest z index among the planes before `index` that overlap it; + /// nil when none does. + private static func highestOverlappedZIndex(before index: Int, in planes: [Plane], zIndexes: [Int], showsHiddenItems: Bool) -> Int? { + let frame = planes[index].frameToRoot + var highest: Int? + for otherIndex in 0 ..< index { + let other = planes[otherIndex] + if other.isInHiddenHierarchy, !showsHiddenItems { + continue + } + if let highest, zIndexes[otherIndex] <= highest { + continue + } + if frame.intersects(other.frameToRoot) { + highest = zIndexes[otherIndex] + } + } + return highest + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ResponseCountdown.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ResponseCountdown.swift new file mode 100644 index 00000000..a2d1234a --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ResponseCountdown.swift @@ -0,0 +1,56 @@ +import Foundation + +/// How long a request waits for its next response frame before it times +/// out, counted on the main run loop in the default mode only +/// (`-performSelector:withObject:afterDelay:`, as the Objective-C request +/// class did). +/// +/// The Host hands the frames it reads to the connection layer a time slice +/// per main-queue turn (`PacedMainQueueDelivery`), so a response can arrive in +/// time and still wait behind other frames when the countdown ends. Then the +/// countdown starts over instead of expiring: the inspected app answered, +/// only the Host has not caught up yet. +@MainActor +public final class ResponseCountdown: NSObject { + private let interval: TimeInterval + private let isResponseWaiting: @MainActor () -> Bool + private let onExpire: @MainActor () -> Void + + /// - Parameters: + /// - interval: must be positive. + /// - isResponseWaiting: whether a response the request is waiting + /// for has been read but not handed over yet; asked when the + /// countdown ends. + /// - onExpire: the request timed out. + public init( + interval: TimeInterval, + isResponseWaiting: @escaping @MainActor () -> Bool, + onExpire: @escaping @MainActor () -> Void + ) { + self.interval = interval + self.isResponseWaiting = isResponseWaiting + self.onExpire = onExpire + } + + /// Starts the countdown, or starts it over. + public func restart() { + stop() + guard interval > 0 else { + assertionFailure("the countdown interval is 0") + return + } + perform(#selector(handleEnd), with: nil, afterDelay: interval) + } + + public func stop() { + NSObject.cancelPreviousPerformRequests(withTarget: self) + } + + @objc private func handleEnd() { + if isResponseWaiting() { + restart() + return + } + onExpire() + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ActiveRequestsTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ActiveRequestsTests.swift new file mode 100644 index 00000000..96deb229 --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ActiveRequestsTests.swift @@ -0,0 +1,87 @@ +@testable import LookInsideHostCore +import Testing + +private final class SampleRequest: ChannelRequest { + let type: UInt32 + + init(type: UInt32) { + self.type = type + } +} + +@MainActor +struct ActiveRequestsTests { + private static let pingType: UInt32 = 200 + private static let hierarchyType: UInt32 = 202 + private static let detailsType: UInt32 = 203 + + private func makeActiveRequests() -> ActiveRequests { + ActiveRequests(typesThatMayOverlap: [Self.pingType]) + } + + /// The requests an admission replaced; nil when it was refused. + private func replacedRequests(_ admission: ActiveRequests.Admission) -> [SampleRequest]? { + switch admission { + case let .admitted(replacedRequests): + replacedRequests + case .refused: + nil + } + } + + @Test func aNewerRequestReplacesTheOneOfItsTypeInFlight() throws { + let activeRequests = makeActiveRequests() + let older = SampleRequest(type: Self.detailsType) + let newer = SampleRequest(type: Self.detailsType) + _ = activeRequests.admit(older, yieldsToRequestInFlight: false) + + let replaced = try #require(replacedRequests(activeRequests.admit(newer, yieldsToRequestInFlight: false))) + #expect(replaced.map(ObjectIdentifier.init) == [ObjectIdentifier(older)]) + #expect(activeRequests.requests.map(ObjectIdentifier.init) == [ObjectIdentifier(newer)]) + } + + /// The inspector's own detail requests yield, so they never knock out + /// the one the MCP bridge sent. + @Test func aRequestThatYieldsIsNotAddedWhileOneOfItsTypeIsInFlight() { + let activeRequests = makeActiveRequests() + let inFlight = SampleRequest(type: Self.detailsType) + _ = activeRequests.admit(inFlight, yieldsToRequestInFlight: false) + + let admission = activeRequests.admit(SampleRequest(type: Self.detailsType), yieldsToRequestInFlight: true) + #expect(replacedRequests(admission) == nil) + #expect(activeRequests.requests.map(ObjectIdentifier.init) == [ObjectIdentifier(inFlight)]) + } + + @Test func aRequestThatYieldsIsAddedWhenNoneOfItsTypeIsInFlight() throws { + let activeRequests = makeActiveRequests() + let hierarchy = SampleRequest(type: Self.hierarchyType) + let details = SampleRequest(type: Self.detailsType) + _ = activeRequests.admit(hierarchy, yieldsToRequestInFlight: false) + + let replaced = try #require(replacedRequests(activeRequests.admit(details, yieldsToRequestInFlight: true))) + #expect(replaced.isEmpty) + #expect(activeRequests.requests.map(ObjectIdentifier.init) == [ObjectIdentifier(hierarchy), ObjectIdentifier(details)]) + } + + @Test func aRemovedRequestNoLongerHoldsItsType() throws { + let activeRequests = makeActiveRequests() + let finished = SampleRequest(type: Self.detailsType) + _ = activeRequests.admit(finished, yieldsToRequestInFlight: false) + activeRequests.remove(finished) + + #expect(activeRequests.containsRequest(ofType: Self.detailsType) == false) + let replaced = try #require(replacedRequests(activeRequests.admit(SampleRequest(type: Self.detailsType), yieldsToRequestInFlight: true))) + #expect(replaced.isEmpty) + } + + @Test func pingsDoNotReplaceOneAnother() throws { + let activeRequests = makeActiveRequests() + let firstPing = SampleRequest(type: Self.pingType) + let secondPing = SampleRequest(type: Self.pingType) + _ = activeRequests.admit(firstPing, yieldsToRequestInFlight: false) + + let replaced = try #require(replacedRequests(activeRequests.admit(secondPing, yieldsToRequestInFlight: false))) + #expect(replaced.isEmpty) + #expect(activeRequests.requests.count == 2) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AnsweredTaskRecordTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AnsweredTaskRecordTests.swift new file mode 100644 index 00000000..04514bac --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AnsweredTaskRecordTests.swift @@ -0,0 +1,37 @@ +@testable import LookInsideHostCore +import Testing + +struct AnsweredTaskRecordTests { + @Test func theTasksOfARequestAnsweredInFullAreRecorded() { + var record = AnsweredTaskRecord() + record.record(["window solo", "label group"], sentForTreeVersion: record.treeVersion) + #expect(record.contains("window solo")) + #expect(record.contains("label group")) + } + + @Test func aReloadForgetsEveryTask() { + var record = AnsweredTaskRecord() + record.record(["window solo"], sentForTreeVersion: record.treeVersion) + record.startNewTree() + #expect(record.contains("window solo") == false) + } + + /// The bug this guards against: a request sent for the tree a reload + /// replaced finished afterwards, and the tasks it marked as answered + /// were never asked for in the new tree. + @Test func aRequestSentBeforeAReloadRecordsNothing() { + var record = AnsweredTaskRecord() + let treeVersionAtSend = record.treeVersion + record.startNewTree() + record.record(["window solo"], sentForTreeVersion: treeVersionAtSend) + #expect(record.contains("window solo") == false) + } + + @Test func forgottenTasksAreNoLongerRecorded() { + var record = AnsweredTaskRecord() + record.record(["window solo", "label group"], sentForTreeVersion: record.treeVersion) + record.forget { $0.hasPrefix("window") } + #expect(record.contains("window solo") == false) + #expect(record.contains("label group")) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/DetailFetchPlanningTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/DetailFetchPlanningTests.swift new file mode 100644 index 00000000..00179d53 --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/DetailFetchPlanningTests.swift @@ -0,0 +1,182 @@ +@testable import LookInsideHostCore +import Testing + +struct DetailFetchPlanningTests { + typealias Request = DetailFetchPlanning.Request + + /// window (expanded) + /// container (folded) + /// label + /// stack (folded) + /// icon + /// badge + private func sampleTree() -> [DetailFetchPlanning.Node] { + [ + node(children: [1, 4], isExpandable: true, isExpanded: true), + node(children: [2, 3], isExpandable: true, isExpanded: false), + node(children: []), + node(children: [5], isExpandable: true, isExpanded: false), + node(children: []), + node(children: []), + ] + } + + @Test func onScreenNodesGetTheScreenshotThePreviewShowsAndTheirAttributes() { + let requests = DetailFetchPlanning.onScreenRequests(for: sampleTree(), displayedIndexes: [0, 1, 4]) + #expect(requests == [ + Request(nodeIndex: 0, screenshot: .solo, wantsAttributes: true), + Request(nodeIndex: 1, screenshot: .group, wantsAttributes: true), + Request(nodeIndex: 4, screenshot: .group, wantsAttributes: true), + ]) + } + + @Test func onScreenRequestsSkipWhatTheNodesAlreadyHave() { + var nodes = sampleTree() + nodes[0].hasSoloScreenshot = true + nodes[0].hasAttributes = true + nodes[1].hasGroupScreenshot = true + nodes[4].hasAttributes = true + let requests = DetailFetchPlanning.onScreenRequests(for: nodes, displayedIndexes: [0, 1, 4]) + #expect(requests == [ + Request(nodeIndex: 1, screenshot: nil, wantsAttributes: true), + Request(nodeIndex: 4, screenshot: .group, wantsAttributes: false), + ]) + } + + @Test func anExpandedNodeWithoutPixelBearingChildrenShowsItsGroupScreenshot() { + var nodes = sampleTree() + nodes[0].hasPixelBearingSubitems = false + let requests = DetailFetchPlanning.onScreenRequests(for: nodes, displayedIndexes: [0]) + #expect(requests == [Request(nodeIndex: 0, screenshot: .group, wantsAttributes: true)]) + } + + @Test func nodesThatMayNotBeScreenshotGetAttributesOnlyAndCustomNodesNothing() { + var nodes = sampleTree() + nodes[1].mayFetchScreenshot = false + nodes[4].isUserCustom = true + let requests = DetailFetchPlanning.onScreenRequests(for: nodes, displayedIndexes: [1, 4]) + #expect(requests == [Request(nodeIndex: 1, screenshot: nil, wantsAttributes: true)]) + } + + @Test func prefetchGoesBreadthFirstThroughWhatExpandingWouldShowThenWhatCollapsingWould() { + let requests = DetailFetchPlanning.prefetchRequests(for: sampleTree(), displayedIndexes: [0, 1, 4]) + #expect(requests == [ + // Expanding the container: its solo screenshot, its children's group screenshots. + Request(nodeIndex: 1, screenshot: .solo, wantsAttributes: false), + Request(nodeIndex: 2, screenshot: .group, wantsAttributes: false), + Request(nodeIndex: 3, screenshot: .group, wantsAttributes: false), + // One level further: expanding the stack. + Request(nodeIndex: 3, screenshot: .solo, wantsAttributes: false), + Request(nodeIndex: 5, screenshot: .group, wantsAttributes: false), + // Last, collapsing the window, which shows its solo screenshot now. + Request(nodeIndex: 0, screenshot: .group, wantsAttributes: false), + ]) + } + + @Test func prefetchSkipsScreenshotsAlreadyThereAndNodesThatMayNotBeScreenshot() { + var nodes = sampleTree() + nodes[0].hasGroupScreenshot = true + nodes[1].hasSoloScreenshot = true + nodes[2].hasGroupScreenshot = true + nodes[3].mayFetchScreenshot = false + let requests = DetailFetchPlanning.prefetchRequests(for: nodes, displayedIndexes: [0, 1, 4]) + #expect(requests == [Request(nodeIndex: 5, screenshot: .group, wantsAttributes: false)]) + } + + @Test func collapsingAsksForNoScreenshotTheNodeShowsAlready() { + var nodes = sampleTree() + // No child draws anything: the expanded window shows its group screenshot. + nodes[0].hasPixelBearingSubitems = false + let requests = DetailFetchPlanning.prefetchRequests(for: nodes, displayedIndexes: [0, 1, 4]) + #expect(requests.contains(Request(nodeIndex: 0, screenshot: .group, wantsAttributes: false)) == false) + } + + @Test func prefetchAsksForNoSoloScreenshotWhenNoChildDrawsAnything() { + var nodes = sampleTree() + nodes[1].hasPixelBearingSubitems = false + let requests = DetailFetchPlanning.prefetchRequests(for: nodes, displayedIndexes: [1]) + #expect(requests.contains(Request(nodeIndex: 1, screenshot: .solo, wantsAttributes: false)) == false) + #expect(requests.first == Request(nodeIndex: 2, screenshot: .group, wantsAttributes: false)) + } + + @Test func onceEveryNodeIsExpandedOnlyWhatCollapsingWouldShowIsLeft() { + var nodes = sampleTree() + nodes[1].isExpanded = true + nodes[3].isExpanded = true + let requests = DetailFetchPlanning.prefetchRequests(for: nodes, displayedIndexes: [0, 1, 2, 3, 4, 5]) + #expect(requests == [ + Request(nodeIndex: 0, screenshot: .group, wantsAttributes: false), + Request(nodeIndex: 1, screenshot: .group, wantsAttributes: false), + Request(nodeIndex: 3, screenshot: .group, wantsAttributes: false), + ]) + } + + @Test func aSavedFileGetsEverythingTheNodesMissDisplayedNodesFirst() { + let requests = DetailFetchPlanning.wholeTreeRequests(for: sampleTree(), displayedIndexes: [0, 1, 4]) + #expect(requests == [ + // What the displayed nodes show, as on screen. + Request(nodeIndex: 0, screenshot: .solo, wantsAttributes: true), + Request(nodeIndex: 1, screenshot: .group, wantsAttributes: true), + Request(nodeIndex: 4, screenshot: .group, wantsAttributes: true), + // Then, in list order, every other screenshot a node can have and + // the attributes not asked for yet. + Request(nodeIndex: 0, screenshot: .group, wantsAttributes: false), + Request(nodeIndex: 1, screenshot: .solo, wantsAttributes: false), + Request(nodeIndex: 2, screenshot: .group, wantsAttributes: true), + Request(nodeIndex: 3, screenshot: .group, wantsAttributes: true), + Request(nodeIndex: 3, screenshot: .solo, wantsAttributes: false), + Request(nodeIndex: 5, screenshot: .group, wantsAttributes: true), + ]) + } + + @Test func aSavedFileAsksOnlyForWhatTheNodesMiss() { + var nodes = sampleTree() + for index in nodes.indices { + nodes[index].hasSoloScreenshot = true + nodes[index].hasGroupScreenshot = true + nodes[index].hasAttributes = true + } + nodes[2].hasAttributes = false + nodes[3].hasSoloScreenshot = false + let requests = DetailFetchPlanning.wholeTreeRequests(for: nodes, displayedIndexes: [0, 1, 4]) + #expect(requests == [ + Request(nodeIndex: 2, screenshot: nil, wantsAttributes: true), + Request(nodeIndex: 3, screenshot: .solo, wantsAttributes: false), + ]) + } + + @Test func aSavedFileGetsNoSoloScreenshotWhereNoChildDrawsAnything() { + var nodes = sampleTree() + nodes[3].hasPixelBearingSubitems = false + let requests = DetailFetchPlanning.wholeTreeRequests(for: nodes, displayedIndexes: []) + #expect(requests.filter { $0.nodeIndex == 3 } == [Request(nodeIndex: 3, screenshot: .group, wantsAttributes: true)]) + } + + @Test func nodesThatMayNotBeScreenshotGoIntoASavedFileWithTheirAttributesAlone() { + var nodes = sampleTree() + nodes[2].mayFetchScreenshot = false + let requests = DetailFetchPlanning.wholeTreeRequests(for: nodes, displayedIndexes: []) + #expect(requests.filter { $0.nodeIndex == 2 } == [Request(nodeIndex: 2, screenshot: nil, wantsAttributes: true)]) + } + + @Test func customNodesAreNotAskedForWhenSaving() { + var nodes = sampleTree() + nodes[5].isUserCustom = true + let requests = DetailFetchPlanning.wholeTreeRequests(for: nodes, displayedIndexes: []) + #expect(requests.contains { $0.nodeIndex == 5 } == false) + } + + private func node(children: [Int], isExpandable: Bool = false, isExpanded: Bool = false) -> DetailFetchPlanning.Node { + DetailFetchPlanning.Node( + childIndexes: children, + isExpandable: isExpandable, + isExpanded: isExpanded, + hasPixelBearingSubitems: !children.isEmpty, + mayFetchScreenshot: true, + isUserCustom: false, + hasSoloScreenshot: false, + hasGroupScreenshot: false, + hasAttributes: false + ) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PacedMainQueueDeliveryTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PacedMainQueueDeliveryTests.swift new file mode 100644 index 00000000..c99596e5 --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PacedMainQueueDeliveryTests.swift @@ -0,0 +1,177 @@ +import Foundation +@testable import LookInsideHostCore +import Testing + +/// What the handlers and the other main-queue blocks did, in order. +@MainActor +private final class Recorder { + var entries: [String] = [] +} + +/// Lets a handler reach the delivery that calls it. +@MainActor +private final class DeliveryReference { + var delivery: PacedMainQueueDelivery? +} + +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct PacedMainQueueDeliveryTests { + /// Waits until `condition` holds, polling, or fails after `timeout`. + private func waitUntil( + timeout: Duration = .seconds(5), + _ condition: () -> Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !condition() { + if clock.now >= deadline { + Issue.record("condition did not become true in time", sourceLocation: sourceLocation) + return + } + try await Task.sleep(for: .milliseconds(5)) + } + } + + /// Queues a block on the main queue behind everything queued so far. + private func enqueueOtherMainQueueWork(_ recorder: Recorder) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + recorder.entries.append("other") + } + } + } + + @Test func handsItemsOverInOrderOnLaterTurns() async throws { + let recorder = Recorder() + let delivery = PacedMainQueueDelivery(timeBudget: .seconds(3600)) { item in + recorder.entries.append("item \(item)") + } + for item in 1 ... 100 { + delivery.enqueue(item) + } + #expect(recorder.entries.isEmpty) + #expect(delivery.pendingCount == 100) + + try await waitUntil { recorder.entries.count == 100 } + #expect(recorder.entries == (1 ... 100).map { "item \($0)" }) + #expect(delivery.pendingCount == 0) + } + + /// The bug this type fixes: a burst handled in one block kept everything + /// else on the main queue waiting until the last item. + @Test func otherMainQueueWorkRunsBetweenTurns() async throws { + let recorder = Recorder() + // A zero budget ends each turn after one item. + let delivery = PacedMainQueueDelivery(timeBudget: .zero) { item in + recorder.entries.append("item \(item)") + } + for item in 1 ... 3 { + delivery.enqueue(item) + } + enqueueOtherMainQueueWork(recorder) + + try await waitUntil { recorder.entries.count == 4 } + #expect(recorder.entries == ["item 1", "other", "item 2", "item 3"]) + } + + @Test func oneTurnHandlesWhatFitsInTheBudget() async throws { + let recorder = Recorder() + let delivery = PacedMainQueueDelivery(timeBudget: .seconds(3600)) { item in + recorder.entries.append("item \(item)") + } + for item in 1 ... 3 { + delivery.enqueue(item) + } + enqueueOtherMainQueueWork(recorder) + + try await waitUntil { recorder.entries.count == 4 } + #expect(recorder.entries == ["item 1", "item 2", "item 3", "other"]) + } + + @Test func theItemThatRunsPastTheBudgetEndsTheTurn() async throws { + let recorder = Recorder() + let delivery = PacedMainQueueDelivery(timeBudget: .milliseconds(100)) { item in + recorder.entries.append("item \(item)") + if item == 3 { + Thread.sleep(forTimeInterval: 0.15) + } + } + for item in 1 ... 5 { + delivery.enqueue(item) + } + enqueueOtherMainQueueWork(recorder) + + try await waitUntil { recorder.entries.count == 6 } + #expect(recorder.entries == ["item 1", "item 2", "item 3", "other", "item 4", "item 5"]) + } + + @Test func itemsTheHandlerEnqueuesWaitBehindPendingOnes() async throws { + let recorder = Recorder() + let reference = DeliveryReference() + let delivery = PacedMainQueueDelivery(timeBudget: .seconds(3600)) { item in + recorder.entries.append("item \(item)") + if item == 1 { + reference.delivery?.enqueue(99) + } + } + reference.delivery = delivery + for item in 1 ... 3 { + delivery.enqueue(item) + } + + try await waitUntil { recorder.entries.count == 4 } + #expect(recorder.entries == ["item 1", "item 2", "item 3", "item 99"]) + } + + @Test func removePendingDropsTheMatchingItems() async throws { + let recorder = Recorder() + let reference = DeliveryReference() + let delivery = PacedMainQueueDelivery(timeBudget: .seconds(3600)) { item in + recorder.entries.append("item \(item)") + if item == 2 { + reference.delivery?.removePending { $0.isMultiple(of: 2) } + } + } + reference.delivery = delivery + for item in 1 ... 6 { + delivery.enqueue(item) + } + + try await waitUntil { delivery.pendingCount == 0 && recorder.entries.count >= 4 } + // Give a stray turn the chance to hand over a dropped item. + try await Task.sleep(for: .milliseconds(20)) + #expect(recorder.entries == ["item 1", "item 2", "item 3", "item 5"]) + } + + @Test func aPendingItemCanBeFoundUntilItIsHandedOver() async throws { + let recorder = Recorder() + let delivery = PacedMainQueueDelivery(timeBudget: .seconds(3600)) { item in + recorder.entries.append("item \(item)") + } + delivery.enqueue(1) + delivery.enqueue(2) + #expect(delivery.containsPending { $0 == 2 }) + + try await waitUntil { recorder.entries.count == 2 } + #expect(delivery.containsPending { $0 == 2 } == false) + } + + @Test func pendingItemsKeepTheDeliveryAlive() async throws { + let recorder = Recorder() + weak var releasedDelivery: PacedMainQueueDelivery? + do { + let delivery = PacedMainQueueDelivery(timeBudget: .seconds(3600)) { item in + recorder.entries.append("item \(item)") + } + delivery.enqueue(1) + releasedDelivery = delivery + } + + try await waitUntil { recorder.entries.count == 1 } + #expect(recorder.entries == ["item 1"]) + // Nothing pending: the turn's block was the last owner. + try await waitUntil { releasedDelivery == nil } + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreviewZOrderingTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreviewZOrderingTests.swift new file mode 100644 index 00000000..e6285aa6 --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreviewZOrderingTests.swift @@ -0,0 +1,180 @@ +import CoreGraphics +import Foundation +@testable import LookInsideHostCore +import Testing + +struct PreviewZOrderingTests { + @Test func aPlaneSitsOneLevelAboveTheHighestPlaneItOverlaps() { + let planes = [ + plane(CGRect(x: 0, y: 0, width: 100, height: 100), parent: .none), + plane(CGRect(x: 10, y: 10, width: 20, height: 20), parent: .plane(index: 0)), + plane(CGRect(x: 15, y: 15, width: 5, height: 5), parent: .plane(index: 1)), + plane(CGRect(x: 60, y: 60, width: 10, height: 10), parent: .plane(index: 0)), + plane(CGRect(x: 500, y: 500, width: 10, height: 10), parent: .none), + ] + #expect(PreviewZOrdering.zIndexes(of: planes, showsHiddenItems: false) == [0, 1, 2, 1, 0]) + } + + @Test func foldedPlanesTakeTheirParentsZIndex() { + let planes = [ + plane(CGRect(x: 0, y: 0, width: 100, height: 100), parent: .none), + plane(CGRect(x: 0, y: 0, width: 50, height: 50), parent: .plane(index: 0)), + plane(CGRect(x: 0, y: 0, width: 10, height: 10), parent: .plane(index: 1), isDisplayed: false), + plane(CGRect(x: 0, y: 0, width: 10, height: 10), parent: .outside(zIndex: 7), isDisplayed: false), + plane(CGRect(x: 0, y: 0, width: 10, height: 10), parent: .none, isDisplayed: false), + ] + #expect(PreviewZOrdering.zIndexes(of: planes, showsHiddenItems: false) == [0, 1, 1, 7, 0]) + } + + @Test func coplanarOverlaysShareTheirOwnersPlane() { + let planes = [ + plane(CGRect(x: 0, y: 0, width: 100, height: 100), parent: .none), + plane(CGRect(x: 0, y: 0, width: 50, height: 50), parent: .plane(index: 0)), + plane(CGRect(x: 0, y: 0, width: 50, height: 50), parent: .plane(index: 1), isCoplanarOverlay: true), + plane(CGRect(x: 0, y: 0, width: 50, height: 50), parent: .outside(zIndex: -3), isCoplanarOverlay: true), + ] + #expect(PreviewZOrdering.zIndexes(of: planes, showsHiddenItems: false) == [0, 1, 1, 0]) + } + + @Test func hiddenPlanesCountOnlyWhenHiddenItemsAreShown() { + let planes = [ + plane(CGRect(x: 0, y: 0, width: 100, height: 100), parent: .none), + plane(CGRect(x: 0, y: 0, width: 50, height: 50), parent: .plane(index: 0), isInHiddenHierarchy: true), + plane(CGRect(x: 0, y: 0, width: 10, height: 10), parent: .plane(index: 0)), + ] + #expect(PreviewZOrdering.zIndexes(of: planes, showsHiddenItems: false) == [0, 1, 1]) + #expect(PreviewZOrdering.zIndexes(of: planes, showsHiddenItems: true) == [0, 1, 2]) + } + + /// The pass the preview ran before, item by item and pair by pair, on + /// random trees: the new pass must give every plane the same z index. + @Test(arguments: 1 ... 40) + func matchesTheOriginalPassOnRandomTrees(seed: UInt64) { + var generator = SeededGenerator(seed: seed) + let planes = randomPlanes(count: Int.random(in: 1 ... 120, using: &generator), using: &generator) + for showsHiddenItems in [false, true] { + #expect(PreviewZOrdering.zIndexes(of: planes, showsHiddenItems: showsHiddenItems) == originalZIndexes(of: planes, showsHiddenItems: showsHiddenItems)) + } + } + + // MARK: - The original pass + + /// `PreviewView.updateZIndex(for:)` and `highestOverlappedItem(below:)` + /// before the change, over the same inputs. + private func originalZIndexes(of planes: [PreviewZOrdering.Plane], showsHiddenItems: Bool) -> [Int] { + var zIndexes = [Int](repeating: -1, count: planes.count) + func parentZIndex(_ plane: PreviewZOrdering.Plane) -> Int? { + switch plane.parent { + case .none: + nil + case let .plane(index): + zIndexes[index] + case let .outside(zIndex): + zIndex + } + } + for index in planes.indices { + let plane = planes[index] + zIndexes[index] = -1 + if plane.isCoplanarOverlay, let parentZIndex = parentZIndex(plane) { + zIndexes[index] = max(parentZIndex, 0) + continue + } + if plane.isDisplayed { + var targetIndex: Int? + for otherIndex in (0 ..< index).reversed() { + let other = planes[otherIndex] + guard !other.isInHiddenHierarchy || showsHiddenItems else { continue } + guard plane.frameToRoot.intersects(other.frameToRoot) else { continue } + if let current = targetIndex, zIndexes[otherIndex] <= zIndexes[current] { + continue + } + targetIndex = otherIndex + } + zIndexes[index] = targetIndex.map { zIndexes[$0] + 1 } ?? 0 + } else if let parentZIndex = parentZIndex(plane) { + zIndexes[index] = parentZIndex + } else { + zIndexes[index] = 0 + } + if zIndexes[index] < 0 { + zIndexes[index] = 0 + } + } + return zIndexes + } + + // MARK: - Fixtures + + private func plane( + _ frame: CGRect, + parent: PreviewZOrdering.Parent, + isDisplayed: Bool = true, + isCoplanarOverlay: Bool = false, + isInHiddenHierarchy: Bool = false + ) -> PreviewZOrdering.Plane { + PreviewZOrdering.Plane( + frameToRoot: frame, + parent: parent, + isDisplayed: isDisplayed, + isCoplanarOverlay: isCoplanarOverlay, + isInHiddenHierarchy: isInHiddenHierarchy + ) + } + + /// A random tree in hierarchy order: each plane's parent comes earlier, + /// frames nest loosely inside their parents, flags vary, and a few + /// parents sit outside the list. + private func randomPlanes(count: Int, using generator: inout SeededGenerator) -> [PreviewZOrdering.Plane] { + var planes: [PreviewZOrdering.Plane] = [] + for index in 0 ..< count { + let parent: PreviewZOrdering.Parent + let parentFrame: CGRect + switch index == 0 ? 0 : Int.random(in: 0 ..< 10, using: &generator) { + case 0: + parent = .none + parentFrame = CGRect(x: 0, y: 0, width: 400, height: 800) + case 1: + parent = .outside(zIndex: Int.random(in: -1 ... 5, using: &generator)) + parentFrame = CGRect(x: 0, y: 0, width: 400, height: 800) + default: + let parentIndex = Int.random(in: 0 ..< index, using: &generator) + parent = .plane(index: parentIndex) + parentFrame = planes[parentIndex].frameToRoot + } + let width = Double.random(in: 0 ... max(parentFrame.width, 1), using: &generator) + let height = Double.random(in: 0 ... max(parentFrame.height, 1), using: &generator) + let frame = CGRect( + x: parentFrame.minX + Double.random(in: -20 ... max(parentFrame.width - width, 0) + 20, using: &generator), + y: parentFrame.minY + Double.random(in: -20 ... max(parentFrame.height - height, 0) + 20, using: &generator), + width: width, + height: height + ) + planes.append(PreviewZOrdering.Plane( + frameToRoot: frame, + parent: parent, + isDisplayed: Int.random(in: 0 ..< 4, using: &generator) != 0, + isCoplanarOverlay: Int.random(in: 0 ..< 8, using: &generator) == 0, + isInHiddenHierarchy: Int.random(in: 0 ..< 6, using: &generator) == 0 + )) + } + return planes + } +} + +/// SplitMix64, so a failing seed reproduces. +struct SeededGenerator: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var mixed = state + mixed = (mixed ^ (mixed >> 30)) &* 0xBF58_476D_1CE4_E5B9 + mixed = (mixed ^ (mixed >> 27)) &* 0x94D0_49BB_1331_11EB + return mixed ^ (mixed >> 31) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ResponseCountdownTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ResponseCountdownTests.swift new file mode 100644 index 00000000..7efd866a --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ResponseCountdownTests.swift @@ -0,0 +1,71 @@ +import Foundation +@testable import LookInsideHostCore +import Testing + +/// What the countdown reported. +@MainActor +private final class CountdownRecorder { + var isResponseWaiting = false + var waitingChecksCount = 0 + var expiredCount = 0 +} + +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct ResponseCountdownTests { + /// Waits until `condition` holds, polling; false after `timeout`. + private func waitUntil(timeout: Duration = .seconds(5), _ condition: () -> Bool) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !condition() { + if clock.now >= deadline { + return false + } + try await Task.sleep(for: .milliseconds(5)) + } + return true + } + + private func makeCountdown(_ recorder: CountdownRecorder) -> ResponseCountdown { + ResponseCountdown( + interval: 0.05, + isResponseWaiting: { + recorder.waitingChecksCount += 1 + return recorder.isResponseWaiting + }, + onExpire: { + recorder.expiredCount += 1 + } + ) + } + + @Test func aCountdownExpiresWhenNoResponseWaits() async throws { + let recorder = CountdownRecorder() + let countdown = makeCountdown(recorder) + countdown.restart() + #expect(try await waitUntil { recorder.expiredCount == 1 }) + } + + /// The bug this type fixes: a reply that arrived in time waited behind + /// other frames on the Host, and the request timed out meanwhile. + @Test func aCountdownThatEndsWhileAResponseWaitsStartsOver() async throws { + let recorder = CountdownRecorder() + recorder.isResponseWaiting = true + let countdown = makeCountdown(recorder) + countdown.restart() + #expect(try await waitUntil { recorder.waitingChecksCount >= 2 }) + #expect(recorder.expiredCount == 0) + + recorder.isResponseWaiting = false + #expect(try await waitUntil { recorder.expiredCount == 1 }) + } + + @Test func aStoppedCountdownDoesNotExpire() async throws { + let recorder = CountdownRecorder() + let countdown = makeCountdown(recorder) + countdown.restart() + countdown.stop() + try await Task.sleep(for: .milliseconds(200)) + #expect(recorder.expiredCount == 0) + } +} diff --git a/Scripts/test-mcp-bridge.sh b/Scripts/test-mcp-bridge.sh index 87acb400..cc093218 100755 --- a/Scripts/test-mcp-bridge.sh +++ b/Scripts/test-mcp-bridge.sh @@ -36,4 +36,10 @@ OBJECT_IDENTIFIER_LIST_TEST="$ROOT/Tests/MCPBridge/MCPBridgeObjectIdentifierList swiftc -parse-as-library "$FRAME" "$OBJECT_IDENTIFIER_LIST" "$OBJECT_IDENTIFIER_LIST_TEST" -o "$TMPDIR/object-identifier-list-test" "$TMPDIR/object-identifier-list-test" +RECOVERY_HINT="$ROOT/LookInside/MCPBridge/MCPBridgeRecoveryHint.swift" +RECOVERY_HINT_TEST="$ROOT/Tests/MCPBridge/MCPBridgeRecoveryHintTests.swift" + +swiftc -parse-as-library "$RECOVERY_HINT" "$RECOVERY_HINT_TEST" -o "$TMPDIR/recovery-hint-test" +"$TMPDIR/recovery-hint-test" + echo "MCPBridge tests passed" diff --git a/Scripts/test-message-attribute.sh b/Scripts/test-message-attribute.sh new file mode 100755 index 00000000..7a1baeb1 --- /dev/null +++ b/Scripts/test-message-attribute.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +# MessageAttribute's subscriptions: duplicates, related objects, targets that +# went away, and that subscribing many targets stays linear. The class only +# needs AppKit, so it compiles with its test into a standalone binary. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MESSAGE_ATTRIBUTE_TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/lookinside-message-attribute.XXXXXX")" +trap 'rm -rf "$MESSAGE_ATTRIBUTE_TEST_DIR"' EXIT +swiftc -parse-as-library -O \ + "$ROOT/LookInside/Base/Message/MessageAttribute.swift" \ + "$ROOT/Tests/Message/MessageAttributeTests.swift" -o "$MESSAGE_ATTRIBUTE_TEST_DIR/message-attribute" +"$MESSAGE_ATTRIBUTE_TEST_DIR/message-attribute" diff --git a/Scripts/test.sh b/Scripts/test.sh index 27c52098..e6025d3d 100755 --- a/Scripts/test.sh +++ b/Scripts/test.sh @@ -59,6 +59,7 @@ run_xcode_build() { run_command "sync derived source" bash Scripts/sync-derived-source.sh run_command "gesture capture model tests" bash Scripts/test-gesture-models.sh +run_command "MessageAttribute tests" bash Scripts/test-message-attribute.sh run_command "MCPBridge tests" bash Scripts/test-mcp-bridge.sh run_swift_build "swift build" build run_xcode_build "LookInside" "Debug" diff --git a/Tests/MCPBridge/MCPBridgeRecoveryHintTests.swift b/Tests/MCPBridge/MCPBridgeRecoveryHintTests.swift new file mode 100644 index 00000000..02df7384 --- /dev/null +++ b/Tests/MCPBridge/MCPBridgeRecoveryHintTests.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Coverage for what bridge errors tell the agent to do next. +/// +/// An agent follows these hints as written. A hint that sends it to a +/// cache-only read for data only a fetch can supply brings back the same +/// error, and nothing in that response says the advice was wrong. Each +/// test pins which tool one hint has to name. +@main +struct MCPBridgeRecoveryHintTests { + static func main() { + testAGoneObjectAsksForAFreshTree() + testAnUnknownIdentifierAsksForALookUpOrAFreshTree() + testUnfetchedAttributesAskForReadViewDetails() + testAMissingAttributeOnAFetchedViewAsksForNoSecondFetch() + print("MCPBridge recovery hint tests passed") + } + + // MARK: - The tree + + /// `get_hierarchy` and `find_views` read the host's tree, which still + /// lists an object the app has released; only a refresh drops it. + private static func testAGoneObjectAsksForAFreshTree() { + let hint = MCPBridgeRecoveryHint.objectIsGone + expect( + hint.contains("refresh_hierarchy"), + "an object the app no longer has must send the agent to refresh_hierarchy, got: \(hint)" + ) + } + + /// An identifier from an older tree is found again by searching the + /// current one; a view newer than the host's tree needs a refresh first. + private static func testAnUnknownIdentifierAsksForALookUpOrAFreshTree() { + let hint = MCPBridgeRecoveryHint.identifierIsUnknown + expect( + hint.contains("find_views") && hint.contains("refresh_hierarchy"), + "an identifier missing from the tree must send the agent to find_views, and to refresh_hierarchy for a changed screen, got: \(hint)" + ) + } + + // MARK: - Attributes + + private static func testUnfetchedAttributesAskForReadViewDetails() { + let message = MCPBridgeRecoveryHint.attributeNotFoundMessage( + attributeIdentifier: "BasicViewClass_BackgroundColor", + viewDetailsFetched: false + ) + expect( + message.contains("read_view_details"), + "attributes the host has not fetched must send the agent to read_view_details, got: \(message)" + ) + expect( + message.contains("read_attributes") == false, + "read_attributes only returns what the host holds, so it cannot fetch the attributes, got: \(message)" + ) + } + + /// Fetching a view's attributes again returns the same list, so once + /// they are in, a missing attribute is one this view does not have. + private static func testAMissingAttributeOnAFetchedViewAsksForNoSecondFetch() { + let message = MCPBridgeRecoveryHint.attributeNotFoundMessage( + attributeIdentifier: "ThisAttrDoesNotExist", + viewDetailsFetched: true + ) + expect( + message.contains("read_view_details") == false, + "a view whose attributes are in must not send the agent to fetch them again, got: \(message)" + ) + expect( + message.contains("ThisAttrDoesNotExist"), + "the message must name the attribute the view does not have, got: \(message)" + ) + } + + // MARK: - Helpers + + private static func expect(_ condition: Bool, _ message: String) { + guard condition else { + fail(message) + } + } + + private static func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) + Foundation.exit(1) + } +} diff --git a/Tests/Message/MessageAttributeTests.swift b/Tests/Message/MessageAttributeTests.swift new file mode 100644 index 00000000..7fe4f48a --- /dev/null +++ b/Tests/Message/MessageAttributeTests.swift @@ -0,0 +1,88 @@ +import AppKit + +/// Counts the actions a `MessageAttribute` sends it. +final class RecordingTarget: NSObject { + private(set) var receivedValues: [Any?] = [] + + @objc func handleChange(_ parameters: MessageActionParameters) { + receivedValues.append(parameters.value) + } +} + +@main +struct MessageAttributeTests { + static func main() { + _ = NSApplication.shared + testDuplicateSubscriptionsAreIgnored() + testRelatedObjectsMakeSubscriptionsDistinct() + testATargetThatWentAwayDoesNotBlockANewOne() + testSubscribingManyTargetsStaysLinear() + print("MessageAttribute tests passed") + } + + private static let action = #selector(RecordingTarget.handleChange(_:)) + + static func testDuplicateSubscriptionsAreIgnored() { + let attribute = MessageAttribute(value: NSNumber(value: false)) + let target = RecordingTarget() + attribute.subscribe(target, action: action, relatedObject: nil) + attribute.subscribe(target, action: action, relatedObject: nil) + attribute.setValue(NSNumber(value: true), ignoreSubscriber: nil, userInfo: nil) + verify(target.receivedValues.count == 1, "a second identical subscription has no effect") + } + + static func testRelatedObjectsMakeSubscriptionsDistinct() { + let attribute = MessageAttribute(value: NSNumber(value: 0)) + let target = RecordingTarget() + let firstRelatedObject = NSObject() + let secondRelatedObject = NSObject() + attribute.subscribe(target, action: action, relatedObject: firstRelatedObject) + attribute.subscribe(target, action: action, relatedObject: secondRelatedObject) + attribute.subscribe(target, action: action, relatedObject: firstRelatedObject) + attribute.setValue(NSNumber(value: 1), ignoreSubscriber: nil, userInfo: nil) + verify(target.receivedValues.count == 2, "one action per related object") + withExtendedLifetime((firstRelatedObject, secondRelatedObject)) {} + } + + /// Preview nodes come and go with every document; a node created where + /// an old one used to be must still be subscribed, even when it reuses + /// the old node's address. + static func testATargetThatWentAwayDoesNotBlockANewOne() { + let attribute = MessageAttribute(value: NSNumber(value: 0)) + for round in 1 ... 200 { + autoreleasepool { + let departingTarget = RecordingTarget() + attribute.subscribe(departingTarget, action: action, relatedObject: nil) + } + let arrivingTarget = RecordingTarget() + attribute.subscribe(arrivingTarget, action: action, relatedObject: nil) + attribute.setValue(NSNumber(value: round), ignoreSubscriber: nil, userInfo: nil) + verify(arrivingTarget.receivedValues.count == 1, "round \(round): a new target is subscribed even where an old one went away") + } + } + + /// The 3D preview subscribes every one of its nodes to three preference + /// attributes; a 6,000-node hierarchy spent seconds here while a document + /// opened when every subscription scanned all the earlier ones. + static func testSubscribingManyTargetsStaysLinear() { + let attribute = MessageAttribute(value: NSNumber(value: false)) + let targets = (0 ..< 20000).map { _ in RecordingTarget() } + let start = ContinuousClock.now + for target in targets { + attribute.subscribe(target, action: action, relatedObject: nil) + } + let elapsed = ContinuousClock.now - start + verify(elapsed < .seconds(2), "subscribing 20,000 targets took \(elapsed)") + attribute.setValue(NSNumber(value: true), ignoreSubscriber: nil, userInfo: nil) + verify(targets.allSatisfy { $0.receivedValues.count == 1 }, "every target receives the change once") + } +} + +/// Prints the failure and exits non-zero: an optimized build's +/// `precondition` would trap without saying which check failed. +func verify(_ condition: @autoclosure () -> Bool, _ message: @autoclosure () -> String) { + guard condition() else { + FileHandle.standardError.write(Data("MessageAttribute test failed: \(message())\n".utf8)) + exit(1) + } +}