Repository navigation
Refresh large hierarchies in about 2 s instead of 20 - #43
Merged
Merged
Conversation
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.
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.
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.
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.
The host reads a channel 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 (op_q in libdispatch's io.c). A serial queue goes on draining for as long as new items arrive in time, and each frame was unarchived and applied inside the handler. So whenever a Server answered faster than the host applied its replies, a whole multi-frame response ran without the main thread getting back to its run loop: no events, no timers, no drawing. A 7,000-task detail request from a Mac app held it for 2.4 s. ServerChannel now queues the frames and the end in PacedMainQueueDelivery (LookInsideHostCore), which hands them to the delegate for up to 8 ms per main-queue turn and leaves the rest to a later turn. The main queue only runs the blocks it held when its drain began, so the run loop gets a pass in between. Order is kept, the end comes after every frame, and frames read but not delivered when the host closes the channel are dropped, as the frame channel stops reading. A frame header is still accepted as it is read; a frame accepted for a request that ends before it is delivered is dropped by the connection manager, as one arriving after a timeout already was. Two of the delivery's tests fail when it stops yielding.
With fast mode off, the update manager asked for every item's group screenshot, every expandable item's solo screenshot and every item's attributes at once (6,594 tasks for the 68 rows a 4,500-item hierarchy displays) and deduplicated them with Array.contains, which alone took 3.1 s. The window could not refresh until all of it was in. Requests now come in two kinds, which DetailFetchPlanning (LookInsideHostCore) plans: - Foreground: what the displayed items miss of the screenshot the preview shows for them, and their attributes. The window shows its progress and the reload button stops it, as before. - Prefetch: unless fast mode is on, once the foreground request is answered, breadth first from the folded displayed items, each one's solo screenshot and its children's group screenshots, without attributes, in packages of at most 20 tasks and 500,000 pt². It reports no progress and steps aside, resuming later from what is still missing, whenever the displayed items need something, a reload starts (before its hierarchy request is sent) or another detail request needs the channel. Fast mode now means no prefetch. Only one detail request can be in flight on a channel, so both kinds go through the manager. A request another detail request discards (the MCP bridge asks for screenshots that way) is retried a second later without an alert. Attributes now come only for displayed items, so the MCP bridge's attributes.read, which reads what is cached, answers detailsCached false more often; details.read fetches. Building tasks takes the host version and the platform once per batch, and asks the SwiftUI license gatekeeper once per batch instead of once per task; for a Mac app that cut building 7,000 prefetch tasks from 365 ms to 195 ms, the rest being the per-item SwiftUI check of an unlicensed host. Each step runs in a signpost interval. With the changes before it, a refresh in an open window went from 21.5 s to 2.25 s on the iOS Simulator (4,517 items; the prefetch then takes 11.8 s in the background) and from 18.1 s to 2.3 s on macOS (6,133 items; 2.9 s of prefetch).
…e dump A refresh started while the previous load still prefetched would cancel that prefetch and measure something else, so the DEBUG end-to-end dump now waits for the prefetch too before the next refresh or the snapshot. While details load, each tick of the dump is a "Driver tick" signpost event inside a "Driver loading details" interval: a gap between ticks longer than the 0.25 s tick interval is a time the main thread kept its run loop waiting. The first load enters that phase after its reload, as a refresh does, so the interval holds no reload work.
The build matrix ran neither: Scripts/test-message-attribute.sh is new, and LookInsideHostCore's swift-testing suite, which now also covers the detail fetch planning, the preview z order and the paced frame delivery, only ran locally.
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.
The review of this branch found five problems it introduced: - The prefetch retried a second after another client's 203 discarded it, and the retry discarded that client's request in turn, so an MCP read that took longer than a second could fail over and over. The inspector's own detail requests now yield: one that finds a 203 in flight on the channel is not sent, and the fetch resumes once the channel is free. A cancel reaches only the request it was issued for, including the one a SwiftUI mode switch sends, which cancelled whichever 203 came first; a request cancelled while still pinging is no longer sent. - Export, Save As and Open in New Window wrote only what had been fetched, so in the default mode the file lacked the attributes of rows never shown and the screenshots the prefetch had not reached. They now fetch everything the tree still lacks first, with progress in the window and the refresh button to stop it. - A reply that had arrived but still waited in the paced main-queue delivery could time out, since the delivery hands the main thread back between slices. The countdown now starts over while a frame for its request is pending. - A reload stopped only the prefetch, so the last package of an older foreground request could mark the new tree's tasks as answered. A reload now stops every request, and an answer counts only toward the tree it was asked for. - Collapsing an expanded row waited a round trip for its group screenshot. The prefetch now ends with those. The decisions moved into LookInsideHostCore (ActiveRequests, ResponseCountdown, AnsweredTaskRecord and DetailFetchPlanning.wholeTreeRequests), and each test failed before its fix. The DEBUG end-to-end dump can fetch the whole tree after its loads (LOOKINSIDE_DEBUG_E2E_WHOLE_TREE=1) to time that.
modify.attributeNotFound told the agent to call read_attributes or to open the view in the inspector, and the stale-object errors said to refresh via get_hierarchy or to reload the inspector. read_attributes and get_hierarchy only read what the host already holds, so an agent that took the advice got the same error back. read_view_details and refresh_hierarchy, which fetch from the app, landed alongside or after those messages, and the messages were never updated. Since this branch fetches attributes only for the rows the inspector has shown, agents reach the missing-attribute error routinely. The hints now live in MCPBridgeRecoveryHint and name the fetching tool for each case. A view whose attributes are already in no longer sends the agent to fetch them again: the error says the view has no such attribute. Scripts/test-mcp-bridge.sh compiles the hints together with MCPBridgeRecoveryHintTests, which failed on the old wording.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Refreshing an app with a few thousand layers took about 20 seconds, and for long stretches of it the window did not respond. Most of that time went into work nobody looked at. With fast mode off (the default), the host asked the Server in one request for every item's group screenshot, every expandable item's solo screenshot and every item's attributes: 6,594 detail tasks for the 68 rows a 4,500-item hierarchy displays. Several host-side passes also grew with the square of the item count.
A refresh now asks for what the displayed rows show and is done when that is in; the rest of the screenshots follow in the background. Measured with a Debug build against a test app, refreshing an open window twice after the first load:
After a refresh, the background prefetch takes another 11.8 s on the iOS Simulator and 2.9 s on macOS. It reports no progress and does not hold up a refresh, and while it runs the main thread never stays away from its run loop for more than about 0.15 s. Before and after used the same Server build, so the difference is all the host's.
What changes for users
attributes.read, which only reads what is cached, answersdetailsCached: falsemore often for items never displayed;details.readfetches them.Commits
com.lookinside.app, categoryRefreshPerformance: the hierarchy and detail requests, unarchiving each response frame, the data source reload, building the detail tasks, applying each reply, the preview render, the z-position pass and the outline reload. They show in Instruments' os_signpost instrument or withlog stream --signpost.timings.json, and withLOOKINSIDE_DEBUG_E2E_REFRESH_COUNTit reloads the open window that many more times, the way the toolbar button does.MessageAttributesubscriptions stay linear. Every preview node subscribes to three shared preference attributes, and the duplicate check scanned every subscriber: 94% of the first preview render (2.9 s for 4,500 items on iOS, 5.8 s for 6,100 on macOS). Subscriptions are now keyed by target, action and related object, and subscribers whose target went away are pruned as they pile up rather than only when the value changes. A refresh reuses the preview nodes, so this helps the first load.PreviewZOrdering(inLookInsideHostCore) computes each frame once and keeps the same rules, and those changes now ask for one pass per main-queue turn.ServerChannelnow queues frames inPacedMainQueueDelivery, which hands them on for at most 8 ms per main-queue turn, in order, with the end after every frame.DetailFetchPlanning(inLookInsideHostCore) plans the two kinds of request, andStaticAsyncUpdateManagersends them one at a time, since a newer detail request makes the connection discard the older one. Building tasks reads the host version and the platform once per batch, and asks the SwiftUI license gatekeeper once per batch rather than once per task.Scripts/test-message-attribute.shand theLookInsideHostCoretests.Testing
LookInsideHostCore: 139 tests in 26 suites pass. New among them:DetailFetchPlanningTests,PreviewZOrderingTests, which compare with the old algorithm on 40 random trees, andPacedMainQueueDeliveryTests, two of which fail when the delivery stops yielding.Scripts/test-message-attribute.shpasses. Before the fix, subscribing 20,000 targets took 3.1 s and it failed.Not measured on a USB device, and not checked by hand in the running app: