Cap LSP worker thread pool to fix memory scaling with project count - #1776
Open
markwpearce wants to merge 13 commits into
Open
Cap LSP worker thread pool to fix memory scaling with project count#1776markwpearce wants to merge 13 commits into
markwpearce wants to merge 13 commits into
Conversation
…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>
Collaborator
Author
|
This change makes a substantial difference - this allows opening the Roku Samples repo (~139 projects) without issues. Additionally, this same fix in |
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>
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)
Creating Worker threads is just slow, unfortunately. |
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Opening a multi-root VSCode workspace with many BrighterScript projects (e.g. ~15+, as in
brighterscript-game-engine's 17bsconfig.jsonfiles) can crash the language server with an out-of-memory error.Root cause:
LanguageServer.enableThreadingDefaultdefaults totrue, andProjectManagerspawns one dedicated OS worker thread (a full separate V8 isolate) per project viaWorkerPool.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 wholebrighterscriptmodule graph (roku-typesstatic 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:WorkerPoolnow has a configurablemaxWorkers(defaultos.cpus().length). Each project still gets its ownProject/Programinstance and its own dedicatedMessagePort(viaMessageChannel), but once the cap is reached, additional projects' ports are attached to an existing worker instead of spawning a new one.languageServer.maxProjectWorkerssetting, mirroring the existingprojectActivationConcurrencyLimitpattern, so this is tunable per-workspace.critical-failurenotification (previously: requests on that worker would hang forever with no signal to the user).preload()/assignProject()agree on the cap, andassignProject()defends against a misconfiguredmaxWorkers <= 0.Testing
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, andLanguageServer.spec.ts.npm run lintandnpm run buildclean.brighterscript-game-engine's multi-project workspace in VSCode (recommended as a manual follow-up check).Notes for reviewers
master(notv1) because the affected code (src/lsp/worker/*) is currently identical between the two branches — landing here first means it flows tov1on the nextmastermerge, rather than requiring a manual backport.Program.tscache-leak fixes identified during investigation (fileClusters,getFilePathCache,symbolDependencies,util.tspath caches never pruned) — smaller, unrelated issue, tracked separately.critical-failuretoday; this PR keeps that event's existing one-way-notification behavior rather than introducing new reload semantics.🤖 Generated with Claude Code