From 366ad6f420b5d182bcc2194106988546193f0819 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 17 Oct 2026 18:26:54 +0800 Subject: [PATCH 01/10] Mark the stages of a refresh in the host with signposts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host's share of a slow refresh was invisible. Each stage now runs in a signpost interval under subsystem com.lookinside.app, category RefreshPerformance (FrameworkToolbox's @Signpostable / #signpostInterval): the hierarchy and detail requests from send to the moment they leave the channel, unarchiving each response frame (with its type and size as an event), the data source reload, building the detail tasks, applying each detail reply, the preview render, the z-position pass and the outline reload. A request's interval ends in ServerChannelConnectionState.remove(_:), the one place every way a request ends goes through — completion, failure, timeout, discard, cancel. The DEBUG end-to-end dump also writes timings.json next to the snapshot: the hierarchy request, the reload, the detail fetch (to the first idle tick of the update manager) and the item count. --- LookInside/Connection/ConnectionManager.swift | 16 +++++++- LookInside/Connection/ConnectionRequest.swift | 11 ++++++ LookInside/DebugE2E/DebugE2EDump.swift | 37 +++++++++++++++++++ .../Hierarchy/HierarchyDataSource.swift | 7 ++++ LookInside/Hierarchy/HierarchyView.swift | 6 ++- LookInside/Static/Preview/PreviewView.swift | 15 ++++++-- .../Static/StaticAsyncUpdateManager.swift | 9 ++++- 7 files changed, 94 insertions(+), 7 deletions(-) diff --git a/LookInside/Connection/ConnectionManager.swift b/LookInside/Connection/ConnectionManager.swift index 4dfca6ee..be370499 100644 --- a/LookInside/Connection/ConnectionManager.swift +++ b/LookInside/Connection/ConnectionManager.swift @@ -50,6 +50,8 @@ final class ServerChannelConnectionState { } func remove(_ request: ConnectionRequest) { + // Every way a request ends goes through here. + request.endSignpostInterval() activeRequests.removeAll { $0 === request } } } @@ -68,6 +70,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() @@ -431,6 +434,14 @@ final class ConnectionManager: NSObject { let attachment = ConnectionAttachment() attachment.data = data let payload = archivedPayload(rootObject: attachment) + switch Int(type) { + case LookinRequestTypeHierarchy: + request.signpostInterval = #signpost(.begin, "Hierarchy request") + case LookinRequestTypeHierarchyDetails: + request.signpostInterval = #signpost(.begin, "Detail request") + default: + break + } // 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 @@ -486,7 +497,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..d4effce5 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 @@ -57,6 +58,9 @@ final class ConnectionRequest: NSObject { 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 @@ -86,6 +90,13 @@ final class ConnectionRequest: NSObject { perform(#selector(handleTimeout), with: nil, afterDelay: timeoutInterval) } + /// Closes `signpostInterval`, once. + func endSignpostInterval() { + guard let signpostInterval else { return } + self.signpostInterval = nil + #signpost(.end, signpostInterval) + } + /// Stops the countdown. Called before the request is dropped. func endTimeoutCount() { NSObject.cancelPreviousPerformRequests(withTarget: self) diff --git a/LookInside/DebugE2E/DebugE2EDump.swift b/LookInside/DebugE2E/DebugE2EDump.swift index 8659bb4c..2878d3db 100644 --- a/LookInside/DebugE2E/DebugE2EDump.swift +++ b/LookInside/DebugE2E/DebugE2EDump.swift @@ -32,6 +32,12 @@ /// 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. + /// /// UI snapshots (Scripts/reborn-e2e/ui-snapshots.sh) add: /// /// LOOKINSIDE_DEBUG_E2E_START_FILE= @@ -101,6 +107,11 @@ 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? private var settleUntil = Date() private init(directory: String, environment: [String: String]) { @@ -304,7 +315,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 @@ -319,6 +332,7 @@ if !alreadyOpen { document.hierarchyDataSource?.reload(with: info, keepState: false) } + reloadEndInstant = .now NavigationManager.shared.closeLaunch() log("hierarchy loaded, \(document.hierarchyDataSource?.flatItems?.count ?? 0) items; waiting for details") } @@ -338,6 +352,7 @@ } guard let idleSince else { idleSince = Date() + detailsIdleInstant = .now return } if !sawUpdating || -idleSince.timeIntervalSinceNow < Self.idleInterval { @@ -372,6 +387,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 +410,22 @@ exit(ExitStatus.success.rawValue) } + private func writeTimings(itemCount: Int, to url: URL) throws { + 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 + } + var timings: [String: Any] = ["itemCount": itemCount] + timings["hierarchyRequestMilliseconds"] = milliseconds(from: hierarchyRequestInstant, to: hierarchyResponseInstant) + timings["reloadMilliseconds"] = milliseconds(from: hierarchyResponseInstant, to: reloadEndInstant) + timings["detailsMilliseconds"] = milliseconds(from: reloadEndInstant, to: detailsIdleInstant) + timings["totalMilliseconds"] = milliseconds(from: hierarchyRequestInstant, to: detailsIdleInstant) + 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/Static/Preview/PreviewView.swift b/LookInside/Static/Preview/PreviewView.swift index 1e2e45e8..77130d97 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? @@ -186,6 +187,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 @@ -230,10 +237,12 @@ 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. func updateZPosition() { - for item in flatDisplayItems { - updateZIndex(for: item) + #signpostInterval("Update z positions") { + for item in flatDisplayItems { + updateZIndex(for: item) + } + updateZPositionByZIndex() } - updateZPositionByZIndex() } private func updateZPositionByZIndex() { diff --git a/LookInside/Static/StaticAsyncUpdateManager.swift b/LookInside/Static/StaticAsyncUpdateManager.swift index a5df2b17..65c8145d 100644 --- a/LookInside/Static/StaticAsyncUpdateManager.swift +++ b/LookInside/Static/StaticAsyncUpdateManager.swift @@ -28,6 +28,7 @@ enum ModifyingUpdateEvent { /// Fetches the details (screenshots, attributes, sub items) of the items in /// one inspector window's hierarchy from the inspected app. @Loggable(subsystem: "com.lookinside.app") +@Signpostable(subsystem: "com.lookinside.app", category: "RefreshPerformance") @MainActor final class StaticAsyncUpdateManager: NSObject { /// Set by the owner (window controller / live document), which holds it @@ -76,7 +77,9 @@ final class StaticAsyncUpdateManager: NSObject { return } endUpdating() - let newTasks = makeMaximumTasks() + let newTasks = #signpostInterval("Make detail tasks") { + makeMaximumTasks() + } guard !newTasks.isEmpty else { return } send(newTasks) } @@ -387,7 +390,9 @@ final class StaticAsyncUpdateManager: NSObject { guard let self else { return } switch event { case let .value(value): - receive(details: value as? [DisplayItemDetail] ?? []) + #signpostInterval("Apply details") { + self.receive(details: value as? [DisplayItemDetail] ?? []) + } case .failure: fail() case .completion: From 993f1b693a840671ad1ee6b95564c11c3352bc91 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 17 Oct 2026 18:41:48 +0800 Subject: [PATCH 02/10] Measure refreshes in an open window in the end-to-end dump The first load of a document builds a preview node for every item; a refresh in an open window reuses them, so the two cost differently and the dump only measured the first. With LOOKINSIDE_DEBUG_E2E_REFRESH_COUNT it now reloads the hierarchy that many more times through the window controller, the toolbar button's path, each once the previous details are in, and adds a `refreshes` entry per reload to timings.json. --- LookInside/DebugE2E/DebugE2EDump.swift | 84 +++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/LookInside/DebugE2E/DebugE2EDump.swift b/LookInside/DebugE2E/DebugE2EDump.swift index 2878d3db..4636919c 100644 --- a/LookInside/DebugE2E/DebugE2EDump.swift +++ b/LookInside/DebugE2E/DebugE2EDump.swift @@ -38,6 +38,13 @@ /// update manager, so it is accurate to tickInterval. Refresh benchmark /// drivers read it. /// + /// 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. + /// /// UI snapshots (Scripts/reborn-e2e/ui-snapshots.sh) add: /// /// LOOKINSIDE_DEBUG_E2E_START_FILE= @@ -56,6 +63,7 @@ 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" /// Exit statuses, so the driver script can tell the failing step. private enum ExitStatus: Int32 { @@ -92,6 +100,7 @@ private let startFile: String? private let timeout: TimeInterval private let expectsLicensed: Bool + private let refreshCount: Int private var loggedStartWait = false private var loggedLicenseWait = false private var notedFirstWindow = false @@ -112,6 +121,12 @@ 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? private var settleUntil = Date() private init(directory: String, environment: [String: String]) { @@ -121,6 +136,7 @@ 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) super.init() } @@ -358,11 +374,63 @@ if !sawUpdating || -idleSince.timeIntervalSinceNow < Self.idleInterval { return } + if let refreshStartInstant { + recordRefresh(startedAt: refreshStartInstant) + self.refreshStartInstant = nil + } + if refreshTimings.count < refreshCount { + if firstLoadDetailsIdleInstant == nil { + firstLoadDetailsIdleInstant = detailsIdleInstant + } + startRefresh() + return + } log("details loaded") 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 @@ -411,16 +479,14 @@ } private func writeTimings(itemCount: Int, to url: URL) throws { - 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 - } var timings: [String: Any] = ["itemCount": itemCount] - timings["hierarchyRequestMilliseconds"] = milliseconds(from: hierarchyRequestInstant, to: hierarchyResponseInstant) - timings["reloadMilliseconds"] = milliseconds(from: hierarchyResponseInstant, to: reloadEndInstant) - timings["detailsMilliseconds"] = milliseconds(from: reloadEndInstant, to: detailsIdleInstant) - timings["totalMilliseconds"] = milliseconds(from: hierarchyRequestInstant, to: detailsIdleInstant) + 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 + } let data = try JSONSerialization.data(withJSONObject: timings, options: [.prettyPrinted, .sortedKeys]) try data.write(to: url, options: .atomic) log("timings: \(String(data: data, encoding: .utf8) ?? "")") From 6faa2c7864924dee07bf7da768c84758bf853941 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 17 Oct 2026 20:00:04 +0800 Subject: [PATCH 03/10] Keep MessageAttribute subscriptions linear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every preview node subscribes to three shared preference attributes, and subscribe(_:action:relatedObject:) scanned every existing subscriber for a duplicate, so opening a document with n items cost O(n²): 94% of the first preview render (2.9 s for a 4,500-item iOS hierarchy, 5.8 s for a 6,100-item Mac one). Subscribers whose target went away were only dropped when the value changed, so every document opened made the next one slower. Subscriptions are now keyed by target, action and related object, and a hit only counts as a duplicate while its weak target is still that object, so a reused address is not taken for the old one. Dead subscribers are pruned once the subscriptions since the last pruning reach half the list (at least 1,024). Scripts/test-message-attribute.sh builds the class with its tests into a standalone binary, and Scripts/test.sh runs it. Before the change, subscribing 20,000 targets took 3.1 s and the test failed. --- .../Base/Message/MessageAttribute.swift | 44 +++++++++- Scripts/test-message-attribute.sh | 14 +++ Scripts/test.sh | 1 + Tests/Message/MessageAttributeTests.swift | 88 +++++++++++++++++++ 4 files changed, 144 insertions(+), 3 deletions(-) create mode 100755 Scripts/test-message-attribute.sh create mode 100644 Tests/Message/MessageAttributeTests.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/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/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) + } +} From 25c8b5076227663fb532aa882e1c54ab7f0d4666 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sat, 17 Oct 2026 20:00:15 +0800 Subject: [PATCH 04/10] Compute preview z order with each plane's frame worked out once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The z-position pass compared every displayed plane with every plane before it and recomputed both frames to the root inside the comparison, O(d·n·depth), and it ran again for every frame, hidden or alpha change while details were applied. PreviewZOrdering (LookInsideHostCore) takes each plane's frame once and compares by index, with the same rules; a test checks it against the old algorithm on 40 random trees. Changes while details are applied now ask for one pass on the next main-queue turn instead of one each. --- .../Static/Preview/PreviewController.swift | 4 +- LookInside/Static/Preview/PreviewView.swift | 114 ++++++----- .../LookInsideHostCore/PreviewZOrdering.swift | 99 ++++++++++ .../PreviewZOrderingTests.swift | 180 ++++++++++++++++++ 4 files changed, 334 insertions(+), 63 deletions(-) create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreviewZOrdering.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreviewZOrderingTests.swift 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 77130d97..cb0ebe79 100644 --- a/LookInside/Static/Preview/PreviewView.swift +++ b/LookInside/Static/Preview/PreviewView.swift @@ -28,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 @@ -236,15 +238,64 @@ 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() { #signpostInterval("Update z positions") { - for item in flatDisplayItems { - updateZIndex(for: item) + 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() + } + } + private func updateZPositionByZIndex() { let interspace = PreviewGeometry.layerInterspace(is3D: dimension == .dimension3D, zInterspace: zInterspace) let zIndexes = displayItemNodes.map { $0.displayItem?.previewZIndex ?? 0 } @@ -261,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[..