Repository navigation
fix(#2641): a re-subscribe must declare the same wire capabilities as the first, and 'Frame loss detected' is a resync counter - #2744
Conversation
… the first (#2654 owns the residual) AcceptsStringSplice — the one negotiated capability of the owner→subscriber fan-out (#1284/#1414) — was set at exactly ONE of the three sites that post a SubscribeRequest: the initial subscribe. Resubscribe (owner recycle / StreamEndedEvent) and SynchronizationStream.RequestFreshSnapshot (the frame-loss resync) both omitted it. When the owner cannot match a re-subscribe to a live server-side stream it BUILDS a fresh one and reads the capability off THAT request — which is exactly what an eviction on the router's TargetUnserved verdict (#2620), an owner recycle or a torn-down sync hub produces. From that cycle on, the owner fans out whole-string `replace` frames to a mirror that understands splices: the per-subscriber quadratic #1284/#1414 removed, reinstated silently and permanently, on precisely the paths that fire during a resync storm. Nothing fails — a replace is a shape every subscriber understands — so no assertion on the FRAMES could catch it; the only place it is visible is the request. Fixed at the root, not with a third literal: JsonSynchronizationStream.MintSubscribeRequest is now the one place a C# subscriber mints a SubscribeRequest, because a capability is a property of the applier this assembly ships, not of the moment one post was written. Nothing else changes — the resync still posts with no identity and the same Subscriber, the recycle re-ask still with SystemUserId. ResubscribeKeepsNegotiatedCapabilitiesTest drives the real resync (the deterministic one-frame wire loss StreamFrameLossResyncTest already uses) and asserts every posted SubscribeRequest declares the capability. Verified RED on the unfixed tree, GREEN with the fix; MeshWeaver.Data.Test 394/394. Docs: DataSyncAndCrdt §6 now documents the BasedOnVersion chain and states how to read `[SYNC_STREAM] Frame loss detected` — a RESYNC counter, not a data-loss counter. The raw total diagnoses nothing; the per-stream count and whether a Full follows do, and the driver is almost always upstream (a latched stream attach #2633/#2692 → #2645, the #2620 eviction cycle, an owner recycle). That is the recorded disposition of #2641's 847-lines-in-30-min. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Ensures synchronization stream re-subscriptions keep the same negotiated wire capabilities as the initial subscription (specifically SubscribeRequest.AcceptsStringSplice), preventing silent fallback to heavier whole-string replace frames after resync/reconnect cycles, and documents how to interpret “Frame loss detected” log lines.
Changes:
- Centralizes
SubscribeRequestcreation viaJsonSynchronizationStream.MintSubscribeRequest(...)and routes all subscribe / resubscribe paths through it. - Adds a regression test that deterministically triggers a frame-loss resync and asserts every subscribe request declares
AcceptsStringSplice. - Adds two What’s New entries and expands the Data Sync/CRDT architecture documentation to clarify that “Frame loss detected” is a recovery/resync counter.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Data.Test/ResubscribeKeepsNegotiatedCapabilitiesTest.cs | New regression test asserting re-subscribe retains negotiated capability after deterministic frame loss. |
| src/MeshWeaver.Data/Serialization/JsonSynchronizationStream.cs | Introduces MintSubscribeRequest and refactors subscribe/resubscribe posts to use the centralized factory. |
| src/MeshWeaver.Data/Serialization/SynchronizationStream.cs | Updates frame-loss resync path (RequestFreshSnapshot) to mint subscribe request via the shared factory. |
| src/MeshWeaver.Documentation/Data/Architecture/DataSyncAndCrdt.md | Documents the BasedOnVersion chain and how to read “Frame loss detected” logs as recovery signals. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-reconnecting-no-longer-makes-streaming-heavier.md | What’s New (Fix) entry describing the user-visible improvement from retaining splice capability on reconnection/resync. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-frame-loss-in-the-log-is-a-resync-counter.md | What’s New entry pointing readers to the updated doc guidance for interpreting frame-loss logs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 3)707 tests 515 ✅ 4m 54s ⏱️ Results for commit a91bdf7. |
Test Results (shard 2)970 tests 970 ✅ 5m 41s ⏱️ Results for commit a91bdf7. |
Test Results (shard 1)793 tests 789 ✅ 6m 48s ⏱️ Results for commit a91bdf7. |
Test Results (shard 0)917 tests 917 ✅ 5m 34s ⏱️ Results for commit a91bdf7. |
Test Results (shard 4) 10 files 10 suites 5m 32s ⏱️ Results for commit a91bdf7. |
Test Results (shard 5)1 534 tests 1 534 ✅ 6m 41s ⏱️ Results for commit a91bdf7. |
Test Results 41 files 41 suites 35m 12s ⏱️ Results for commit a91bdf7. |
Settles the open half of #2641 and fixes the one real defect that reading its resync path turned up.
The defect: a re-subscribe silently withdrew a negotiated wire capability
SubscribeRequest.AcceptsStringSpliceis the one negotiated capability of the owner→subscriberfan-out (#1284 / #1414): a changed string leaf travels as the changed span instead of the whole new
string, but only for a subscriber that says it can apply one.
grep -n AcceptsStringSplice src/found
= trueat one of the three sites that post aSubscribeRequest:CreateExternalClient— the initial subscribeResubscribe— owner recycle /StreamEndedEventre-askSynchronizationStream.RequestFreshSnapshot— the frame-loss resyncWhen the owner cannot match a re-subscribe to a live server-side stream it builds a fresh one and
reads the capability off that request (
GetClientSubscription→ miss → newreduced+ToDataChanged(…, request.AcceptsStringSplice)). A miss is not exotic — it is exactly what aneviction on the router's
TargetUnservedverdict (#2620), an owner recycle, or a torn-downsync/hub produces. So from the first such cycle the owner fans out whole-string
replaceframes to amirror that understands splices: the per-subscriber quadratic #1284/#1414 removed, reinstated
permanently and silently, on precisely the paths that fire during a resync storm.
Nothing fails — a
replaceis a shape every subscriber understands — so no assertion on theframes could have caught it. The only place it is observable is the request.
Fixed at the root, not with a third literal.
JsonSynchronizationStream.MintSubscribeRequestisnow the one place a C# subscriber mints a
SubscribeRequest: a capability is a property of theapplier this assembly ships, not of the moment a particular post was written, so declaring it once
means there is no second site to forget. The three posts go through it; nothing else changes (the
resync still posts with
identity: nulland the sameSubscriber, the recycle re-ask still withSystemUserId).ResubscribeKeepsNegotiatedCapabilitiesTestdrives the real resync — the deterministic one-framewire loss
StreamFrameLossResyncTestalready uses — and asserts everySubscribeRequestthesubscriber posts carries the capability. Verified failing on the unfixed tree and passing on the
fixed one:
The documentation: how to read
Frame loss detected#2641 reported 847 of those lines in 30 minutes as data loss. They are not. Every line is a gap that
was detected and answered — that is what the
BasedOnVersionchain exists for — so the raw countdiagnoses nothing on its own. Data Synchronization & CRDT §6 now
documents the chain and states the two numbers that do mean something (the per-stream count, and
whether a Full follows), plus the three log lines to correlate it with, because the driver is
almost always upstream: a latched stream attach (#2633 / #2692, fixed by #2645), the eviction cycle
(#2620), an owner recycle.
That matches the reading the merged design page already takes (#2724): the version chain is the
durable stream.
Issue disposition
#2641 is closed with a per-symptom table on the issue. In short: the
UserIdentityCachehalf wasfixed by #2723; the frame-loss half is a duplicate of #2654, which owns the residual "does the
resync converge" question with per-stream counts and a user-visible surface; the
no live subscriberClientSubscriptionEvictionlines are End the dead-subscriber delivery storms: the owner evicts on the router's TargetUnserved verdict (#2426, #2546) #2620 working (verified an ancestor of the reported image,and the logger name exists only in that commit); the 33
Circuit connection DOWNare SignalRtransport blips, not circuit churn, which falsifies an earlier comment of mine on that issue.
Verification
dotnet build src/MeshWeaver.Data/MeshWeaver.Data.csproj -c Release -warnaserror→0 Warning(s), 0 Error(s)dotnet build test/MeshWeaver.Data.Test/… -c Release -warnaserror→0 Warning(s), 0 Error(s)dotnet build test/MeshWeaver.Documentation.Test/… -c Release -warnaserror→0 Warning(s), 0 Error(s)MeshWeaver.Data.Testfull project, Release → see belowWhatsNewEntryIntegrityTest→ passed🤖 Generated with Claude Code