Debugging Message Flow & Hangs

When a hub handler looks like a deadlock — a test times out, a response never arrives — resist the urge to bisect blindly or rerun the test two or three times to see if it sticks. The framework already emits a structured trace at Trace level. Turn it on, run once, grep, fix.

Hub A Hub B MESSAGE_FLOW trace tags Unpacking message at Hub A (outbound) Unpacking message … Request ROUTING_TO_HIERARCHICAL → HIERARCHICAL_ROUTING_RESULT Hub B unpacks and routes locally Unpacking … (Hub B) ROUTING_TO_LOCAL_EXECUTION handler executes ✓ Response (ResponseFor) hub.Observe(…) emits → completes Hang diagnosis: find the last MESSAGE_FLOW: tag that fired and check what follows

Healthy request-response trace: Hub A routes the request through the hierarchy to Hub B, which executes the handler and sends the response back.


Log Levels — Edit the Test appsettings, Not the Source

ABSOLUTE: Never flip LogInformationLogDebugLogTrace in src/ for a debugging session. Log levels in source code are a production cost contract — every Information line is emitted to stdout and shipped to Loki (via Promtail), where ingest/retention has a cost. Toggling them temporarily silently bleeds budget the next time the branch is deployed.

Two sanctioned paths to raise verbosity:

Context What to edit
Test debugging session test/<Suite>/bin/Debug/net10.0/appsettings.json (or the shared test/appsettings.json at the runtime location). reloadOnChange: true is wired, so the level flips mid-run without a rebuild. Revert before committing.
Production debugging session ../MeshWeaver.Plugins/src/Memex.Portal.Distributed/appsettings.json under the top-level Logging:LogLevel. That gates what reaches stdout, which Promtail ships to Loki.

If a Log* call is genuinely too noisy or too quiet at its current level, fix it permanently with a commit explaining the cost/value trade-off — never sneak it in alongside an unrelated change.


One-Shot Recipe

1. Crank logging to Trace in the test's runtime appsettings:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "MeshWeaver.Messaging": "Trace",
      "MeshWeaver.Data.Serialization": "Trace",
      "MeshWeaver.Mesh": "Debug",
      "MeshWeaver.Hosting.Persistence": "Debug",
      "Microsoft": "Warning",
      "System": "Warning"
    }
  }
}

2. Run the failing test once, capturing all output:

dotnet test test/<Suite> --no-build --filter "FullyQualifiedName~<TestName>" \
    > /tmp/trace.log 2>&1

3. Grep the structured tags:

grep -E "MESSAGE_FLOW|SYNC_STREAM|exception occurred|deliveryId" /tmp/trace.log

If the test hangs inside the runner, per-test log files are captured here: test/<Suite>/bin/Debug/net10.0/test-logs/<TestClass>_<TestMethod>.log


Structured Trace Tag Reference

Every tag below is emitted as a single structured log line. The source column names the file and the emitting method — grep for the tag itself rather than a line number, which goes stale on the next edit to the file.

Tag Source What it tells you
MESSAGE_FLOW: Unpacking message … MessageService.cs A message was decoded at this hub
MESSAGE_FLOW: ROUTING_TO_HIERARCHICAL … MessageService.cs Hub is delegating routing to its parent / hierarchy
MESSAGE_FLOW: HIERARCHICAL_ROUTING_RESULT … MessageService.cs Routing returned — check Result to see if the message was forwarded, processed, or failed
MESSAGE_FLOW: ROUTING_TO_LOCAL_EXECUTION … MessageService.cs Hub recognised itself as the target and is invoking handlers
MESSAGE_FLOW: EXECUTION_START / EXECUTION_COMPLETED / EXECUTION_FAILED / EXECUTION_TIMEOUT_DURING_DISPOSAL MessageService.cs The handler-invocation window itself, with a Duration on the terminal lines
Buffering message … MessageService.cs Hub isn't initialised yet — message went into the deferred buffer
Deferring on-target message … MessageService.cs Hub received the message but a WithInitializationGate is still closed
Allowing message … through gate … MessageService.cs A specific gate predicate let the message through pre-init
Cancelling execution pipeline … MessageService.cs Hub is shutting down — in-flight work gets cancelled
An exception occurred during the processing of MessageDelivery … MessageHub.cs A handler threw — full delivery payload + stack are dumped here. This is your prime suspect when a request seems to vanish.
No handler found for request <T> (ID: …) in <Address> - sending DeliveryFailure response MessageHub.cs Hub received the message but no handler matched — a DeliveryFailure is sent back to the caller
Could not deserialize message in hub <addr> — type '<T>' is not registered in this hub's TypeRegistry. MessageService.DeserializeDelivery Receiving hub fell back to JsonElement because the inbound $type discriminator isn't registered. Fix: WithType(typeof(T), nameof(T)) on the receiving hub's config.
SYNC_STREAM … JsonSynchronizationStream.cs Cross-hub workspace-stream traffic. Look here when a GetRemoteStream<> subscription never emits.

Reading the Trace

For a request-response hang, the healthy timeline looks like this:

[T0]  MESSAGE_FLOW: Unpacking …                           (request arrives at Hub A)
[T0]  MESSAGE_FLOW: ROUTING_TO_HIERARCHICAL Target=B …
[T1]  MESSAGE_FLOW: Unpacking … Hub=B
[T1]  MESSAGE_FLOW: ROUTING_TO_LOCAL_EXECUTION Hub=B
[T2]  <handler logs at Information level>
[T3]  MESSAGE_FLOW: Unpacking …                           (response arrives back at Hub A)
[T3]  hub.Observe(request) emits, then completes          (the caller's subscription fires)

hub.Observe(...) is the only request/response primitive — there is no RegisterCallback registry and no AwaitResponse on IMessageHub to grep for. Observe is AsyncSubject-backed and pre-registers before posting, so a synchronously-handled response cannot slip past it.

Find the last MESSAGE_FLOW: line that fired and look at what should have happened next:

Last observed tag Likely cause Where to look next
No Unpacking at the target hub at all Message lost in routing HIERARCHICAL_ROUTING_RESULT State field
Unpacking but no ROUTING_TO_LOCAL_EXECUTION Message deferred or buffered Deferring on-target / Buffering lines
ROUTING_TO_LOCAL_EXECUTION but no handler logs Handler threw immediately An exception occurred during the processing of MessageDelivery
Handler logged success but caller hangs Response not routed back Find the o.ResponseFor(request) post and its matching Unpacking at the sender's address

The handler-side fate trail — read it before reconstructing the table above by hand

The table above is a manual reconstruction from Trace-level MESSAGE_FLOW: lines, which are off by default and invisible in CI. For a request someone is still awaiting, the framework now keeps that reconstruction for you: RequestFateLedger records the stages the delivery actually reached, and the trail is printed whenever a pending callback is reported.

[QUIESCE-TIMEOUT] client/475b…: 1 callback(s) still pending after 0.3s.
  Pending: 0iFBGDmtTUyOnOgbHS3ezQ=SwallowedRequest@host/1(302ms)
  handler-side fate (what happened to the delivery):
    0iFBGDmtTUyOnOgbHS3ezQ=SwallowedRequest:
      POSTED target=host/1@client/475b…(+0ms) → RECEIVED runLevel=Started@client/475b…(+0ms)
      → ENQUEUED@client/475b…(+0ms) → RECEIVED runLevel=Started@mesh/1(+1ms)
      → ROUTED onTarget=False state=Forwarded@mesh/1(+1ms) → RECEIVED runLevel=Started@host/1(+1ms)
      → ROUTED onTarget=True state=Submitted@host/1(+1ms) → HANDLER_ENTER@host/1(+1ms)
      → HANDLER_EXIT state=Processed@host/1(+1ms)

Each trail ends in a verdict (⇒ …) naming which failure shape it is, so you do not have to infer it from the stages. Verdict and stages share one line, deliberately: these records are read out of /tmp/meshweaver-test-trace.log with grep, and a newline before the verdict would let grep <messageId> return the evidence while hiding the conclusion.

The first stage is always AWAITING …, written when the callback is registered. That guarantees a trail is never empty — so "AWAITING and nothing else" is a positive statement (nothing was ever posted under this correlation), not an absence of evidence.

Read it by what is missing:

The trail shows Verdict
No RECEIVED anywhere Never delivered — a routing or post-pipeline problem, not a handler problem
RECEIVED but no ROUTED onTarget=True It kept being forwarded — no hub ever accepted it as its own
DEFERRED / DROPPED_* / SHED_AGGREGATE as the last stage The receiver took it and then parked or discarded it; the stage names which gate or breaker
HANDLER_EXIT state=Ignored + NO_HANDLER_MATCHED Delivered, but no handler matched the type
HANDLER_EXIT state=Processed and no RESPONSE_POSTED A handler ran and produced no reply for this correlation — the caller will wait forever
RESPONSE_POSTED but the callback is still pending The reply was posted and lost on the way home — chase the response delivery, not the handler

Reading an ORDER defect: the queue-and-depth stamps

🚨 A hub has TWO queues, and a delivery moves between them at TURN time. mainQueue holds turns in arrival order; a turn that is dequeued and found on-target with an initialization gate closed is pushed onto deferredQueue — so it leaves mainQueue — and OpenGate later drains the deferred run back to the front (MeshWeaver#3408). Any question of the form "why did B run before A?" is therefore a question about which queue each delivery was in and how many turns were ahead of it, and a trail that records only ENQUEUED / DEFERRED cannot answer it: a delivery parked in front of an empty queue and one parked in front of two messages that then ran look identical.

Every transition consequently carries both depths:

Stage Means
ENQUEUEDQUEUED queue=main depth=N Joined mainQueue with N-1 turns ahead of it. Read under the same turnGate as the enqueue, so it is the queue's real state at that instant
DEFERRED gates=[…]QUEUED queue=deferred pos=P mainBehind=M It left mainQueue for deferredQueue at position P. 🚨 M is the count still waiting behind it — precisely the messages that can now overtake it
DEFERRED_DRAINEDQUEUED queue=main depth=N Its deferred turn is running. Compare with the mainBehind above: if N has dropped, those messages already ran and this delivery is now out of order
GATE_DRAIN gate=… deferred=D behind=B (hub-level, MessageTrace) The restore point. 🚨 deferred=0 here, paired with a delivery whose fate says it was deferred, proves the deferral landed after its drain and must wait for a later one — the one conclusion neither stamp shows alone
REQUEUED_IN_ARRIVAL_ORDER seq=S behind=N The arrival-order barrier fired: this turn was running when the last gate opened, so it was about to overtake N older turns the drain had just restored. It re-joined the queue at its own arrival position instead. Seeing this is normal and correct; it is also the marker that the hub was in exactly the window Plugins#1394 was about

🚨 A turn can be in NEITHER queue — dequeued, and not yet at its gate check. That is where the reorder that survived #3408 lived, and no depth stamp can see it because it is in neither count. Read Turn-Loop Arrival Order before attributing an order defect to a queue: it gives the two windows, why the permutation names the message that straddled the gate open rather than the defect, and the invariant that now holds.

🚨 The depth is a SEPARATE QUEUED stage, and that is not cosmetic — a stage token is a matched CONTRACT. Stages render as {stage}@{hub}, and suites wait on that literal substring:

// DisposalRaceNackTest, SubscribeDuringRecycleTest
.Where(trail => trail.Contains($"ENQUEUED@{victimAddress}", StringComparison.Ordinal))

Writing the depth inside the token — ENQUEUED queue=main depth=1 — deletes ENQUEUED@…, those waits never fire, and the suites time out looking like routing stalls that have nothing to do with the change. Measured while adding these stamps: 0/5 with the token altered, 5/5 with it restored. So append new stages; never edit an existing token. MessageTrace lines are free-form, but a fate stage's leading token is API.

Why this exists (Plugins#1394). ActivationBacklogFifoTest has produced four different permutations — A, C, B, B, C, A, C, A, B and B, A, C — for one address and, in the captured case, Distinct probe-hub instances: 1. A structural read of RoutingServiceBaseMonolithRoutingService.RouteImplMessageService says every one of them is unreachable: the per-address ActivationSerializer is inbox.Select(RouteOne).Concat(), so a later message's routing begins only after the earlier one's hub.DeliverMessage has run; the defer decision and the deferredQueue.Enqueue are inside the same lock (gateStateLock) that OpenGate also takes; and DeferredTurnsResumeAheadOfLaterArrivalsTest pins the restore ordering deterministically. When the code says a reorder cannot happen and it keeps happening, the missing evidence is where each delivery waited — not more reading.

🚨 A permutation does not name a mechanism. Four distinct ones from one test is itself evidence that this is not a single fixed swap, and triaging from the permutation alone has already sent two investigations to the wrong subsystem.

Three things to know before trusting it:

When the handler replies from work it detached

HANDLER_ENTER / HANDLER_EXIT bound the rule chain only. The canonical mesh handlers return request.Processed() immediately and owe their reply from a composed observable they subscribed and let run — so for those, HANDLER_EXIT state=Processed tells you nothing about whether a reply is coming. A handler that answers in 3 s, one that faults silently, and one whose chain completes EMPTY all look identical from the pipeline.

Only the handler can split them, so it records its own terminal arms:

hub.NoteRequestStage(request.Id, $"CREATE_CHAIN_EMITTED mode={mode}");   // produced a result
hub.NoteRequestStage(request.Id, $"CREATE_CHAIN_ERROR {ex.GetType().Name}");
hub.NoteRequestStage(request.Id, "CREATE_CHAIN_COMPLETED_EMPTY …");      // ← terminated with nothing
hub.NoteRequestStage(request.Id, $"CREATE_SAVE_DECLINED adapter={…} path={…}");

The two handlers behind every cross-hub mesh-node write record their arms the same way (MeshWeaver#2543 — the bake wedge's trail ended at HANDLER_EXIT state=Processed with nothing after it, on both hops, and could not say which wait it was in):

trail ends at what it means
UPSERT_READ … (nodeops, CreateOrUpdateNodeRequest) the existing-node read answered; the arrow names the branch taken (create, no-op probe, update)
UPSERT_WRITE_THROUGH_STREAM path=… nodeops handed the write to the per-node OWNER; the wait is now on that owner's PatchDataRequest — read ITS trail
UPSERT_REPLY ok … / UPSERT_REPLY fail reason=… nodeops posted its verdict; a caller still waiting lost the reply in transit
PATCH_MERGE_DISPATCHED and nothing after the owner queued its merge turn on the primary stream's executor and the turn NEVER RAN — the executor is behind other work (a compile, a burst of sibling writes); the offset of the next stage, when it comes, is the queueing time
PATCH_MERGE_TURN entered the turn ran; a trail ending here faulted inside the merge without a verdict
PATCH_MERGE_DEFERRED cold-store the owner was activating cold; the retry re-arms once the store loads (deferred-retry)
PATCH_MERGE_STAMPED v=… refused=… and nothing after the merge committed on the executor but the echo CONTAINING it never reached the ack watcher — the reduced stream is not emitting
PATCH_MERGE_NOCHANGE refused=… the merge changed nothing (a no-op or a fully refused write); the verdict follows immediately
PATCH_ECHO_SEEN and nothing after 🚨 Since #3510 this row should be IMPOSSIBLE: the bound is now armed as the FIRST thing the echo arm does, so PATCH_FLUSH_BOUND_ARMED follows the echo unconditionally and a verdict is owed from that instant. A trail that still ends here means the echo arm did not reach even the timer — read it as a NEW defect, not this one. What it used to mean: the ack watcher's onNext neither returned nor threw — it BLOCKED. 🚨 Measured 2026-09-06 (#2543, Reinsurance gate 34044287799): twelve stalls ended here with no PATCH_ACK, no FLUSH_OUTLIVED_BOUND — the earlier reading of this row ("the wait is the durable flush, the bound names it") was FALSE, the bound never fired. 🚨 And since #3480 the row no longer means "a synchronous throw" either: measured 2026-09-07 over 25 core CD bake jobs, every one on a commit descended from #3480's merge, 5 of them carrying PATCH_ECHO_SEEN stall trails (43 · 19 · 8 · 4 · 4)PATCH_FLUSH_FAULTED_SYNC fired 0 times. The trail's tail is a sliding window (RequestFate.Add), so the newest stages always survive and the absence is real. What is left is flush(committed) itself parking: GetService<IPostCommitFlush>()GetService<IStorageAdapter>() / <IMeshChangeFeed> / <PostCommitFlushRegistry>flushed.Claim(...)adapter.Write(...) — note WriteAndPublishUpdated calls adapter.Write eagerly, at build time, on the echo thread. That park is what #3510's fourth arm was — a routed write whose partition hub was mid-recycle under PackageInstaller — and arming the bound first is what answers it
PATCH_FLUSH_BOUND_ARMED (immediately after the echo) the flush bound exists; from here the verdict is owed by the flush OR by the bound's scheduler, never by nobody. This stage precedes the flush being built (#3510)
PATCH_FLUSH_BOUND_ARMED and nothing else, for the whole bound the flush's BUILD or its Subscribe parked on the echo thread and the bound has not expired yet; at expiry PATCH_FLUSH_BOUND_FIRED + [PatchAck] FLUSH_OUTLIVED_BOUND … subscribed=False names it
PATCH_FLUSH_SUBSCRIBED the flush's Subscribe returned; it follows PATCH_FLUSH_BOUND_ARMED, and a synchronous flush has acked before this, so PATCH_ACK may precede it
PATCH_FLUSH_FAULTED_SYNC building <Exception> BUILDING the durable flush (IPostCommitFlush.Flush, which resolves services on the ack path) THREW on the echo thread; the owner NACKed with the classified error and the writer retries. Before this stage existed the throw escaped the echo subscription and the trail ended at PATCH_ECHO_SEEN in silence — CD 7937's bake host, 13 stalls (#2543)
PATCH_FLUSH_SUBSCRIBED and nothing after, past the bound the flush is in flight AND the bound timer exists, yet neither produced a verdict — the bound's scheduler (Scheduler.Default, the thread pool) never ran the callback: read the [STALE-CALLBACK] line's [pool threads=… pendingWork=…] — a pinned thread count with a large pending count is a starved pool
PATCH_FLUSH_BOUND_FIRED the bound expired before the flush answered; [PatchAck] FLUSH_OUTLIVED_BOUND … subscribed=<bool> is the same event from the log side and PATCH_ACK ok follows — the commit is acked, the flush keeps running. subscribed=True is a slow flush (storage behind); subscribed=False is a build/Subscribe that parked on the echo thread (#3510)
PATCH_ACK ok / PATCH_ACK nack=<code> the owner posted its verdict

NoteRequestStage is a no-op unless something is awaiting that id, so it is free to call unconditionally. Add the onCompleted arm: a chain that completes empty posts nothing, and without a stage there it is indistinguishable from one that is still running. HandleCreateNodeRequest is the worked example.

A detached chain must ANSWER on every terminal arm, not just record one

Recording the arms is diagnostics; answering on them is the contract. HandleCreateNodeRequest carries both, and the shape is worth copying:

The general rule: if a handler owes its reply from a detached observable, the observable's onCompleted is a terminal arm like any other. Leaving it unhandled is not "nothing to do" — it is the one path that hangs the caller forever.

Terminated vs still-running: a Timeout catches only ONE of them

This is the distinction that left several #981 captures unexplained, and it is worth internalising because it applies to every bounded reactive chain in the mesh:

.Timeout(...) faults on SILENCE, not on a clean finish. A source that completes without emitting sails straight through Timeout, through any Catch behind it, and through every SelectMany downstream — producing nothing, reporting nothing, bounded by nothing.

So a chain guarded only by Timeout still has an unhandled terminal case. The two failure shapes need different evidence and have different fixes:

Shape What the trail shows What bounds it Fix
Still running (slow upstream) last stage is the await, nothing after the Timeout nothing — or a budget that reflects the real wait
Terminated empty a *_COMPLETED_EMPTY stage nothing DefaultIfEmpty / an explicit empty arm

Silent Completion treats that second row on its own terms — the shape, its instances outside the request/response path (a filtered decline sentinel, a render generator that never delivers), and how to guard a chain so its empty terminal case fails closed.

EnsurePartitionBootstrap's authorization probe carries both stages for exactly this reason — BOOTSTRAP_PERM_AWAIT before the fold, then one of BOOTSTRAP_PERM_VERDICT / BOOTSTRAP_PERM_FAULTED / BOOTSTRAP_PERM_COMPLETED_EMPTY. A capture that ends at BOOTSTRAP_PERM_AWAIT is a create waiting on a slow-but-healthy permission fold, not a terminated one.

That distinction matters for reading a quiescing-timeout report. The permission fold is bounded at 15 s (a cold-start synced query on a fresh partition legitimately takes seconds), while the teardown quiescing budget that detects the pending callback is 2 s. A create caught mid-probe is therefore reported as a leaked callback long before its own bound would have fired — the report is the detector, not the defect, and raising either number fixes nothing.

Can that fold complete empty in practice? Through the shipped evaluator, no — and the reason is the same reason it can stall. PermissionEvaluator's fold rides SyncedQueryMeshNodes, whose allChanges is upstream.Merge(externalChanges).Merge(feedRemovals); externalChanges is a Subject that is never completed, and Merge completes only when every source does. So that substrate can never complete at all — only stall, which the Timeout does catch. The empty case remains reachable only through the EffectivePermissionsDelegate DI extension point (an evaluator returning Observable.Empty<Permission>() is a legal implementation), which is why the empty arm is a guard rather than a hot path.

The same block is printed by [STALE-CALLBACK] (every 5 s, for callbacks older than MESHWEAVER_STALE_CALLBACK_MS, default 30 s) — that is the one that fires while the mesh is still live, so a repro run that dials the env var down gets the handler side before teardown is involved.


The Cross-Hub Border — JsonSynchronizationStream

workspace.GetRemoteStream<TReduced, TReference>(addr, ref) subscribes via a SubscribeRequest posted to the owning hub. If the owning hub has no handler for SubscribeRequest — or no matching reducer — the subscription receives a DeliveryFailure instead of a SubscribeResponse, and the SynchronizationStream errors out:

[Warning] [MeshWeaver.Data.Serialization.SynchronizationStream] Stream <id>
   received DeliveryFailure: No handler found for message type SubscribeRequest

This is the smoking gun for the "remote read returns nothing" class of hang. The fix lives on the owning side (register a SubscribeRequest handler or add a MeshDataSource so the hub has the reducer), not on the caller side.


Type-Registry Mismatch

The handler is registered, but the message arrives at the receiving hub as JsonElement because the receiver's ITypeRegistry is missing the type. The sender surfaces a clean exception:

DeliveryFailureException: Could not deserialize message in hub <addr> —
    type 'MyRequest' is not registered in this hub's TypeRegistry.

MessageService.DeserializeDelivery catches the JsonElement fallback, calls ReportFailure(delivery.Failed(...)), and posts the DeliveryFailure back via the standard ResponseFor(delivery) path — the sender's hub.Observe(...) surfaces it as OnError.

Fix: add the type to the receiving hub's config:

hub.WithTypes(typeof(MyRequest), typeof(MyResponse));

For Orleans deployments, register on both sides — the silo's hub config and any client/portal hub that posts the request. A ping-pong guard suppresses DeliveryFailure responses when the inbound $type is itself DeliveryFailure, so a misconfigured pair won't spin forever.

FQN vs Short-Name Mismatches

The wire $type discriminator must match the receiver's registered typeName. The polymorphic serializer picks the discriminator from the sender's ITypeRegistry:

A receiver that registered WithType(typeof(T), nameof(T)) only matches short names, so an FQN on the wire fails the lookup and produces a DeliveryFailure even though both sides technically "have" the type.

Triage with the file trace (MESHWEAVER_MSG_TRACE=1). The file is Path.GetTempPath() + meshweaver-msg-trace.log%TEMP%\… on Windows, but on macOS that is $TMPDIR (a per-user /var/folders/… directory, not /tmp) and on Linux /tmp. Find it with ls "${TMPDIR:-/tmp}"/meshweaver-*.log; a POSIX shell expands $TEMP to the empty string, so a copied-from-Windows "$TEMP/meshweaver-msg-trace.log" silently reads /meshweaver-msg-trace.log and finds nothing. The NotifyAsync ENTER line stamps msg=... with the JSON $type discriminator from RawJson.Content. If it reads msg=MeshWeaver.Mesh.CreateNodeRequest (FQN) instead of msg=CreateNodeRequest (short), a hub somewhere along the hop didn't register T in its TypeRegistry — register on every hub the message transits, not just the originator and the final target.

For test setup specifically: MessageHubConfiguration.TypeRegistry is mutable per call (WithType returns the same instance), so configuration.TypeRegistry.AddAITypes(); (discarded return) is sufficient — but the call must reach the configuration of every hub that serializes the message, including hosted sub-hubs like {path}/_Exec and any cross-cutting ConfigureDefaultNodeHub chain.

Auto-registration warms only the SERIALISING hub

A type nobody registered explicitly still gets a short-name $type: PolymorphicTypeInfoResolver auto-registers an unregistered, non-collectible type the first time a hub writes one (it logs a Warning naming the hub). That entry lands in that hub's registry only — GetOrAddType writes the local map and never the parent, and a child registry can read its parent but not the reverse.

So which hub does the writing decides where the mesh learns a content type. Move a workload to a different hub and every hub whose registry chained to the old one starts reading that content back as an untyped JsonElement: Content is T goes false, validators stop firing, views render empty. The tell is a pair of lines — Unregistered type … on hub A followed by Received '$type':'…' which is NOT registered in this (receiving) hub on some hub B.

When two hubs must be one serialization identity, share the registry rather than registering twice:

mesh.GetHostedHub(address, config => config
    .WithTypeRegistry(mesh.TypeRegistry)   // FIRST — before AddData()/WithType/WithHandler
    .AddData());

WithTypeRegistry carries anything the configuration already registered over to the shared registry (and never clobbers an entry the shared one already owns), so a call made out of order still cannot lose a registration. It is deliberately rare: the mesh's node-operation execution hub uses it because node CRUD serializes node Content on behalf of the whole mesh.


Common Gotchas


🚨 Reading a request timeout: it describes the hub that GAVE UP, not the one that stayed silent

TimeoutException: No response received in hub X within 00:01:00 for request Y → target Z is the most common banner in this system, and the most commonly misread. It is written by the waiter. The waiter cannot see the target at all — it can only report that nothing arrived, which is consistent with several different worlds.

The message used to end "The request may have been undeliverable or the target hub was not found". Two buckets, asserted as though they were the whole set. They are not, and the missing third is the one to rule out first:

world what actually happened
routing the target never received the request
the target is wedged it received it and stopped answering (a per-node hub that stops responding — #2896)
the reply was lost it answered and the response did not land
the CALLER is wedged the response arrived and this hub never processed it

The last one is invisible from the sentence and indistinguishable from the others. A single-threaded actor whose action block is busy, gated, or backed up looks — from inside — exactly like a peer that never replied. So the message now carries the waiter's own RunLevel and queue snapshot, and states the classification explicitly:

The production case that motivated it

2026-09-02: a document read failed with the old wording, naming cache/… as the waiter and a node path as the target. Both offered buckets were wrong. The node existed — version 53, edited the previous evening — and its hub resolved fine; exactly one per-node hub was wedged while every sibling answered instantly. The sentence sent the reader to "does this node exist?", the one question never in doubt.

The control arm is what settled it, and it is cheap enough to run every time:

point-read the failing path      -> times out (twice, different ids => deterministic, not transient)
point-read a SIBLING path        -> instant  => partition, store and routing are fine
list the namespace via the index -> healthy  => the node exists and is indexed

Two probes separate "the mesh is broken" from "one hub is wedged", and the second is the answer far more often.

🚨 recycle fixes it and destroys the evidence

recycle posts a DisposeRequest and forces re-initialisation. It cleared the wedge above with no data loss (all 53 versions intact) — and it also destroyed the only state that could have explained it. Capture before recycling when the situation allows: the owner's RunLevel, queue depth, and whether it sits in Starting with an open gate. Recycling first is the right call when someone is blocked on live data; just say so plainly rather than reporting a diagnosis you no longer have the evidence for.

The string coupling nobody can see

🚨 MeshNodeStreamCache.IsTransientOwnerFailure and AreaErrorClassifier.IsTransientHubFailure decide retryability by substring match on this message. "No response received in hub" is what keeps a hub timeout classified RETRYABLE. Change the wording without that phrase and every such read becomes terminal — silently: no compiler error, no exception, just reads that stop being retried and wait out their budgets. MessageService documents the same hazard from the other direction (a deleted-address NACK must NOT contain any of those markers, or a provable absence degrades back into "retry shortly"). Pinned by TimeoutMessageNamesTheCallersOwnStateTest.TheTimeoutMessage_KeepsTheMarkerThatClassifiesItAsTransient.

The Golden Rule

Run once. Grep the trace. Fix the root cause.

Don't rerun "to see if it still sticks" — it will, and you'll waste minutes per cycle. The trace tells you exactly which message went missing and why.

When you find the broken edge, leave the relevant LogTrace / LogDebug lines in place. They cost nothing at higher log levels and are the only way to debug the next analogous failure without re-instrumenting the code from scratch.


"Deadlock" that is really a missed observation — resurrection on init

A whole class of "hangs" are not locks. The signature in the MESHWEAVER_MSG_TRACE file (meshweaver-msg-trace.log under the temp dir — see above) is decisive:

To tell them apart, compute the max gap between handler-enters for the stuck node (grep "hub=<path> " trace | grep "HandleMessageAsync ENTER" → diff the timestamps). A big mid-work gap ⇒ lock. Continuous work then silence ⇒ missed observation. The volume asymmetry is another tell: a synced-query subscription re-emitting on every change shows far more GetDataResponse than GetDataRequest, and the count scales with load — slower round-trips ⇒ more iterations.

The root pattern

A long-lived operation (a thread round, a parent waiting on a delegated child, an activity) is driven by an in-memory observer — a Subscribe on a node stream, a TaskCompletionSource resolved by a callback. Two ways that observer silently dies and the operation parks forever:

  1. One-shot with a give-up. stream.Take(1).Timeout(15s).Subscribe(...) — if the loaded-state emission is dropped during the subscribe handshake (see the init-gate-drops-patches note) or merely arrives late under load, the Timeout fires onError, the recovery gives up, and never retries. The node stays non-terminal forever.
  2. Lost on reactivation. The observer lives only in the agent-loop / grain that set it up. When the grain deactivates and reactivates (Orleans) or the hub re-inits, the subscription is gone and is never rebuilt, so the child's eventual completion is never observed.

The fix — self-healing resurrection on init

Lifecycle recovery (ThreadExecution.InitializeThreadLifecycle, and the analogous activity init) must obey:

Don't "fix" this by bumping the test timeout — that hides a missed emission behind a longer wait. Find the observer that died and make it restart.


The subscribe handshake that delivers SubscribeAck but never the initial Full

A specific, high-value instance of the missed-observation class: a consumer GetMeshNodeStream / GetQuery / GetRemoteStream never emits for ~one heartbeat interval, then recovers. The fingerprint in the MESHWEAVER_MSG_TRACE file is unmistakable:

SubscribeRequest → HandleMessageAsync → SubscribeAck routed      (handshake acked)
   … ~45 s of dead silence on the owner node …
HeartBeatEvent HandleMessageAsync ENTER                          (the heartbeat fires)
RawJson HUB.DeliverMessage ENTER                                 (content FINALLY arrives)

The owner acknowledged the subscription but never sent the initial Full — the subscriber sits dark until the next SyncStreamOptions.HeartbeatInterval (45 s) re-emits. ~42 s between "subscribe" and "first data" ≈ one heartbeat is the tell.

Confirm it's idle, not a deadlock — dotnet-stack

Before assuming a lock, dump the managed stacks mid-freeze — it settles deadlock-vs-missed-observation in one shot:

# launch the test, then mid-freeze (the compile/source-read window).
# macOS/Linux — `tasklist` is Windows-only; use pgrep:
pid=$(pgrep -f testhost | head -1)
dotnet-stack report -p "$pid" > /tmp/stacks.txt
grep -vE "System\.|Microsoft\.|xunit|testhost" /tmp/stacks.txt | sort -u   # any APP frame?

dotnet-stack is a separate global tool (dotnet tool install -g dotnet-stack); it is not part of the SDK.

If every thread is parked on LowLevelLifoSemaphore.Wait / Task.Wait / WaitOne and there is no MeshWeaver frame on any thread, nothing is running — it's a missed reactive emission, not a blocking deadlock or CPU starvation. (8 cores + total trace silence + idle stacks ⇒ a dropped emission, never a hot loop.)

Two owner-side drops that cause it

  1. ChangedBy collapsed to empty → echo-filter false-positive. The owner forwards changes through reduced.ToDataChanged(c => !reduced.ClientId.Equals(c.ChangedBy)) to suppress echoing the subscriber's own writes back. ChangedBy is the stream-echo-suppression key — the identity of the originating stream, always the StreamId, never empty. It is NOT the access identity: deriving it from CaptureCallerAccessContext()?.ObjectId ?? ClientId collapses to "" when there is no ObjectId and ClientId is empty, and the filter becomes !"".Equals("") == false → the initial Full is dropped. Fix: ChangedBy = StreamId in SynchronizationStream.BuildChangeItem / BuildFullChangeItem. The AccessContext (RLS / LastModifiedBy) flows orthogonally and must never leak into ChangedBy.

  2. The echo-filter dropped FULLS. A ChangeType.Full is the owner's complete authoritative snapshot (initial subscribe state, or a re-assert/rollback) and is never the subscriber's echo — subscribers only ever send Patches via DataChangeRequest. So the owner-side filter must always forward Fulls: c => c.ChangeType == ChangeType.Full || !reduced.ClientId.Equals(c.ChangedBy). This is the echo filter (owner side) — it always forwards Fulls because a Full is never a subscriber echo. Do not confuse it with the client-side version guard in UpdateStream, which now drops both stale Patches and stale Fulls (Version < Current.Version). A real Full is never below Current because every owner frame rides one clock (OwnerVersion()); see DataSyncAndCrdt.md §2–3.

The heartbeat must NOT re-broadcast the Full

The 45 s heartbeat is a keepalive (it keeps the remote owner grain alive). It must not be the mechanism that re-delivers content — relying on it both (a) masks the dropped-initial-Full bug above behind a 45 s stall, and (b) would re-ship every stream's entire content every 45 s, which is lethal at scale. Fix the handshake so the initial Full lands on subscribe; keep the heartbeat content-free.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.