anton-vinogradov commented on PR #13329:
URL: https://github.com/apache/ignite/pull/13329#issuecomment-4895522810

   Reviewed the whole change (the `CompressedMessage`/serializer part matches 
my #13325, so the findings there carry over; the `DirectMessageReader` part — 
`curStream` cache and `tmpReader` reuse — is new and I looked at it from 
scratch). The `curStream` cache itself checks out: all three mutation points 
(`setBuffer` / `beforeNestedRead` / `afterNestedRead`) keep it in sync, depth 
only ever changes inside `try/finally` in `DirectByteBufferStream`, and every 
production `reset()` is followed by `setBuffer` before the next read. The 
benchmark compiles with `-Pbenchmarks` and runs. Found the following, roughly 
by severity.
   
   ### 1. `tmpReader` reuse: one partial inner read poisons every subsequent 
compressed field on the connection (reproduced)
   
   `reset()` is a shallow reset: `DirectMessageState.reset()` only zeroes 
`stack[0].state` and asserts `pos == 0`. It does **not** clear the per-stream 
parse state (`msgTypeDone`, half-read `msg`, `readSize`/`readItems`, map 
iteration, `tmpArr`) — those are only cleared when a `readMessage`/`readMap` 
*finishes successfully* — and `backward(false)` intentionally preserves the 
state of abandoned deeper items for resume.
   
   The old code was immune by construction (`new DirectMessageReader(...)` per 
compressed field). With reuse, this sequence breaks: a `CompressedMessage` 
whose inflated payload is logically incomplete (incompatible peer, corruption, 
understated `dataSize` — see #4) makes `fun.apply(tmpReader)` return with 
`lastFinished == false` and **no exception**, so the session stays alive. The 
next, fully valid compressed field on the same per-connection reader reuses the 
poisoned `tmpReader`: `readMessage` sees the stale `msgTypeDone == true`, skips 
the type header of the new payload and resumes the *old* half-read message at a 
wrong offset → the valid field reads as `null` with `lastRead == false` forever 
(outer parser waits for bytes that never come), or a silently corrupted message 
is assembled.
   
   I verified this with a quick repro against the branch classes: after one 
truncated payload, a following valid field fails to deserialize; with a fresh 
reader (old behavior) the same field reads fine.
   
   The clean fix falls out of an invariant this method already has: 
`tmpReader`'s buffer is always the *complete* payload, so a partial inner read 
is never "need more bytes" — it is always corruption. Instead of propagating it 
as `lastRead = false`:
   
   ```java
   T res = fun.apply(tmpReader);
   
   if (!tmpReader.state.item().stream.lastFinished()) {
       tmpReader = null; // Don't reuse a reader with half-read state.
   
       throw new IgniteException("Failed to deserialize compressed payload: " +
           "uncompressed data ended unexpectedly [dataSize=" + msg0.dataSize() 
+ ']');
   }
   
   lastRead = true;
   ```
   
   This also turns the silent protocol desync from #4 into a localized, 
diagnosable error, and dropping `tmpReader` matters because the session is not 
guaranteed to die: the legacy selector loop (`IGNITE_NO_SELECTOR_OPTS=true`, 
`GridNioServer.processSelectedKeys`) logs the exception and keeps the session — 
only the optimized path closes it.
   
   ### 2. `tmpReader` retains the last uncompressed payload for the connection 
lifetime
   
   `tmpReader` is a field of the per-connection reader (session meta, 
`GridDirectParser`), and after the read its `stack[0].stream` keeps referencing 
the `ByteBuffer.wrap(uncompressed)`. For exchange traffic 
(`GridDhtPartitionsFullMessage` on large topologies) that's megabytes per 
connection held until the *next* compressed message on that connection or TCP 
session close; on the coordinator — one payload per node connection. The old 
per-call reader made it garbage immediately. One line after `fun.apply`:
   
   ```java
   tmpReader.setBuffer(EMPTY_BUF); // static final ByteBuffer EMPTY_BUF = 
ByteBuffer.wrap(new byte[0]);
   ```
   
   ### Carried over from #13325 (identical `CompressedMessage`/serializer code)
   
   These three have no benign trigger — the sender computes `dataSize = 
buf.remaining()` correctly by construction — but the reworked receive path made 
the wire `dataSize` load-bearing, while master was insensitive to it (`tmpBuf` 
grew from actually received chunks, `readAllBytes()` sized the output, 
`dataSize` was assert-only). So on a corrupted stream the failure quality 
degrades vs master, and the guards are cheap:
   
   **3. `dataSize > 0` + immediate `finalChunk` → bare NPE.** `readFrom()` 
legitimately returns `true` with `chunks == null` when the first `finalChunk` 
boolean is `true` (state 1 exits before state 2 runs) — the new null-chunk 
guard doesn't cover this path. `uncompress()` then returns `null` and the 
caller hits `ByteBuffer.wrap(null)` → NPE in the NIO worker. Master threw 
`IgniteException (EOFException: Unexpected end of ZLIB input stream)` on the 
same input. Fix in `uncompress()`:
   
   ```java
   if (chunks == null)
       throw new IgniteException("Compressed stream is truncated [expected=" + 
dataSize + ", inflated=0]");
   ```
   
   (The `null` return contract is dead: the caller already short-circuits 
`dataSize() == 0`.)
   
   **4. Understated `dataSize` truncates silently.** Both inflate loops are 
bounded by `off < dataSize`, so a valid stream that inflates to *more* than 
`dataSize` is cut short and the `off != dataSize` check can't fire — it only 
covers the "shorter" direction, while master's `assert uncompressedData.length 
== dataSize` covered both. The consequence is the partial-read cascade from #1. 
Besides the reader-level guard above, the invariant is cheap to restore locally 
with a probe inflate after the main loop (hoist the chunk index out of the 
`for`):
   
   ```java
   byte[] probe = new byte[1];
   
   while (!inflater.finished()) {
       if (inflater.needsInput()) {
           if (i == chunks.size())
               break;
   
           inflater.setInput(chunks.get(i++));
       }
   
       if (inflater.inflate(probe, 0, 1) > 0)
           throw new IgniteException("Compressed stream is longer than expected 
[expected=" + dataSize + ']');
   }
   ```
   
   A well-formed stream just consumes the deflate terminator and finishes here. 
Worth a unit test next to `testReadFailsOnNullChunk`: valid compressed payload 
with the `dataSize` field patched smaller → expect `IgniteException`.
   
   **5. `dataSize` drives allocations before any validation.** Negative values: 
`dataSize <= -20480` → `new ArrayList<>(msg.dataSize / CHUNK_SIZE + 1)` → 
`IllegalArgumentException: Illegal Capacity`; `-1..-20479` → `new 
byte[dataSize]` → `NegativeArraySizeException`. Huge values: `dataSize = 
Integer.MAX_VALUE` + one tiny chunk → ~2 GB upfront allocation → 
`OutOfMemoryError` → failure handler. Minimal guard in `readFrom` state 0:
   
   ```java
   if (msg.dataSize < 0)
       throw new IgniteException("Invalid compressed message data size: " + 
msg.dataSize);
   ```
   
   Optionally bound the allocation by what was actually received (raw deflate 
can't expand beyond ~1032:1): before `new byte[dataSize]`, check `dataSize <= 
totalCompressedLen * 1032L + 64`.
   
   ### 6. Benchmark nit
   
   `@Setup` relies on `assert finished` after `writeMessage`, but asserts are 
off in the JMH fork by default — if the message ever outgrows the 4 MB buffer 
(larger `entries` param), the benchmark will happily measure deserialization of 
a truncated stub. Make it explicit: `if (!finished) throw new 
IllegalStateException("Message doesn't fit the buffer");`
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to