307 lines
19 KiB
Markdown
307 lines
19 KiB
Markdown
# Windows Performance Investigation — Progress Log
|
||
|
||
Goal: (1) significantly improve Windows startup time (~1 min cold start reported),
|
||
(2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance.
|
||
All changes must be proven with before/after benchmark numbers.
|
||
|
||
## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency
|
||
|
||
Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes.
|
||
Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase
|
||
timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture).
|
||
Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1` → `[pty-spawn-timing]` lines
|
||
(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn).
|
||
|
||
Findings (baseline, this machine):
|
||
- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it —
|
||
~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts
|
||
admitted this). Premise (16-context budget) stale since #7064 raised budget to 128.
|
||
- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon
|
||
provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new
|
||
pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first
|
||
ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal.
|
||
- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs
|
||
scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear +
|
||
refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts
|
||
recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers
|
||
risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) —
|
||
left as follow-up.
|
||
- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system
|
||
ConPTY corruption + perf differences on degraded-mode/fresh-local spawns).
|
||
|
||
Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis):
|
||
- A/D: WebGL context retention across hide/show and the suspended-pane atlas recovery scoping
|
||
were reverted/parked for more terminal lifecycle testing. Hidden workspaces return to the
|
||
previous dispose-on-hide behavior.
|
||
- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon.
|
||
- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the
|
||
first user terminal doesn't pay the ~2.7s first-ConPTY cost.
|
||
|
||
Follow-ups (documented, not in this branch):
|
||
- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual
|
||
tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback
|
||
when column count changed while hidden.
|
||
- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons
|
||
+ synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF,
|
||
pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes.
|
||
- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is
|
||
Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty
|
||
→ rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at
|
||
that renderer connect layer, not in reconnectPersistedTerminals.
|
||
- First terminal opened immediately after launch also waits on the one-time daemon-init
|
||
barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured
|
||
preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays
|
||
it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain
|
||
existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it
|
||
does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize
|
||
on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing
|
||
behind the in-flight warm-up, not unwarmed cost.
|
||
- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn
|
||
storms serializing the daemon loop.
|
||
|
||
Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim
|
||
PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts).
|
||
|
||
## Status
|
||
|
||
- [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`)
|
||
- [x] Startup bottleneck FIXED + verified: **19.31s → 1.80s median** (fixture);
|
||
real-world profile was 62s of blocked main thread → now 0 icacls spawns steady-state
|
||
- [x] OpenCode freeze ROOT CAUSE found + fixed: MessagePart hook flood (see F5/D2).
|
||
Benchmark: 22.9 MB / 540 ms / 400 main-process fanouts per turn → 469 KB / 79 ms / 120
|
||
(legacy vs throttled plugin behavior through the real hook HTTP pipeline)
|
||
- [x] General Windows sync-work audit (results below); audit item #2 (readHooksJson per
|
||
status IPC) investigated and found NOT hot — renderer barely calls those handlers.
|
||
Fixed pre-existing Windows-only test failures (hydrate-shell-path delimiter).
|
||
- [x] Windows ConPTY e2e perf validation (F7 below)
|
||
|
||
## Key facts / environment
|
||
|
||
- Branch: `Jinwoo-H/windows-launch-time`
|
||
- Electron app, entry: `src/main/index.ts` (~1557 lines)
|
||
- Existing startup diagnostics: `ORCA_STARTUP_DIAGNOSTICS=1` writes `[startup] <event>` lines to stderr
|
||
(`src/main/startup/startup-diagnostics.ts`)
|
||
- Prior art: PR #4618 "perf: speed up desktop startup", #5011 "stop main-thread PowerShell ACL storm
|
||
on env-store reads", #4526 "Avoid OpenCode config cleanup freezes on Windows", b240d5eee
|
||
"Measure startup hydration phases"
|
||
|
||
## Follow-ups / known issues (out of scope for this branch)
|
||
|
||
- Pre-existing Windows-only unit test failures: `daemon-pty-adapter.test.ts` (61) and
|
||
`history-manager.test.ts` (3, chmod-based fs-error simulation is a no-op on Windows).
|
||
Identical with/without this branch's changes. CI never sees them (ubuntu-only).
|
||
- Consider a Windows CI lane for the terminal-perf e2e suite (F6/F7) and these unit suites.
|
||
- Typing-latency load-sensitivity (F7): possible deeper work on daemon checkpoint
|
||
scheduling/priority if user reports persist after D3.
|
||
- Audit leftovers (F4): non-recursive `grantDirAcl` execFileSync on hook install
|
||
(installer-utils.ts:210) could be async; readHooksJson caching unnecessary (not hot).
|
||
|
||
### D3 — Async checkpoint writes (implemented)
|
||
|
||
`HistoryManager.checkpoint` (every ~5s per dirty session, Electron main process) switched
|
||
from writeFileSync+renameSync (~1MB snapshot JSON, inflated by Defender on Windows) to
|
||
fs.promises with the same tmp+rename atomicity; ordering preserved by the adapter's
|
||
checkpointInFlight guard.
|
||
|
||
## Suspects (startup)
|
||
|
||
1. **`grantDirAcl(userData, { recursive: true })`** — `src/main/index.ts:517-523`, win32 only,
|
||
runs **synchronously on the main process inside `openMainWindow()` before window creation**.
|
||
Spawns `icacls <userData> /grant:r <user>:(OI)(CI)(F) /T /C` with a **60s timeout**.
|
||
The comment itself admits large userData dirs (tens of thousands of Chromium cache files)
|
||
can take >10s. This blocks first paint for the whole walk. Matches "1 minute launch" and
|
||
"Windows only".
|
||
2. Windows Defender real-time scan of exe/asar/native modules on cold start (environmental,
|
||
can't fix in code, but reducing file count / sync IO helps).
|
||
3. TBD: store sync load, daemon init, i18n init, sherpa-onnx native module load.
|
||
|
||
### F3 — Baseline benchmark (2026-06-10)
|
||
|
||
Harness: `node tools/benchmarks/startup-time-bench.mjs --label baseline --iterations 3 --files 28000`
|
||
(28k-file synthetic Chromium-cache-shaped userData fixture in %TEMP%, headless launch of
|
||
the electron-vite build with `ORCA_STARTUP_DIAGNOSTICS=1`, milestones parsed from stderr).
|
||
|
||
| phase (median of 3) | baseline |
|
||
|---|---|
|
||
| spawnToAppReady | 857ms |
|
||
| appReadyToServices | 178ms |
|
||
| servicesToI18n | 2ms |
|
||
| i18nToOpenWindow | 7ms |
|
||
| **aclGrantMs** | **15.65s** |
|
||
| windowCreatedToLoaded | 1.06s |
|
||
| **totalToDidFinishLoad** | **19.31s** |
|
||
|
||
ACL walk = 81% of total. (Fixture is kinder than the real profile: same file count but
|
||
freshly-written small files → real %APPDATA%\Orca measured 62s for the same command.)
|
||
JSON: tools/benchmarks/results/startup-baseline-2026-06-10T19-36-01-305Z.json
|
||
|
||
### F4 — Sync main-thread audit (subagent, 2026-06-10)
|
||
|
||
Ranked offenders beyond the ACL grant (#1):
|
||
2. `readHooksJson` + JSON.parse re-read per agent-status IPC call across ~10 hook services
|
||
(`src/main/*/hook-service.ts` via `agent-hooks/installer-utils.ts:50`) — 10-100ms per
|
||
status snapshot, all platforms. Remediation: in-memory cache.
|
||
3. `whoami.exe` SID resolution (win32-utils.ts:92) — already cached, OK.
|
||
4. macOS-only `defaults read` per browser probe — not Windows.
|
||
5. `installer-utils.ts:210` non-recursive grantDirAcl on hook install (execFileSync,
|
||
500ms-2s) — infrequent write path, low priority.
|
||
6. `secure-file.ts` sync PowerShell on credential write path — by design (#5011), leave.
|
||
|
||
## Suspects (OpenCode freeze)
|
||
|
||
- User report: UI freezes ~5s after sending prompt; OpenCode session itself continues fine
|
||
(visible from external terminal). So the agent process is healthy — the freeze is in Orca's
|
||
main process or renderer. Spinner in left panel still animates (= renderer compositor alive?
|
||
or just that one timer). Need to find sync main-process work triggered by OpenCode activity.
|
||
- Prior fix #4526 "Avoid OpenCode config cleanup freezes on Windows" — re-check that path.
|
||
|
||
### Research results (subagent, 2026-06-10) — ranked candidates
|
||
|
||
1. **ConPTY output flood vs PTY batching/backpressure** (HIGH): Windows ConPTY re-renders
|
||
full TUI frames → 10-100x output volume vs macOS. Batching in `src/main/ipc/pty.ts`
|
||
(16KB chunks / 8ms flush, 512KB renderer in-flight window). If renderer xterm.write is
|
||
slow, ACKs stall → in-flight fills → main stalls. Tests: terminal-foreground-redraw-freeze,
|
||
artificial-opencode-terminal-load e2e.
|
||
2. **Sync `runtime.onPtyData` per data event before batching** (MED-HIGH):
|
||
`src/main/ipc/pty.ts:1376-1430` → `orca-runtime.ts:3256-3420`: normalizeTerminalChunk +
|
||
tail-buffer append + agent-status OSC parsing run synchronously per chunk on main.
|
||
Daemon PTY path. High event rate × per-event cost can saturate the main loop.
|
||
3. **`mirrorUserConfig` recursive fs work in `buildPtyEnv` on PTY spawn** (MED):
|
||
`src/main/opencode/hook-service.ts:359-524` + `pty/overlay-mirror.ts:63-110` —
|
||
readdir/safeRemoveTree/symlinks on main thread at spawn; #4526 fixed only clearPty side.
|
||
Timing mismatch with "5s after prompt" though.
|
||
4. Agent-status event fan-out per OSC title (LOW-MED). 5. Tail-buffer O(n²) (LOW).
|
||
|
||
Gap in coverage: no test exercises rapid continuous ConPTY-scale data + sync onPtyData
|
||
accumulation on Windows.
|
||
|
||
### F5 — ROOT CAUSE (2026-06-10): OpenCode MessagePart hook flood
|
||
|
||
Eliminated candidates first: ran `terminal-foreground-redraw-freeze.spec.ts` on THIS Windows
|
||
machine (real ConPTY + daemon provider) — passes; renderer output scheduler protections hold.
|
||
The raw TUI-output-flood theory doesn't explain an OpenCode-specific permanent freeze.
|
||
|
||
The actual mechanism (src/main/opencode/hook-service.ts plugin source):
|
||
- OpenCode publishes `message.part.updated` with the FULL accumulated text of the part on
|
||
every streamed append (architecture: parts are republished, not deltas).
|
||
- Orca's plugin POSTed that full text to the agent-hook server on EVERY event →
|
||
**O(n²) bytes per streaming turn**. A 120KB reply in 400 updates = ~23 MB through
|
||
loopback HTTP + main-process JSON.parse; real turns are worse (per-token updates).
|
||
- Main process spends its whole loop on HTTP receive + parse + normalize + fanout. UI symptom
|
||
matches the user report exactly: everything dead (window close needs main + renderer
|
||
round-trip), EXCEPT the sidebar agent indicator — which is the one thing fed by the very
|
||
agentStatus:set flood that's starving everything else.
|
||
- Why Windows-biased: same flood exists on macOS but combines on Windows with ConPTY
|
||
full-frame redraw volume and generally slower process IO; also Windows daemon-PTY path
|
||
adds main-process onPtyData work.
|
||
- Why "5 seconds after sending the prompt": that's when the accumulated text gets big.
|
||
- Why OpenCode keeps working: plugin POST failures are swallowed; the session is healthy.
|
||
- Downstream payloads were already bounded (prompt 200 chars, lastAssistantMessage 8000
|
||
chars via agent-status-types normalization) — the renderer wasn't the bottleneck; the
|
||
main-process ingest was.
|
||
|
||
### F7 — Windows ConPTY e2e perf validation (2026-06-10)
|
||
|
||
Ran the terminal-perf budget specs on this Windows machine (real ConPTY + daemon PTY
|
||
provider — a path CI never exercises):
|
||
- `terminal-output-scheduler.spec.ts`: PASS (all tests)
|
||
- `terminal-foreground-redraw-freeze.spec.ts`: PASS
|
||
- `terminal-typing-latency.spec.ts`: PASSES in isolation, repeatedly — median 13.6-23.1ms,
|
||
worst 34-42ms (budgets: 250ms median / 1000ms worst). Two earlier runs that exceeded the
|
||
worst-key budget (1054.9ms, 2016.1ms outlier on a single key) occurred while other heavy
|
||
tooling (vitest/tsgo/builds) ran concurrently on the machine → load-sensitivity, not a
|
||
deterministic product defect. Note the product implication: under heavy host load
|
||
(exactly what coding agents generate), a keystroke can stall >1s on Windows. Plausible
|
||
contributors for follow-up: daemon checkpoint ticks (5s interval; snapshot serialize in
|
||
daemon + sync writeFileSync of checkpoint JSON on main — daemon-pty-adapter.ts:592,
|
||
history-manager.ts:109), Defender scanning fresh build artifacts.
|
||
|
||
### F6 — Windows e2e perf coverage gap
|
||
|
||
All terminal-perf e2e specs run on ubuntu-latest in CI. Verified they DO run on a Windows
|
||
dev machine (`npx playwright test ... --project electron-headless` works locally). Consider
|
||
a Windows CI lane for the terminal-perf suite.
|
||
|
||
## OpenCode fix (D2)
|
||
|
||
1. **Plugin throttle + cap (source fix)** — `src/main/opencode/hook-service.ts`:
|
||
assistant MessagePart posts are trailing-edge coalesced to ≥250ms apart and text is
|
||
capped at 4000 chars (leading edge posts immediately so previews stay snappy; pending
|
||
snapshot flushed before SessionIdle so the done-row preview is the final message; user
|
||
prompts bypass the throttle slot). Plugin file is rewritten on every Orca-launched
|
||
OpenCode spawn, so the fix deploys to new sessions immediately.
|
||
2. **Listener-side cap (stale-plugin defense)** — `src/shared/agent-hook-listener.ts`:
|
||
OpenCode MessagePart text capped at 8000 chars at ingest (OPENCODE_HOOK_TEXT_MAX_CHARS)
|
||
so pre-fix plugins in long-running OpenCode processes can't blow up state maps.
|
||
3. **Benchmark/regression test** — `src/main/agent-hooks/opencode-message-part-flood-bench.test.ts`
|
||
drives the real hook HTTP pipeline with both behaviors. Measured on this machine:
|
||
| metric/turn | legacy plugin | throttled plugin |
|
||
|---|---|---|
|
||
| posts | 400 | 120 |
|
||
| bytes through main | 22.9 MB | 469 KB (49x less) |
|
||
| wall time | 540 ms | 79 ms |
|
||
| listener fanouts | 400 | 120 |
|
||
4. Behavioral plugin tests — `src/main/opencode/hook-plugin-message-part-throttle.test.ts`
|
||
executes the generated plugin with fake timers + stubbed fetch.
|
||
|
||
## Findings
|
||
|
||
### F1 — Recursive icacls walk is the ~1 min startup (CONFIRMED, 2026-06-10)
|
||
|
||
- This machine's real packaged-Orca userData: `%APPDATA%\Orca` = **28,650 files / 2.06 GB**
|
||
(mostly Chromium caches: Cache, Code Cache, GPUCache, blob_storage…).
|
||
- Measured the exact command Orca runs in `openMainWindow()` (src/main/index.ts:517-523):
|
||
- `icacls <userData> /grant:r <user>:(OI)(CI)(F) /T /C` → **62.0 s**
|
||
- App runs it with `execFileSync` (main thread, BLOCKING, before BrowserWindow creation)
|
||
with a **60s timeout** → every cold launch freezes ~60s, then the grant *times out and
|
||
silently fails* (execFileSync throws, caught). Users pay the full minute and get nothing.
|
||
- Non-recursive root-only grant: **4.8 s** (NTFS propagates inheritable ACE internally).
|
||
- `icacls <userData>\* /grant:r …` (immediate children, 48 entries): **4.7 s**.
|
||
- Why it exists (PR #1152): Chromium's BrowserWindow ctor resets userData DACL with
|
||
Inherit-Only ACEs → EPERM on writes in existing subdirs (codex-runtime-home, agent-hooks…).
|
||
Explicit child ACEs survive propagation. Per-write EPERM retries exist as backstop in
|
||
`codex-accounts/fs-utils.ts` + `agent-hooks/installer-utils.ts`.
|
||
- Windows ACL inheritance recalculates from the immediate parent during propagation, so
|
||
explicit ACEs on userData + immediate children are sufficient; per-file ACEs on 28k
|
||
Chromium cache files are useless work.
|
||
|
||
### F2 — Instrumentation prior art
|
||
|
||
- `ORCA_STARTUP_DIAGNOSTICS=1` → `[startup] <event>` lines on stderr (startup-diagnostics.ts).
|
||
Only 2 events exist today (single-instance lock). Commit b240d5eee (branch
|
||
perf/startup-first-window, NOT merged here) has a full StartupPhaseTimer framework —
|
||
too large to cherry-pick; adding minimal milestone logs instead.
|
||
- Hermetic benchmark launch path: `ORCA_E2E_USER_DATA_DIR=<dir>` redirects userData
|
||
(works packaged + dev), `ORCA_E2E_HEADLESS=1` keeps window hidden. Dev/preview mode
|
||
skips single-instance lock → safe alongside installed Orca.
|
||
|
||
## Decisions / fixes
|
||
|
||
### D0 — RESULTS: ACL fix benchmark (2026-06-10)
|
||
|
||
| phase (median) | baseline (3 it.) | after fix (4 it.) | steady state (3 it.) |
|
||
|---|---|---|---|
|
||
| aclGrantMs | **15.65s sync/blocking** | async (off critical path) | **0ms (marker hit)** |
|
||
| totalToWindowCreated | 18.25s | 930ms | 814ms |
|
||
| totalToDidFinishLoad | **19.31s** | **2.04s** | **1.80s** |
|
||
|
||
- First launch after fix: total 2.06s while the background grant ran 6.81s concurrently.
|
||
- Marker verified written by real icacls run; subsequent launches log `acl-grant-done
|
||
mode=marker-hit` with zero spawns.
|
||
- JSON evidence: tools/benchmarks/results/startup-{baseline,acl-fix,acl-fix-steady}-*.json
|
||
- Files: src/main/startup/windows-user-data-acl.ts (+tests), src/main/index.ts (wire-up +
|
||
startup milestones), src/main/win32-utils.ts (export identity resolver),
|
||
tools/benchmarks/startup-time-bench.mjs (harness).
|
||
|
||
### D1 — ACL grant fix (implemented as planned)
|
||
|
||
Replace the synchronous recursive walk with:
|
||
1. A persisted marker (`windows-acl-grant.json` in userData, keyed on identity + scheme
|
||
version): when present → skip everything (steady-state launches: 0 icacls spawns, 0 ms).
|
||
2. When marker missing (first launch after install/profile import): grant root +
|
||
immediate children via **async spawn** (never blocks window creation); write marker
|
||
on success. Per-write EPERM retries remain the backstop during the async window —
|
||
that's exactly what they're for (#1152 comment says so).
|
||
3. Drop the /T full-tree walk entirely; it grants nothing the immediate-children
|
||
ACEs + inheritance propagation don't already cover.
|