Skip to content

A hub past [QUIESCE-OK] still accepts new correlated requests — the intake gate opens one phase too late #3506

Closed

Description

@rbuergi

A hub past [QUIESCE-OK] still ACCEPTS new correlated requests, and they can never be answered

Split out of #3499, which measured this shape but was wedged by a different one. #3499's fix does
NOT cover this; nothing does.

The defect

MessageService.ScheduleNotify's shutdown gate refuses new deliveries only from
MessageHubRunLevel.DisposeHostedHubs onward:

if (hub.RunLevel >= MessageHubRunLevel.DisposeHostedHubs
    && delivery.Message is not ShutdownRequest and not DisposeRequest)

The Quiescing phase is therefore wide open. A hub that has entered [QUIESCE-START] — and even
one that has already logged [QUIESCE-OK], having drained everything it owed — keeps accepting
inbound deliveries, keeps handling them, and keeps registering response callbacks for requests it
POSTS. #3261 measured it creating hosted hubs in that window too:

11.928 [QUIESCE-START] TestData/dnt…: 0 pending callbacks at dispose entry: <none>
11.928 [QUIESCE-OK]    TestData/dnt…: drained 0 callback(s) in 0ms
11.968 Start processing SubscribeRequest (ID: p2qsFux3…, cache/iIN4… -> TestData/dnt…)
11.969 Creating new hosted hub for address sync/4wzOafy… in host TestData/dnt…

Work taken on after the drain has, by construction, no drain left to finish it: the quiesce budget
has already been spent, so the next phase cancels it.

Measurement (2026-09-06, three independent bake runs)

Counted from the RECEIVED runLevel=<level>@<issuing hub> stage in the request-fate trails printed
by [QUIESCE-TIMEOUT]. Six of nine pending callbacks were registered by a hub that was ALREADY
Quiescing:

run hub runLevel at intake
core main-cd #7937 (34058018897) Cornerstone, Publish, RemoteControl, Voice Quiescing
core main-cd #7937 Chess Started
core main-cd #7941 (34061838257) RolePlay Started
core main-cd #7941 Video, Essentials Quiescing
MeshWeaver.Plugins #1422 gate (34059652860) Chess Quiescing

Each one costs the full 2 s quiesce budget, ends in forcibly cancelling, and hands the requester a
HubDisposedBeforeResponseException.

Why this is filed separately rather than fixed with #3499

It is benign in every occurrence measured so far, and it is not what wedges an install. The
requester DOES get a typed, transient answer, and eight packages carrying this shape passed the gate
on run #7937 alone. The wedge #3499 fixed is the other row of the table: a request accepted while the
hub was Started, whose owner-side patch had already committed and whose teardown fault was
classified as the terminal Unknown instead of the retryable OwnerDisposing
(#3505).

That said, forcibly cancelling is the tell of an unfixed root, and this is the root: teardown lets
accepted work finish; it must not keep accepting new work it cannot finish.

🚨 Not the fix

Widening the 2 s QuiesceTimeout. #3261 settled that already: a bigger budget converts a leaked
callback into a slower leaked callback. The same goes for re-arming it further
(MaxQuiesceRearms exists for a different case — a reply owed by a sibling in this mesh that is
itself shutting down and WILL answer).

Acceptance

  • A hub that has entered Quiescing does not take on new correlated work it cannot answer: the
    intake gate refuses from the first instant of disposal, not from DisposeHostedHubs, with the same
    transient ErrorType.ShuttingDown NACK the later gate already posts (NackThroughParent, activation
    identity and all).
  • The exemptions the later gate needs are preserved — ShutdownRequest / DisposeRequest, and
    whatever else teardown itself must route — plus, deliberately, any delivery that settles work
    already accepted
    (a response correlating to a pending callback), which is exactly what the
    quiesce drain is waiting for and must not be refused.
  • A deterministic pin that goes RED on today's code. [QUIESCE-OK] followed by an intake is a
    reproducible sequence (A hosted hub's leaked callback is reported on its owner, and a subtree drains under one Quiescing budget #3261's DanglingNodeTypeUpdateTest with MESHWEAVER_MSG_TRACE=1 and
    MeshWeaver.Messaging: Debug produced it), and after fix(#3499): a teardown fault is a RETRYABLE verdict, not a terminal one — an install survives its own root recycling #3505 the tester image
    logs [QUIESCE-START] so a bake occurrence is attributable in one read.
  • No bound is widened.

Provenance

Measured while root-causing #3499; the [QUIESCE-OK]-then-late-intake trace is #3261's.

Activity

  1. rbuergi commented on Sep 7, 2026

    @rbuergi
    ContributorAuthor

    Closing — all four acceptance criteria measured on origin/main

    Verified 2026-09-07T12:25Z against origin/main (fbf9e9be1); the merge queue does not fire
    Closes links, so this is measurement rather than the PR's declaration.

    Merged: #3557, merge commit 44ea4f6a3 — "Merge pull request #3557 from
    Systemorph/fix/3506-quiescing-intake-gate"
    .

    acceptance criterion measurement
    the gate refuses from the first instant of disposal, not from DisposeHostedHubs MessageService.RefusesIntake (src/MeshWeaver.Messaging.Hub/MessageService.cs:536-560) opens with if (runLevel < MessageHubRunLevel.Quiescing) return false;. Quiescing is the run level immediately after Started (MessageHubRunLevel.cs:11-23), i.e. the first disposal state. It is the single call site of the intake gate (:919, in ScheduleNotify).
    the same transient ShuttingDown NACK the later gate posts the refusal path runs AnswerUnreleasableDelivery → NackThroughParent / ReportFailure, ErrorType.ShuttingDown, activation identity included
    the exemptions are preserved ShutdownRequest/DisposeRequest pass at every level; tier 1 additionally lets through anything carrying PostOptions.RequestId (a delivery that settles work already accepted — exactly what the quiesce drain waits for), anything addressed elsewhere (transit to hosted children still alive), and fire-and-forget. IsAwaitedBySender is factored so "refused" and "NACKed" are the same set by construction
    a deterministic pin that goes red on the old code test/MeshWeaver.Messaging.Hub.Test/QuiescingHubRefusesNewWorkTest.cs (new, 13,955 bytes): NewCorrelatedRequest_IsRefused_WhileTheHubIsDrainingItsQuiesce asserts ErrorType.ShuttingDown, ShutdownNack.IsAnsweredByOwner, RunLevel=Quiescing in the message and that the handler never ran; ReplySettlingWorkAcceptedBeforeQuiesce_StillLands_AndTheDrainCompletes pins the narrowness in the other direction and that the victim reaches Dead
    no bound is widened the diff of 44ea4f6a3 changes no Timeout/Budget/Bound/Rearm constant in src/ — the only such symbol added is HeldQuiesceBudget inside the new test file

    SubscribeDuringRecycleTest was extended in the same change (+218/-…) rather than relaxed.

    Measured 2026-09-07T12:25Z on origin/main fbf9e9be1: git log --merges --grep for the merge
    commit; grep -n on MessageService.cs and MessageHubRunLevel.cs; git show --stat and
    git diff 44ea4f6a3^1 44ea4f6a3 filtered for bound-shaped assignments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions