Skip to content

fix(compiler): bound compile CPU on a dedicated-thread IIoPool lane - #5375

Merged
meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/compile-lane-bound
Sep 22, 2026
Merged

meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/compile-lane-bound

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

What

This follow-up to #5327 closes the two gaps that PR left open.

  1. Distinct-NodeType compile concurrency was unbounded. fix(compiler): keep NodeType compiles off the silo's shared ThreadPool #5327 moved the emit onto CompileThread. That is off the ThreadPool, but it starts one new OS thread per distinct NodeType compiling at once, and a roll produces hundreds of those.
  2. Language-service diagnostics still ran on the ThreadPool. They were _ioPool.Run leaves on the Compile pool. The script workspace also bound with Roslyn's default ConcurrentBuild.

Change: one bounded CPU lane over dedicated threads

  • IoPool dedicated-thread mode. A new constructor overload takes dedicatedThreads. With it on, InvokeBlocking leaves run on threads that LimitedConcurrencyLevelTaskScheduler starts itself (named mw-cpu-lane) instead of ThreadPool work items. The queue and the cap are the same as before. There is no new primitive and no SemaphoreSlim.
  • New lane IoPoolNames.CompileCpu. Its cap is IoPoolOptions.CompileCpu, which defaults to the processor count. The registry builds this pool with dedicated threads.
    • It is deliberately a separate pool from Compile. Compile also runs kernel scripts, which may wait on a compile. If an emit queued behind that same cap, a script holding a slot could wait forever on a compile that needs one: the nested-gate deadlock the compile leaf already hit once.
    • A lane leaf only computes: no pool call, no mesh read, no waiting on another leaf. So it cannot nest.
  • What runs on the lane:
    • EmitCompiled (generators, bind, emit, artifact write);
    • the failure-path CompileDiagnostics.DiagnoseInputs;
    • every language-service diagnostics bind: the node workspace, speculative proposals and script cells.
  • Diagnostics are split in two. Each diagnostics request now has an IO half (NuGet references, GetCompilationAsync) that stays on the Compile pool, and a bind that runs on the lane. SpeculativeCompilation gains CreateCompilationAsync and Diagnose for this; GetDiagnosticsAsync stays as the composition of the two.
  • Script workspace now binds with ConcurrentBuild off.

Tests (each has a negative control that fails on the unfixed code)

  • CompileCpuLaneIsBoundedAndOffThePoolTest (Hosting.Test): six parked leaves on a lane capped at 2.
    • Never more than 2 run at once, all 6 complete, and none runs on a pool worker.
    • Control: the ordinary Compile pool with the same cap runs all 6 on pool workers.
    • Built without dedicated threads, the lane test fails.
  • LanguageServiceDiagnosticsRunOnTheCpuLaneTest: all three diagnostics arms bind on mw-cpu-lane threads. It fails when the binds are routed back to the Compile pool.
  • ServiceCompileStaysOffTheThreadPoolTest: now also asserts that the emit runs on the lane thread. It fails when the emit is routed through the Compile pool.

Local runs:

  • Release -warnaserror builds are clean for Mesh.Contract dependents (Hosting, Hosting.Orleans, Graph, Kernel.Hub, Compiler.Pipeline, Documentation).
  • Compiler.Pipeline.Test: 1081 of 1081 passed.
  • Documentation.Test (guards): 624 of 624 passed.
  • The IoPool classes in Hosting.Test: 12 of 12 passed.

Still on the ThreadPool, by design (written up in the doc)

  • Hover and completion. Roslyn's async QuickInfoService and CompletionService resume with ConfigureAwait(false), so they cannot be pinned to a thread.
  • The IO halves of the diagnostics requests.
  • CompileResultFromAssembly. That is assembly load and user type initializers, not a CPU compile. It has its own timeout and stays on CompileThread.

Docs

  • Doc/Architecture/CompileOffTheThreadPool: new section "The CPU lane".
  • Doc/Architecture/ControlledIoPooling: new CompileCpu row in the pool table.

Refs #5327, #5291, #5262, #5249, #5307, #5226

These issues need post-roll verification. None is closed here.

Pairs-with: none — nothing public removed. This PR only adds a public IoPool constructor overload and an IoPoolNames/IoPoolOptions member.

Implementers: none — no interface or abstract member is added.

Recycle after deploy: none. The lane is process-wide and takes effect at pod start.

🤖 Generated with Claude Code

Follow-up to #5327, which moved the NodeType emit off the ThreadPool onto
CompileThread: off the pool, but one new thread per DISTINCT NodeType
compiling at once — hundreds on a roll. And the language service's
diagnostics binds still ran as Compile-pool leaves on ThreadPool workers
(the script workspace with Roslyn's default ConcurrentBuild on top).

- IoPool gains dedicatedThreads: InvokeBlocking leaves drain on threads the
  pool's LimitedConcurrencyLevelTaskScheduler starts itself (mw-cpu-lane),
  same queue and cap, never a ThreadPool worker.
- IoPoolNames.CompileCpu (cap IoPoolOptions.CompileCpu = processor count),
  built by the registry with dedicatedThreads. Separate from Compile, which
  also runs kernel scripts that may wait on a compile — sharing its cap is
  the nested-gate deadlock. A lane leaf only computes, so it cannot nest.
- EmitCompiled and the failure-path DiagnoseInputs run on the lane.
- Every language-service diagnostics bind (workspace, speculative, script)
  splits into an IO half on the Compile pool and a bind on the lane;
  SpeculativeCompilation exposes CreateCompilationAsync + Diagnose.
  The script workspace binds with ConcurrentBuild off.
- Tests with negative controls that fail on the unfixed routing:
  CompileCpuLaneIsBoundedAndOffThePoolTest, LanguageServiceDiagnosticsRunOn
  TheCpuLaneTest, and ServiceCompileStaysOffTheThreadPoolTest (now asserts
  the lane thread).
- Docs: CompileOffTheThreadPool (the lane), ControlledIoPooling (pool row).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 22, 2026 20:39
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

  1 files  ±0    1 suites  ±0   2m 55s ⏱️ +7s
326 tests +6  326 ✅ +6  0 💤 ±0  0 ❌ ±0 
330 runs  +6  330 ✅ +6  0 💤 ±0  0 ❌ ±0 

Results for commit 468c6ac. ± Comparison against base commit 08b4dbd.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

429 tests   - 14   429 ✅  - 14   1m 3s ⏱️ +8s
  2 suites  -  1     0 💤 ± 0 
  2 files    -  1     0 ❌ ± 0 

Results for commit 468c6ac. ± Comparison against base commit 08b4dbd.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

542 tests   - 1 080   542 ✅  - 1 080   1m 6s ⏱️ - 2m 15s
  1 suites  -     1     0 💤 ±    0 
  1 files    -     1     0 ❌ ±    0 

Results for commit 468c6ac. ± Comparison against base commit 08b4dbd.

♻️ This comment has been updated with latest results.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The critical scheduler failure and unresolved cancellation, async-boundary, and exception-path issues block approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 4 Medium severity

Open (5)
What changed in this PR

This PR bounds Roslyn compilation and diagnostic CPU work on a dedicated, limited-concurrency CompileCpu lane.

Changes:

  • Adds dedicated-thread IoPool scheduling and CompileCpu configuration.
  • Routes emits and diagnostic binding through the CPU lane.
  • Adds regression tests and updates architecture documentation.
File Summary
test/​MeshWeaver.Hosting.Test/​CompileCpuLaneIsBoundedAndOffThePoolTest.cs Tests lane concurrency limits and dedicated-thread execution.
test/​MeshWeaver.Compiler.Pipeline.Test/​ServiceCompileStaysOffTheThreadPoolTest.cs Verifies emits run on the CPU lane.
test/​MeshWeaver.Compiler.Pipeline.Test/​LanguageServiceDiagnosticsRunOnTheCpuLaneTest.cs Verifies diagnostic binding runs on lane threads.
src/​MeshWeaver.Mesh.Contract/​Threading/​LimitedConcurrencyLevelTaskScheduler.cs Adds dedicated-thread scheduling. Critical: startup failure can leave the scheduler wedged.
src/​MeshWeaver.Mesh.Contract/​Threading/​IoPoolRegistry.cs Configures the CompileCpu pool.
src/​MeshWeaver.Mesh.Contract/​Threading/​IoPoolOptions.cs Defines lane configuration. Nit: the type summary contradicts the dedicated-thread exception.
src/​MeshWeaver.Mesh.Contract/​Threading/​IoPool.cs Adds dedicated-thread pool construction.
src/​MeshWeaver.Documentation/​Data/​Architecture/​ControlledIoPooling.md Documents the new pool. Nit: existing no-custom-threads wording is now contradictory.
src/​MeshWeaver.Documentation/​Data/​Architecture/​CompileOffTheThreadPool.md Documents the CPU-lane design.
src/​MeshWeaver.Compiler/​SpeculativeCompilation.cs Separates compilation creation from diagnosis. Moderate: the added async boundary may lose scheduler context.
src/​MeshWeaver.Compiler.Pipeline/​MeshNodeLanguageService.cs Splits diagnostics IO and CPU binding. Moderate: Task-returning helpers introduce unsafe async boundaries.
src/​MeshWeaver.Compiler.Pipeline/​MeshNodeCompilationService.cs Moves emits and failure diagnostics to the lane. Moderate: cancellation is discarded, an async bridge remains, and the exception path synchronously uses another pool.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +83 to +87
new Thread(_ => DrainQueue(), maxStackSize: 0)
{
IsBackground = true,
Name = DedicatedThreadName,
}.UnsafeStart();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in 468c6ac. The reservation is now taken inside lock(_tasks) together with the queued node. If NotifyThreadPoolOfPendingWork() throws (thread creation or UnsafeStart failed), the catch decrements _delegatesQueuedOrRunning, removes that node from _tasks and rethrows. TaskFactory.StartNew in InvokeBlocking then faults the leaf, so the scheduler never sits at its cap with no drain loop behind it.

? Observable.Return<IReadOnlyList<Lsp.DiagnosticInfo>>(Array.Empty<Lsp.DiagnosticInfo>())
: OnThreadPool(() => CompileDiagnostics.DiagnoseInputs(inputs)))
// Pure Roslyn compute -> the bounded CPU lane, off the ThreadPool (CompileOffTheThreadPool).
: _cpuLane.InvokeBlocking(_ => CompileDiagnostics.DiagnoseInputs(inputs)))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in 468c6ac. CompileDiagnostics.DiagnoseInputs now takes a CancellationToken and passes it to RunSourceGenerators and GetDiagnostics(ct). The service hands it the lane token: _cpuLane.InvokeBlocking(ct => CompileDiagnostics.DiagnoseInputs(inputs, ct)). An abandoned failure diagnose therefore releases its CompileCpu slot when it is cancelled. The emit leaf already linked the lane token with the compile's own bound.

// queue. EmitCompiled does nothing but compute and write its own artifact — no pool call, no
// mesh read — so it can never wait on another lane leaf (the nested-gate deadlock the Compile
// pool hit). Doc/Architecture/CompileOffTheThreadPool.
return await _cpuLane

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This await is not new. CompileAsyncCore was already an async Task compile leaf (NuGet restore, debug source write). #5327 awaited CompileThread.Run at this exact spot, and this PR swaps that for the lane.

  • It never parks a turn scheduler. The leaf is entered only through OnThreadPool(Func<Task<T>>) (Task.Run), never on a hub or grain thread.
  • Identity is preserved. OnThreadPool re-establishes ImpersonateAsSystem inside the async lambda for its whole span, and EmitCompiled does no mesh reads.
  • The wait uses the sanctioned bridge. It goes through ObservableAwait.Await(ct) (RunContinuationsAsynchronously), not ToTask/await observable.

Turning the whole compile leaf reactive means replacing the CompileAsync/_inflightCompiles Task shape. That refactor is unrelated to bounding CPU, so I've kept it out of this PR. No change here.

});

/// <summary>The IO half of a node workspace's diagnostics: the project's compilation (not yet bound).</summary>
private static async Task<Compilation?> WorkspaceCompilationAsync(CachedWorkspace cached, CancellationToken ct)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WorkspaceCompilationAsync / ScriptCompilationAsync are the former GetDiagnosticsAsync / GetScriptDiagnosticsAsync bodies with the bind moved out; they are not new async boundaries.

  • Both are private and are reached only as _ioPool.Run(ct => …) leaves, so the IO pool already owns the async leaf.
  • Every other private leaf in this service (GetHoverAsync, GetCompletionsAsync, …) has the same shape.
  • The async part is Roslyn's Project.GetCompilationAsync, which has no synchronous form.

No change.

string sourcePath,
string proposedCode,
CancellationToken ct)
=> Diagnose(await CreateCompilationAsync(inputs, sourcePath, proposedCode, ct).ConfigureAwait(false), ct);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SpeculativeCompilation.GetDiagnosticsAsync was already a public async Task helper before this PR. Its callers use it as an IIoPool.Run leaf. This PR only splits it into CreateCompilationAsync (the IO half: NuGet resolve, parse) and a synchronous Diagnose, and keeps GetDiagnosticsAsync as their composition so existing callers are unchanged.

The language service now does what you suggest: _ioPool.Run(ct => CreateCompilationAsync(...)).SelectMany(c => _cpuLane.InvokeBlocking(ct => Diagnose(c, ct))). The async IO leaf sits behind IIoPool, and the result is composed into the lane bind rather than awaited. No change.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    2 files   -   1      2 suites   - 1   6m 26s ⏱️ +22s
2 584 tests +601  2 584 ✅ +601  0 💤 ±0  0 ❌ ±0 
2 585 runs  +601  2 585 ✅ +601  0 💤 ±0  0 ❌ ±0 

Results for commit 468c6ac. ± Comparison against base commit 08b4dbd.

♻️ This comment has been updated with latest results.

…art returns its slot

Review of #5375: DiagnoseInputs ran generators and GetDiagnostics with
CancellationToken.None, so an abandoned failure diagnose held a CompileCpu
slot to the end; it now takes the lane token. And a dedicated-thread drain
whose Thread.Start throws now rolls back its reservation and withdraws the
task instead of leaving the scheduler at its cap with no drain loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 585 tests  +854   1 585 ✅ +1 046   9m 26s ⏱️ + 2m 13s
    2 suites  -   1       0 💤  -   192 
    2 files    -   1       0 ❌ ±    0 

Results for commit 468c6ac. ± Comparison against base commit 08b4dbd.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    4 files   -   1      4 suites   - 1   15m 25s ⏱️ -3s
3 343 tests  - 641  3 343 ✅  - 639  0 💤  - 2  0 ❌ ±0 
3 347 runs   - 641  3 347 ✅  - 639  0 💤  - 2  0 ❌ ±0 

Results for commit 468c6ac. ± Comparison against base commit 08b4dbd.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   12 files   -   5     12 suites   - 5   36m 24s ⏱️ +33s
8 809 tests  - 274  8 809 ✅  - 80  0 💤  - 194  0 ❌ ±0 
8 818 runs   - 274  8 818 ✅  - 80  0 💤  - 194  0 ❌ ±0 

Results for commit 468c6ac. ± Comparison against base commit 08b4dbd.

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit 39f4949 Sep 22, 2026
42 of 43 checks passed
This was referenced Sep 23, 2026
This was referenced Sep 23, 2026
@rbuergi
rbuergi deleted the fix/compile-lane-bound branch October 10, 2026 13:10
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.

2 participants