Skip to content

Write sint32 values as varints of at most 5 bytes - #42

Merged
merlimat merged 1 commit into
streamnative:masterfrom
merlimat:fix-sint32-varint-size
Oct 2, 2026
Merged

merlimat merged 1 commit into
streamnative:masterfrom
merlimat:fix-sint32-varint-size

Conversation

@merlimat

@merlimat merlimat commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

For a sint32 field, getSerializedSize() counts the zigzag-encoded value as an unsigned 32-bit varint, at most 5 bytes, as protobuf does. The writer, however, went through writeRawVarInt(), which is the int32 writer: it encodes a negative int as a 10-byte varint.

The zigzag value is negative for every v >= 2^30 and every v < -2^30. For each such value, writeTo() wrote 5 bytes more than it had reserved. This happens on freshly built messages, with no parsing involved:

  • toByteArray() throws ArrayIndexOutOfBoundsException;
  • writeTo() into a pooled heap buffer writes the extra bytes into the next buffer of the same chunk, because pooled heap buffers share one backing array.

Singular, repeated, packed and map sint32 fields are all affected.

Example

Numbers n = new Numbers().setXSint32(1 << 30);
n.getSerializedSize();   // 6
n.toByteArray();         // ArrayIndexOutOfBoundsException: 11 bytes written into 6

The current writer:

static int writeRawSignedVarInt(byte[] a, int i, int n) {
    return writeRawVarInt(a, i, encodeZigZag32(n));   // int32 writer: a negative n takes 10 bytes
}

Change

A new writeRawVarUInt() writes an unsigned 32-bit varint of at most 5 bytes, for both the byte[] and the NIO sink. writeRawSignedVarInt() uses it:

static int writeRawVarUInt(byte[] a, int i, int n) {
    while (true) {
        if ((n & ~0x7F) == 0) {
            a[i++] = (byte) n;
            return i;
        }
        a[i++] = (byte) ((n & 0x7F) | 0x80);
        n >>>= 7;
    }
}

static int writeRawSignedVarInt(byte[] a, int i, int n) {
    return writeRawVarUInt(a, i, encodeZigZag32(n));
}

Writes now match computeSignedVarIntSize(), and the output matches protobuf-java.

Testing

NumbersTest and RepeatedNumbersTest cover the values whose zigzag value has its top bit set: 2^30, -2^30 - 1, Integer.MAX_VALUE and Integer.MIN_VALUE. For singular, repeated and packed fields, sizes and bytes match protobuf-java. These tests fail on master. Full mvn verify passes.

getSerializedSize() counts a sint32 as its zigzag value encoded as an
unsigned 32-bit varint, at most 5 bytes, as protobuf does. But
writeRawSignedVarInt() went through writeRawVarInt(), the int32 writer,
which writes a negative int as a 10-byte varint. The zigzag value has its
top bit set for every v >= 2^30 and every v < -2^30, so for each such
value writeTo() wrote 5 bytes more than it had reserved, with no parsing
involved: toByteArray() threw ArrayIndexOutOfBoundsException, and a
pooled heap target got the extra bytes written into the neighbouring
buffer of its chunk.

Write the zigzag value with a new writeRawVarUInt() (byte[] and NIO
sinks), which never writes more than 5 bytes. The output now matches
protobuf-java for singular, repeated and packed sint32 fields.
@merlimat
merlimat merged commit 5f9caf8 into streamnative:master Oct 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant