perf(launch): open the first window on the next run-loop turn instead of after a fixed 150ms - #2598
Merged
Merged
Conversation
… of after a fixed 150ms
…h path, and wait for a real presented frame
… one signature check
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This was referenced Sep 2, 2026
Merged
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.
What this is
TablePro took 468ms from
execto the first presented frame of the Welcome window, and 490ms with one user-installed plugin. A bare AppKit app with a real Edit menu reaches its first frame in ~95ms on the same machine, so most of that was ours. This brings it to 261ms and 270ms.Measured on an arm64 Release build, warm page cache, empty store, 6 runs each,
origin/mainand this branch instrumented with the same tracer.Root cause
1. 160ms of deliberate dead time.
AppLaunchCoordinator.didFinishLaunching()started a fixed 150ms "intent collection window" before it would decide which window to show. Nothing was built during it: the window the person is waiting for was not even constructed until the timer fired, so the wait and the ~115ms of window construction ran strictly in series.The window existed because a
tablepro://URL or a document open can arrive as a separate event. Measured on macOS 27 with a signed probe app registered for a URL scheme and a document type, 5 runs per case:application(_:open:)beforeapplicationDidFinishLaunchingreturns (108.2ms vs 108.3ms);DispatchQueue.main.asyncandRunLoop.main.perform(inModes: [.common]).2. A signature check on the launch thread, linear in plugins installed.
discoverPluginandregisterLazyManifesteach ranSecStaticCodeCheckValiditywithkSecCSCheckAllArchitectures, which hashes the whole bundle, synchronously duringapplicationWillFinishLaunching. Measured at 12.7ms per user-installed plugin. Neither of those functions loads any code.The change
Intents drain serially instead of waiting on a clock.
AppLaunchCoordinatornow queues every intent and drains it with a single consumer. An intent that arrives while another is suspended inensureConnectedjoins the pass in flight rather than starting a second task, so two tasks can no longer each find no session for one connection and each open it. That also makes the launch independent of when LaunchServices happens to deliver: a straggler is routed in order either way, and the fixed window is gone.LaunchPhase.collectingIntentsloses itsdeadlinepayload, which was written and never read.One gate in front of every path that loads a plugin's executable. The check that matters was already in the right place, immediately before
PluginBundleLoader.load. Discovery and lazy registration no longer duplicate it;sweepPluginSignatures()re-checks off the main actor after the first frame and withdraws anything that fails, so the Plugins pane still lists a bad bundle. Auditing that turned up a path that was never gated:setEnabled(true)readsBundle.principalClass, which loads the executable, with no version, signature, or trust check. It now takes the same gate, and a deadPluginEntry.exportPluginaccessor that did the same thing with no callers is gone.PluginSignatureGatePlacementTestsscans the sources so a new load path cannot skip the gate silently.Deferred work starts after the first frame, not during the window build.
PostLaunchWorkowns the main-actor work no window reads: memory-pressure monitoring, the signature sweep, query-history cleanup, the registry manifest fetch, the favourites prune, and the MCP server. It starts on the first window's first presented frame. What deliberately stays inapplicationDidFinishLaunching, because deferring it is observable: theUNUserNotificationCenterdelegate and the two services that own the categoriesNotificationRouterlooks an action up in (a notification that launched the app is delivered as soon as the delegate returns), and the two stale-tunnel sweeps, which a restored connection needs before it binds its port.A launch trace.
LaunchTracerrecords the launch as one Instruments interval under Points of Interest with an event per stage, at debug level so it costs nothing uncaptured, plusTABLEPRO_LAUNCH_TRACE=1for the same table on standard error. It measures from process exec viasysctl(KERN_PROC_PID), so the number includes dyld. The first frame comes fromNSView.displayLink(target:selector:), the documented "the display is about to show the next frame" callback;CATransaction.setCompletionBlocktracks a transaction's animations and fires atcommit()for a non-animated first draw, which is a frame early.No visual change
The Welcome window is identical: same content, and the same 800x512 frame on a cleared autosave, verified by screenshot and by reading back
NSWindow Frame welcome. An earlier revision of this branch changedhosting.sizingOptionsto[], which made the window 32pt shorter; that is reverted here and reported below instead.Verified
verify.sh build: PASS (arm64 Release, and Debug through the test run)verify.sh test AppLaunchCoordinatorTests LaunchPhaseTests LaunchTracerTests PluginSignatureSweepTests PluginSignatureGatePlacementTests KeyOrderingTests: PASS, 27/27main: PASS, 52/52verify.sh lint TablePro TableProTests: 0 violationsreviewandadversarial-review. Both are acted on below.New suites:
AppLaunchCoordinatorTests(11 cases, including an intent arriving mid-route through a suspending fake),LaunchPhaseTests,LaunchTracerTests,PluginSignatureSweepTests,PluginSignatureGatePlacementTests. There was no test of any kind overAppLaunchCoordinatororLaunchPhasebefore this.No UI automation. The change alters when the existing launch flow completes, not what it does, and a wall-clock assertion on a CI runner would be flaky. No
docs/change: the existingTableLoadTraceris undocumented too, andLaunchTracerwrites nothing to disk, so it needs no privacy-page entry.Review findings acted on
Codex's
reviewraised five, all real, all fixed: notification routing deferred (dropped a notification action that launched the app),CATransactioncompletion is not a presentation callback, post-launch work held behind a slowensureConnected, tunnel sweeps racing a restored connection, and withdrawal deleting a lazy key a valid plugin also declared.adversarial-reviewreturned no-ship on four. Three are fixed: the ungatedsetEnabledcode load, the completion latch committing before a frame observation could succeed (a window closed before vsync would have left post-launch work off for the process, now latched only on a real frame with a 2s backstop), and the intent race, now a serialized drain. Its claim thatRunLoop.main.performblocks run before input sources and could precede a pending launch event does not reproduce: measured 5/5, the block fires after all three launch URLs. The drain makes the point moot regardless.Reported, not fixed
CompareSyncSession/tunnel managers:sweepStalePidsIfNeededsendsSIGTERMand returns without confirming exit or port release, so a crash-survivingcloudflaredorcloud-sql-proxyon a fixed local port can still be holding it when a restored connection binds. Pre-existing; the shorter launch makes it easier to hit. A real fix is a barrier inside each tunnel manager thatcreateTunnelawaits.WelcomeWindowController:hosting.sizingOptions = [.minSize]overrides the explicitsetContentSize(800x480), so the window is 800x512. Setting it to[]gives the 800x480 the code asks for and saves ~11ms of launch, but it is a visible 32pt change and belongs in its own PR.AppDelegate.runPostLaunchActivationIfNeeded: still runs on the main actor fromapplicationDidBecomeActive, andmigratePluginSecureFieldsIfNeededreads the connection store and keychain. Not measurable on an empty store; it grows with saved connections.KeyOrdering.compare:origin/maindoes not compile in Release under Swift 6.4 (Xcode 26A5406e);swift-frontendcrashes inCopyPropagation. CI pins Xcode 26.4.1 so nobody hits it yet. The second commit here rewrites that loop to an index loop, which is exactly equivalent and unblocks a local Release build. Drop that commit if you would rather keep it separate.Claude-Session: https://claude.ai/code/session_01Gy6Q4tzwG3bL9h15SMqep1