Repository navigation
fix(#2638): hold the silo stop until accepted routing work has landed - #2726
Conversation
The open question #2647 left: where in the silo host's stop sequence is the Autofac root disposed relative to routing quiescence, and can a lifecycle hook hold it without deadlocking. From source: - The root container is disposed by the HOST, after every hosted service (the silo included) has stopped and MeshTeardownHostedService.StoppedAsync has drained the mesh — MeshHostApplicationBuilder / MeshTeardownHostedService. - Nothing in that sequence ever waited on a route: RoutingGrain.RouteMessage is O(1) (Orleans' DeactivationTimeout wait sees no request), the routing pool holds a leg's permit only for its subscribe (so IoPoolSiloTeardown's join at stage First covers nothing past it), and the per-node grain delivery plus every NACK were subscribed inside a Select with the subscription discarded — detached from even that. Their tails ran through grain deactivation, the message-centre stop, the mesh drain and the container disposal, and died resolving a grain proxy's codec provider from a disposed LifetimeScope. The seam: RoutingQuiescence counts every leg and NACK from dispatch to termination; RoutingQuiescenceSiloParticipant holds the silo stop at ServiceLifecycleStage.Active — before MembershipAgent announces ShuttingDown (BecomeActive) and before Catalog.DeactivateAllActivations (GrainDeactivation) — until the count is zero, bounded at 30 s with the residual named at Error. The per-node delivery is composed INTO the leg (SelectMany), so the in-flight slot, the gauge and the pool drain all cover it; NACKs go through the routing pool and the gauge too. ApplicationStopping fires before any hosted service stops, so DeliverMessage already refuses new dispatches and the count converges. Pinned by RoutingQuiescenceTest (real SiloLifecycleSubject, a probe at the grain-deactivation stage; mutation-verified) and RoutingQuiescenceClusterTest (a stalled leg is counted on the silo's gauge until it terminates). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPCWramjFYquX5SaMdeJKW
There was a problem hiding this comment.
Pull request overview
This PR addresses #2638 by ensuring a silo shutdown does not proceed past the Orleans Active stop stage while there is still accepted routing work (route legs and NACK delivery) in flight, preventing routing/NACK tails from executing after the host DI container has been disposed.
Changes:
- Introduces a mesh-scoped
RoutingQuiescencegauge and an Orleans lifecycle participant that holds silo stop atServiceLifecycleStage.Activeuntil routing work drains (bounded, with explicit logging on expiry/cancellation). - Refactors
RoutingGrainso grain delivery is composed into the route leg (SelectMany+DeliverToGrainRoute) and NACK delivery is tracked/drained via the routing pool and the quiescence gauge. - Adds targeted unit + cluster tests validating the participant behavior and real-silo wiring, plus a user-facing “What’s New” entry.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Orleans.Test/RoutingQuiescenceTest.cs | Adds pure lifecycle/participant tests proving the stop hold, bounds, and delivery-leg composition. |
| test/MeshWeaver.Hosting.Orleans.Test/RoutingQuiescenceClusterTest.cs | Verifies real-silo DI wiring and that dispatched legs are reflected on the silo’s quiescence gauge until termination. |
| src/MeshWeaver.Hosting.Orleans/RoutingQuiescence.cs | Adds the routing in-flight gauge, the silo lifecycle participant hold, and DI registration extension. |
| src/MeshWeaver.Hosting.Orleans/RoutingGrain.cs | Tracks legs/NACKs via RoutingQuiescence, composes grain delivery into the leg, and routes NACK subscriptions through the routing pool for drain visibility. |
| src/MeshWeaver.Hosting.Orleans/OrleansServerRegistryExtensions.cs | Registers routing quiescence in the Orleans server service wiring. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-a-departing-pod-lets-accepted-messages-land.md | Adds a “What’s New” fix entry describing the user-visible effect during rolling deploy shutdown windows. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 3)701 tests 509 ✅ 4m 57s ⏱️ Results for commit bf3b1ae. |
Test Results (shard 0)913 tests 913 ✅ 5m 35s ⏱️ Results for commit bf3b1ae. |
Test Results (shard 5)1 532 tests 1 532 ✅ 5m 25s ⏱️ Results for commit bf3b1ae. |
Test Results (shard 2)941 tests 941 ✅ 5m 4s ⏱️ Results for commit bf3b1ae. |
Test Results (shard 4) 10 files 10 suites 5m 52s ⏱️ Results for commit bf3b1ae. |
Test Results (shard 1)818 tests 814 ✅ 7m 32s ⏱️ Results for commit bf3b1ae. |
Test Results 41 files 41 suites 34m 27s ⏱️ Results for commit bf3b1ae. |
…T_CRASHED in the log is script echo (#2739) * docs(ReadingCiSignals): a red shard is classified by its EXIT MARKER — TESTFAIL vs MASKED — and `HOST_CRASHED` in the log is script echo Measured 2026-08-30 while triaging two main reds that were reported as "2x then 4x HOST_CRASHED". The markers said one TESTFAIL (a SilentReadNackTest flake; the host completed normally) and one genuine MASKED. The HOST_CRASHED hits were the summarize step echoing its own explanatory sentence, so a grep -c over the log counts echoes, not crashes. Adds the marker table (TESTFAIL / MASKED / TIMEOUT / SIGNAL), the echo trap, and the attribute-by-reachability rule that exonerated #2726 (Monolith.Test references no Orleans project) and #2721 in the same triage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPCWramjFYquX5SaMdeJKW * docs: MASKED is DERIVED and has been wrong — the xUnit execution summary outranks the marker Correction from meshweaver-61, verified in run 33298742141 shard 1: the assembly printed `MeshWeaver.Hosting.Monolith.Test Total: 331, Errors: 0, Failed: 2, ... Time: 461.193s`, so the host COMPLETED — the MASKED classification was the harness inferring a crash from `rc != trxfails`, an assumption that xUnit v3 exits with the failure count (2 failures exited 1). Neither of the two reds was a crash. The section now says the marker is where to LOOK, names MASKED as derived, and makes the execution summary the decisive evidence; a real crash is the ABSENCE of that line. Classifier fix tracked in #2738. Also: xUnit capitalization in prose per WritingTests.md (Copilot). The marker quote keeps the harness's own lowercase spelling — it is a verbatim transcript. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPCWramjFYquX5SaMdeJKW --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Closes #2638. Residual of #2647, which made the window fail clean and classified and left the root cause open.
The open question, answered from source
Where the root goes. The host's root container IS the mesh's Autofac container (
MeshHostApplicationBuilder→Host.ConfigureContainer(new MessageHubServiceProviderFactory(BuildHub)),src/MeshWeaver.Hosting/MeshHostApplicationBuilder.cs:46;MessageHubServiceProviderFactory.CreateServiceProvider→CreateMeshWeaverServiceProvider→new AutofacServiceProvider(containerBuilder.Build()),src/MeshWeaver.ServiceProvider/ServiceProviderExtensions.cs:57-60). The root hub does not dispose it —MessageHub.cs:2295has that line commented out on purpose. It is disposed by the host, inRunAsync'susing, strictly afterStopAsynchas (1) firedApplicationStopping, (2) stopped everyIHostedServicein reverse registration order —SiloHostedServiceamong them (UseOrleansruns after theMeshHostApplicationBuilderctor inUseOrleansMeshServer,OrleansServerRegistryExtensions.cs:39-45, so the silo stops beforeMeshTeardownHostedServiceand Kestrel stops after both) — and (3) runIHostedLifecycleService.StoppedAsync, whereMeshTeardownHostedServicedrains the mesh root hub (MeshTeardownHostedService.cs:72-108).What the silo stop does, in order (Orleans 10.2.2,
Silo.Participate,Silo.cs:466-486; stops run in descending stage, same-stage observers in parallel,LifecycleSubject.OnStop,LifecycleSubject.cs:154-186):ActiveOnActiveStop: gateway stop-send, grain servicesBecomeActiveMembershipAgent.OnBecomeActiveStop→ membershipShuttingDownGrainDeactivationCatalog.DeactivateAllActivations(ct)— each activation bounded byGrainCollectionOptions.DeactivationTimeout(30 s)RuntimeServicesmessageCenter.BlockApplicationMessages()RuntimeInitializemessageCenter.StopAsync();TerminatedFirstint.MinValueIoPoolSiloTeardown:IoPoolRegistry.Dispose()+ joinWhy none of it waited for a route. Three tiers, none joined:
RoutingGrain.RouteMessagedoes O(1) work and returnsForwarded(One non-reentrant RouteMessage can wedge a whole silo's routing — no timeout on that path #1028) — the leg is handed to the routing pool. From Orleans' point of view the routing grain never has a request in flight, soDeactivateAllActivationsdeactivates it instantly and theDeactivationTimeoutwait never applies. The hypothesis "a turn parked inStreamPostTimeout/ retries / the subscriber probe outlives the deactivation budget" is false — there is no such turn.SubscribeThroughPoolholds its permit for the subscribe window only (IoPool.cs:391-440, andMeshTeardownExtensions.cs:60-66says so). A leg past its subscribe is invisible toIoPoolSiloTeardown's join; the drain at stageFirstdisposes it and completes it silently — no NACK.BuildGrainRoutesubscribedDeliverToGrainWithRetryinside a.Selectand discarded the subscription; everyPostFailureNACK was a bare.Subscribe. So the grain call, its ≤6 retry timers (Scheduler.Default) and its NACK ran on continuations nothing counted and nothing drained. That is the prod tail:Delivery to 'Planning' failed: Instances cannot be resolved…is the retry chain'sgrainCall()→GetGrainresolving a codec provider from the disposed scope, and the NACK about it dying the same way.So: the root is disposed after the silo has fully stopped and the mesh has drained — the ordering is what the comments claimed — but routing quiescence was never a stage of anything. The window is not "the scope goes too early"; it is "nothing ever waits for a route".
The hook, and why it cannot deadlock. A silo lifecycle participant at
ServiceLifecycleStage.Active— the first thing the stop does, before membership announcesShuttingDownand before a single grain deactivates. The wait is a subscription on a mesh-scoped gauge, bridged once at the boundary withReactiveCompletion.ObserveCompletion(never.ToTask(), which would resumeLifecycleSubject.OnStopinline on the gauge's event-loop thread). It parks nothing a leg needs: legs run on pool/thread-pool threads, their grain calls are answered by a fully running scheduler, their NACKs land on hubs that are still alive (the pod-process hubs are disposed only inStoppedAsync). And the count converges becauseApplicationStoppingfires before any hosted service stops, soOrleansRoutingService.DeliverMessagealready refuses new dispatches asShuttingDown— the gauge is monotone non-increasing from the first stop step.The change
RoutingQuiescence(mesh-scoped,ActivityTracker's reactive shape — deltas in, count out, serialised on one scheduler, no lock/gate) counts every leg and every NACK from dispatch to termination.RoutingQuiescenceSiloParticipantholds the silo stop atActiveuntil the count is zero. Bounded at 30 s (the same family asMeshTeardownHostedService/IoPoolSiloTeardown); expiry is reported at Error naming the residual — that leg is the defect, and this line is the only attribution its tail will get. A non-graceful stop (token already cancelled —Silo.Dispose()'s path, on which Orleans' own Active-stage stop returns immediately) holds nothing; the host'sShutdownTimeoutexpiring mid-hold releases it.RoutingGrain: the per-node delivery is part of the leg now (SelectMany,DeliverToGrainRoutereturningIObservable<Unit>;DeliverToGrainWithRetryis that leg subscribed, kept for the pure tests). Every NACK goes through the routing pool and the gauge. On a healthy roll the count reaches zero in milliseconds; a leg the hold released over is terminated by the pool drain at stageFirst, so nothing routing-related executes after the silo has stopped, let alone after the container is gone.Not a gate: nothing is refused. This is the "ordering" shape the #2647 comment asked for — accepted work lands, over a transport that still exists.
Tests
RoutingQuiescenceTest— realSiloLifecycleSubject(the typeSilo.StopAsyncawaits), a probe observer atGrainDeactivationrecording the count it found:SiloStop_HoldsBeforeGrainDeactivation_UntilTheRoutingWorkHasLandedSiloStop_WithNothingInFlight_DoesNotHoldSiloStop_BudgetExpiry_ReleasesTheSilo_AndNamesTheResidualSiloStop_NonGraceful_DoesNotHold_AndReportsWhatItAbandonsSiloStop_HostBudgetExpiringMidHold_ReleasesTheSiloTheGrainDelivery_IsPartOfTheLeg_WhichTerminatesOnlyOnceTheCallHasAnsweredTheGrainDelivery_NacksInsideTheLeg_ThenTerminatesRoutingQuiescenceClusterTest— real silo, theStallingPathResolvervehicle from #1028: a dispatched leg is counted on the silo's gauge until it terminates, and the silo lifecycle carries the participant.Non-vacuity, by mutation — participant's
OnStopforced to returnTask.CompletedTask(the pre-fix shape):Reverted:
Passed! - Failed: 0, Passed: 75across this file plusRoutingGrainDeliveryRetryTest,RoutingAfterScopeTeardownTest,RoutingGrainFailureClassificationTest,RoutingGrainPodHubDeliveryRetryTest,RoutedFailureClassificationTest,RoutingGrainStreamPostBoundTest,OrleansDirectoryInstabilityClassificationTest,SiloShutdownActivationGateTest,OrleansRoutingShutdownClassificationTest,IoPoolSiloTeardownTest,RoutingGrainTurnIsolationTest,StreamPostTimeoutAttributionTest,DeadTargetRefusalWindowTest,RoutingFailureTakesTheLocalRouteTest; and the wholeMeshWeaver.Hosting.Orleans.Testproject locally (Release).What's New:
2026-08-30-a-departing-pod-lets-accepted-messages-land.md(Category: Fix).🤖 Generated with Claude Code
https://claude.ai/code/session_01EPCWramjFYquX5SaMdeJKW