Skip to content

perf(launch): open the first window on the next run-loop turn instead of after a fixed 150ms - #2598

Merged
datlechin merged 6 commits into
mainfrom
perf/launch-time-to-ready
Sep 1, 2026
Merged

perf(launch): open the first window on the next run-loop turn instead of after a fixed 150ms#2598
datlechin merged 6 commits into
mainfrom
perf/launch-time-to-ready

Conversation

@datlechin

Copy link
Copy Markdown
Member

What this is

TablePro took 468ms from exec to 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/main and this branch instrumented with the same tracer.

before after
empty store 468ms 261ms
one user plugin 490ms 270ms
plugin discovery, one user plugin 15.4ms 2.8ms

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:

  • the gesture that launches the app always delivers application(_:open:) before applicationDidFinishLaunching returns (108.2ms vs 108.3ms);
  • several documents from one gesture arrive coalesced into a single call;
  • a straggler from a second request arrives 2.4 to 7.1ms later, and in 5/5 runs before the first turn of the main queue, for both DispatchQueue.main.async and RunLoop.main.perform(inModes: [.common]).

2. A signature check on the launch thread, linear in plugins installed. discoverPlugin and registerLazyManifest each ran SecStaticCodeCheckValidity with kSecCSCheckAllArchitectures, which hashes the whole bundle, synchronously during applicationWillFinishLaunching. 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. AppLaunchCoordinator now queues every intent and drains it with a single consumer. An intent that arrives while another is suspended in ensureConnected joins 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.collectingIntents loses its deadline payload, 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) reads Bundle.principalClass, which loads the executable, with no version, signature, or trust check. It now takes the same gate, and a dead PluginEntry.exportPlugin accessor that did the same thing with no callers is gone. PluginSignatureGatePlacementTests scans the sources so a new load path cannot skip the gate silently.

Deferred work starts after the first frame, not during the window build. PostLaunchWork owns 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 in applicationDidFinishLaunching, because deferring it is observable: the UNUserNotificationCenter delegate and the two services that own the categories NotificationRouter looks 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. LaunchTracer records the launch as one Instruments interval under Points of Interest with an event per stage, at debug level so it costs nothing uncaptured, plus TABLEPRO_LAUNCH_TRACE=1 for the same table on standard error. It measures from process exec via sysctl(KERN_PROC_PID), so the number includes dyld. The first frame comes from NSView.displayLink(target:selector:), the documented "the display is about to show the next frame" callback; CATransaction.setCompletionBlock tracks a transaction's animations and fires at commit() 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 changed hosting.sizingOptions to [], 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/27
  • earlier full run including the compare suites from main: PASS, 52/52
  • verify.sh lint TablePro TableProTests: 0 violations
  • Codex reviewed the diff twice, review and adversarial-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 over AppLaunchCoordinator or LaunchPhase before 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 existing TableLoadTracer is undocumented too, and LaunchTracer writes nothing to disk, so it needs no privacy-page entry.

Review findings acted on

Codex's review raised five, all real, all fixed: notification routing deferred (dropped a notification action that launched the app), CATransaction completion is not a presentation callback, post-launch work held behind a slow ensureConnected, tunnel sweeps racing a restored connection, and withdrawal deleting a lazy key a valid plugin also declared.

adversarial-review returned no-ship on four. Three are fixed: the ungated setEnabled code 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 that RunLoop.main.perform blocks 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: sweepStalePidsIfNeeded sends SIGTERM and returns without confirming exit or port release, so a crash-surviving cloudflared or cloud-sql-proxy on 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 that createTunnel awaits.
  • WelcomeWindowController: hosting.sizingOptions = [.minSize] overrides the explicit setContentSize(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 from applicationDidBecomeActive, and migratePluginSecureFieldsIfNeeded reads the connection store and keychain. Not measurable on an empty store; it grows with saved connections.
  • KeyOrdering.compare: origin/main does not compile in Release under Swift 6.4 (Xcode 26A5406e); swift-frontend crashes in CopyPropagation. 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

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin
datlechin merged commit e36a44a into main Sep 1, 2026
9 checks passed
@datlechin
datlechin deleted the perf/launch-time-to-ready branch September 1, 2026 20:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant