Skip to content

VS Code extension blocks ~20s on activate() during startup (PATH scan on the hot path) #5

Description

@smoochy

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

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions