A standing watch re-opens after a transient fault, and only after one

Most mesh reads are one-shot. A caller asks, gets an answer or a fault, and the next caller asks again. The layers below are built for that:

A standing watch has no next caller. It is one subscription held for the process's life, so it is the caller. If it does not re-open after a fault, nothing will until the process restarts.

The defect (MeshWeaver#6183)

The pre-warmer's live NodeType-record census (DynamicTypePreWarmer.ObserveLiveRecordCensus, the LIVE RECORD CENSUS sentence of /health's bake-report) is such a watch. On memex-cloud, pod memex-portal-deployment-65fcf777bc-pt5dc, at 2026-10-05 23:30:44Z, one NpgsqlException: Exception while reading from stream → IOException → SocketException (104) Connection reset by peer arrived while the PostgreSQL provider streamed the catalog's rows. The census ended. /health then reported that a record re-keyed after boot was "no longer being watched for" until the replica restarted.

The rule

StandingWatchRecovery.ReopenOnTransientFault re-opens a standing watch when both hold:

  1. The fault is typed as transient (StandingWatchRecovery.IsTransient). That means one of:

    • a database connect or I/O fault (StorageFaults.IsTransientConnectFault, InfrastructureFault.IsTransient);
    • the fan-in's stall terminal (QueryProviderStalledException). That is an availability failure, never a verdict.

    Anything else ends the watch on its first occurrence, as before: a query or schema error, a deserialization defect, a disposed mesh. Re-reading a defect only repeats it on a timer.

  2. The budget is not spent. The watch gets at most 5 re-opens in a row with no emission between them. The backoff is 2 s, 5 s, 15 s, 30 s, then 60 s. An emission proves the watch reads again and resets the count. When the budget is spent, the last fault surfaces unchanged. A database that stays down is reported after about two minutes, not hidden.

Each re-open calls the factory again. For the census that means asking the synced-query cache again, and the cache has already evicted the faulted chain. So the re-open gets a fresh upstream, not a replay of the latched fault. A factory that hands back the same cached observable would defeat the rule.

A re-open never nests. The helper is one flat pipeline, and RetryWhen re-subscribes it in place. An earlier version re-opened from inside its own Catch. Each fault then wrapped one more operator layer around the next opening. That chain, and the stack every later emission climbed, grew with each fault the watch survived, and it was never released. StandingWatchRecoveryDoesNotNestTest pins this structurally: the number of Rx operator objects the live subscription holds is the same after 1000 faults as after one, and the old shape grows by at least one per fault. It counts objects, not stack frames. A frame count depends on what the JIT inlined: on a Release CI host the old shape measured flat.

The gap is never silent. Before each re-open, the hosted service records the interruption on the census (NodeTypeBakeReportRegistry.RecordLiveRecordsFault), so /health degrades and says why. The next reading clears it (RecordLiveRecords). A terminal fault is never followed by a reading, so it stays on /health.

The build-claim handshake: the same fault in a second shape (MeshWeaver#5716)

The pre-warm sweep starts with a claim handshake to Admin/Build. It already retried "no answer in time" (BuildProtocolDriver.RetryUnreachableCoordination, #1635). Once the attempts were spent it reported BuildCoordinationUnreachableException, which sends the decision to the durable-witness door (#3404).

But "no answer in time" arrives in two shapes, and the handshake knew only one:

where the bound fired what the caller receives
in this process TimeoutException
in the transport (Orleans' 30 s response or placement timeout) DeliveryFailureException, text Delivery to 'Admin/Build' failed: Response did not arrive on time in 00:00:30 …

The router flattens the second shape to text, because the exception object does not survive the grain boundary. The handshake read it as a hard verdict. It was not retried and not reported as unreachable, so the witness was never asked and the whole warm-up faulted on one slow exchange. That happened on memex-cloud, pod 76cf776847-v4txp, at 2026-09-24 06:51:10Z.

TransportTimeout.IsNoAnswerInTime (MeshWeaver.Messaging) is now the one definition of both shapes. The handshake and the view retry (AreaErrorClassifier) both use it. It matches only the transport's own two timeout phrases, never a NACK that carries a verdict. Re-asking is safe: the claim registration is idempotent per holder, even if the timed-out request did land.

The same defect in eight more places

A sweep for watches with no guard of their own found the same shape again: a live query subscribed once, with an error arm that only logs. Each now re-opens.

site what one fault used to cost how it re-opens now
InstanceSecretCatalog (Graph) every per-sender key refused until a restart (it fails closed) SubscribeHubWatcher. It stays fail-closed (Faulted) from the fault until the re-opened query lists the set again.
UiContributionCatalog (Graph) menus frozen at the last known set SubscribeHubWatcher. The next full listing replaces the frozen set.
EventSubscriptionRunner, both live queries (Graph) no pending subscription fired, no legacy action migrated SubscribeHubWatcher on each
ImageClosureIngest inbox (Graph) no closure record ingested SubscribeHubWatcher over one InboxDrainWatch opening (see below)
PartitionRegistry (Graph) the fault became a null that the shared Replay(1) entry served to every later caller the faulted entry is evicted. Its current callers get null, and the next lookup opens a fresh query. It also folds the change feed now: a delta for another partition used to read as "no definition".
RegistryUpdateReconciler module-published inbox (PluginCatalog) broadcasts drained only at the next boot SubscribeHubWatcher over one InboxDrainWatch opening (see below)
InstanceAutoRegistrationService consent wait (PluginCatalog) the fault answered false, the waiter filtered it out, and registration never ran until a restart StandingWatchRecovery.ReopenOnTransientFault with no budget, so the wait stays open as long as its waiter does. While waiting, the record's node stream stays LIVE: only its first value is bounded. The query and the node stream are independent feeds, so a record that completes on the node stream after the last query emission still registers.
OutboundEmailSender outbound-mail watch (portal) no outbound mail sent until a restart SubscribeHubWatcher (SubscribeOutboundWatch). A re-open re-offers every still-New mail; the send queue's re-read of the authoritative node still decides each send.
InvitationEmailSender invitation watch (portal) no invitation e-mailed until a restart SubscribeHubWatcher (SubscribeInvitationWatch). The in-process claim and the durable EmailSentAt stamp still decide each send.
RegistryUpdateReconciler safety net (PluginCatalog) a pass that faulted outside the per-registry catch ended every later scheduled pass (.Repeat() re-subscribes on completion only) each tick's pass is caught, logged at Warning, and the next interval runs a fresh one

The two inbox drains share one opening, InboxDrainWatch (Graph). The listing and the drain queue are independent. A listing.Select(drain).Concat() pipeline held a listing fault back until the drain in flight finished, so a hung drain kept a faulted query from being re-opened at all. Now a listing fault ends the opening at once. A claim belongs to the opening that made it until its drain has run. When the opening ends, every delivery it claimed and did not finish is released, so the re-opened watch drains it instead of skipping it as already picked up.

The secret catalog publishes its entries and its fail-closed flag as ONE snapshot. They used to be two fields written one after the other. A verifier reading between the two writes could see the feed as recovered while the entries were still the pre-outage set.

SubscribeHubWatcher re-opens after any fault that does not end the watch for good (its own hub disposing, poisoned own content, its own node gone). It ends with the hub that owns it. Tests drive the re-open through its scheduleReEstablish seam on a manual clock, never on wall time.

The hub watcher's cadence is paced

SubscribeHubWatcher used to re-open after a fixed 1 s and log every re-open at Error, forever. During a real outage of its source that was about 3600 Error lines an hour per watcher — and the Error channel files incidents — plus 3600 fresh opens an hour against the dependency that was failing. It now has the same shape as StandingWatchRecovery, except that it never gives up (a stopped control-plane watcher leaves its hub half-alive):

HubWatcherPacesItsReEstablishTest (MeshWeaver.Hosting.Test) pins this on virtual time. Putting back the fixed 1 s / Error-per-re-open body fails both of its tests.

The guard

StandingQueryWatchRatchetGuard (MeshWeaver.Documentation.Test) counts .Subscribe( statements whose chain opens a live query (.Query<, GetQuery(, ObserveQuery) without one of these:

It is a ratchet, and the count may only fall. It reads text, not symbols, so it MISSES some watches: a query subscribed in another statement or by a method's caller, a GetMeshNodeStream or change-feed watch, and a standing chain with a per-item Take. A green run means no new watch of the measured shape, not that every watch is guarded. The baseline is zero: the two sites it counted at seeding, the portal's e-mail senders, are converted.

Tests