Skip to content

A shutdown reject classified as Failed reads as terminal - #1023

Merged
rbuergi merged 7 commits into
mainfrom
fix/shutdown-errortype
Aug 9, 2026
Merged

rbuergi merged 7 commits into
mainfrom
fix/shutdown-errortype

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

MonolithRoutingService.PostNotFound classified a routing reject issued during mesh
shutdown as ErrorType.Failed. Two consumers match on ErrorType.ShuttingDown
explicitly and change behaviour on it:

  • JsonSynchronizationStream.cs:375 — the SubscribeRequest OnError arm returns early and
    keeps the stream and its keep-alive alive for the change-feed resubscribe latch.
  • SynchronizationStream.cs:1019 — the DeliveryFailure handler returns Processed()
    instead of calling OnError.

Failed reads as terminal to both: the stream faults, the resubscribe latch dies with it, and
every read of a mid-recycle NodeType waits to its timeout. That is CI 30003419841 /
NodeTypeCompileParkTest.RecycleRetry — the exact regression the ShuttingDown member exists
to prevent. Introduced by #1022 (3b1423208); a reviewer flagged it there and it merged past
the unresolved thread. This is the forward fix.

Scope. This is the monolith router: MonolithRoutingService is registered only by
UseMonolithMesh — Memex.Portal.Monolith, Memex.LocalMesh, Memex.Client (MAUI),
Memex.NodeType.Bake, and every MonolithMeshTestBase test in CI. It is not used by
Memex.Portal.Distributed, which routes through Orleans (UseOrleansMeshServer), so the AKS
portals 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:

Test Guards Negative control
ShutdownRoutingRejectClassificationTest (Hosting.Monolith.Test) the router emits ShuttingDown revert to ErrorType.Failed → RED (Expected … ShuttingDown … but found Failed), while the shutdown-message assertion still passes — proving it rode the branch under test, not an ordinary NotFound
ShutdownFailureRideOutTest (Data.Test) the consumer rides it out and still faults on anything else disable the ShuttingDown branch in SynchronizationStream → RED (but found "transient-shutdown-reject")

1b40c1bbe — raises the quiesce budget for that one test class. The fixture's 500 ms budget
is 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:

  • The routing reject only exists while IsDisposing is true and RunLevel < DisposeHostedHubs; outside that, routing short-circuits and nothing is NACKed at all. The
    mesh action block is stalled with a gated handler (the TeardownHubCreationFreezeTest
    pattern) and the probe is queued ahead of Dispose()'s ShutdownRequest. Because
    Observe pre-registers its callback before posting, the pending callback that Quiescing
    waits 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.
  • The ride-out test delivers both failures back-to-back to the stream hub's single-threaded
    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 DeliveryFailure construction on a routing path was reviewed. None has the same
shutdown-vs-terminal misclassification
, because none of them has a shutdown signal in scope:

  • RoutingServiceBase.PostNotFound (:325) — NotFound, and it is only reached when the path
    genuinely does not resolve. Correct.
  • RoutingGrain :182 NotFound (unresolvable path — mirrors the monolith), :210 Failed
    (resolution faulted/timed out), :270/:288 Failed (grain could not service the delivery,
    after DeliverToGrainWithRetry has already retried the transient Orleans reject), :112
    Failed (memory-stream forward fault). All terminal answers for that delivery, none gated on
    a disposal flag.
  • MeshNodeStreamCache.cs:1127 documents the Orleans mid-deactivation reject as
    ErrorType.Failed deliberately — 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-codes ErrorType.Failed for every
failure it relays, discarding whatever classification the grain decided and keeping only the
message text. The grain's own PostFailureToSender carries the correct type on the live path,
so this is a latent flattening rather than an active defect.

Note on the FutuRe.Test exit=139 seen on an earlier run of this PR

That SIGSEGV is an upstream CoreCLR background-GC fault, not this change:
WKS::gc_heap::background_sweep() dereferencing a swept object whose MethodTable pointer is
exactly 0x0 (si_code=SEGV_MAPERR, si_addr=0x0). It is characterised at instruction
granularity across three dumps, it survived #996, #967 and 74bc3a07c, and #613 is closed as
external
. 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" mechanism
does 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

rbuergi and others added 3 commits August 9, 2026 17:21
#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>
Copilot AI lite review requested due to automatic review settings August 9, 2026 15:48
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>

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.

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.PostNotFound to emit ErrorType.ShuttingDown (not Failed) when rejecting due to mesh shutdown.
  • Add a monolith integration test pinning the router’s shutdown-reject classification.
  • Add a data-layer test pinning SynchronizationStream behavior: ride out ShuttingDown, 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.

Comment thread test/MeshWeaver.Data.Test/ShutdownFailureRideOutTest.cs Outdated
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>
@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

685 tests   685 ✅  13m 54s ⏱️
 10 suites    0 💤
 10 files      0 ❌

Results for commit c7e00c4.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 284 tests   1 279 ✅  7m 2s ⏱️
   11 suites      5 💤
   11 files        0 ❌

Results for commit c7e00c4.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 198 tests   1 198 ✅  6m 3s ⏱️
   11 suites      0 💤
   11 files        0 ❌

Results for commit c7e00c4.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 725 tests   1 533 ✅  5m 56s ⏱️
   11 suites    192 💤
   11 files        0 ❌

Results for commit c7e00c4.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files     11 suites   5m 56s ⏱️
1 963 tests 1 855 ✅ 108 💤 0 ❌
2 100 runs  1 992 ✅ 108 💤 0 ❌

Results for commit c7e00c4.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 912 tests   1 910 ✅  6m 22s ⏱️
   11 suites      2 💤
   11 files        0 ❌

Results for commit c7e00c4.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   65 files     65 suites   45m 15s ⏱️
8 767 tests 8 460 ✅ 307 💤 0 ❌
8 904 runs  8 597 ✅ 307 💤 0 ❌

Results for commit c7e00c4.

♻️ This comment has been updated with latest results.

@rbuergi
rbuergi merged commit 225c85d into main Aug 9, 2026
17 checks passed
@rbuergi
rbuergi deleted the fix/shutdown-errortype branch August 19, 2026 10:31
rbuergi added a commit that referenced this pull request Sep 7, 2026
…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
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