Repository navigation
Don't size writeTo() output from the parsed wire size - #43
Merged
Merged
Conversation
parseFrom() leaves the number of bytes it parsed in _cachedSize unless a parser flagged the message (unknown fields, unknown enum values, bools other than 0 or 1, map entries with a missing or repeated key or value, or a flagged nested message), and getSerializedSize() returned it as the serialized size. That holds only when re-serializing reproduces the input, and valid input that none of the flags catch breaks it: - Larger when written back: a negative int32 or enum in fewer than 10 bytes, the packed encoding of an unpacked repeated field or a single unpacked element of a packed one, a uint32 >= 2^31 in protobuf-java's 5-byte encoding (LightProto writes 10), any of these inside a nested message, a nested message whose fields run past its declared length when written on its own, and a nested message changed through its getter right after parsing. - Smaller: overlong varints, tags and lengths, a singular field, message field or oneof present more than once, proto3 default values on the wire, and empty or split packed chunks. writeTo() reserves getSerializedSize() bytes and writes heap buffers in place through their backing array. Too small a size writes past the reservation: pooled heap buffers share one array per chunk, so the extra bytes overwrite the neighbouring buffer, while direct buffers throw or get a truncated message. Too large a size leaves part of the reserved bytes unwritten. Flagging each case at parse time would take a check on every field. Only trust the sizes getSerializedSize() computes: it caches them with the sign bit set, and returns a cached size only when that bit is set. parseFrom() is unchanged. The plain wire size it leaves is now only the estimate of what the instance retains, for the clear() release gate, whose unsigned compare still settles it on its own; computed sizes and -1 go on to a compare with the bit masked off. _isSizeCached() becomes _cachedSize != -1, which gives the same answer right after a parse and stays true once a size is computed. A parsed message that is written back pays one size computation, like a built one.
Setting a field of the nested message resets only the nested message's cached size, so on master M kept reporting its 5-byte wire size after x.a grew to 33 bytes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
writeTo()reservesgetSerializedSize()bytes in the target, then writes the message in place through the target's backing array. Nothing checks those writes against the reservation.For a parsed message,
getSerializedSize()returned the number of bytes thatparseFrom()consumed. That is the serialized size only if LightProto writes the message back exactly as it arrived. #24, #25, #28, #30 and #35 stop the caching for some inputs: unknown fields, unknown enum values, bools other than 0 or 1, map entries with a missing or repeated key or value, and nested messages that hit one of these. Other valid input still breaks it.Written back larger than it arrived (
writeTo()writes past its reservation):Written back smaller (part of the reserved bytes is left unwritten):
Pooled heap buffers share one backing array per chunk, so the extra bytes overwrite the next buffer, and
writeTo()returns normally with a truncated message. Direct buffers throw or get a truncated message, andtoByteArray()throws. Code that writes back a parsed message is affected, e.g. a proxy, or a gRPC service forwarding a request: the gRPC marshaller sizes its buffer withgetSerializedSize().Example
Repeated.x_int32is declared unpacked in repeated_numbers.proto. This input sends it as one packed chunk, as a proto3 producer would:The current generated code:
Flagging each remaining case at parse time would take a check on nearly every field: varint lengths, presence before overwrite, default values, and the end of every length-delimited field.
Change
getSerializedSize()only trusts the sizes it computed itself. It caches them with the sign bit set, and returns a cached size only when that bit is set:parseFrom()is unchanged. It is byte-identical in every generated message. The plain wire size (>= 0) it leaves is no longer returned as the serialized size, so for a parsed message the size is computed from the fields, as for a built one.writeTo(),toByteArray()and the length prefixes in parent messages all go throughgetSerializedSize(), so they now get the right size whatever the input encoding.clear()release gate still uses the wire size as its estimate of how much data the instance retains. The original unsigned compare settles a parsed size on its own. Computed sizes and -1 both have the sign bit set, so they go on to a second compare with that bit masked off:_isSizeCached()becomes_cachedSize != -1. Right after a parse it returns the same result as before, so the propagation from Don't cache the wire size after dropping nested unknown fields #28 is unchanged, and it stays true once a computed size is cached._hasUnknownFieldsflags keep working. They no longer matter for size safety: they only makeclear()take the release path.The cost is one size computation when a parsed message is written back, the same computation a built message already pays. Measured with JMH, interleaved A/B, one fork per variant per round, on an earlier version of this change that used the single masked compare in
clear():writeTo()(the cost above): 3-8% slower; AddressBook was 21% slower on JDK 21, over 4 rounds.Testing
NonCanonicalInputTestcovers every case listed above, 32 in total. Each case comparesgetSerializedSize(),toByteArray()andwriteTo()against acopyFrom()copy, which computes the size from the fields. ThewriteTo()checks use heap targets, direct targets (both the scratch and the NIO path), and a pooled heap buffer of exactlygetSerializedSize()bytes. For the pooled buffer, the test also checks that no byte of its backing array outside the buffer changed. 23 of the 32 fail on master; the other 9 are the cases already fixed by #24/#25/#28/#30.ClearReleaseTestchecks that a small parsed message still keeps the O(1) clear. It fails if the gate does not mask the sign bit.Full
mvn verifypasses.The sint32 writer overrun is fixed separately in #42.