Repository navigation
fix(#2638): routing that runs after the Autofac root scope is disposed fails as a lifecycle transition, not a terminal defect - #2647
Conversation
…s a lifecycle transition Orleans builds a grain proxy by resolving its codec provider out of the DI container (OrleansGeneratedCodeHelper.GetService → AutofacServiceProvider.GetService), so once the silo host has disposed its root Autofac LifetimeScope every remaining delivery — and every NACK about one — faults with Autofac's ObjectDisposedException before it reaches a transport at all. Two defects followed, and both are fixed here. 1. CLASSIFICATION. The delivery was NACK'd terminal (ErrorType.Failed), so consumers with recovery machinery of their own — SynchronizationStream's resubscribe latch, MeshNodeStreamCache's transient-owner rule — tore down permanently for a pod that was merely exiting; they RIDE OUT ErrorType.ShuttingDown. That is the identical damage #2346/#2357 removed for the directory-unstable and silo-departing shapes and left standing for this one. ClassifyDeliveryException now recognises it, gated on a PROBE of the container itself (MessageHub.IsTerminatedByScopeTeardown's shape, #2444) — never the bare ObjectDisposedException type test, because an unrelated disposed dependency is a genuine defect and must stay terminal. 2. THE SECOND TRANSPORT. PostFailure fell back to the stream publish, which resolves through the SAME disposed scope — so it could only ever restate the first fault, and prod logged an AggregateException naming one cause twice, at Error, for a process that was exiting. When the first transport died on a disposed container the fallback is now skipped and the outcome is stated once at Information, naming the cause. The sender still waits out its own budget — nothing inside a dead container can prevent that — but it is now a bounded wait against a stated cause and a verdict its recovery machinery can act on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On the test method — what is and is not pinnedBeing explicit, because "a test that passes because the race did not happen is worthless": Pinned deterministically. Everything downstream of the race — the classification of a disposed-container fault, that it is seen through an NOT pinned, and I am not claiming otherwise. The race itself — a Closing the window itself is the open design question in my comment on #2638, and a test for it belongs with that change — it would be a host-stop-sequence ordering assertion, not a routing one. |
There was a problem hiding this comment.
Pull request overview
This PR addresses #2638 by making Orleans routing failures that occur after the Autofac root scope is disposed during silo shutdown behave like a lifecycle transition (classified as ErrorType.ShuttingDown) rather than a terminal defect, and by avoiding redundant “second transport” attempts that can only fail on the same disposed container.
Changes:
- Adds a “container disposed” probe and uses it to classify
ObjectDisposedException-shaped routing failures asShuttingDown(opt-in via probe so unrelated disposed dependencies remain terminal). - Downgrades logging for shutdown-shaped directed delivery failures and skips the stream-publish fallback when the container is confirmed disposed (to avoid duplicate-fault
AggregateExceptionnoise). - Introduces a focused test suite (
RoutingAfterScopeTeardownTest) covering classification through aggregates, negative controls, and end-to-end retry/NACK behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/MeshWeaver.Hosting.Orleans/RoutingGrain.cs |
Adds a DI-scope disposal probe, extends failure classification with scope-teardown detection, adjusts log severity on shutdown-shaped failures, and avoids attempting a second transport when the container is already disposed. |
test/MeshWeaver.Hosting.Orleans.Test/RoutingAfterScopeTeardownTest.cs |
New unit tests pinning the expected ShuttingDown classification and non-retry behavior for disposed-container routing/NACK scenarios, including negative controls. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| "Instances cannot be resolved and nested lifetimes cannot be created from this LifetimeScope " | ||
| + "as it (or one of its parent scopes) has already been disposed."; | ||
|
|
||
| private static Exception ScopeDisposed() => new ObjectDisposedException(ScopeDisposedText, (Exception?)null); |
There was a problem hiding this comment.
Not correct — measured rather than argued, because the overload set here is genuinely confusing.
ObjectDisposedException has three constructors and only two of them touch ObjectName:
public ObjectDisposedException(string? objectName) // Message = SR.ObjectDisposed_Generic
public ObjectDisposedException(string? objectName, string? message) // sets _objectName
public ObjectDisposedException(string? message, Exception? innerException) : base(message, innerException)ScopeDisposed() binds the third — (string message, Exception? innerException) — which passes the text straight to Exception(message, inner) and leaves _objectName empty. The Message override only appends the "Object name: 'x'" line when ObjectName is non-empty, so with it empty the property returns base.Message unchanged.
Verified against net10.0:
ObjectName=[]
Message=[Instances cannot be resolved and nested lifetimes cannot be created from this LifetimeScope as it (or one of its parent scopes) has already been disposed.]
MATCHES_VERBATIM=True
So the setup is consistent with its documentation: the exception carries the production text verbatim, and a future assertion on that exact message would hold.
Worth adding for completeness, since it is the more important half: no assertion in this PR depends on the message at all, and that is deliberate. IsScopeTeardown classifies on ExceptionChain.Contains<ObjectDisposedException> plus the container probe — never on text — precisely because Autofac's wording is not a contract we control. The verbatim text is there so a reader can match the test to the incident log, not so the classifier can match on it.
Test Results (shard 3)495 tests 491 ✅ 4m 43s ⏱️ Results for commit 11b885c. |
Test Results (shard 2)957 tests 957 ✅ 5m 9s ⏱️ Results for commit 11b885c. |
Test Results (shard 4) 10 files 10 suites 5m 5s ⏱️ Results for commit 11b885c. |
Test Results (shard 1)1 020 tests 1 020 ✅ 6m 25s ⏱️ Results for commit 11b885c. |
Test Results 42 files 42 suites 34m 16s ⏱️ Results for commit 11b885c. |
…#2726) 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). Claude-Session: https://claude.ai/code/session_01EPCWramjFYquX5SaMdeJKW Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Linking, not closing: #2638.
The mechanism
Orleans builds a grain proxy by resolving its codec provider out of the DI container (
OrleansGeneratedCodeHelper.GetService→AutofacServiceProvider.GetService). Once the silo host has disposed its root AutofacLifetimeScope, every remaining delivery — and every NACK about one — faults with Autofac'sObjectDisposedExceptionbefore it reaches a transport at all.Prod (memex, 2026-08-29) therefore logged, at
Error, anAggregateExceptioncarrying the same fault twice:Two defects, both fixed
1. The classification was terminal.
Delivery to 'Planning' failed: Instances cannot be resolved …was NACK'd asErrorType.Failed. Consumers with recovery machinery of their own —SynchronizationStream's resubscribe latch,MeshNodeStreamCache's transient-owner rule — ride outErrorType.ShuttingDownand tear down on a terminal verdict. So a routine pod exit permanently killed live mirrors that would have resumed against the surviving pod seconds later. That is the identical damage #2346/#2357 removed for the directory-unstable and silo-departing shapes, and left standing for this one.ClassifyDeliveryExceptionnow recognises it — gated on a probe of the container itself, never on the bareObjectDisposedExceptiontype test:The probe is the same shape
MessageHub.IsTerminatedByScopeTeardownuses for #2444, and it is what keeps an unrelated disposed dependency — a genuine defect — terminal. With no probe supplied the answer is exactly the pre-fix one. The walk isExceptionChain's graph walk, because this arrives through RxCatcharms and a two-transportAggregateExceptionwhere which fault sits at index 0 is a race.2. The second transport was attempted on the same dead container.
PostFailure's stream fallback resolves through the very scope the directed call just died on, so it could only restate the first fault. When the first transport dies on a disposed container the fallback is now skipped, and the outcome is stated once, at Information, naming the cause — a process finishing its shutdown is not an incident to page on. The refusal is still logged, still windowed by the samenackLog, and still tells the reader the sender will wait out its budget.The sender still waits out that budget — nothing running inside a dead container can prevent that — but it is now a bounded wait against a stated cause, and the verdict it eventually reads is one its recovery machinery can act on.
Tests —
RoutingAfterScopeTeardownTestPure: the classifier and the retry primitive are
internal static, the exception text is verbatim from the production log, the container probe is a delegate. No cluster, no host, no clock.DisposedContainer_ClassifiesAsShuttingDownDisposedContainer_IsSeenThroughAnAggregateDisposedDependency_WhileTheContainerLives_StaysTerminalFailedTheOtherClassificationsAreUnchangedSiloUnavailableExceptionstillShuttingDown; a real defect during shutdown stillFailedDeliveryOnADeadContainer_NacksTheSenderAsShuttingDown_WithoutRetryingDeliverToGrainWithRetry: one attempt (a dead container is not transient), NACK carriesShuttingDownNon-vacuity, by mutation
The tests cannot be compiled against unfixed code — the fix adds the probe parameter they pass — so non-vacuity is proven the way #2635 asks for:
IsScopeTeardownforced to the pre-fix answer (false &&), which is exactly what the classifier could see before.The two that still pass are the negative controls — correctly, since they assert the unchanged behaviour.
With the mutation reverted, this file plus every neighbouring routing/classification suite:
(
RoutingAfterScopeTeardownTest,RoutingGrainFailureClassificationTest,OrleansDirectoryInstabilityClassificationTest,RoutedFailureClassificationTest,RoutingGrainDeliveryRetryTest,RoutingGrainPodHubDeliveryRetryTest,RoutingFailureTakesTheLocalRouteTest,DeadTargetRefusalWindowTest,RoutingGrainStreamPostBoundTest.)What this deliberately does NOT do
The issue's own root-cause framing — "the silo shutdown sequence disposes the service provider before routing work is quiesced" — is carried at medium confidence, and quiescing routing before container disposal is a change to the host stop sequence, not to the router. This PR makes the window fail clean and correctly classified; it does not close the window. See my comment on #2638 for the precise open question.
Not related to #2644
#2644 (issue #2635) joins
DisposalCompletedin test harnesses; this is the production silo-shutdown path. No file overlap.🤖 Generated with Claude Code