Repository navigation
A shutdown reject classified as Failed reads as terminal - #1023
Conversation
#1022 added the errorType branch in MonolithRoutingService.PostNotFound and picked ErrorType.Failed for the shutting-down case. Consumers with their own recovery machinery key on ErrorType.ShuttingDown to ride a reject out instead of tearing down — JsonSynchronizationStream and SynchronizationStream both match on that member explicitly. So the shutdown reject reached them looking terminal. That is the CI 30003419841 wedge the ShuttingDown member exists to prevent: erroring the sync stream on this NACK killed the resubscribe latch and wedged every read of a mid-recycle NodeType. The reviewer caught this on #1022 and I merged past the thread. Fixing forward. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The one-line fix in 3d63926 is only as durable as what pins it, and nothing did: reverting ErrorType.ShuttingDown to ErrorType.Failed left the whole suite green. Two tests now make each half of the contract impossible to reintroduce, and each was verified to FAIL against the defect it guards. ShutdownRoutingRejectClassificationTest pins the producing side — a routing reject issued while the mesh is shutting down carries ShuttingDown. That reject only exists in a narrow window (IsDisposing true, RunLevel still below DisposeHostedHubs; outside it routing short-circuits and nothing is NACKed), so the window is constructed rather than waited for: the mesh action block is stalled with a gated handler and the probe is queued ahead of Dispose()'s ShutdownRequest. Observe pre-registers the probe's callback before posting, so the pending callback that Quiescing waits to drain is the probe itself — Quiescing cannot race past the reject, and the NACK drains it. No sleep, no polling for a state. Reverted, it fails on the ErrorType while still asserting the shutdown message, which is what proves it rode the branch under test rather than an ordinary NotFound. ShutdownFailureRideOutTest pins the consequence, which is the half that actually costs something. A correctly-labelled reject that the consumer still treats as terminal reproduces the wedge exactly, and the SynchronizationStream ride-out branch had no test at all. Both failures go to the stream hub's single-threaded action block back to back, so FIFO decides which branch ran: fault on the terminal one behind it and the shutdown reject was ridden out; fault on the shutdown reject and the transient branch is gone. One observation, no timing dependency, and the terminal half is asserted in the same breath so the ride-out cannot widen into a blanket swallow of DeliveryFailure. Refs #981 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fixture's 500 ms quiesce budget exists to catch a leaked Observe subscription, and it assumes no test has a request in flight when Quiescing starts. This one does by construction — the probe's pending callback IS what holds the shutdown window open. Its answer crosses two thread-pool hops, so on a loaded 2-core runner the budget can expire first and fail the CLASS with a leak report instead of the assertion, which reads as a flake in whichever shard drew it. Budget raised for this class only, and the probe's Rx timeout lowered to 10 s so it fires FIRST. A genuinely unanswered request therefore still fails here, loudly, on the right assertion — the detector is not weakened, it is just no longer the thing that speaks first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes routing failure classification during mesh shutdown so downstream consumers that explicitly key off ErrorType.ShuttingDown treat the condition as transient (ride out) rather than terminal (fault stream), preventing sync-stream wedge/timeouts during mid-recycle.
Changes:
- Correct
MonolithRoutingService.PostNotFoundto emitErrorType.ShuttingDown(notFailed) when rejecting due to mesh shutdown. - Add a monolith integration test pinning the router’s shutdown-reject classification.
- Add a data-layer test pinning
SynchronizationStreambehavior: ride outShuttingDown, but still fault on other failures.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/MeshWeaver.Hosting.Monolith/MonolithRoutingService.cs | Classifies shutdown routing rejects as ErrorType.ShuttingDown to preserve transient handling semantics. |
| test/MeshWeaver.Hosting.Monolith.Test/ShutdownRoutingRejectClassificationTest.cs | Deterministically reproduces the shutdown window and asserts the router emits ShuttingDown. |
| test/MeshWeaver.Data.Test/ShutdownFailureRideOutTest.cs | Asserts SynchronizationStream rides out ShuttingDown but remains terminal for other failures. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Copilot review: SynchronizationStream<T> owns a hosted sub-hub, and the test left it to the host's teardown cascade. That works, but it is not deterministic — and on the failing path the assertion throws before any cleanup at all. `using` releases it on both paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test Results (shard 0)685 tests 685 ✅ 13m 54s ⏱️ Results for commit c7e00c4. ♻️ This comment has been updated with latest results. |
Test Results (shard 4)1 284 tests 1 279 ✅ 7m 2s ⏱️ Results for commit c7e00c4. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)1 198 tests 1 198 ✅ 6m 3s ⏱️ Results for commit c7e00c4. ♻️ This comment has been updated with latest results. |
Test Results (shard 5)1 725 tests 1 533 ✅ 5m 56s ⏱️ Results for commit c7e00c4. ♻️ This comment has been updated with latest results. |
Test Results (shard 3) 11 files 11 suites 5m 56s ⏱️ Results for commit c7e00c4. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)1 912 tests 1 910 ✅ 6m 22s ⏱️ Results for commit c7e00c4. ♻️ This comment has been updated with latest results. |
Test Results 65 files 65 suites 45m 15s ⏱️ Results for commit c7e00c4. ♻️ This comment has been updated with latest results. |
…gins#1426) The version checker was vendored in six repos and had drifted to five vintages — Plugins 1157 lines, Crm 646, Education 646, Reinsurance 644, SocialMedia 643, Manufacturing 305 — so every fix landed in whichever copy hit the bug and the other five kept it: #434 (a release has two witnesses), #942/#1023 (--resolve), and Plugins#1426, where the trunk baseline is resolved against the remote's LIVE tip. On a push run of main for a commit that is no longer the tip — every intermediate commit of a merge-queue group — the tree under test is an ancestor of the baseline it is compared against, so the checker demands a version the tree never needed and reds. Manufacturing's copy never grew the trunk witness at all, so it also has no --resolve and no self-test. .github/scripts/gen-manifests.py is now the canonical copy, fetched by node-repo-validate.yml at the caller's platform-ref and run against the caller's tree — the same centralization as compile-check.py, with the same MW_REPO_ROOT contract and the same no-fallback fetch. What stays in the caller is one allow-file, scripts/gen-manifests.config.json: skip required. Per-repo by construction, and equal to validate-repos.py's SKIP. No default: a guessed list either demands a manifest.lock for scripts/ or silently stops versioning a real module. hashModuleSources default false. The #878 fix (hash a mixed package's src/ project and its riding siblings into moduleVersion) needs the caller's project-closure.py to expose graph_of/module_owned/ riding_siblings; asking for it without one is an error, never a quiet fall-back to the smaller hash. Default false because turning it on MOVES every mixed package's version. Both are declared, never inferred from whether a file exists — a capability that degrades silently on a missing input is the skip-trapdoor shape AGENTS.md forbids, and here it would change what a published version means without saying so. The dot-directory rule only Education's copy had is now everyone's. The lane fetches and self-tests the canonical on EVERY call; the new centralized-gen-manifests input only chooses which copy is BELIEVED, so neither setting skips a gate and a repo that has not adopted runs its vendored copy in a step that says so. Retiring a caller's vendored copy is a separate step — it stays its local command and, in most callers, validate-repos.py's library for check_manifests, until a platform-script.py-style resolver replaces it. Verified. `--self-test` grew the config contract to five cases (missing config, a typo'd key, a missing project-closure.py, one predating #1118, and the flip actually moving the hash) beside the #1426 case and its controls; with the #1426 branch removed from _trunk_commit it fails with "must derive against ITSELF", and passes with it. Equivalence measured on all six trees as committed: the canonical, with each repo's declared config, reproduces every lock and every derived version the vendored copy wrote — --check and --check-versions identical in all six, including SocialMedia (whose project-closure.py predates #1118 and whose own header warns against hashing src/ here) and Plugins (hashModuleSources true). Manufacturing gains the trunk witness: "verified against their tags" becomes "verified against the published tags and the trunk". The falsification control is that Plugins' copy run UNCONFIGURED against SocialMedia moves that module's content hash, so the sweep is not vacuous. check-workflow-timeouts.py: 0 violations. No What's New entry: CI-internal, nothing a portal user can notice — as Plugins#1427. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LzrcSMRzD2R5UcGDk5TKU
MonolithRoutingService.PostNotFoundclassified a routing reject issued during meshshutdown as
ErrorType.Failed. Two consumers match onErrorType.ShuttingDownexplicitly and change behaviour on it:
JsonSynchronizationStream.cs:375— theSubscribeRequestOnErrorarm returns early andkeeps the stream and its keep-alive alive for the change-feed resubscribe latch.
SynchronizationStream.cs:1019— theDeliveryFailurehandler returnsProcessed()instead of calling
OnError.Failedreads as terminal to both: the stream faults, the resubscribe latch dies with it, andevery read of a mid-recycle NodeType waits to its timeout. That is CI 30003419841 /
NodeTypeCompileParkTest.RecycleRetry— the exact regression theShuttingDownmember existsto prevent. Introduced by #1022 (
3b1423208); a reviewer flagged it there and it merged pastthe unresolved thread. This is the forward fix.
Scope. This is the monolith router:
MonolithRoutingServiceis registered only byUseMonolithMesh—Memex.Portal.Monolith,Memex.LocalMesh,Memex.Client(MAUI),Memex.NodeType.Bake, and everyMonolithMeshTestBasetest in CI. It is not used byMemex.Portal.Distributed, which routes through Orleans (UseOrleansMeshServer), so the AKSportals were never affected.
What is in the PR
3d639265a— the one-line correction plus the comment saying why the member is load-bearing.c2cb04e77— the two pins that were missing. Reverting the fix left the whole suite green,which is what made the regression possible in the first place. Each test was verified to fail
against the defect it guards:
ShutdownRoutingRejectClassificationTest(Hosting.Monolith.Test)ShuttingDownErrorType.Failed→ RED (Expected … ShuttingDown … but found Failed), while the shutdown-message assertion still passes — proving it rode the branch under test, not an ordinary NotFoundShutdownFailureRideOutTest(Data.Test)ShuttingDownbranch inSynchronizationStream→ RED (but found "transient-shutdown-reject")1b40c1bbe— raises the quiesce budget for that one test class. The fixture's 500 ms budgetis a leaked-callback detector that assumes no test has a request in flight when Quiescing starts;
this one does by construction (the probe's pending callback is what holds the window open), so on
a loaded 2-core runner the detector could fire before the assertion and fail the whole class with
a leak report — a flake in whichever shard drew it. The probe's own Rx timeout was lowered to 10 s
so it speaks first: an unanswered request still fails here, loudly, on the right assertion.
Both windows are constructed, not waited for — no sleeps, no polling:
IsDisposingis true andRunLevel < DisposeHostedHubs; outside that, routing short-circuits and nothing is NACKed at all. Themesh action block is stalled with a gated handler (the
TeardownHubCreationFreezeTestpattern) and the probe is queued ahead of
Dispose()'sShutdownRequest. BecauseObservepre-registers its callback before posting, the pending callback that Quiescingwaits to drain is the probe — Quiescing cannot race past the reject, and the NACK drains
it. Runs in ~0.5 s with a clean teardown, no quiesce timeout.
action block, so FIFO decides which branch ran. The terminal half is asserted in the same
observation, so the ride-out cannot be widened into a blanket swallow of
DeliveryFailure.Sibling routing paths — checked, no second instance
Every other
DeliveryFailureconstruction on a routing path was reviewed. None has the sameshutdown-vs-terminal misclassification, because none of them has a shutdown signal in scope:
RoutingServiceBase.PostNotFound(:325) —NotFound, and it is only reached when the pathgenuinely does not resolve. Correct.
RoutingGrain:182NotFound(unresolvable path — mirrors the monolith),:210Failed(resolution faulted/timed out),
:270/:288Failed(grain could not service the delivery,after
DeliverToGrainWithRetryhas already retried the transient Orleans reject),:112Failed(memory-stream forward fault). All terminal answers for that delivery, none gated ona disposal flag.
MeshNodeStreamCache.cs:1127documents the Orleans mid-deactivation reject asErrorType.Faileddeliberately — it is matched by message content(
IsTransientOwnerFailure), not by type. Left alone, as intended.One adjacent smell noted but not changed here (no shutdown misclassification, out of scope):
OrleansRoutingService.SendDeliveryFailure(:255) hard-codesErrorType.Failedfor everyfailure it relays, discarding whatever classification the grain decided and keeping only the
message text. The grain's own
PostFailureToSendercarries the correct type on the live path,so this is a latent flattening rather than an active defect.
Note on the
FutuRe.Testexit=139 seen on an earlier run of this PRThat SIGSEGV is an upstream CoreCLR background-GC fault, not this change:
WKS::gc_heap::background_sweep()dereferencing a swept object whose MethodTable pointer isexactly
0x0(si_code=SEGV_MAPERR,si_addr=0x0). It is characterised at instructiongranularity across three dumps, it survived #996, #967 and
74bc3a07c, and #613 is closed asexternal. The crashing test's name carries no information for this class. Note this is not
the ALC use-after-unload story in the older RCA — that faults on a non-null unmapped address,
never on
0x0— so the "stragglers re-enter hub construction over an unloading ALC" mechanismdoes not apply here.
Stated at its honest strength, and no stronger: keeping sync streams alive rather than faulting
them could in principle increase exposure to a concurrent background sweep. That is exposure,
not causation, and the fault itself is upstream and closed.
What's New
Skipped, deliberately. #1022 introduced this defect at 15:20Z today and this corrects it the
same day — there is no delta a user could observe between the last shipped state and this one,
so there is nothing to announce as fixed. #1022 itself correctly shipped no entry for the same
reason: this is messaging-layer classification, internal to the routing/sync plumbing.
Refs #981
🤖 Generated with Claude Code