Skip to content

fix(decode): stop infinite loop on non-object repeated message elements - #6

Open
Fyzu wants to merge 2 commits into
mainfrom
claude/vigilant-archimedes-ygynf3
Open

Fyzu wants to merge 2 commits into
mainfrom
claude/vigilant-archimedes-ygynf3

Conversation

@Fyzu

@Fyzu Fyzu commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A tiny untrusted payload can make decoding loop until OutOfMemoryError:

BuffJson.decoder().decode("{\"repeatedNested\":[1]}", TestNesting.class); // never returns, then OOM

When a message reader met a non-object token, it returned an empty message without consuming the token. It ignored nextIfObjectStart()'s result, and readFieldName() returns null on a non-name token, which ended the field loop. Inside a repeated field, the enclosing while (!reader.nextIfArrayEnd()) loop then saw the same token forever, appending messages until the heap was exhausted.

Inputs that looped before this change (-Xmx96m, 8 s timeout):

input codegen typed reflection
{"repeatedNested":[1]}, [true], [[]], [{..},2] OOM OOM OOM
{"repeatedNested":[null]} OOM skipped skipped
{"repeatedNested":5}, {"repeatedNested":{}} (not an array) OOM OOM OOM
{"repeatedStruct":[1]}, [[1]] OOM OOM OOM
{"repeatedStruct":[null]} OOM skipped skipped
{"repeatedEmpty":[1]}, [[]] ok OOM OOM

Singular and map values were rejected only by accident: the leftover token made the top-level "input not end" check fail. The official conformance cases for null repeated elements appeared to pass only because ConformanceTestee catches Throwable, which reported the OutOfMemoryError as a parse failure.

Fix

1. Every reader consumes the container it expects, or throws a JSONException that names the proto type:

  • { for messages, maps, Struct, Any and Empty;
  • [ for repeated fields and ListValue.

The checks live in two helpers, FieldReader.requireObjectStart and requireArrayStart, and the generated decoders call them too. So all three paths produce the same error, for example Expected a JSON object for message io.suboptimal.buffjson.proto.NestedMessage, offset 32, …. Changed code:

  • DecoderGenerator — message, repeated, map and inline Empty reads
  • ProtobufMessageReader, TypedMessageReaderSchema, FieldReader.readRepeated/readMap
  • WellKnownTypes.readStruct/readListValue/readAny

Every element read now consumes at least its opening token, so each iteration of an array loop makes progress. Termination no longer depends on what the element reader does. The checks test a boolean the reader already returned, so there is no cost on the success path.

2. Null elements in repeated fields, found by the same test matrix:

  • Codegen now rejects a null element with a JSONException (FieldReader.requireNonNullElement), as JsonFormat does. Before, it threw NullPointerException (Timestamp, Duration, FieldMask, String/BytesValue) or added a phantom default element (int64, bool, enum, wrappers).
  • google.protobuf.Value / NullValue elements keep null as a value on every path (a wrapped NullValue, or NULL_VALUE). The typed and reflection paths used to drop them, so {"repeatedValue":[null,1]} lost an element, even though that is exactly what the encoder writes.

3. Empty input. Empty or whitespace-only input decodes to null from every overload (byte[], slice, InputStream), like an empty String. Without this, the new object-start check would have turned these inputs into exceptions.

Behavior changes (invalid input only)

  • Non-object and non-array containers now fail with a clear JSONException instead of looping or failing on the trailing-input check. Valid proto3 JSON always uses these shapes, and JsonFormat rejects everything else.
  • Codegen Empty: a bare number or array for google.protobuf.Empty is now rejected, matching the runtime paths.
  • Codegen null elements are now rejected (see 2).
  • Empty input: empty byte[]/InputStream or whitespace-only input now returns null. On main it returned an empty message; an empty String already returned null.

Tests

  • New BuffJsonMalformedContainerTest: each case runs on all three decode paths inside assertTimeoutPreemptively. It covers:

    • non-object repeated message, Struct, ListValue, Any and Empty elements;
    • non-array repeated values;
    • non-object map values, singular message values and top-level input;
    • malformed objects inside containers;
    • truncated input and null repeated message elements (termination only);
    • null scalar and WKT elements;
    • repeated Value and repeated NullValue nulls, checked against JsonFormat plus a round-trip;
    • empty input from every overload.

    Against the unfixed code, the forked test JVM died with Java heap space.

  • New TestRepeatedNullValue test message: added to the test protos so the generated NullValue branch is compiled and exercised.

  • mvn -B clean verify: all modules pass (729 tests in buff-json-tests), including the benchmark and conformance-testee builds.

  • Not run locally: the official conformance_test_runner. CI runs it for all three BUFFJSON_PATHs.

Consumers must rebuild with mvn clean install to regenerate decoders. The protobuf Maven plugin only regenerates when .proto inputs change.

Not changed (possible follow-ups)

  • Decoders generated by the old plugin still loop, because generated decoders call each other directly and no runtime guard can reach them. They need regeneration, or a plugin/runtime version check.
  • Null elements in non-Value repeated fields are still skipped by the typed and reflection paths, while codegen rejects them.
  • Null map values: codegen drops the entry, while the runtime paths insert the default value, and neither matches JsonFormat. runtimeNullMapValuesAndUnknownEnumNumbersMatchReflection pins the runtime behavior, so this needs a decision on which is intended.
  • A member without a name still ends an object early, as before. So an unclosed object such as [{"a":1, {"a":2}] is still accepted as two elements. The existing truncated-object leniency (BuffJsonErrorTest.truncatedObjectParsesLeniently) is unchanged.
  • assertTimeoutPreemptively can't stop a runaway decode thread. If the fix regresses, the test fails at the timeout, but the fork may still run out of memory afterwards.

🤖 Generated with Claude Code

https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY

A message reader that met a non-object token returned an empty message
without consuming the token. Inside a repeated field the enclosing
array loop then saw the same token forever and appended messages until
OutOfMemoryError: {"repeatedMsg":[1]} or [true] on all three decode
paths, {"repeatedMsg":[null]} on the codegen path, repeated Struct and
Empty elements, and non-array values for repeated message fields.

Every reader now consumes the container it expects or throws a
JSONException naming the proto type: '{' for messages, maps, Struct,
Any and Empty, '[' for repeated fields and ListValue. The checks live
in FieldReader.requireObjectStart/requireArrayStart, which generated
decoders call too, so all paths report the same error. Valid proto3
JSON always has these shapes, so no valid input changes behavior.

Codegen also no longer accepts a bare number or array as
google.protobuf.Empty, matching the runtime paths.

BuffJsonMalformedContainerTest runs 209 malformed-container cases on
all three paths under a timeout. Before the fix the test JVM died with
"Java heap space".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY
@Fyzu
Fyzu force-pushed the claude/vigilant-archimedes-ygynf3 branch from b2e4ee6 to f56e58e Compare September 28, 2026 23:53
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Performance comparison

Workflow and raw JMH artifacts
Commit: 867e35b

Java 21 performance

Base: ad10fe5 → candidate: 867e35b
Shared benchmark source: ddb898473de6f0f66081d1bdef7adf18cd447119

Throughput alerts are advisory. Existing allocation budgets are enforced separately.
A timing signal needs at least 10% change and separated JMH 99.9% intervals; otherwise it is inconclusive.
Allocation alerts need both >5% and >16 B/op growth (or >16 B/op from zero).

Benchmark Base ops/s Candidate ops/s Change Timing B/op base → candidate Allocation
simpleCodegenUtf16 10,528,589 ±107,428 10,513,805 ±126,230 -0.1% inconclusive 295.5 → 295.5 within alert threshold
simpleCodegenUtf8 10,206,807 ±242,372 9,502,190 ±1,475,447 -6.9% inconclusive 271.5 → 271.5 within alert threshold
simpleTypedUtf16 7,928,782 ±34,135 7,956,098 ±65,732 +0.3% inconclusive 295.5 → 295.5 within alert threshold
simpleTypedUtf8 8,333,952 ±288,017 8,421,022 ±215,883 +1.0% inconclusive 271.5 → 271.5 within alert threshold
simpleReflectionUtf16 4,102,211 ±125,765 4,063,652 ±289,569 -0.9% inconclusive 340.0 → 340.0 within alert threshold
simpleReflectionUtf8 4,132,113 ±23,141 4,022,077 ±90,241 -2.7% inconclusive 316.0 → 316.0 within alert threshold
complexCodegenUtf16 718,641 ±27,307 717,349 ±18,584 -0.2% inconclusive 1481.9 → 1481.9 within alert threshold
complexCodegenUtf8 725,616 ±13,566 722,920 ±10,705 -0.4% inconclusive 1457.9 → 1457.9 within alert threshold
complexTypedUtf16 655,039 ±22,105 643,729 ±17,287 -1.7% inconclusive 1289.9 → 1289.9 within alert threshold
complexTypedUtf8 668,922 ±2,698 686,764 ±21,531 +2.7% inconclusive 1265.9 → 1265.9 within alert threshold
complexReflectionUtf16 374,231 ±4,020 369,964 ±10,409 -1.1% inconclusive 1353.9 → 1353.9 within alert threshold
complexReflectionUtf8 392,992 ±5,260 389,912 ±20,786 -0.8% inconclusive 1329.9 → 1329.9 within alert threshold
mapCodegenUtf16 102,445 ±18,156 102,820 ±18,333 +0.4% inconclusive 5025.2 → 5025.3 within alert threshold
mapCodegenUtf8 94,397 ±11,709 92,695 ±10,184 -1.8% inconclusive 5001.3 → 5001.3 within alert threshold
mapTypedUtf16 68,543 ±7,274 71,203 ±10,770 +3.9% inconclusive 4805.6 → 4805.5 within alert threshold
mapTypedUtf8 63,379 ±15,395 68,535 ±6,276 +8.1% inconclusive 4781.6 → 4781.5 within alert threshold
mapReflectionUtf16 44,073 ±1,212 46,172 ±1,941 +4.8% inconclusive 7634.2 → 7634.1 within alert threshold
mapReflectionUtf8 45,164 ±3,409 45,443 ±3,587 +0.6% inconclusive 7610.1 → 7610.3 within alert threshold
structCodegenUtf16 668,836 ±54,931 684,474 ±17,823 +2.3% inconclusive 735.7 → 735.7 within alert threshold
structCodegenUtf8 644,707 ±31,759 650,948 ±4,332 +1.0% inconclusive 711.7 → 711.7 within alert threshold
structTypedUtf16 678,478 ±39,610 671,173 ±26,665 -1.1% inconclusive 735.7 → 735.7 within alert threshold
structTypedUtf8 635,499 ±52,003 643,495 ±8,449 +1.3% inconclusive 711.7 → 711.7 within alert threshold
structReflectionUtf16 651,943 ±43,560 593,948 ±104,301 -8.9% inconclusive 735.7 → 735.7 within alert threshold
structReflectionUtf8 609,951 ±54,136 617,220 ±12,624 +1.2% inconclusive 711.7 → 711.7 within alert threshold
timestampCodegenUtf16 3,933,872 ±27,535 3,831,198 ±179,507 -2.6% inconclusive 464.0 → 464.0 within alert threshold
timestampCodegenUtf8 4,138,676 ±24,944 4,155,149 ±42,022 +0.4% inconclusive 440.0 → 440.0 within alert threshold
timestampTypedUtf16 3,197,883 ±116,491 3,182,529 ±143,294 -0.5% inconclusive 464.0 → 464.0 within alert threshold
timestampTypedUtf8 3,440,025 ±66,713 3,456,852 ±30,713 +0.5% inconclusive 440.0 → 440.0 within alert threshold
timestampReflectionUtf16 2,471,187 ±67,304 2,501,325 ±10,578 +1.2% inconclusive 464.0 → 464.0 within alert threshold
timestampReflectionUtf8 2,586,442 ±26,235 2,575,872 ±34,763 -0.4% inconclusive 440.0 → 440.0 within alert threshold

Java 25 performance

Base: ad10fe5 → candidate: 867e35b
Shared benchmark source: ddb898473de6f0f66081d1bdef7adf18cd447119

Throughput alerts are advisory. Existing allocation budgets are enforced separately.
A timing signal needs at least 10% change and separated JMH 99.9% intervals; otherwise it is inconclusive.
Allocation alerts need both >5% and >16 B/op growth (or >16 B/op from zero).

Benchmark Base ops/s Candidate ops/s Change Timing B/op base → candidate Allocation
simpleCodegenUtf16 13,200,030 ±516,958 13,346,012 ±178,158 +1.1% inconclusive 295.5 → 295.5 within alert threshold
simpleCodegenUtf8 14,121,219 ±44,203 14,413,093 ±355,802 +2.1% inconclusive 271.5 → 271.5 within alert threshold
simpleTypedUtf16 10,593,177 ±40,980 10,571,471 ±262,820 -0.2% inconclusive 295.5 → 295.5 within alert threshold
simpleTypedUtf8 11,255,426 ±210,166 11,214,586 ±252,536 -0.4% inconclusive 271.5 → 271.5 within alert threshold
simpleReflectionUtf16 5,357,486 ±73,387 5,315,865 ±157,223 -0.8% inconclusive 340.0 → 340.0 within alert threshold
simpleReflectionUtf8 5,457,194 ±48,565 5,392,222 ±30,814 -1.2% inconclusive 316.0 → 316.0 within alert threshold
complexCodegenUtf16 975,088 ±42,662 972,969 ±1,925 -0.2% inconclusive 1481.9 → 1481.9 within alert threshold
complexCodegenUtf8 982,024 ±27,050 989,577 ±12,723 +0.8% inconclusive 1457.9 → 1457.9 within alert threshold
complexTypedUtf16 874,193 ±3,472 875,076 ±13,395 +0.1% inconclusive 1289.9 → 1289.9 within alert threshold
complexTypedUtf8 928,400 ±3,629 919,746 ±9,560 -0.9% inconclusive 1265.9 → 1265.9 within alert threshold
complexReflectionUtf16 509,942 ±5,974 500,065 ±15,312 -1.9% inconclusive 1353.9 → 1353.9 within alert threshold
complexReflectionUtf8 534,366 ±6,653 534,603 ±3,095 +0.0% inconclusive 1329.9 → 1329.9 within alert threshold
mapCodegenUtf16 139,551 ±11,502 135,406 ±21,145 -3.0% inconclusive 4589.5 → 4589.5 within alert threshold
mapCodegenUtf8 137,951 ±16,806 126,455 ±20,638 -8.3% inconclusive 4565.5 → 4565.5 within alert threshold
mapTypedUtf16 90,026 ±8,761 94,139 ±8,000 +4.6% inconclusive 4805.5 → 4805.5 within alert threshold
mapTypedUtf8 92,136 ±6,445 87,826 ±4,911 -4.7% inconclusive 4781.5 → 4781.5 within alert threshold
mapReflectionUtf16 52,403 ±2,570 51,698 ±2,173 -1.3% inconclusive 7634.1 → 7634.2 within alert threshold
mapReflectionUtf8 52,845 ±814 50,872 ±2,173 -3.7% inconclusive 7610.2 → 7610.2 within alert threshold
structCodegenUtf16 863,275 ±25,796 863,140 ±38,973 -0.0% inconclusive 735.7 → 735.7 within alert threshold
structCodegenUtf8 824,230 ±54,144 834,045 ±13,114 +1.2% inconclusive 711.7 → 711.7 within alert threshold
structTypedUtf16 865,858 ±22,822 861,428 ±20,139 -0.5% inconclusive 735.7 → 735.7 within alert threshold
structTypedUtf8 836,090 ±27,506 834,176 ±20,347 -0.2% inconclusive 711.7 → 711.7 within alert threshold
structReflectionUtf16 845,846 ±4,888 849,800 ±35,790 +0.5% inconclusive 735.7 → 735.7 within alert threshold
structReflectionUtf8 820,617 ±17,767 808,028 ±25,454 -1.5% inconclusive 711.7 → 711.7 within alert threshold
timestampCodegenUtf16 4,990,793 ±101,467 5,002,316 ±38,528 +0.2% inconclusive 464.0 → 464.0 within alert threshold
timestampCodegenUtf8 6,024,149 ±162,922 6,059,771 ±28,565 +0.6% inconclusive 440.0 → 440.0 within alert threshold
timestampTypedUtf16 4,258,748 ±11,481 4,262,180 ±12,029 +0.1% inconclusive 464.0 → 464.0 within alert threshold
timestampTypedUtf8 4,919,523 ±89,221 4,951,288 ±25,538 +0.6% inconclusive 440.0 → 440.0 within alert threshold
timestampReflectionUtf16 3,179,179 ±17,488 3,160,394 ±26,898 -0.6% inconclusive 464.0 → 464.0 within alert threshold
timestampReflectionUtf8 3,460,018 ±230,356 3,528,944 ±86,934 +2.0% inconclusive 440.0 → 440.0 within alert threshold

… checks

Follow-ups from review of the infinite-loop fix:

- Codegen rejects null repeated elements with a JSONException
  (FieldReader.requireNonNullElement), as JsonFormat does. They used to
  throw NullPointerException (Timestamp, Duration, FieldMask,
  String/BytesValue) or add a phantom default element (int64, bool,
  enum, wrappers). Value elements keep null-as-NullValue, and NullValue
  elements now map null to NULL_VALUE.
- The typed and reflection repeated readers keep null elements of
  repeated Value/NullValue as values (FieldReader.nullValueFor) instead
  of dropping them, matching codegen, JsonFormat and the encoder's
  output. Other null elements are still skipped on those paths.
- Empty or whitespace-only input decodes to null from every overload
  (byte[], slice, InputStream), like an empty String. The new
  object-start check would otherwise have turned these into exceptions.
- Hoist the descriptor in readMessage(JSONReader, Builder), use the
  FIELD_READER constant for every generated FieldReader reference, and
  correct the docs.

Adds TestRepeatedNullValue to the test protos so the generated
NullValue branch is compiled and tested on all three paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AbdzHqfNXSdynywckLnWY
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.

2 participants