perf(snapshot): defer inbound plaintext UTF-8 byte count to first access (#53)#54
Conversation
…ess (#53) Every successful UnpackAsync registers an InboundMessageSnapshot, but only ObserverDelivery's byte-budget admission reads Utf8ByteCount. Move the Encoding.UTF8.GetByteCount scan from the constructor to a lazily computed, memoized property so unpack-only consumers and observer-less dispatcher graphs stop paying an O(plaintext) pass per inbound message. Registration stays at the unpack boundary: the verified-at-unpack immutability binding from #51 is untouched. The unsynchronized lazy compute is a benign race — deterministic value over an immutable string, atomic int write; worst case is a duplicate computation of the same number. New tests pin the contract: no scan on registration (backing field still unset), exact byte count for multibyte plaintext on first read, memoized thereafter. Full suite (595 core + 159 interop) passes unchanged. Closes #53 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
moisesja
left a comment
There was a problem hiding this comment.
A clean, correctly scoped perf change. I reviewed the diff, the full InboundMessageSnapshot, and every consumer of Utf8ByteCount.
What I verified
Scope is honest. One production file touched. RegisterVerified stays at the unpack boundary, so the #51 verified-at-unpack immutability binding is genuinely unchanged — not just claimed.
Byte-budget accounting stays balanced (the main correctness risk). ObserverDelivery reserves OutstandingBytes += snapshot.Utf8ByteCount on admission (ObserverDelivery.cs:154) and releases the same value on all three exit paths (:158, :199, :351). Because the lazy value is deterministic over the immutable PlaintextJson and memoized on first read, reserve and every release read an identical int — the ledger can't drift.
The "benign race" is genuinely benign. int reads/writes are atomic per ECMA, and every first-reader computes the same GetByteCount, so there's no torn read and no path to an under-count that could sneak a payload past the budget. The -1 sentinel is safe (a real count is never negative; empty plaintext → 0). Skipping Lazy/locking on this hot path is the right call.
Tests pin the contract meaningfully. The multibyte payload makes byte count > char count, so the exact-size and memoization assertions are non-trivial, and RegisterVerified_DoesNotScanPlaintextForByteCount proves the deferral structurally.
Nit (non-blocking): the test couples to the private _utf8ByteCount field name via reflection. Acceptable — the MissingFieldException message tells a future renamer exactly what to fix.
No missing tests, tight scope, clear messages. LGTM.
Closes #53.
What
InboundMessageSnapshot.Utf8ByteCountis now a lazily computed, memoized property instead of being eagerly computed in the constructor. One production file changed;EnvelopeReader,ProtocolDispatcher, andObserverDeliveryare deliberately untouched.Why
Every successful
UnpackAsyncregisters a snapshot, but onlyObserverDelivery's byte-budget admission ever reads its UTF-8 size. Unpack-only consumers and dispatcher graphs without observers were paying anO(plaintext)Encoding.UTF8.GetByteCountscan (bounded byMaxReceiveBytes, default 1 MiB) per inbound message for a value nothing read. This was surfaced by the PR #51 adversarial review and deliberately deferred out of that security PR (see the issue's "Why this wasn't fixed in #51").This implements the issue's preferred option (1): defer the byte count, keep the security placement.
Mapping to the issue's acceptance criteria
Encoding.UTF8.GetByteCount(or equivalent O(plaintext) work) is performed on the unpack path when the snapshot's byte size is never read."The constructor no longer scans; the scan happens on first access of
Utf8ByteCount, and onlyObserverDeliveryreads it.RegisterVerified_DoesNotScanPlaintextForByteCountproves the backing field is still unset after registration.RegisterVerifiedstays exactly where it was inUnpackAsync; no registration gating, no relocation. All existing feat(discover-features): initiator round-trip + inbound observer seam (#49, #50) #51 correlator/observer trust tests (cross-tenant reply rejection, snapshot immutability, byte-budget admission) pass unmodified: 595 core + 159 interop, 0 failed.InboundMessageSnapshotTestspins the lazy contract structurally: no scan on registration, exact byte count for a multibyte payload on first read (byte count ≠ char count, so the assertion is meaningful), memoized thereafter.On the unsynchronized lazy compute
Deliberate benign race, documented in the code:
GetByteCountis deterministic over the immutablePlaintextJsonand int writes are atomic, so concurrent first readers at worst each compute the same value once. No lock, noLazy<T>allocation on a hot path.🤖 Generated with Claude Code