Compiling Off the ThreadPool

A portal silo has ONE shared .NET ThreadPool. Orleans grain turns, the routing pool's subscribe legs (IIoPool.SubscribeThroughPool, see Reading a Routing Saturation Report) and every reactive continuation that delivers a cross-hub response all run on it. Its minimum worker count is Environment.ProcessorCount — 6 on an AKS portal pod (CPU limit 6) — and past that it grows by hill-climbing, roughly one or two threads a second. Anything that holds pool workers for hundreds of milliseconds therefore stops the silo delivering messages while nothing is stuck.

The two ways a compile held the pool

1. The CPU-bound half of the compile ran on a pool worker. MeshNodeCompilationService starts a compile through OnThreadPool(() => CompileAsync(...)). CompileAsync returns a Task, so overload resolution binds the Func<Task<T>> overload — Task.Run — and not the Func<T> overload that uses CompileThread's dedicated thread. CompileAsyncCore then awaits the debug source write (EnableSourceDebugging is on by default) and, for #r "nuget:" sources, the NuGet restore; after the first await the rest — parse, source generators, bind, emit — resumed on whatever pool worker the await completed on. So every production emit ran on the pool, although CompileThread's own comment said the compile leaf did not.

2. Roslyn fanned each emit back out onto the pool. CSharpCompilationOptions.ConcurrentBuild defaults to true. With it, Roslyn compiles declarations and method bodies through Parallel.For and Task.Run on TaskScheduler.Default, with no degree-of-parallelism cap — from whatever thread started the emit. A dedicated thread therefore kept only the CALLER off the pool, never the work.

The measurement

A standalone probe (Environment.ProcessorCount = 6): every 5 ms queue one ThreadPool work item and record how long it waits to START, while N concurrent emits of a 20-class × 20-method source run.

N launch ConcurrentBuild probe p50 probe p99 pool queue max
0 — — 0.01 ms 0.04 ms 1
1 Task.Run on (was prod) 7.7 ms 41 ms 19
1 dedicated off (now) 0.01 ms 0.40 ms 1
6 Task.Run on (was prod) 46 ms 417 ms 115
6 Task.Run off 326 ms 687 ms 114
6 dedicated on 33 ms 354 ms 114
6 dedicated off (now) 0.01 ms 0.18 ms 1
12 Task.Run on (was prod) 124 ms 812 ms 147
12 dedicated off (now) 0.02 ms 0.68 ms 1

Both halves are needed: switching ConcurrentBuild off alone pins one pool worker per compile (row "6 / Task.Run / off" is the WORST row), and a dedicated thread alone still fans out. Wall time for the batch is unchanged at N ≥ 6 (729 ms vs 799 ms at N = 6; 1404 vs 1216 ms at N = 12); a lone compile takes longer on one core (about 150 → 500 ms for this source), which is the price of not borrowing the silo's grain-turn threads to finish it.

The fix

The CPU lane (IoPoolNames.CompileCpu)

Every purely CPU-bound Roslyn leaf goes through ONE bounded lane: an IIoPool whose InvokeBlocking leaves run on at most IoPoolOptions.CompileCpu (default: processor count) threads the lane starts itself — named mw-cpu-lane, never ThreadPool workers. The bound is the pool's ordinary blocking scheduler (LimitedConcurrencyLevelTaskScheduler) with dedicatedThreads: true: the same queue and the same cap, only the drain loops run on their own threads. A burst of hundreds of distinct-type compiles now QUEUES behind the cap instead of becoming hundreds of threads, and the OS time-slices the lane's threads fairly against the ThreadPool the grain turns run on.

🚨 The compile's time bound does not count the queue. CompilationCacheOptions.RoslynCompileTimeout bounds the roslyn-compile leg so that a hung leaf cannot park a NodeType at Compiling for ever. Its clock used to start when the leg was subscribed, so a compile still WAITING for a lane slot when the bound elapsed failed terminally as "hung" although it had not started. Measured in the combo verification lane (#4413) on a CI runner: one package dispatched 47 types at once, the lane drained them over five minutes, and the ten still queued at the bound were parked at CompilationStatus=Error, which read as a red package. Now (CpuLaneBound) the bound has three phases, each with its own named expiry:

A caller that joins a compile already in flight times against the leader's queue and admission.

🚨 An expiry is not a verdict about the code, so it settles Unavailable and is retried (#6195). Every expiry throws CpuLaneExpiredException (still a TimeoutException), which NodeTypeCompilationHelpers.IsAvailabilityNonVerdict reads: the type is stamped CompilationStatus=Unavailable, the park budget is untouched, and the failed-verdict re-drive compiles it again, at most MaxAutomaticFailureRedrives times per process. That re-drive also covers a type that still HOLDS a last good build on this framework, which is nobody else's case: HasStaleFrameworkBuild claims a build only when the framework moved. Measured on memex.systemorph.com on 2026-11-03: a Hosting source sync made the prebuilt bundle stale, three Hosting types each compiled a 302-file source closure (about 240 s of Roslyn on a healthy replica) together on a replica spending about 20 % of wall time in GC pauses, and all three crossed the 300 s bound. They were stamped Error under the live inputs and kept their old build, so no path retried them, and they stayed red for about 7 h until a roll replaced the pods.

Three rules keep that retry reachable, each pinned through the real watchers by ALaneExpiryIsRetriedByTheWatcherTest:

What a person reads is one catalog sentence per phase, activity.compile.laneExpired.{prefix|stalled|emit}, with every reading passed as a named argument. The sentence is in the viewer's language and the numbers use the viewer's culture. The exception's English Message goes to server logs only. The record carries the same sentence: CompilationError holds its English rendering, and CompilationErrorText holds the keyed form. Its readings stay numeric, so they render in the viewer's culture (4,26 GiB for a German viewer), and a JSON number renders exactly as the number it was. That is why the English text is still reproduced after the record's round trip. The compile-log panel (NodeTypeStatusView) and the NodeType status area both render the keyed form in the viewer's language through KeyedTexts.Localize.

The expiry message now carries the readings that incident had to reconstruct from the liveness log: the lane's occupancy (CPU lane r/n running, q queued) and the process's GC pause share, total pause time, heap size and gen2 count at the moment it fired. Read them before anything else. A full lane with an empty queue and a high GC share is a slow compile on a starved process. A full lane with a queue is a wave. A lane with free slots and a low GC share is the emit itself.

CpuLaneBoundTest pins each expiry against a real one-slot pool, and the draining case that must not expire. LaneExpiryIsNotACompileVerdictTest pins the classification and the retry of a type that holds a last good build. CompileBoundExcludesCpuLaneQueueTest runs the draining case through the real compile service.

Its leaves:

🚨 The lane is separate from the Compile pool on purpose. Compile also runs kernel SCRIPTS — user code that may activate a NodeType and wait for its compile — and cell-surface assembly loads. An emit queued behind the same cap is the nested-gate deadlock the compile leaf already hit once (every slot held by a script waiting for a compile that needs a slot). A lane leaf does nothing but compute: no pool call, no mesh read, no wait on another leaf, so it cannot nest by construction. Keep it that way — a leaf that needs IO splits it off, as the three diagnostics arms do.

A kernel script holds no Compile slot (the KernelScript lane)

The paragraph above kept the EMIT out of the Compile pool, but the script's EXECUTION — and the session warm-up wait in front of it (KernelScriptReferences.GetReferencesAsync, which awaits the process-wide SharedWarmup) — were still async leaves of that same pool, holding one of its async slots (default: one per processor, so two to four on a CI runner) for the cell's whole run. That is the same nested-gate deadlock one level up: a running script can wait on another of the pool's ASYNC leaves — another kernel session's warm-up or execution (a cell that runs a cell and waits for it), a language-service workspace compile (MeshNodeLanguageService). With every slot held by a script waiting on such a leaf, none is ever admitted and no script ever finishes. Every Activity involved then stays Running for ever, and — because nothing faults — nothing is logged. Below saturation the same hold still capped the process at one running cell per processor and queued the language service behind user scripts.

Measured while pinning it: the pool's BLOCKING leaves (InvokeBlocking — prebuilt adoption's MVID read in PrebuiltAssemblySeeder.StandingBytesResolve, the cell-surface loads in CellSurfaceAssemblyProvider) are admitted by a separate limited-concurrency scheduler, not by the async gate, so a script holding an async slot does NOT starve them. A script waiting on a NodeType activation is therefore not, by itself, this deadlock; a script waiting on another cell is.

Both waits now run on their own lane, IoPoolNames.KernelScript (IoPoolOptions.KernelScript, 256 — a drain hook and a runaway stop, not a throttle; the CPU half of a cell is bounded by CompileCpu). Rule: a leaf that WAITS — on the mesh, on another leaf, on user code — never holds a Compile slot; Compile leaves are short file/PE reads and Roslyn workspace IO.

KernelScriptHoldsNoCompileSlotTest (Graph.Test) pins it on a real mesh whose Compile pool has ONE slot: a cell that runs another cell and waits for it completes, and a plain cell completes while the pool's only async slot is held by other work. Both stay Running on the code before the change.

The lane REFUSES, it does not queue. Moving the waits off Compile alone only moves the cycle onto the new lane: a parent cell holds its KernelScript slot while it waits, and the child needs a slot of the same lane to warm up and execute — so with every slot held by a waiting parent a queued child is never admitted, at any cap. The kernel therefore admits both leaves with IoPool.InvokeOrRefuse: a free slot runs the leaf, a full lane fails it at once with IoPoolSaturatedException, and the refused run's Activity reads Failed naming the refusal — which its parent sees and reports. Saturation becomes a visible terminal instead of a silent queue. KernelScriptLaneRefusesInsteadOfQueuingTest pins it with a ONE-slot lane: the child is refused and the parent completes with inner Failed; with a queue both stay Running.

Still open (not the cause found here, recorded so it is not re-investigated blind): the cell-surface resolution's store lookup (IAssemblyStore.TryGetBuildPath in CellSurfaceAssemblyProvider.ResolveOne) carries no bound of its own. The file-system store answers synchronously, so it cannot park there; a remote store that never answers would stall the submission before it runs.

A C# run that the kernel did not settle is failed, not left Running

The dispatcher (CodeNodeType) used to post a C# cell's SubmitCodeRequest fire-and-forget, on the assumption that the Roslyn kernel always settles the ActivityLog. It does — when it runs the script. Every failure before that point (the kernel container's dispatch faulting and answering Success = false, a DeliveryFailure, a routing NotFound) left the run Running for ever, silently. The C# branch now observes the kernel's answer through hub.Observe exactly as the Python/JS branch does, and writes any non-success onto the Activity as Failed with the reason (a keyed, localized transcript entry, activity.codeRun.failed). The reconcile never overwrites a status the kernel already settled. It adds no timeout: a kernel that never answers is a defect of its own, to be fixed where it stalls — the deadlock above is one such stall, fixed at its root. Pinned by CSharpCellKernelFaultFailsTheActivityTest (a kernel plug-in that faults) and its control CSharpCellRunKeepsTheKernelsVerdictTest (the real kernel: success stays Succeeded, a script that throws and a script that does not compile both end Failed, settled by the kernel with its own reason — the exception text or the compiler diagnostic). That the reconcile never overwrites a run the kernel settled is pinned by ARunTheKernelCancelledAndSettled_IsNotOverwrittenWithFailed.

🚨 A request timeout is not a verdict on the run. The kernel's reply is the END of the execution, and each wait for it — the dispatcher's on the Code hub, the kernel container's on the Activity hub — is bounded by its hub's RequestTimeout of silence. A healthy cell computing past that budget is still running. So neither hop turns its own timeout into a failure: the dispatcher writes Failed only for a delivery fault or a failed reply, and the container no longer answers Success = false when its own wait expires. The Activity stays the authority and the kernel settles it when the script ends. LongCellOutlivesThe{Dispatchers,KernelContainers}RequestBudgetTest pin each hop with a two-second budget and a six-second cell; both read Failed while the script was still running before the change.

But the dispatcher's timeout is SAID, not swallowed (#7362). Not a verdict is not the same as silence: a run whose kernel never answered used to stay Running with nothing on it, indistinguishable from one computing normally. When the dispatcher's wait expires the run now says so — "No answer from the kernel yet. The run is still in progress and completes when the kernel answers." (activity.codeRun.noKernelReplyYet, en + de) — and its status stays Running. While a run is Running the Output pane shows its latest transcript line, so that is what the reader sees.

Who decides is the Activity's OWNER, never the dispatcher. The dispatcher does not own the Activity, so its write runs on a mirror and ships a merge patch (Conditional Writes Across Hubs). A notice appended there behind a mirror-read "still Running" guard could land after the kernel's terminal write — and survive on a finished run, or, since a changed array travels whole, replace the kernel's final transcript with the mirror's stale one. So the dispatcher states only a FACT, ActivityLog.ReplyOverdueSince, unconditionally and as System (the timeout fires on a timer thread with no user context, so a caller-context write would fail closed). The Activity hub, which owns the node, folds it on its own serialised write path (KernelReplyOverdue): it consumes the fact, appends the notice once if the run is still Running, appends nothing if it has finished, and removes a notice that reached a finished run. The notice is logged at Information, so a slow run that then succeeds never reads as one with warnings. A late reply settles the run exactly as an in-time one does: the kernel's next snapshot rewrites the transcript, and its terminal write leaves no notice behind.

KernelReplyTimeoutNoticeTest parks a real cell on a gate node past a two-second dispatcher budget: the notice appears on a Running Activity, and once the gate opens the run ends Succeeded with its return value and without the notice. KernelReplyOverdueAfterTheTerminalWriteTest delivers the fact, and the old notice itself, to a run that has already finished, and requires the owner's answer to carry neither. KernelReplyOverdueFoldTest pins the fold state by state. The control, KernelReplyInTimeShowsNoNoticeTest, collects every snapshot of an in-time run and finds the notice on none of them.

Three more holes the same review found, each of which could again leave a run wrong or Running:

The executor settles every run it owns (#7362). Two gaps remained inside KernelExecutor, and either could leave a run Running for good:

Pinned in Graph.Test on a real mesh with the real kernel:

The first two fail on the unfixed executor: the run ends Failed without the reason, or stays Running until the test's bound.

Tests, each with a negative control that fails on the unfixed code:

What the production evidence does and does not say

The routing back-pressure flood of 2026-09-22 (core issues #5226–#5307) was read against the pods named in each issue's evidence table. Of the 65 distinct crossings quoted in those bodies that printed waiting for a pool slot ≥ 45 with subscribing ≤ 1 — the thread-shortage reading — 63 were logged while two or more ReplicaSets of the same deployment were live within ±10 minutes, i.e. during a roll (the public instance went through five ReplicaSets between 10:50Z and 13:05Z; the control instance rolled at 17:33Z). The two exceptions are both on the control instance at 18:20Z, where no second ReplicaSet appears in the sample. The starved pod was as often a SURVIVOR of the old ReplicaSet (5c89c7f5f9-sddb5, 12:34–13:02Z, while 7cc85f47c rolled in) as a freshly booted one (7bc86fb6f-qnznf, 11:32–11:43Z), so this is "during a roll", not "on the new pod". The [ReleasePostCondition] compile lines on …-sddb5 at 12:38Z fall inside its starvation window. A roll is exactly when compiles happen on serving silos: a framework identity change makes NodeTypes framework-stale, and release requests and first activations compile.

Read the 63/65 against its base rate: the sample is the log lines the incident filer quoted (at most five per issue), and on the public instance a roll was in progress for most of the sampled day, so "during a roll" is the expected state of most samples, not a rare one.

What was NOT established: the portal's own ThreadPool metrics for those windows (the control instance was unavailable, so no Logs/Prometheus read was taken), and therefore how much of each window's starvation the compiles account for. Other CPU on a rolling survivor — re-activating the grains of the pods being replaced — competes for the same cores and is not addressed here. Still on the ThreadPool by design: the language service's hover and completion requests (Roslyn's async QuickInfoService / CompletionService, which schedule their own work with ConfigureAwait(false) and cannot be pinned to a thread), the IO halves of the diagnostics requests, and CompileResultFromAssembly (assembly load and user type initializers — bounded by its own timeout, on CompileThread, not a CPU compile).