refactor!: debloat streaming, repo and core wire primitives #182
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!182
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "debloat-a-streaming"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #180 (pass A: streaming, repo and core wire primitives).
Fewer lines and one way to do each thing in the streaming, repository and wire code, with the same behaviour except where an owner decision removes something. The existing tests are the safety net. Every regression, security and interop test still passes, retargeted where its API moved.
What changed
Streaming (S1, O1, owner decisions)
EventStreamLoopno longer knows about frames. A handler reads raw socket messages, andCborEventStreamHandler<T>reads the{op, t}DAG-CBOR header for the XRPC streams (firehose, labels, chat moderation).JetstreamConsumer(its own copied reconnect loop) andTapChannel.ReadAllAsync(another copy) now run on it.TapChannelkeeps its acknowledgements by adapting its duplex connector to the loop's connector seam.CursorTracker.StartAsyncreplaces the four copies of load, log,Startand flush in the firehose, label, Jetstream and replay consumers. The tracker isIAsyncDisposable, soawait usingreplaces thefinally { FlushAsync }blocks.CborEventStreamHandler.Endpoint(serviceUrl, nsid, cursor)builds the endpoint for the firehose, label and chat moderation streams.FirehoseClientandJetstreamClientare removed. The consumers cover them:Reconnect.MaxAttempts = 0reads a single connection, and noCursorStoremeans no persistence.FirehoseVerifieris removed, withVerificationResultandTypedFirehoseConsumerOptions.Verifier.VerifyCidskeeps the local CID check, now inside the consumer.SyncVerifieris the signature path.StreamConsumerOptionskeepsServiceUrl,Reconnect,Logger,OnStreamErrorandOnEventDropped. The newCursorStreamConsumerOptionsaddsCursorStore,StreamIdandCursorPersistInterval.ChatModerationEventConsumerOptionsandTapClientOptionsderive from the base: their own copies of the shared settings go,TapClientOptions.ServiceUrlbecomes a string like every other stream's, and Tap'sOnErrorbecomes the sharedOnEventDropped.StreamConsumerOptions: at-least-once delivery, the cursor interval, cancellation and error frames. The six consumer classes,StreamReconnectPolicy,IStreamCursorStoreandEventStreamExceptionpoint to it with<see cref>.docs/firehose.mdhas one "Delivery, cursors and errors" section, whichjetstream.mdandtap.mdlink to.PLC export stream (P1)
PlcClient.StreamExportAsyncruns onStreamSocket. To support that,StreamSocketOptionsgains anHttpMessageInvoker(so the identity address policy still applies) and a per-connection message limit.StreamSocketalso sends the SDK'sUser-Agent, which the PLC client already did.OutdatedCursor, is now anEventStreamExceptionwhoseErroris the reason.PlcExportStreamExceptionis removed, and the test seam is aStreamConnector.Repository and wire primitives (R1, M1, C1, D1, W1)
CommitBlock.Readsplicessigout in the same strict pass that reads the fields, refuses anything but version 3, and keeps the duplicate-key refusal of the old strict splice.CommitBlock.FromCar(car, out reader, expectedRoot)is the one "parse the CAR, find the root commit" preamble forRepoSyncVerifier,RepoSnapshotandRecordProof.FirehoseVerifier.ExtractSignedView/MapHeaderLength/WriteMapHeaderandRecordProof.ReadCommitare gone. A 20-lineCommitBlock.WriteMapHeaderkeeps the pinned boundary vectors.MstNodeData.ReadKeys(lower, upper)rebuilds a node's keys and checks them (valid, strictly increasing, inside the parent's range) forMerkleSearchTree,PartialMerkleSearchTreeandRecordProof.MerkleSearchTree.Deserializechecks ranges now, not an in-order last key. That is equivalent: every subtree is non-empty, so a subtree linked twice still fails.MstNodeData.WriteStart,WriteEntryandWriteEndare the one encoding of a node.ToBytesand the partial tree'sEncodeboth use them, and the partial tree keeps its allocation-free encode. I wrote three steps rather than an instanceWrite(CborWriter)for that reason.IsValidKey<T>serves strings and node bytes.DagCborDecoder.IsMalformedreplaces six of the seven copiedCborContentExceptioncatch lists. The seventh is inSpaces/: see Deferred.Base32Lower.TryDecodeis the one canonical base32 decoder.CidandCidComputation.DecodeCidStringboth use it, so the latter now refuses non-canonical text too. The internalCid.FromBytesreplaces nineCid.Parse(EncodeCidToString(x))round trips, andDagCborEncoderdecodes$bytesthroughLexBase64.DagCborDecoder.TryValidateis removed.Decodealready covers the 100k-nesting, float and malformed-input cases.AtProtoHttp.WithScheme(uri, webSocket)replaces the five ws↔http converters: the Jetstream endpoint, the archive client, the resync upstream, the Tap channel and the PLC stream.Options (C-Q4, C-Q5)
RepoResyncOptionskeeps onlyFetchers.UpstreamandAllowPrivateNetworksgo: passIRepoFetchers.FetchTimeoutgoes. The fetchers bound their own downloads, and the coordinator keeps the same five-minute per-attempt cap internally.JetstreamReplayConsumerhas one way to configure it. The(options, archiveClient)constructor goes, so the archive client is always built fromJetstreamArchiveOptions.JetstreamArchiveOptions.AfterSeqalso goes, becauseReplayAsync(afterSeq)already takes the resume position. TheAfterSeq < BeforeSeqcheck now runs inReplayAsyncand covers a stored cursor too.Tests (T2, T4 firehose part, loop collapse)
EventStreamLoopTestsholds the loop contract, run through the firehose consumer:FutureCursor) throw at once;The duplicates in the Jetstream (v1/v2), Tap and firehose files go. The consumer-specific ones stay: cursor semantics, the Jetstream exception type, info and error frames, Tap acknowledgements, the chat token.
JetstreamEndpointTests: protocol theories for paths, filters and compression, and one validation theory that now also pins each message.FirehoseVerifierTestsbecomesCommitBlockTests: the splice, the header-boundary vectors, and a malformed-commit theory that adds version 2 and duplicate keys. The null, empty and malformed blocks cases become aVerifyCidsconsumer theory. TheExtractSignedViewinterop checks inRepoCommitTests,RepoWritePathTestsandRecordProofTestsnow useCommitBlock.Read.FirehoseVerifierIdentityTestsgoes with the verifier.RepoSyncVerifierTestsalready pins the same refetch-once and invalidate rules: refreshes, rotated keys and invalidations.ScriptedJetstreamandJetstreamFrames, so the parser path is covered too.StreamSocket.Breaking changes (each has a CHANGELOG bullet and a migration-guide entry)
FirehoseClient(withSubscribeLabelsAsync),JetstreamClientTypedFirehoseConsumer/LabelStreamConsumer/JetstreamConsumer.ConsumeAsync(cursor); for a single connection, setReconnect = new StreamReconnectPolicy { MaxAttempts = 0 }FirehoseVerifier,VerificationResult,TypedFirehoseConsumerOptions.VerifierSyncVerifier = new RepoSyncVerifier()(in-memory state by default), plusResync;VerifyCidsfor CIDs aloneDagCborDecoder.TryValidateDagCborDecoder.Decodeand catchFormatException; key order is no longer checkedJetstreamReplayConsumer(options, archiveClient),JetstreamArchiveOptions.AfterSeqJetstreamArchiveOptions;ReplayAsync(afterSeq)StreamConsumerOptions.CursorStore/StreamId/CursorPersistIntervalmoved toCursorStreamConsumerOptionsThese are new in 0.7 and never shipped, so their Unreleased Added bullets are amended instead of getting breaking bullets:
PlcExportStreamException:StreamExportAsyncnow throwsEventStreamExceptionwithErrorset to the close reason, and a connect failure is anEventStreamExceptioninstead ofDidResolutionException.TapClientOptions.OnError/Uri ServiceUrl.RepoResyncOptionsknobs.Existing Unreleased bullets and docs that named the removed types were brought up to date.
PublicAPI.Shipped.txt: 67 lines removed, and the eightCursorStreamConsumerOptionslines added.Unshipped.txtis empty.Behaviour changes worth knowing
EventStreamExceptionwhoseErroris the reason. Before, it was a silent close. A retryable reason still reconnects; aFutureCursorclose is no longer retried forever.CidComputation.DecodeCidStringandTryDecodeCidStringrefuse upper case, padding and non-canonical trailing bits. These have a Changed bullet.Verification
dotnet build: 0 warnings, 0 errors (full solution, including doc snippets, samples and integration tests).Unit tests (before the review fixes): 5027 passed, 0 failed, 5 skipped, run twice. The baseline on
origin/mainwas 5059 / 0 / 5; the difference is the collapsed duplicates listed above.Performance: a Stopwatch harness, Release build, a scripted connector with no I/O, and interleaved A/B runs of
origin/mainagainst this branch. The machine is shared: 12 threads at a load average of about 6 from other agents, so round-to-round noise is ±5%. The table shows the best of three runs of 31 rounds each. Allocations are per run.#commitframes, parseAllocations are unchanged on every path, and the Sync 1.1 path is about 1% faster now that the commit block is read in one pass. The loop paths measure 1–2% slower, inside the noise. That fits the one or two extra virtual calls per frame that sharing the loop costs. The Jetstream archive decode (
JetstreamSegmentReader) is untouched.Line counts (
loc.py origin/main HEAD)src/ATProtoNet/Streamingsrc/ATProtoNet/Reposrc/ATProtoNet/Identitysrc/ATProtoNet/Tapsrc/ATProtoNet/Httptests/ATProtoNet.TestsdocsPublicAPI.*.txtReview fixes
Six new commits on top of
967439a. Each code fix has a regression test that fails without it; I checked by running the test against the committed source.A finished bounded replay can run again (
848d897).BeforeSeqis inclusive, so a completed snapshot leaves its store at exactlyBeforeSeq, and a second run threw. A start at or aboveBeforeSeq, explicit or stored, now ends the replay with nothing to read, which ismain's result. It also no longer spends a metered plan request on an empty window. Test:BoundedSnapshot_RunAgainWithItsStore_EndsWithNothingToRead.A frame that does not decompress fails the connection (
330a2cd), as onmain. Decompression moved out of the handler into the consumer's connector, which is where the removedJetstreamClientdid it. I chose this over skipping the frame:Test:
ConsumeAsync_FrameThatDoesNotDecompress_FailsTheConnectionAndReconnects.Commit blocks check their links and refuse trailing bytes (
7fa09b5). This flaw is also onmain, so it gets a Security CHANGELOG bullet.data, andprevwhen it is not null, must be valid CIDs. Before, a commit signed with the repository's own key and carrying a malformeddatamadeCid.FromBytesthrow out ofTypedFirehoseConsumer.Tests: three new malformed rows in
CommitBlockTests, a nullprevaccepted, a validprevkept in the signed bytes, andVerifySyncAsync_SignedCommitWhoseDataIsNotACid_IsInvalidRatherThanThrowing.Jetstream cursor coverage restored (
d6cadc5):YieldsEventsFromSource.Single-connection wording (
676810c). WithMaxAttempts = 0the end of the connection throws; the CHANGELOG bullet,jetstream.mdandStreamReconnectPolicy.MaxAttemptsnow say so.DagCborDecoder.IsMalformedno longer matchesArgumentNullException(9fb842e), with a comment on whyArgumentExceptioncounts as malformed input in those parse-only blocks. Test:IsMalformed_CountsWhatTheBytesCauseAndNotABug.After the fixes, the full solution builds with 0 warnings. Unit tests: 5039 passed, 0 failed, 5 skipped, run twice. The benchmark paths are unaffected: fix 2 adds an iterator layer only when a
Decompressoris set.Noticed, not changed:
RepoSnapshot.RecordscallsCid.FromByteson MST entry values, which the tree reader does not validate. A snapshot whose tree holds a value that is not a CID therefore throwsArgumentExceptionwhile the caller enumeratesRecords.mainbehaves the same way (Cid.Parse(EncodeCidToString(...))), and the consumer never enumerates it, so this is outside Pass A. The fix is to check entry values inMstNodeData.Deferred or flagged
src/ATProtoNet/Lexicon/Com/AtProto/Label/LabelModels.cs(pass C's area) namedFirehoseClient.SubscribeLabelsAsync. It now namesLabelStreamConsumer.SpaceRecordCbor's catch list (pass B's area) can useDagCborDecoder.IsMalformedtoo. I did not touch it.RepoDownload.CreateClientand the fetchers' HTTP clients (N1), and the verify-with-refresh helper (V1), whichRepoSyncVerifierstill copies.///docs on internal types.🤖 Generated with Claude Code
- EventStreamLoop is frame-agnostic: a handler reads raw socket messages, and a CBOR handler base reads the {op, t} header for the XRPC streams. The Jetstream consumer and the Tap channel now run on it instead of their own reconnect loops, and CursorTracker.StartAsync replaces four copies of the load/start/flush preamble. - FirehoseClient and JetstreamClient are removed (owner decision): the consumers with Reconnect.MaxAttempts = 0 read a single connection. - StreamConsumerOptions keeps ServiceUrl, Reconnect, Logger and the two hooks; CursorStreamConsumerOptions adds the cursor settings. The chat moderation and Tap options derive from the base (Tap's OnError is OnEventDropped). The stream contract is documented once, on StreamConsumerOptions. - PlcClient.StreamExportAsync runs on StreamSocket, which gains an invoker, a per-connection message limit, the SDK User-Agent and close reasons; PlcExportStreamException gives way to EventStreamException. - AtProtoHttp.WithScheme serves the Jetstream, Tap and PLC endpoints too. - RepoResyncOptions keeps Fetchers; its limits are internal, and Upstream, AllowPrivateNetworks and FetchTimeout go (the fetchers own their clients). - JetstreamReplayConsumer is configured by its options alone: the (options, archiveClient) constructor and JetstreamArchiveOptions.AfterSeq go. - Tests: generic reconnect tests collapse into EventStreamLoopTests; the two Jetstream URL builder files merge into protocol theories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-27T17:58:44.877Z) — nothing to re-trigger.
@claude /review — debloat pass A of #180 (streaming, repo, core wire primitives). The diff will be truncated; please read in full from the branch:
Streaming/EventStreamLoop.csplus the new CBOR-header base handler, and the Jetstream consumer andTapChannelported onto the loop;CursorTracker.StartAsync;StreamConsumerOptions/CursorStreamConsumerOptionssplit;Repo/CommitBlock.cs, which now splices outsigin one strict pass. This is the signed-bytes input for commit signature checks, so it must be byte-exact against the pinned vectors.CommitBlock.FromCar,MstNodeData.ReadKeysand the writer steps;Cid.FromBytesand the base32 decoder;PlcClientonStreamSocket.What to look for:
FutureCursorclose is no longer retried, a PLC connection failure now throwsEventStreamException, andDecodeCidStringis now strict. Are these acceptable?🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
💬 Verdict: comment — notes only, nothing blocking.
This is a very large, well-documented mechanical refactor (streaming loop unification, CommitBlock/base32/DagCbor primitive consolidation), and the diff supplied is truncated partway through the repo/wire section, so this review only covers what's visible: Http/AtProtoHttp, Identity/Cid, Identity/PlcClient, Repo/CarReader, Repo/CidComputation, Repo/CommitBlock, Repo/DagCborDecoder, Repo/DagCborEncoder, Repo/MerkleSearchTree (partial), the docs and CHANGELOG/PublicAPI files, and the LabelModels cref fix.
Within that scope the change is careful and internally consistent with its own description:
AtProtoHttp.WithSchemecorrectly folds five separate ws/http converters into one, preserving scheme/port semantics.Base32Lower.TryDecode's newbits < 5check is a genuine correctness improvement over the code it replaces (the removed inline decoder inCid.csrelied on the caller having already fixed the string length; the shared decoder now independently rejects non-minimal-length/non-canonical base32, which is required since it's now used more generally byCidComputation.DecodeCidString).CborReaderinStrictconformance mode does enforce byte-level map-key uniqueness (confirmed by a related dotnet/aspnetcore issue about semantically duplicate keys with different encodings slipping through bytewise checks), soCommitBlock.Read's claim of "keeps the duplicate-key refusal of the old strict splice" holds for the common case, with the same known edge-case caveat (differently-encoded-but-equal keys) that presumably existed before too.CommitBlock.Read's manual re-splice of the map header (WriteMapHeader) plus the byte ranges aroundsiglooks correct: it copies bytes beforesig's key and everything aftersig's value, and rewrites only the map header forentries - 1, preserving byte-for-byte signed content.PlcClient.StreamExportAsync's move to the sharedStreamSocket/Connectorloop reads correctly, matches the documented breaking behaviour (a close-with-reason and even a connect failure now surface asEventStreamExceptioninstead ofPlcExportStreamException/DidResolutionException), and CHANGELOG/migration-guide/docs entries for that are present and consistent.One mild concern, not blocking:
DagCborDecoder.IsMalformednow also treats bareArgumentExceptionas "malformed input" (see below), which is used at several call sites (CarReader,CommitBlock,DagCborDecoder.Decodeitself) to catch-and-rewrap asFormatException.ArgumentOutOfRangeException/ArgumentNullExceptionare both subtypes ofArgumentException, so this could occasionally swallow a genuine internal bug (e.g. a bad slice length from a real coding error) and report it to callers as bad/malformed network or CAR data instead of surfacing as an unhandled exception during development/testing. Given the surrounding readers are all operating on attacker-controlled bytes this is probably intentional and low-risk, but worth a second look since it's a behavior-widening change stacked onto several call sites at once.I could not review the remaining ~60% of the diff (MstNodeData.ReadKeys internals, RepoSyncVerifier, JetstreamReplayConsumer/JetstreamArchiveOptions, RepoResyncOptions defaults, the EventStreamLoop/CborEventStreamHandler consolidation, and the test changes) because the diff was cut off; nothing in the visible portion points to a problem there, but that part of the PR is unverified by this pass.
⏱ 2m 23s · 4 turns · tokens: 94.5k in / 12.2k out (+353.6k cache-read)
📜 Full session transcript
@ -159,0 +45,4 @@/// What <see cref="CborReader"/>, and the readers built on it, throw for input that is not/// well-formed or not the shape being read: never a bug in the caller./// </summary>internal static bool IsMalformed(Exception ex) =>IsMalformednow also matches plainArgumentException(and thereforeArgumentOutOfRangeException/ArgumentNullException, both subtypes). It's used at multiple call sites (CarReader.cs,CommitBlock.Read, this file's ownDecode) to catch-and-rewrap failures asFormatExceptionfor "bad input from the network/CAR". If any of the underlyingCborReader/span code ever throws anArgumentExceptiondue to an actual internal bug (not malformed input), it will now be silently reported to the caller as malformed data instead of surfacing as a crash/test failure. Given this only wraps parsers over untrusted bytes it's likely intentional, but worth double-checking that no legitimate caller-side argument validation exceptions can reach these catch sites.🤖 Resolving the merge conflicts — the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the fix runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-27T20:54:08.259Z) — nothing to re-trigger.
967439a23addd8c59551@claude /review, please re-review. I rebased onto main after #183 merged. The conflicts were only in
PublicAPI.Shipped.txt; I resolved them by applying each commit's own line delta onto main's baseline. Six new commits (after the rebase, the last six on the branch) fix an independent adversarial review and your earlier note. See "Review fixes" in the description:BeforeSeqends empty, as on main;CommitBlock.Readvalidates thedataandprevCIDs and refuses bytes after the commit map (Security bullet: main has the same flaw);MaxAttempts = 0is fixed;IsMalformedno longer treatsArgumentNullExceptionas malformed, with a comment explaining whyArgumentExceptioncounts there.The adversarial review also compared
CommitBlock.Readagainst the old path on 200,000 mutated blocks with zero mismatches, so it didn't need to change. Please read the fix commits in full.🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-27T21:18:16.208Z) — nothing to re-trigger.
✅ Verdict: approve — no blocking issues found.
This is a re-review after six "review fixes" commits landed on top of the version I last looked at. I verified each fix against the actual PR branch (
origin/debloat-a-streaming) rather than relying on the (again partially truncated) diff text:de3e8a4) — thestart >= BeforeSeqcheck moved from an eager throw to ayield breakinside theawait usingtracker block, so a finished snapshot's store position now ends the replay quietly instead of throwing, and the tracker still gets disposed correctly on that path. Matchesmain's behavior as claimed.966d61f) — decompression moved from the read-side handler into a connector-wrappingDecompressiterator, so anInvalidDataExceptionnow surfaces throughconnection.MoveNextAsync()insideEventStreamLoop.RunAsync, which is caught by the loop's genericcatch (Exception ex) { failure = handler.Failed(ex); break; }and correctly triggers the normal backoff/reconnect path rather than being silently skipped as one bad frame.af2fe1f) —CommitBlock.Readnow validatesdataand (when present)prevare valid CIDs viaCid.IsValid, and refuses bytes after the map viareader.ReadEndMap()+BytesRemaining > 0.RepoSyncVerifier.TryReadCommitonly catchesFormatExceptionfromCommitBlock.FromCar, which is exactly what these new checks throw, so the fix is correctly wired through the whole verification path (I checked the call sites atRepoSyncVerifier.cs:150/203/359).ArgumentNullExceptionno longer swallowed (ddd8c59) — this directly resolves the concern I flagged in my previous pass:IsMalformednow excludesArgumentNullExceptionspecifically (ArgumentException and not ArgumentNullException), with a comment explaining the distinction and a new test (IsMalformed_CountsWhatTheBytesCauseAndNotABug) pinning both the inclusion (ArgumentException,ArgumentOutOfRangeException) and exclusion (ArgumentNullException,NullReferenceException) cases.I also re-checked the shared
EventStreamLoop/CborEventStreamHandler/EventStreamFrameplumbing (not covered in my last pass since the diff was truncated there too): the header/error/seq parsing and JSON transcoding are all guarded by the sharedDagCborDecoder.IsMalformedfilter consistently, and the loop's reconnect/backoff/error-frame handling is straightforward and matches the documented contract.My previous pass was a non-blocking "comment" (the one concern raised is now fixed), and I found no new issues in this pass. Approving.
⏱ 2m 16s · 33 turns · tokens: 114.7k in / 9.6k out (+4002.4k cache-read)
📜 Full session transcript
Merge check against the updated main (#181 merged after this approval). Forgejo merges
ddd8c59cleanly, withdocs/migrating-to-0.7.md,docs/tap.mdandPublicAPI.Shipped.txtauto-merged. I tested the merge result locally in Release: 0 warnings (PublicApiAnalyzers included) and 5,000 unit tests passing. CHANGELOG has no duplicate bullets and no markers. Merging on the approval of this head.