Claude: changes for issue #87 #88
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
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!88
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "claude/issue-87"
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?
Closes #87.
🤖 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 large, carefully-executed perf pass (Firehose/Jetstream parsing, MST build/lookup, CarReader indexing, CID encoding, JSON options). I checked the trickier rewrites against the pre-PR logic:
Did.Methodslicing (Value[4..Value.IndexOf(':', 4)]) is equivalent to the oldSplit(':')[1]and correctly handlesdid:web:identifiers with extra colons in the method-specific part; existing tests cover bothdid:plcanddid:web.CarReader.FindBlock's deferred CID index (with the span-alternate-key comparer) preserves first-occurrence-wins semantics for duplicate CIDs, and the lazy-build race is benign since a losing writer's localindexis still used for that call's return value.MerkleSearchTree.BuildLayerTopDown's index-based rewrite is a faithful translation of the old list-slicing recursion (verified against the actual PR branch source, not just the diff) — global start/end bounds line up correctly with the layer/atLayer indices.Get/Remove/TryUpdatecleanups that drop unusedkeyDepthlocals only removed genuinely-dead reads;Insert'skeyDepth(which is actually used for the level decision) was correctly left alone.FirehoseEventParser's CBOR→JSON direct-write rewrite still routes through the same outer try/catch that silently drops malformed frames, so error handling behavior is preserved despite bypassingDagCborDecoder/JsonNode.FirehoseClient/JetstreamClientpreserve the empty-message-returns-null behavior and fall back correctly for multi-frame messages.One real issue:
AtProtoJsonDefaults.Optionsis now frozen viaMakeReadOnly()at type-init time. That's a legitimate behavioral break for any consumer who was mutating the shared singleton (e.g. registering a custom converter), and it isn't called out as a breaking change anywhere — the CHANGELOG entry for this PR explicitly says "no public behaviour changes." See inline finding.⏱ 2m 26s · 24 turns · tokens: 60.3k in / 12.2k out (+1722.4k cache-read)
📜 Full session transcript
@ -19,0 +17,4 @@/// Initialized by the runtime's type initializer rather than a <c>??=</c> on first read:/// concurrent first calls could otherwise each build their own instance, and every/// <see cref="JsonSerializerOptions"/> carries its own reflection-derived contract cache./// </remarks>Optionsis now built eagerly by the type initializer and frozen withMakeReadOnly(). Previously it was a mutable singleton (built lazily via??=), so any external consumer that didAtProtoJsonDefaults.Options.Converters.Add(myConverter)at startup — a very natural extension point for a static, publicly-exposedJsonSerializerOptions— would now get anInvalidOperationExceptionat that call site.This is a genuine runtime-behavior break, not just a perf tweak, and per this repo's own CLAUDE.md rule ("Binary-incompatible API changes ... MUST go under Breaking changes with a one-line migration note") it arguably belongs there rather than under
Changedwith a "no public behaviour changes" framing. Worth at least a changelog callout and a one-line migration note (e.g. "pass customJsonSerializerOptionsto the client constructors instead of mutating the shared default").🤖 On it — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
Pushed
7d2d0ceto claude/issue-87.The finding was right — and the freeze wasn't buying anything
The reviewer flagged
MakeReadOnly()onAtProtoJsonDefaults.Optionsas a real behavioural break:AtProtoJsonDefaults.Options.Converters.Add(myConverter)at startup is the natural extension point for a public staticJsonSerializerOptions, and it started throwing.Rather than reclassify it as a breaking change in the CHANGELOG, I removed the freeze — because the perf justification for it doesn't hold up.
JsonSerializercallsMakeReadOnly()itself on first use, so pre-freezing only skips one bool check on the very first serialization; the comment claiming it avoids "re-checking mutability on every call" was wrong. I measured it (2M serializations per round, warmed, .NET 10):No measurable difference. So the freeze cost a public extension point for nothing.
What stays is the part of that bullet that was actually load-bearing:
Optionsis still built by the type initializer instead of??=, which is what removes the concurrent-first-call race where two threads each build an options instance with its own reflection-derived contract cache.Verified with a throwaway console app referencing the built
ATProtoNetproject, exercising the exact pattern from the finding:I did not add a unit test for this. Any test asserting the shared singleton is mutable would be order-dependent — the first test in the run that serializes with
AtProtoJsonDefaults.Optionsfreezes it for every test after, so such a test would pass or fail depending on collection scheduling.Changes
src/ATProtoNet/Serialization/AtProtoJsonDefaults.cs— droppedoptions.MakeReadOnly(); the<remarks>now records why the instance is deliberately left unfrozen and points consumers who need permanent customisation at passing their ownJsonSerializerOptionsto the client constructors.CHANGELOG.md— that bullet no longer claims a freeze/fast-path win and states explicitly that startup-timeConverters.Add(...)keeps working. It stays underChanged; with the freeze gone there is no behavioural break to migrate from, soBreaking changeswould now be inaccurate.Build clean (0 warnings, 0 errors) and unit tests green: 1296 passed, 0 failed, 5 skipped.
The rest of the review was confirmations of correct rewrites, so nothing else needed action.
⏱ 3m 34s · 24 turns · tokens: 34.5k in / 10.4k out (+954.8k cache-read)
📜 Full session transcript
🤖 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: approve — no blocking issues found.
Re-review: the only issue raised in my previous pass — the concern that
AtProtoJsonDefaults.Optionswas now frozen viaMakeReadOnly()at type-init time — is resolved, and on closer inspection was actually a misreading on my part rather than a real regression. Looking at the current source,CreateOptions()never callsMakeReadOnly();JsonSerializerOptionsonly becomes read-only implicitly on first use byJsonSerializer(true both before and after this PR, lazy or eager construction doesn't change that). The new remarks block onOptionscorrectly documents this ("deliberately not frozen here ... a startup-timeAtProtoJsonDefaults.Options.Converters.Add(...)keeps working"), so the CHANGELOG's "no public behaviour changes" framing is accurate and doesn't need to move to Breaking changes.Spot-checked the rest of the diff again for correctness on top of my last pass:
Did.Methodslicing is still correct fordid:webidentifiers with extra colons.CarReader.FromStreamAsyncpassesms.GetBuffer().AsSpan(...)intoFromBytes(ReadOnlySpan<byte>), which can't retain a span past the call, so there's no use-after-dispose risk from skipping theToArray()copy.CarReader.FindBlock's deferred index and duplicate-CID first-wins semantics are unchanged and now have direct test coverage (FindBlock_RepeatedLookupsResolveEveryBlock,FindBlock_DuplicateCid_ReturnsFirstOccurrence).JetstreamEventParser.ParseFrame(ReadOnlyMemory<byte>, ...)overload uses the realJsonDocument.Parse(ReadOnlyMemory<byte>, ...)API, and the span overload now correctly delegates to it after a singleToArray()(rather than each overload separately copying). New tests cover span/memory parity, malformed-frame handling, and that the parsedRecordsurvives the caller mutating its buffer afterward.FirehoseVerifier.WriteMapHeader/MapHeaderLengthand theFirehoseClient/JetstreamClientsingle-receive fast paths preserve prior null/empty-message semantics, with a correct multi-frame fallback.No new correctness issues found. Good to merge.
⏱ 1m 38s · 19 turns · tokens: 46.5k in / 7.7k out (+1295.4k cache-read)
📜 Full session transcript