VS Code extension blocks ~20s on activate() during startup (PATH scan on the hot path)
Summary
The VS Code extension's activate() awaits resolveBinary() before doing anything else. On a real VS Code startup, resolving the binary took ~19.7s, all of it inside the which('observer') PATH scan. This is onStartupFinished work, so it doesn't freeze the editor, but the Observer daemon (and every view that depends on it) is unavailable for ~20s+ on every launch.
Evidence
From the extension's own Observer output channel (real startup, VS Code 1.128, Windows):
[20:05:27.723Z] Observer extension activating
[20:05:47.374Z] Resolved binary: ...\bin\observer.exe (version 1.18.0, source bundled)
19.65s between those two lines. The only code between them is await resolveBinary(ctx) (vscode/src/extension.ts:28). Total Finish Activate was 35080ms across three separate startups (the remainder is daemon boot).
Root cause
resolveBinary (vscode/src/binary.ts) tries which('observer') (a full PATH scan) before the bundled binary. which() (vscode/src/binary-internals.ts) awaits fileExists per candidate sequentially — on this machine 68 PATH dirs × 11 PATHEXT = 748 sequential awaits.
Each fs.access is sub-millisecond in isolation, but its callback is delivered on the main loop. During startup the single-threaded extension host is saturated by other extensions activating, so each of the 748 callbacks queues behind other work. The scan that takes ~20ms on an idle loop balloons to seconds under contention.
Reproduction (standalone Node harness, same sequential vs concurrent logic, event loop kept busy with periodic synchronous bursts to mimic startup contention):
PATH dirs=68 PATHEXT=11 candidates=748
idle loop sequential=21.4ms parallel=5.8ms speedup=3.7x
contention (8ms bursts) sequential=5993.1ms parallel=13.3ms speedup=452x
contention (15ms bursts) sequential=11225.6ms parallel=19.9ms speedup=563x
Proposed fix
Two small, independent changes (PR incoming):
- Prefer the bundled binary before scanning PATH. The extension ships a version-matched binary;
which() becomes a fallback for installs that ship none. Common case = one fileExists + one --version, no PATH scan. observer.binary.path stays as the explicit override.
- Parallelise
which() (Promise.all over candidates, lowest-index hit wins) so even the fallback can't starve. Precedence unchanged.
Behavior change to be aware of
Change 1 flips PATH-vs-bundled precedence. A user who deliberately puts a different observer on PATH (e.g. a self-built newer daemon) previously had it auto-selected; now the bundled binary wins unless they set observer.binary.path. Happy to gate this behind a setting instead if you'd prefer to preserve the old default — see PR discussion.
Environment: VS Code 1.128.0, Windows 11, extension 1.18.0.
VS Code extension blocks ~20s on
activate()during startup (PATH scan on the hot path)Summary
The VS Code extension's
activate()awaitsresolveBinary()before doing anything else. On a real VS Code startup, resolving the binary took ~19.7s, all of it inside thewhich('observer')PATH scan. This isonStartupFinishedwork, so it doesn't freeze the editor, but the Observer daemon (and every view that depends on it) is unavailable for ~20s+ on every launch.Evidence
From the extension's own
Observeroutput channel (real startup, VS Code 1.128, Windows):19.65s between those two lines. The only code between them is
await resolveBinary(ctx)(vscode/src/extension.ts:28). TotalFinish Activatewas 35080ms across three separate startups (the remainder is daemon boot).Root cause
resolveBinary(vscode/src/binary.ts) trieswhich('observer')(a full PATH scan) before the bundled binary.which()(vscode/src/binary-internals.ts) awaitsfileExistsper candidate sequentially — on this machine 68 PATH dirs × 11 PATHEXT = 748 sequential awaits.Each
fs.accessis sub-millisecond in isolation, but its callback is delivered on the main loop. During startup the single-threaded extension host is saturated by other extensions activating, so each of the 748 callbacks queues behind other work. The scan that takes ~20ms on an idle loop balloons to seconds under contention.Reproduction (standalone Node harness, same sequential vs concurrent logic, event loop kept busy with periodic synchronous bursts to mimic startup contention):
Proposed fix
Two small, independent changes (PR incoming):
which()becomes a fallback for installs that ship none. Common case = onefileExists+ one--version, no PATH scan.observer.binary.pathstays as the explicit override.which()(Promise.allover candidates, lowest-index hit wins) so even the fallback can't starve. Precedence unchanged.Behavior change to be aware of
Change 1 flips PATH-vs-bundled precedence. A user who deliberately puts a different
observeron PATH (e.g. a self-built newer daemon) previously had it auto-selected; now the bundled binary wins unless they setobserver.binary.path. Happy to gate this behind a setting instead if you'd prefer to preserve the old default — see PR discussion.Environment: VS Code 1.128.0, Windows 11, extension 1.18.0.