Skip to content

A message vanished from a live chat at end of streaming: a SubscribeRequest to a NotFound path never answers, so the view dies silently #1469

Closed

Description

@rbuergi

A user's assistant message disappeared from the live chat view at the end of streaming and came back on reload. Reported by the thread owner; reproduced from logs.

Durable state was correct the whole time. What failed is the live subscription.

The evidence (memex-cloud, 2026-08-14, thread felice.buergi/_Thread/schreib-mir-eine-geschichte-es-war-einma-1345)

07:05:39.775  Node created …/e4e1a36f by felice.buergi            ← her message
07:05:40.074  Node created …/927df3b1 by felice.buergi            ← response cell
07:05:40.152  [ROUTE] NotFound: No node found at 'felice.buergi/felice.buergi'.
              Closest ancestor is 'felice.buergi' (remainder='felice.buergi').
07:05:45.148  [ROUTE] NotFound: … same …
07:06:07.036  Node created …/_Notification/KKtZPD5W… by system-security   ← round COMPLETED
07:06:13.155  [STALE-CALLBACK] cache/WMkjfyG-vkahlapz7sfKYw: 1 callback(s) pending > 30000ms:
              hWtayyfN3Uq0u5B838dbcA=SubscribeRequest@felice.buergi/felice.buergi(33008ms)
07:06:18.155  … (38008ms)
07:06:23.154  … (43007ms)

The _Notification at 07:06:07 is EmitCompletionNotification, which only fires on a terminal state — so the answer was generated and durably written. Reload reads durable and shows it. The live view had no working feed.

Defect 1 (the generic one, and the reason the message vanished): a SubscribeRequest to a non-existent path NEVER ANSWERS

Routing knew at 07:05:40 — it logged NotFound and even resolved the closest ancestor. The requester was never told. The callback sat pending for 43 s and counting, re-warned every 5 s by [STALE-CALLBACK].

This is the never-answers class this repo has now fixed four times — #1362/#1364 (a handled GetDataRequest with no completion arm), #1360/#1409 (a re-ask routed back to the same dying hub), #1446/#1454 (a starved permission fold with no terminal), #1440/#1450 (FollowGo awaiting an undeliverable event). The (33008ms) shape is literally #1446's signature (Executing(CreateNodeRequest, 33014ms)).

Any live view whose subscribe targets a missing path is therefore silently dead until reload — this is not specific to chat, and the user-visible symptom (content vanishing mid-stream) gives no clue as to the cause.

🚨 Do not fix by adding a timeout. Routing already has the answer at the moment it logs NotFound; the fix is to deliver it. A timeout would convert a hang into a slow failure and still lose the view.

Defect 2 (the trigger): a doubled path felice.buergi/felice.buergi

Closest ancestor is 'felice.buergi' means the partition root exists and something asked for a child named after the user, inside that user's own partition — i.e. {partition}/{userId} where the two are the same string.

Scope: 3 occurrences in 20 h, only this user. Other NotFounds in the same window are the unrelated Admin/Menu/* set. So it is not universal — establish what is different about this account (a newer user? a home node never created? an id containing a dot?) before assuming a general rule.

This is the same shape as the path-doubling that #1459 removed from node files (MeshNode.Path => $"{Namespace}/{Id}"), but that was declared data; this is a runtime construction, so it is a distinct site.

Why this is worth prioritising

The two defects compose badly. Defect 2 is rare; defect 1 turns any occurrence of it into silent data loss from the user's point of view — the answer they watched being written vanishes, with no error, no toast, and no log the user can see. The only recovery they can discover is reloading.

Fix defect 1 first: it bounds the blast radius of every future missing-path subscribe, whatever the cause.

Activity

  1. rbuergi commented on Aug 14, 2026

    @rbuergi
    ContributorAuthor

    Defect 2 (the trigger) — found, fixed, PR #1482

    MeshOperations.ResolveContextPath (src/MeshWeaver.AI/MeshOperations.cs) decides
    relative-vs-absolute by looking for a /: a token without one is treated as relative to the
    chat context. A single-segment mesh path is by definition a partition root, so when the chat is
    anchored to one — a user's own home page — and that same path comes back through the resolver, it is
    placed underneath itself.

    The chain, each hop verified: ThreadChatView writes the context chip into attachments and ships
    it unfiltered → ThreadExecution sets AgentContext.Context and SetAttachments →
    AgentChatClient calls meshPlugin.Get("@felice.buergi") → MeshPlugin.Get →
    ResolveContextPath returns @felice.buergi/felice.buergi → MeshOperations.FetchNode →
    GetMeshNodeStream("felice.buergi/felice.buergi") → SubscribeRequest → RoutingGrain produces
    the exact production string.

    Scope matches: a user anchored on a sub-node (ACME/Doc/…) takes the existing "multi-segment ⇒
    already absolute" exit and never doubles, so only home-page chatters hit it — once per submitted
    round, not per render. The dot in the id is not load-bearing; rbuergi doubles identically. It
    is silent because AgentChatClient skips content starting with "Not found".

    Fix: the self-prefix guard PathUtils.ResolveRelativePath already carries, matched at a segment
    boundary. Fail-before demonstrated.

    Defect 1 — the generic claim did NOT reproduce

    A SubscribeRequest for a path that does not exist never answers.

    Measured against origin/main, subscribing to a missing path and taking the first notification:

    harness sender shape result
    Monolith cache hub (GetMeshNodeStream) ancestor exists, remainder non-empty OnError 30 ms
    Monolith cache hub no ancestor at all OnError 21 ms
    Orleans, 1 silo client hub (layout-area stream) ancestor exists OnError 63 ms
    Orleans, 1 silo client-side cache hub ancestor exists OnError
    Orleans, 1 silo silo-side cache hub — OnError 126 ms
    Orleans, 2 silos client hub × 6 iterations — OnError 170 ms then cached

    Every one: DeliveryFailureException: No node found at '…'. Closest ancestor is '…' (remainder='…'). So a missing-path subscribe is not silently dead as a rule, and there is no
    failing test to write for the claim as stated. It also means the consumer side already distinguishes
    the three states the issue asks about: AreaErrorClassifier / MeshNodeStreamCache.IsMissingNodeFailure
    treat "No node found" as a terminal, negative-cached, expected-user-action failure and render it,
    while a transient reject stays out of the negative cache.

    The two real gaps in that path, found by reading it

    Both are in RoutingGrain and neither is reachable by a clean-cluster test, which is why they
    survived:

    1. RoutingGrain.PostFailure is the only inbound path to a co-hosted hub that requires the
      Orleans memory-stream subscription.
      Every other same-process delivery to cache/… short-circuits
      on OrleansRoutingService.DeliverMessage's local route table (OrleansRoutingService.cs:162-166)
      and never touches a stream; PostFailure (RoutingGrain.cs:424) writes straight to
      streamProvider.GetStream(delivery.Sender.ToString()). A publish to a stream with no live
      subscriber succeeds
      , so "delivered" and "discarded" are indistinguishable — and unlike the
      forward leg (PostToStream, bounded at 60 s and NACK-on-failure) this post has no timeout at
      all
      : if OnNextAsync never completes, the ContinueWith never runs and nothing is logged
      anywhere. SubscribeWhenStreamingReadyAsync can also give up (returns null after a
      LogCritical, :614-626) while the local route stays live — the code's own comment says
      "in-process routing remains active", which is true for everything except the NACK.
      Compare RoutingServiceBase.PostNotFound and OrleansRoutingService.SendDeliveryFailure: both
      post through the mesh hub, and the latter returns bool and warns when it could not.

    2. The answer-once guards on the routed path are dead code. The mesh hub Package()s the
      delivery before handing it to IRoutingService (MeshBuilder.cs:229-237), so by the time it
      reaches RoutingGrain.PostFailure (:415-417) and OrleansRoutingService.MayAnswer (:380-382)
      delivery.Message is always RawJson — is DeliveryFailure and
      HasAttribute<CanBeIgnoredAttribute>() can never match. Confirmed empirically: the monolith logs
      RouteMessage: NotFound for RawJson → TestData/TestData. So the router NACKs heartbeats — exactly
      the storm JsonSynchronizationStream's comment says routing prevents ("an undeliverable heartbeat
      is [CanBeIgnored], so routing DROPS it without a NACK") — and can NACK a NACK.
      Package already stamps IDiagnosticKeyed.DeliveryProperty onto the envelope for precisely this
      reason (carrying identity across the type erasure); the payload's type name needs the same
      treatment.

    Neither is shipped here: (1) cannot be proven to be what happened without evidence from the pod, and
    guessing at a transport rewrite is the wrong trade; (2) is a real, separately-testable defect but a
    different root from this issue.

    What would settle the incident in one look

    The [STALE-CALLBACK] line is truncated in the report at (33008ms). It is followed by a
    handler-side fate block (MessageHub.ScanStaleCallbacks → FormatPendingCallbackFates):

    • a stage RESPONSE_ARRIVED_NO_SUBJECT type=DeliveryFailure ⇒ the NACK arrived but landed on the
      wrong hub (correlation problem);
    • a trail ending at POSTED target=felice.buergi/felice.buergi with nothing after ⇒ the NACK never
      reached any hub in the process ⇒ gap (1).

    Then, on the same pod: stream subscription could not be attached for cache/ ·
    Failed to deliver NotFound failure to sender · reported a delivery error — frames may have been lost · Route dispatch faulted.

    One correction to the timeline reading

    The two NotFounds are not a repeat. SyncStreamOptions.FirstHeartbeat is 5 s, and the second
    line lands at 07:05:45.148 — 5.001 s after the SubscribeRequest was posted (07:05:40.147,
    back-computed from the 33 008 ms age). So it is the sync stream's first heartbeat, and the
    heartbeat firing at all proves the subscribe's OnError had not fired by then (keepAlive is
    disposed on a terminal NotFound). After that the cadence is 45 s, which is why there is no third line
    inside the window.

  2. rbuergi commented on Aug 14, 2026

    @rbuergi
    ContributorAuthor

    The two gaps found by reading the routing failure path are now filed with their own reproductions and acceptance criteria, so they do not get closed along with this issue:

    Also recording the correction to this issue's own title, since it shaped the investigation: a missing-path subscribe is answered, terminally and fast — 21–170 ms across Monolith, 1-silo and 2-silo Orleans, from client / silo-side cache / client-side cache senders, in both the no-ancestor and ancestor-exists shapes. Consumers already distinguish the three states (MeshNodeStreamCache.IsMissingNodeFailure, AreaErrorClassifier). The title's claim is false; the real trigger was the context-path doubling fixed in #1482.

  3. added a commit that references this issue on Aug 14, 2026
  4. rbuergi commented on Aug 14, 2026

    @rbuergi
    ContributorAuthor

    Fixed by #1482 (merged c694ca358).

    What actually happened, since this issue's title describes a mechanism that does not exist:

    MeshOperations.ResolveContextPath decided relative-vs-absolute by looking for a /. A single-segment mesh path is a partition root, so a user chatting from their own home page had context == attachment == {userId}, and the prepend produced {userId}/{userId} — a node that cannot exist. The failed read was then discarded silently (AgentChatClient skips attachment content starting with "Not found"), so the round continued without the content it had asked for.

    The distinguishing factor was the single-segment context, not the dot in the username. Anyone anchored on a sub-node takes the multi-segment ⇒ already-absolute exit and never doubled. It fires once per submitted round, which is why it was 3 occurrences in 20 h on one account rather than constant — it reads like a rare race and is not one.

    Correction to the title, recorded so nobody re-investigates it: a SubscribeRequest to a NotFound path DOES answer — terminally, in 21–170 ms, measured across Monolith, Orleans 1-silo and 2-silo, from client / silo-side cache / client-side cache senders, in both the no-ancestor and ancestor-exists-with-remainder shapes. Always DeliveryFailureException … ErrorType.NotFound. Consumers already distinguish the three states (MeshNodeStreamCache.IsMissingNodeFailure, AreaErrorClassifier). Related trap: a second NotFound ~5 s after a subscribe is SyncStreamOptions.FirstHeartbeat, and its firing proves the subscribe's OnError had not fired, since a terminal NotFound disposes keepAlive.

    Durable state was correct throughout — the _Notification satellite shows the round completed, which is why a reload brought the message back.

    The two genuine routing defects found while investigating this are filed separately and are not closed by this: #1485 (both routers' answer-once guards are dead code after Package() erases the payload type) and #1486 (PostFailure is unbounded and uniquely dependent on the Orleans stream subscription).

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