perf: close the v0.7 regressions on JSON models, XRPC writes, Jetstream and CAR parsing #191
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
5 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!191
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-190-perf"
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 #190.
The five regressions came from the cost of this cycle's correctness features, not from the features. Profiling showed where that cost was, and each fix below keeps the behaviour it pays for. Every validation rule, open union and extension-data round trip is unchanged.
Hot spots, with the evidence
I profiled with
perfand JIT perf maps, running a scenario loop over the harness's own benchmark bodies, and sampled allocations by type from the runtime'sGCAllocationTickevents. Times below are themainshare of one operation.main(a2ac148)Handle,Nsid,Did,RecordKeyandTidvalidated with[GeneratedRegex]; the Handle and NSID patterns backtrack.HandlePattern4.0 %,NsidPattern3.5 %,DidPattern0.9 %, andMatch.AddMatch/Capture/RunSingleMatch2.3 %, about 21 µs of 194 µs.IdentifierJsonConverter.Readwas 23 % inclusive. Jetstream:NsidPattern7 %. createRecord: NSID and DID patterns about 12 %.stringbefore it was checked. ACidalso kept a separate 36-byte array and eagerly built its string from bytes. AnAtUribuilt all its parts.TranscodeHelperandUtf8Utility.TranscodeToUtf16in every JSON profile. Per-type allocation showedAtUri,AtIdentifier,Nsid,RecordKey,Cidand abyte[]for each value.AtProtoUnionConvertercalledJsonSerializer.Deserialize(ref reader, typeInfo), which first skips the whole value to scope a new reader, so every union member was scanned twice. A nested union is scanned once more. The converter also built a$typestring for each member.GetReaderScopedToNextValue5.4 % inclusive, about 10.7 µs.byte[]. In v0.6.0's code shape the .NET 10 JIT stack-allocated it. The one-byte varints of every CID header went through aReadUvarintthat is no longer inlined.ReadUvarintwas 19 % self time.TryGetProperty(string)transcoded the member name, and the time went throughDateTimeOffset.TryParse.TryGetNamedPropertyValue12 %,GetUtf8FromText2.8 %,DateTimeParseabout 6 %.AtDatetime.ParseinSerializePostRecord's body. v0.6.0 assigned a string there.What changed
Identity/IdentifierSyntax.cs, new). These are hand-written checks for the DID, handle, NSID, record-key and TID grammars. They are generic overcharandbyte, so the same code validates a string and a JSON string's UTF-8 bytes. The character sets are checked by vectorizedSearchValuesscans, which leaves only the structure to walk. Each check names the regex it replaces.IdentifierJsonConverterandAtDatetimeJsonConverterread an unescaped, single-segment JSON string straight fromValueSpan, through a newIIdentifier<T>.TryCreate(ReadOnlySpan<byte>, out T). A default implementation keepsSpaceUrion the old path.@-prefixed falls back to the old string path. The old path also produces the error, so exceptions don't change.AtUri.Authority,Repo,CollectionandRecordKeyare built on first use, from the validated text, into one lazily allocated holder.AtUri.Createpre-fills the holder.Cid.Digestis materialized once, on first use.AtDatetime.TryParseUtcreads UTF-8 text exactly asDateTimeOffset.TryParse(…, AdjustToUniversal | AssumeUniversal)does, for the subset where the two provably agree: at most 7 fractional digits, an offset of at most ±14:00, and an instant in range on both sides of the offset. For anything else it returns false.$type, as before, and matches a declared variant on the raw UTF-8 bytes.JsonConverter<T>on the caller's reader, when that converter is one of System.Text.Json's own. A custom converter still goes throughJsonSerializer.Deserialize. Readers are cached per variant and checked against the options.JsonSerializer.Deserialize, with every union nested in it doing the same, and throws that error. The error and its variant-relative JSON path are exactly what they were.stackallocbuffer. The newCidComputation.WriteBinaryCidis shared withComputeBinaryCid.CidComputation.Verifycompares bytes instead of two encoded strings.ReadUvarintinlines the one-byte case and keeps the general reader out of line.JsonElementExtensions.GetIdentifierOrNull<T>, and fall back toT.TryParsesemantics when escaped.operationis matched withValueEquals.AtDatetime.TryParseUtc, falling back toDateTimeOffset.TryParse.No public API changed. No
PublicAPIfile changed, and there is no migration entry.Review fixes
An adversarial review, with a differential fuzzer against
main, found three problems in the union converter's direct read. Each fix is its own commit, with regression tests that fail without it. They never shipped, so the CHANGELOG keeps its oneChangedbullet.catchblock, and so did every union above it. The member was read 2^depth times, with the failed frames still on the stack. Seven nestedrecordWithMediaembeds (405 bytes) around an invalidexternaloverflowed the stack and killed the process.mainthrows aJsonExceptionin milliseconds, and anyone can publish such a post.catchhas exited, and during it every union reads throughJsonSerializer.Deserialize, as onmain.main's order of errors: a reader error anywhere in the member, such as the depth limit, still wins over a conversion error found earlier.JsonSerializer.Deserialize, as onmain.JsonSerializer.Deserializegives, whether a value or an error, and a nested failure that a converter catches still triggers that reread. Before, a converter that caught an invalid union member and skipped it read on from inside the member, and a reread that succeeded was discarded.Results
Measured on the root server: AMD EPYC-Genoa, 8 vCPU, in a container (Ubuntu 24.04), with BenchmarkDotNet 0.15.8 (default job, in-process toolchain, server GC) on .NET 10.0.12. The columns are v0.6.0 (
7f672c0),mainbefore this PR (a2ac148) and this PR before the review fixes (4ecdde4). Time is the mean, and alloc is per operation.The five paths from the issue, against v0.6.0:
DeserializeGetTimelineResponse): 1.13× the time and 1.18× the allocation, down from 1.53× and 1.41× onmain.XrpcCreateRecord: 1.04× the time, 0.94× the allocation.ParseJetstreamCommitEvent): 0.86× the time, 0.63× the allocation.SerializePostRecord: 1.07× the time, 1.01× the allocation.None of the benchmarks this PR doesn't target is more than 2 % slower than
main.The review fixes change only the union converter's error path and the variants with a custom converter. A local A/B of the timeline on an AMD Ryzen 5 7600X, alternating
4ecdde4with the fixed branch, confirms that the happy path is unchanged. With the first two fixes, five runs each gave a median of the medians of 128.1 µs before and 127.8 µs after. With all three, three runs each gave a mean of the means of 127.2 µs before and 127.7 µs after, which is within the run-to-run noise of about ±3 µs. Every run allocated 182,952 B.Not fully closed, and why
SerializePostRecordis 7 % slower than v0.6.0 (712 vs 669 ns). Its v0.7 benchmark body also parses anAtDatetime, where v0.6.0 assigned a string.Verification
dotnet build -c Releaseof the whole solution, which includes the compiled doc snippets: 0 warnings, 0 errors.dotnet test tests/ATProtoNet.Tests -c Release, run twice: 4964 passed, 0 failed, 5 skipped. The 5 are the Aspire manifest tests, which the next check runs.samples/ManagedPdsSample.AppHost --publisher manifest, thenAspireManifestTestswithATPROTO_REQUIRE_INTEGRATION=1: 6 passed.Identity/IdentifierSyntaxTests):AtUriis compared with the old composition of patterns, part by part...rule), and the tests caught every one.\u-escaped, is compared withTryParse. Datetime fixtures are compared with the wire reading, including validity, instant and offset.AtDatetime.TryParseUtcis compared withDateTimeOffset.TryParseon the fixtures and 5,000 generated times. Removing its 7-digit limit or its 14-hour limit fails the test.OpenUnionTests: an invalid member of a variant still fails with the variant-relative path ($.did,$.uri). Removing the replay fails this test. An escaped$typevalue still resolves.JetstreamV2EventParserTests: 20 time forms, including rounding past 7 digits, offsets beyond ±14:00,-00:00, out-of-range instants and escapes, matchDateTimeOffset.TryParse. Escaped fields read as their text, and invalid required and optional fields drop what they did before.CidTestsandAtDatetimeTests: bytes-built and string-built CIDs are equal with equal hash codes. Valid and lenient datetimes keep their offset.OpenUnionTests). The expected errors are pinned frommain, and each test also passes withmain's union converter swapped in.main'sJsonException(type, path and message) within 10 s. The cases are embeds read asEmbedBase, the same embeds as a post'sembed, and thread parents read asThreadNode. Each runs once on a thread-pool thread and once on a thread with a 256 MB stack. Without the fix, the test process dies of a stack overflow.main. There are 8 cases: the union read outermost or insiderecordWithMedia, a bad or numericdid, and reading member by member or token by token. All 8 fail on the previous head, and the nested token-by-token case fails without the failure flag.MentionFeaturethat reads past its value givesmain's[null, TagFeature], and one that stops short givesmain's error and path ($). Without the fix, the first returns[null]and the second names the union converter at$.facets[0].features[0].main, including the depth-limit error from 64 levels on.mainfor both converters.main's reader error.fixmode has 784 nested-union parity cases (depth 0 to 62, good and bad leaves, sixMaxDepthvalues, depth-versus-conversion races) and 42DeserializeAsyncdocuments with a thread hop on every read. All givemain's result, and so do the six lenient-converter cases. One case still differs in a side effect: a converter that runs its own, unrelated deserialization during a direct read and catches the error. The document's result ismain's, but that converter runs twice. The first run sees the error atPath: $, and the reread seesmain's$.external.🤖 Generated with Claude Code
🤖 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-28T04:14:42.469Z) — nothing to re-trigger.
@claude /review, please. This is the perf follow-up (#190). It changes no behaviour, and the full benchmark table goes into the description once the root-server run finishes.
Please read these in full:
Identity/IdentifierSyntax.cs. It replaces the Handle/NSID/DID/AtUri/RecordKey/TID regexes with hand-written UTF-16/UTF-8 checks. They must accept and reject exactly what the regexes did. See the equivalence tests and the interopSyntaxInteropTests; I'm most interested in edge cases around length limits, label rules, trailing characters and non-ASCII.AtDatetimeJSON converters, which now read straight from UTF-8.Cid's lazy string andAtUri's lazy parts. Check thread safety and equality/hash consistency.AtProtoUnionConverter. It dispatches to the variant converter directly, matches$typeon UTF-8, and replays throughDeserializeon error. Exceptions and JSON paths must be unchanged, and unknown variants must still round-trip.🤖 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.
This is a large, low-level performance change (identifier/datetime validation,
AtUri/Cidlaziness, union single-pass reads, CAR/base32 decoding, Jetstream UTF-8 lookups), and I traced through the trickiest parts by hand rather than skimming:AtDatetime's bit-packed_ticksAndState(ticks in the low 62 bits, state in the top 2): the packing/unpacking is safe because ticks is provably always non-negative and below 2^62 for any year the grammar accepts, so the OR never collides with the state bits.OffsetMinutes()reconstructing the offset from the last 6 characters of_textfor aValidvalue is correct given the fixedZ/±HH:MMsuffix shape the parser already validated.TryParseInstant's calendar math (CommonDaysBeforeMonth+ a leap-day adjustment instead of the old per-month loop) is the standard technique and matches the oldDaysBeforeMonthloop's output.Base32Lower.TryDecode's 8-char/5-byte grouped decode: verified the bit-shifts (group >> 32/24/16/8/0) and the deferredseen > 31invalid-character check — an invalid character corrupts a group's bits, but since it always still tripsseen > 31before any ofbytesis trusted by the caller, no bad output can leak through on failure.AtUri's lazyPartsand offset-basedAuthority/Collection/RecordKey: theauthorityEnd/collectionEndbookkeeping inTryParseSyntaxand in theCreatefactory lines up exactly with howValueis built, including the_collectionEnd == Value.Lengthsentinel for "no record key".AtProtoUnionConverter's single-pass variant read with the on-error replay:ExceptionDispatchInfo.Throw(ex)is only a fallback for the case where the replay unexpectedly doesn't throw; the expected path is thatJsonSerializer.Deserialize(ref start, typeInfo)throws its own exception (with the correct variant-relative path) and that one propagates instead, which matches the PR description and the newOpenUnionTests.Cid's inline binary storage plus lazyValue/Digest: equality/hashing moved to bytes is consistent with the string form being canonical, andGetHashCodestill uses a per-process-seededHashCode, preserving the DoS-resistance the oldstring.GetHashCodehad.I didn't find a functional regression. The one thing I couldn't independently verify (dotnet build/test is blocked in this environment) is whether
bytes.CopyTo(_binary)against thereadonly Binary _binaryinline-array field inCid's constructor actually compiles — C# does permit ref/write access to a readonly instance field from within its own constructor, which is what this relies on, and the PR states a clean Release build, so I'm treating this as very likely fine rather than a blocking concern.Only a cosmetic nit below; nothing that should block merge.
⏱ 7m 7s · 12 turns · tokens: 114.0k in / 39.3k out (+1365.8k cache-read)
📜 Full session transcript
@ -412,6 +412,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0- **Bodies from untrusted senders go through one bounded reader** — the Tap webhook, record proofs, repository downloads and space recovery use the identity and OAuth fetches' reader, with their own limits and errors; a response's declared length pre-allocates at most 1 MiB, and a webhook sender's none (#180)- **Signature checks against a DID document share one refetch-once path** — the Sync 1.1, label, service auth and space token verifiers and `SpaceSyncer` keep their key rules and errors. `SpaceCommitVerifier.Verify` returns `false`, not an exception, for a curve this platform lacks (#180)- **Every bounded cache evicts the least recently used entry** — the DPoP key, nonce, client-metadata, JWK and OAuth metadata caches no longer clear themselves or drop arbitrary entries when full; `CachingLexiconResolver` runs on `CachingDidResolver`'s cache core (#180)- **Faster JSON model (de)serialization, XRPC calls, Jetstream parsing and CAR reading** — identifiers and datetimes are validated without regular expressions, straight from the UTF-8 bytes; `AtUri` builds its parts and `Cid` its string on first use; a union member is read in one pass. What is accepted and produced is unchanged (#190)Minor style nit: this bullet runs to roughly 330 characters, a bit past CLAUDE.md's guideline of "at most ~300 characters" per entry. Not blocking.
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
💬 Verdict: comment — the reviewer did not submit a structured review; the raw output follows.
AtUri.Equalsis explicitly overridden (line 174) to compare onlyValueordinally — so the new_partsholder and offset fields don't affect equality. AndHandle's@-stripping is intact in the string path. Both concerns resolved. Now the union converter — the diff'sFindDiscriminatorlooked suspicious (checkingTokenType == Stringwhile seemingly on the property-name token), so let me read the real code.⏱ 30m 3s · 4 steps · $0.0236 · tokens: 102.9k in / 378 out (+0 cache-read) · Go: 5h 0% · wk 0% · mo 82%
📜 Full session transcript
@claude /review, please re-review. An independent adversarial review found two problems in the union converter, and two new commits fix them.
95a898d(critical). Nested unions with an invalid leaf overflowed the stack. At every level the replay ran inside thecatch, so the member was re-read 2^depth times without the stack unwinding. A 405-byte post was enough. Now a[ThreadStatic]UnionReadModelets only the outermost union catch. It captures the exception, leaves thecatch, and replays once inRereadmode, so every nested union takes main's plainDeserializepath. A failed member is therefore read directly once and replayed once, at any depth, and main's error order is kept. Regression tests nest 60 deep through embeds, a post'sembedand thread parents, on a pool thread, and match main'sJsonException.ee6a1f7(low). A user-supplied custom converter no longer gets the direct read; only System.Text.Json's own converters do. Tests cover an over-reading and an under-reading converter.The reviewer's fuzzer now matches main in all 72 stack-overflow cases (depths 1–100, pool, main and 1 GB stacks), in the custom-converter cases, and in 200k differential union documents. The timeline benchmark is unchanged (128.1 vs 127.8 µs), and 4,956 tests pass. Please read
AtProtoUnionConverter.csin 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.
✅ Verdict: approve — no blocking issues found.
Re-review, focused mainly on the two "review fixes" this round added on top of the design I already approved in my previous pass (the union converter's nested-failure replay and the custom-converter direct-read gate), since those are exactly the kind of subtle concurrency/control-flow bugs that are easy to get wrong.
Nested-union replay (
AtProtoUnionConverter.cs). TracedVariantReader<TVariant>.ReadagainstUnionReadState.Mode:mode == Direct) call_converter.Readwith no try/catch, so a failure unwinds straight through every nested frame to the single outermost frame — this is what avoids the old exponential (2^depth) replay and the stack overflow it caused. Confirmed the mode is reset toNoneinfinallyon both the direct attempt and the reread, so a thrown exception can never leave the thread-static flag stuck.start) viaJsonSerializer.Deserialize, during which nested unions seemode == Rereadand also go throughJsonSerializer.Deserialize— matchingmain's behavior exactly, including error precedence (a reader-level error such as the depth limit still wins over an earlier conversion error, since the reread reparses from scratch).StartObject(thereaderpassed todeclared.Readis untouched by the$typelookahead, which operates on a copy), so skippingJsonSerializer's null-handling wrapper aroundReadis safe here.Custom variant converters. The gate (
typeInfo.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly) correctly restricts the direct-read fast path to System.Text.Json's own converters, which are trusted to read exactly one value; anything else (including the adversarial over-/under-reading converters added in the new tests) goes throughJsonSerializer.Deserialize, which enforces that check. This matches the PR's description and the new regression tests.I did not find a correctness problem in either fix, and the described differential fuzzing (200,000 mutated union documents, 72 nested-depth cases up to 100 levels, 0 divergences from
main) is strong corroborating evidence beyond what I can check by inspection alone.One very minor observation carried over from my last pass: the new CHANGELOG bullet (
CHANGELOG.md, the "Faster JSON model (de)serialization..." line) is about 334 characters, still somewhat past CLAUDE.md's "~300 characters" guideline. Not blocking, same as last time.No new blocking issues found. Approving.
⏱ 1m 34s · 5 turns · tokens: 91.7k in / 9.1k out (+442.6k cache-read)
📜 Full session transcript
✅ Verdict: approve — no blocking issues found.
Re-review of the perf PR (new commits since my last pass). My earlier pass was cut off mid-check on three open threads; I've now finished verifying each against the real code, and all are resolved:
FindDiscriminator(AtProtoUnionConverter.cs) — my truncated pass had flagged theTokenType == Stringcheck as suspicious on the property-name token. Re-reading the current code: line 254 computesisTypeon the property name viaValueTextEquals("$type"u8), then line 255'sreader.Read()advances the copy onto the value before theStringcheck — sodiscriminator.ValueSpan/GetString()read the$typevalue's unquoted unescaped content correctly. False alarm.AtUri.Equalscompares onlyValueordinally (line 174), so the new lazily-built_parts/offset fields don't affect equality, hashing or ordering; a race building a part twice only produces equal values.Handle's@-stripping is intact on the string path; an@-prefixed value fails the UTF-8 fast path (@is outsideDomainChars) and falls back to the string path, preserving the old behavior (@alice.bsky.socialremains parseable as anAtIdentifier, per the fixture test).Beyond that, I traced the parts that changed since the last look:
UnionReadState.Modeis written only insideVariantReader<TVariant>.Read; exactly the outermost union (modeNone→ setsDirect) wraps its member read in try/catch, while variants read underDirect(line 310) and everything underReread(line 307) pass exceptions through without catching, so a failed member is read directly once and replayed exactly once regardless of depth. The replay's "deserialize, thenfailure.Throw()only if the replay didn't throw" structure preservesmain's error-choice order (a later reader error beats an earlier conversion error). The replay runs after thecatchhas exited (post-finally), so the 2^depth stack-growth failure is genuinely gone; the new deep-nesting tests at depth 60 on small stacks cover the regression. Bounded replay: one direct read + one full deserialize, no exponential nesting.IIdentifier<T>.TryCreate(ReadOnlySpan<byte>)default returns false, soSpaceUri(which has no UTF-8 override) stays entirely on the decoded-string path — no behavior change for server space identifiers.!ValueIsEscaped && !HasValueSequenceand fall back to the string path, which also produces the errors — exception surface unchanged.GetIdentifierOrNullfalls back toT.TryCreate(text, text, …)for escaped values; combined withHandle's behavior this keeps@, escapes and normalization identical to the oldGetStringOrNull+TryParseflow.Base32Lower/Cidbyte-form equality: canonical string form means byte comparison matches string comparison;HashCode.AddByteskeeps per-process seeding for hash-flooding resistance.The two adversarial-review fixes (nested-union replay and custom-variant-converter confinement) each carry regression tests that pin expected behavior against
main's converter, plus the differential fuzzer results — that's as strong as static review gets without running the harness here.Minor, non-blocking notes (consolidated, per claude-reviewer's pass):
VariantReader's STJ-assembly check (typeInfo.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly) is a heuristic; any future STJ converter split into another assembly silently takes the safeDeserializepath — a perf only footnote, not correctness.Previous threads: this pass's three open items all resolved as above; claude-reviewer's APPROVED stands with no blockers either. Good to merge.
⏱ 12m 20s · 7 steps · $0.0512 · tokens: 206.1k in / 1.7k out (+156.8k cache-read) · Go: 5h 0% · wk 0% · mo 82%
📜 Full session transcript
After a failed direct read, the outermost union replayed its member on a copy of the reader and rethrew the first error. The caller's reader stayed where the direct read had stopped, inside the member, whereas JsonSerializer. Deserialize puts it back at the member's start on a JsonException. A replay that succeeded was thrown away. A converter that called a union's converter and skipped an invalid member therefore failed ("read too much or not enough"), or silently read on from inside the member. A nested union's raw error could also reach such a converter where main gives it a JsonException. The outermost union now resets the reader to the member's start and returns what JsonSerializer.Deserialize returns, or lets its error through. A union nested in a direct read records its failure in an exception filter, which leaves the exception alone. The outermost union rereads when a failure was recorded, even if a converter in between caught it and the direct read then returned. A member is still read directly once and reread at most once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>@claude /review, please re-review. The adversarial re-review verified both earlier fixes and raised one more LOW difference from main, which was already present in
4ecdde4.ba80496fixes it.JsonSerializer.Deserializein Reread mode, which gives main's value on success and main's error on failure (failure.Throw()is gone).ee6a1f7and pass with main's converter.unionover 220k documents,uniondosin 72 cases andfixin 832 cases, 0 divergences.Please read
AtProtoUnionConverter.csin 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.
✅ Verdict: approve — no blocking issues found.
This is my third pass on #191. The diff supplied here matches exactly what I reviewed and approved in my previous pass (2026-09-28T13:01:01Z): the identifier-syntax rewrite,
AtUri/Cidlaziness, the CAR/base32 changes, the Jetstream UTF-8 lookups, and the three union-converter review fixes (nested-failure replay via the thread-staticUnionReadState, the custom-converter direct-read gate, and the reader-reset-on-failure behavior). I re-traced the trickier pieces I hadn't previously called out in detail:JsonElementExtensions.GetIdentifierOrNull<T>andJetstreamEventParser.TryParseTimeboth useJsonMarshal.GetRawUtf8Value(...)[1..^1]to strip the JSON string's surrounding quotes before checking for\and taking the fast UTF-8 path. This is safe:GetRawUtf8Valueon aJsonValueKind.Stringelement always returns the quoted span, so the slice is correct, and any escape sequence necessarily contains a\byte, so theIndexOf((byte)'\') < 0guard correctly routes anything escaped to the decoded-string fallback.AtDatetime.TryParseUtc's fraction-digit count (utf8[19] == '.' ? utf8.Length - 20 - (utf8[^1] == 'Z' ? 1 : 6) : 0) is only reached afterTryParseInstanthas already validated the full grammar, so the fixed offsets (index 19 for the dot, suffix length 1 forZor 6 for±HH:MM) are safe to rely on.AtDatetime._ticksAndState, theAtUrilazyParts/offset bookkeeping, theCidinline-array storage and hash/equality-on-bytes, and the grouped base32 decode were already traced in my first pass and are unchanged here.I found no new correctness issues. Nothing here changes the assessment from my last two passes — approving again. The only previously-noted non-blocking nit (the CHANGELOG bullet running slightly past CLAUDE.md's ~300-character guideline) still stands but was never blocking.
⏱ 0m 30s · 5 turns · tokens: 83.3k in / 2.6k out (+446.2k cache-read)
📜 Full session transcript
✅ Verdict: approve — no blocking issues found.
Re-review of the new commits since my last pass. All three review fixes are in place and each is the fix I would have demanded:
AtProtoUnionConverter.cs— nested unions (modeDirect/DirectFailed) read via_converter.Readwith an exception filter (UnionReadState.RecordFailure) that setsDirectFailedand returnsfalse, so thecatchbody is never entered at nested level and failures unwind to the single outermost frame. The outermost frame replays only after itsfinallyhas reset the mode (fully unwound stack) and inside the replay setsReread, which forces every nested union throughJsonSerializer.Deserialize— one direct read plus one replay per failed member at any depth, matchingmain's error choice (a later reader error beats an earlier conversion error). Thread-static mode is reset infinallyon both the direct and reread paths, so no stale state survives a throw.typeInfo.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly) correctly confines the direct-read fast path to System.Text.Json's own converters; non-STJ converters (including the adversarial over-/under-readers in the new tests) go throughJsonSerializer.Deserialize, which enforces the read-exactly-your-value contract, as onmain.UnionReadState.Mode == DirectvsDirectFailed), so result, error and final reader position all matchmain.Carried over, re-verified on the current code:
AtUri.Equalscompares onlyValueordinally so the new lazyParts/offset fields don't affect equality, hashCode or ordering;Handle's@-stripping is intact (the UTF-8 fast path rejects@and falls back to the string path that strips it);AtDatetime's bit-packing and offset-from-text reconstruction are safe for every shapeTryParseInstantcan accept.The documented residual divergence (a custom converter that performs an unrelated deserialization during a direct read and catches the error runs twice, with
main's document result) is an accepted, single-vertex wedge of a much wider parity sweep — 784 nested-union cases, 42 async documents and 200k mutated documents with zero divergences — far sharper than manual verification can reach here.The only remaining nit is cosmetic: the CHANGELOG bullet runs ~330 characters, past the ~300-character guideline, unchanged since the first pass. Not blocking.
⏱ 12m 49s · 5 steps · $0.0327 · tokens: 118.6k in / 1.3k out (+172.5k cache-read) · Go: 5h 0% · wk 0% · mo 82%
📜 Full session transcript