Skip to content

Performance: measured wins across boot, broadcast path, and client bundle - #118

Open
ishu86 wants to merge 7 commits into
jakemor:mainfrom
ishu86:perf/measured-wins
Open

Performance: measured wins across boot, broadcast path, and client bundle#118
ishu86 wants to merge 7 commits into
jakemor:mainfrom
ishu86:perf/measured-wins

Conversation

@ishu86

@ishu86 ishu86 commented Sep 11, 2026

Copy link
Copy Markdown

Performance pass across boot, the sidebar broadcast path, and the client bundle. Every number below is measured on this machine, not estimated — commands and methodology in each commit body.

Results

Metric Before After
Entry chunk 2,180 KB / 626 KB gzip 1,183 KB / 356 KB gzip (−43%)
Cold start (median, warm cache) 518 ms ~270 ms
Sidebar re-derives per streaming turn 1 per transcript entry 1 per 15s activity bucket
Tests 1730 pass 1732 pass (+2 new, covering the sidebar-bump change)

What's here

  • Boot: pi SDK now loads on first use instead of at every startup (measured 170ms import cost for a provider most users never touch); the startup update-check fetch is bounded at 5s instead of unbounded.
  • Send latency: chat.send no longer reads the entire payload sidecar just to check if a chat is empty (3ms → 45ms depending on chat size, now skipped almost always).
  • Sidebar broadcast: stateVersion was bumped on every appended transcript entry, forcing a full sidebar re-derive + whole-snapshot stringify per entry — even though the dedupe signature discarded almost all of it as unchanged bytes. Now bumps only when an entry actually moves something the sidebar shows.
  • Git broadcast: a git command triggered an unfiltered broadcast to every socket, including a full terminal snapshot (the serializer walks the whole scrollback — measured 3.7–14.9ms and 72–347KB depending on scrollback size), paid every 5s while the diff panel polls. Scoped to just the affected project + sidebar, matching every other broadcast call site in the file.
  • Command resolution: resolveCommandPath always spawned a synchronous login shell (~5ms, up to 24ms observed); Bun.which now covers the common case first (~0.045ms), with the login shell staying as fallback for PATH entries only it can see.
  • Client bundle: GitPanel, TerminalWorkspace, BrowserPanel and SettingsPage were statically imported into the entry chunk despite being sidebar panels or a separate route. GitPanel alone pulls @pierre/diffs → shiki → ~300 language grammar chunks. Code-split all four.
  • Re-renders: the transcript's per-row wrapper wasn't memoized, so every streamed entry walked all N visible rows through three component invocations before React's inner memo could bail. Extracted and memoized it.

Known trade-offs

  • Scoped git broadcast: two projects pointing at the same repo (e.g. a monorepo opened as separate projects at different subpaths), or the local-projects "last modified" timestamp, now update on the next 5s poll instead of instantly. Self-healing, low-frequency edge case.
  • Terminal dispose is now a dynamic import: disposeCachedTerminal moved behind import() so the 500KB xterm bundle isn't pinned into the entry chunk by one cleanup helper. This lands a microtask later than before; only matters if a new terminal reused the exact same id in that window, which doesn't happen since ids are freshly generated.

Verification

  • bun test: 1732 pass, 0 fail (baseline was 1730; +2 new tests locking the sidebar-bump semantics — verified they fail without the fix)
  • bun run check: typecheck + both production builds clean
  • Cold-start and broadcast-cost numbers measured directly against this repo's dependencies (real @xterm/headless serialization, real EventStore), not estimated

Lazy-load @mariozechner/pi-coding-agent on first pi use instead of at
boot. Measured ~170ms import cost for a provider most users never
select. Also cap the startup npm-registry fetch at 5s so a slow or
dead connection can't hang boot indefinitely.
getMessages() reads the entire transcript payload sidecar end to end
(its own docstring says it's for export/handoff/fork, not per-push
use), but chat.send called it unconditionally to check chat length.
Made it lazy so only the rare handoff/restore paths pay for it.
Measured 3ms on a short chat, 45ms on a 2,300-entry one.
stateVersion was bumped on every appended transcript entry, forcing a
full sidebar re-derive and whole-snapshot JSON.stringify each time -
even though the dedupe signature then discarded almost all of it as
byte-identical. Bump it only when the entry moved a field the sidebar
actually shows (hasMessages, lastMessageAt, or the quantized
lastAgentMessageAt bucket). Verified: 120 agent entries inside one
15s bucket now produce 1 bump instead of 120.
A git command (stage/commit/etc) triggered an unfiltered broadcast,
which rebuilds and resends every topic on every socket - including a
full terminal snapshot, whose serializer walks the entire scrollback.
Measured 3.7ms/72KB at default scrollback, 14.9ms/347KB at max, paid
every 5s while the diff panel polls. Scoped to just this project's
diff snapshot and the sidebar, matching the other 14 broadcast call
sites in this file.
resolveCommandPath always spawned a synchronous login shell, blocking
the event loop for ~5ms (up to 24ms observed) on every call - hit
per-invocation by gh commands in diff-store and github.ts. Bun.which
covers the common case (the server's own PATH) in ~0.045ms; the login
shell stays as the fallback for PATH entries only a login shell would
see (npm globals, ~/.local/bin). Not cached, so a caller that
re-resolves after installing a CLI still gets a fresh answer.
The per-row wrapper (MessageScrollerItem + a div + KannaTranscriptRow)
wasn't memoized, so every push walked all N rows through three
component invocations before React's inner memo could bail. Extracted
it into one memoized component - row identity was already stable via
useStableResolvedRows, so a default shallow memo now bails immediately
for every row but the one that changed. Also memo(SetupWizard): a
zero-prop component that re-rendered on every chat push for nothing.
These were statically imported into the entry chunk despite being
sidebar panels or a separate route, not first paint. GitPanel alone
pulls @pierre/diffs -> shiki core -> ~300 language grammar chunks.
TerminalWorkspace pulls all five @XTerm packages; one helper import
(disposeCachedTerminal) was the only thing pinning it into the entry
chunk, so that one call is now a dynamic import too. Also folded an
inline arrow into a stable useCallback so it stops defeating
ChatInputDock's memo on every streamed entry.

Entry chunk: 2,180KB -> 1,183KB raw (626KB -> 356KB gzip).
@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR should not merge until the startup timeout is separated from interactive package-update lookups.

Fix All in CodexFindings

  1. P1 Timeout Breaks Interactive Updates

Summary

  • Lazily loads settings, terminal, browser, Git, and Pi SDK module graphs.
  • Limits sidebar derivation to changes represented in its read model and scopes Git-command broadcasts.
  • Adds a fast command-path lookup and memoizes transcript/setup components.
  • Introduces a five-second npm lookup timeout, but currently applies it to interactive update operations as well as startup.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Boot[Server boot] --> Update[Package version lookup]
  Boot --> Provider[Provider selected]
  Provider --> Pi[Lazy-load Pi SDK]
  UI[Client entry] --> Route{Opened surface}
  Route --> Settings[Lazy settings chunk]
  Route --> Terminal[Lazy terminal chunk]
  Route --> SidePanel[Lazy Git or browser chunk]
  Stream[Transcript append] --> Signature[Sidebar-visible signature]
  Signature -->|Changed| Sidebar[Re-derive sidebar]
  Signature -->|Unchanged| Skip[Reuse sidebar snapshot]
  GitCommand[Git command] --> ProjectGit[Refresh affected project]
  GitCommand --> Sidebar
Loading

Reviews (1) · Last reviewed commit: "Code-split GitPanel, TerminalWorkspace, ..."

Comment thread src/server/cli-runtime.ts
// captive portal that blackholes the registry would otherwise hang startup
// indefinitely on "checking for updates". UpdateManager re-checks post-listen.
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
signal: AbortSignal.timeout(5000),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Timeout Breaks Interactive Updates

The five-second timeout is applied inside the shared package-version lookup, not only during startup. The interactive stable-reinstall path also calls this helper, so if the npm registry takes more than five seconds to respond, an explicit reinstall is aborted and reported as “Update failed” even though the request could have succeeded. Make the timeout startup-specific or allow interactive callers to choose a longer limit.

Knowledge Base Used: Settings, updates, and usage controls

Fix in Codex

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