Repository navigation
Startup: begin loading OpenTUI once the plan is interactive, overlapping git bootstrap #1062
Description
What do you want to change?
Kick off import("./ui/runInteractiveApp") as soon as prepareStartupPlan is committed to an interactive plan, instead of after the whole bootstrap has finished, so OpenTUI's module evaluation and native dlopen overlap the git and extension I/O that the main thread otherwise waits on.
Why?
Importing @opentui/core runs new FFIRenderLib(...) at module scope, which is a bun:ffi dlopen that compiles stubs for the native library's symbol table. In CPU profiles of hunk diff HEAD~1 (source and the 0.21.1 compiled binary alike) that dlopen is 110 to 125ms of synchronous main-thread time and is the single largest cost before the first frame:
| Phase | ms |
|---|---|
| Bun boot + bundle parse + eager module init | ~120 |
| CLI, config, extensions, git (7 to 11 async spawns) | ~60 |
| Patch parse, changeset, theme derivation | ~40 |
| OpenTUI import + dlopen | ~120 |
| Renderer create + first React render | ~90 |
Today main.tsx awaits prepareStartupPlan() fully, then imports the interactive runner. During the git spawns the main thread is mostly idle, so a concurrent import would hide most of the dlopen. Expected saving is up to ~100ms of a ~440ms first frame.
History: the lazy import landed in #590 (336950e2, 0.17.6) to stop headless commands from extracting the native library into tmpdir (#556), and was reinforced by #750 (createBundledVcsCatalogLoader) and #784 (test/cli/startup-graph.test.ts). Those were about headless paths. The timing of the import on the interactive path was never discussed, and the dlopen cost has never been measured: the release benchmark's first_frame_ms starts its timer after @opentui/react/test-utils is already imported, and #750 measured --version and session list, which never reach OpenTUI. The ordering is a side effect of placing the import at the last possible point in main.tsx, not a decision.
How? (optional)
- In
packages/hunk/src/app/startup.ts, every non-app plan returns before line ~521 (whileStartupOwnsExtensions); the comment just below already says "Past this point the plan always builds a changeset." Start the dynamic import there, before extension loading, the OSC 11 probe, andloadConfiguredSessionBootstrap, and carry the promise on the returned app plan (or a module-level holder) formain.tsxto await. - Keep it a dynamic
import()sotest/cli/startup-graph.test.tsstill passes, and keep it after the pager branch's controlling-terminal check sotest/cli/compiled-headless-native-lib.test.tskeeps all eight headless cases clean. - Leave
createCliRendererwhere it is (insiderunHunkSession), after the theme probe andopenControllingTerminal. Only module evaluation moves earlier; the import has no stdin/stdout side effects. - Attach a rejection handler to the early promise so a bootstrap failure still surfaces through
formatCliErrorrather than an unhandled rejection. - Give
history-interactive(runInteractiveHistory) the same treatment, since it serializes identically. - Add a startup measurement that includes the import (a PTY time-to-first-frame from process spawn, or extend perf(app): skip the changeset graph for commands that never build one #750's methodology to an interactive
diffrun) so the win is protected by the release gate.
Activity
masonmcelvain commentedon Sep 8, 2026
I implemented this as described (startup announces the interactive surface as soon as it is certain,
main.tsxbegins the dynamic import at that point, and a PTY benchmark times the first frame from process spawn). The measurement does not support the expected saving, so I want to share the numbers and close this.4522643, bc57b1e
Measurement
All runs spawn Hunk in a PTY and answer the OSC 11 background probe immediately, plus a temporary trace in
main.tsxthat records when startup commits, when the plan is ready, and when the import resolves. "Early" starts the import at the commit point; "late" is today's behavior. Linux x64, Bun 1.4.2, 3 to 5 launches per cell.diff HEAD~1diff HEAD~1The trace explains why. On the compiled binary against this repo, bootstrap after the commit point takes about 119 ms of wall time for about 72 ms of CPU, and the import takes about 158 ms. In early mode the commit-to-plan interval grows to about 285 ms, which is almost exactly the sum of the two. Nothing overlaps.
Two things cause that:
dlopenleft.import()is called, which is the first real I/O wait in bootstrap. That wait is extension discovery or the firstgit rev-parse, not the diff itself. From then on the sequential git spawns cannot be issued until the block finishes, so a single block overlaps at most one short wait.I also tested a slow (40 ms) and an absent OSC 11 reply to see whether the probe wait would hide the import. It made no difference, because the default theme is a concrete theme rather than
auto, so the startup probe does not run on a default configuration.Proposed future work
The bound here is structural: a synchronous ~120 to 160 ms block can only overlap a wait that is already in flight and at least that long. Options that could actually move the number:
sessionBootstrap/ git provider change, not amain.tsxone.dlopen, stub compilation, and module init in@opentui/core, and see whether OpenTUI can defer parts of it or whether fewer symbols can be bound. This is upstream work but it is the only path that helps every launch.startup_first_frame_msnow gates releases, so any of the above will show up there. It answers the theme probe immediately; if we want to see theautotheme path, a variant that delays the reply would expose that wait.