Skip to content

Startup: begin loading OpenTUI once the plan is interactive, overlapping git bootstrap #1062

Closed

Description

@masonmcelvain

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)

  1. 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, and loadConfiguredSessionBootstrap, and carry the promise on the returned app plan (or a module-level holder) for main.tsx to await.
  2. Keep it a dynamic import() so test/cli/startup-graph.test.ts still passes, and keep it after the pager branch's controlling-terminal check so test/cli/compiled-headless-native-lib.test.ts keeps all eight headless cases clean.
  3. Leave createCliRenderer where it is (inside runHunkSession), after the theme probe and openControllingTerminal. Only module evaluation moves earlier; the import has no stdin/stdout side effects.
  4. Attach a rejection handler to the early promise so a bootstrap failure still surfaces through formatCliError rather than an unhandled rejection.
  5. Give history-interactive (runInteractiveHistory) the same treatment, since it serializes identically.
  6. 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 diff run) so the win is protected by the release gate.

Activity

  1. masonmcelvain commented on Sep 8, 2026

    @masonmcelvain
    ContributorAuthor

    I implemented this as described (startup announces the interactive surface as soon as it is certain, main.tsx begins 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.tsx that 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.

    Run Input Early first frame Late first frame
    source this repo, diff HEAD~1 ~524 ms ~526 ms
    source synthetic 8-file repo ~430 ms ~431 ms
    compiled binary this repo, diff HEAD~1 ~520 ms ~526 ms
    compiled binary synthetic 8-file repo ~439 ms ~445 ms

    The 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:

    1. Bun evaluates the import as a few large synchronous blocks. A 1 ms interval timer fires only four times during a ~290 ms source import, with gaps of ~96 ms and ~69 ms. The compiled binary is the same shape with the transpile cost removed and the dlopen left.
    2. The blocks start on the next event-loop turn after import() is called, which is the first real I/O wait in bootstrap. That wait is extension discovery or the first git 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:

    • Run the provider work concurrently with the rest of bootstrap. Today extension discovery, config resolution, the theme probe, and the git spawns are sequential awaits, each a few milliseconds. If the git commands (rev-parse, numstat, config reads, untracked listing, diff) were issued together, or the diff were issued as early as the repo root is known, there would be one long wait for the import to hide behind. That is a sessionBootstrap / git provider change, not a main.tsx one.
    • Trigger the import when the longest spawn is in flight rather than at the commit point. This is fragile without the first item, since the longest spawn varies by repository.
    • Reduce the block itself. Trace where the compiled binary's ~160 ms goes between 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.
    • Keep the benchmark honest. startup_first_frame_ms now gates releases, so any of the above will show up there. It answers the theme probe immediately; if we want to see the auto theme path, a variant that delays the reply would expose that wait.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions