Skip to content

Cap LSP worker thread pool to fix memory scaling with project count - #1776

Open
markwpearce wants to merge 13 commits into
masterfrom
lsp-worker-pool-memory
Open

Cap LSP worker thread pool to fix memory scaling with project count#1776
markwpearce wants to merge 13 commits into
masterfrom
lsp-worker-pool-memory

Conversation

@markwpearce

Copy link
Copy Markdown
Collaborator

Problem

Opening a multi-root VSCode workspace with many BrighterScript projects (e.g. ~15+, as in brighterscript-game-engine's 17 bsconfig.json files) can crash the language server with an out-of-memory error.

Root cause: LanguageServer.enableThreadingDefault defaults to true, and ProjectManager spawns one dedicated OS worker thread (a full separate V8 isolate) per project via WorkerPool.getWorker(), which had no cap — it always created a new worker if none were free, and a worker was only returned to the pool when its project was disposed (which doesn't happen for open, in-use projects during a normal session). Each isolate independently loads the whole brighterscript module graph (roku-types static data, globalCallables, type caches, etc.), so total memory scaled roughly linearly with project count, multiplying fixed interpreter/type-system overhead by N.

Fix

Replaces WorkerPool's exclusive one-worker-per-project model with a capped, load-aware assignment model:

  • WorkerPool now has a configurable maxWorkers (default os.cpus().length). Each project still gets its own Project/Program instance and its own dedicated MessagePort (via MessageChannel), but once the cap is reached, additional projects' ports are attached to an existing worker instead of spawning a new one.
  • Co-located projects share that worker's JS realm, recovering cross-project sharing of the big fixed-cost singletons that per-project threading had made impossible to share.
  • A worker is terminated and dropped from the pool as soon as its last attached project releases it, so memory is reclaimed promptly rather than kept warm indefinitely.
  • New languageServer.maxProjectWorkers setting, mirroring the existing projectActivationConcurrencyLimit pattern, so this is tunable per-workspace.
  • An unexpected worker exit now surfaces a critical-failure notification (previously: requests on that worker would hang forever with no signal to the user).
  • Worker lifecycle edge cases handled: idempotent dispose (a project can be disposed more than once without side effects on sibling projects sharing its worker), a crashed worker is evicted from the pool's bookkeeping (so it can't silently swallow a future project's bootstrap), preload()/assignProject() agree on the cap, and assignProject() defends against a misconfigured maxWorkers <= 0.

Testing

  • New/updated unit tests in WorkerPool.spec.ts, WorkerThreadProject.spec.ts (including real-worker-thread integration tests proving multiple projects can share one worker while remaining functionally independent, and that a co-tenant surviving a sibling's dispose/double-dispose keeps working), MessageHandler.spec.ts, and LanguageServer.spec.ts.
  • Full suite: 3001 passing, 0 failing. npm run lint and npm run build clean.
  • Not automated: real-world confirmation against brighterscript-game-engine's multi-project workspace in VSCode (recommended as a manual follow-up check).

Notes for reviewers

  • This targets master (not v1) because the affected code (src/lsp/worker/*) is currently identical between the two branches — landing here first means it flows to v1 on the next master merge, rather than requiring a manual backport.
  • Scoped out of this PR: the separate Program.ts cache-leak fixes identified during investigation (fileClusters, getFilePathCache, symbolDependencies, util.ts path caches never pruned) — smaller, unrelated issue, tracked separately.
  • Also scoped out: automatic reload of sibling projects when a shared worker crashes — no existing mechanism reloads a project on critical-failure today; this PR keeps that event's existing one-way-notification behavior rather than introducing new reload semantics.

🤖 Generated with Claude Code

markwpearce and others added 7 commits August 17, 2026 11:58
…cts onto shared workers

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…g shared workers

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…orkerPool

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tial release, orphaned crashed workers, non-idempotent dispose, preload/cap coherence, zero-maxWorkers defense, hung in-flight requests

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ash after worker exit

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ical-failure notifications, contain worker-attach exceptions, fix chai import; bump timeouts on real-worker-thread tests to fix CI flakiness

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@markwpearce

markwpearce commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

This change makes a substantial difference - this allows opening the Roku Samples repo (~139 projects) without issues.

Additionally, this same fix in v1 also allows opening that repo in VSCode, with no noticeable lag... Taking about 20 seconds to load and validate all the projects.

markwpearce and others added 3 commits August 17, 2026 16:10
Root cause: real worker-thread cold-start (spawn + ts-node/register bootstrap)
takes 15-20+ seconds on GitHub's macOS runners. WorkerPool's intentional
'terminate when empty' policy meant the existing workerThreadWarmup hook's
pre-warmed worker was destroyed immediately after its own dispose(), so every
subsequent real-worker test independently paid a full cold-start cost -
comfortably fast locally, but blowing past even a 15s timeout on macOS CI.

Fix: after disposing a project (in wakeWorkerThread() and both
WorkerThreadProject.spec.ts's and ProjectManager.spec.ts's outer afterEach
hooks), call workerPool.preload(1) to leave one idle worker ready. Since
assignProject() already prefers an idle worker over creating a new one, this
collapses cold-start cost to once per test run instead of once per test,
without changing WorkerPool's production behavior at all - verified locally
that every real-worker test after the initial warmup now logs 'Reusing
preloaded/idle worker thread' instead of 'Creating new worker thread'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r object construction

Root cause of the previous fix's continued CI failure: WorkerPool.preload()
marks a newly-created Worker as 'idle and available' the instant new Worker()
returns, but that call is synchronous while the actual OS thread + ts-node/
register bootstrap happens asynchronously in the background - confirmed via
CI logs taking 15-20+ seconds on macOS runners. So the previous 'keep 1 spare
warm' fix handed out workers that looked ready but weren't actually listening
yet, and every test that got one hung until its own timeout.

Fix: run.ts now posts a {type: 'ready'} message once it has actually
registered its attachProject listener. A new preloadAndWaitUntilReady()
test helper waits for that signal on any newly-created worker before
resolving, and is used everywhere the tests previously called
workerPool.preload() directly. Production code (WorkerPool.ts) is
untouched - assignProject()'s synchronous 'create new' path was never
subject to this bug, since it's immediately followed by a real
request/response await inside activate() regardless of preload machinery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eality

The readiness-handshake mechanism (previous commit) was correct, but the
afterEach hooks that call it only had a 15s timeout budget - the same as
the individual test timeouts - when a fresh cold worker-thread boot can
take right up to or past that on macOS CI. Bumped both to 60s, matching
the existing workerThreadWarmup hook's own allowance for the same class
of operation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@markwpearce

markwpearce commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

I don't love that MacOS CI tests take longer, but that's due to Github's test runner hardware, I believe.

On my laptop (m3 Macbook pro) npm run test:

  • previous master - 8s
  • this branch - 12s

Creating Worker threads is just slow, unfortunately.

markwpearce and others added 3 commits August 18, 2026 10:35
… describe block is reached

Only 1 real worker thread is ever created for the whole test suite already
(verified via log inspection - everything else is WorkerPool.spec.ts's own
mocked-factory unit tests, which are already free). The remaining cost is
paying for that one real OS thread + ts-node/register bootstrap at all.

Previously the async boot only started once mocha's sequential execution
reached WorkerThreadProject.spec.ts's own before(workerThreadWarmup) hook -
which happens after thousands of unrelated tests have already run. Kicking
off the same (memoized) promise at module scope instead means mocha's
upfront require-all-spec-files phase starts the boot essentially at time
zero of the whole process, so its cost overlaps with everything that runs
before that hook is reached instead of blocking on top of it. Verified via
log inspection: worker creation now happens at line 1-2 of the full
suite's output instead of line ~3450+.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…l reboots between tests

Root cause (found via commit bisection + isolated boot-cost measurement +
log-timestamp gap analysis): WorkerPool.preload()/createWorker() create
real worker threads SILENTLY (no 'Creating new worker thread' log - that
only fires from assignProject()'s create-new branch), so every
'worker sharing' test dropping its shared worker to zero tenants at
cleanup was triggering an invisible ~1.4s real reboot via the afterEach's
preloadAndWaitUntilReady(1) - four times across that test cluster alone,
accounting for most of a measured 8s -> 16-18s full-suite regression vs
pre-branch baseline.

Fix: a persistent keepAliveProject, created once in workerThreadWarmup and
disposed once in a new after() hook, holds a permanent tenant slot on the
shared worker (with maxWorkers forced to 1) so the worker's tenant count
never actually drops to zero between individual tests in this file.
Verified: the 4 affected tests now run in ~33ms combined (was ~5.9s), and
the full suite is back to ~13.7-15.7s, matching pre-branch baseline (~14.6s)
under the same measurement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comments-only cleanup across this branch's changes - no logic changes.
Verified via diff that every changed line is a comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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