From 360beb18f9ad9e5fb80ca5d6a7c8f5f31863c3ca Mon Sep 17 00:00:00 2001 From: Davide D'Alto Date: Thu, 1 Oct 2026 16:02:01 +0200 Subject: [PATCH] Fix DB2Decoder NPE when chained DSS response is split across TCP segments Fixes #899 A DRDA response from DB2 can consist of multiple chained DSS (Data Stream Structure) segments, linked via a chain bit (0x40) in byte 3 of each segment's header. The computeLength() method is supposed to calculate the total length of all chained segments before the decoder processes them as a single logical response. Bug: When TCP delivers only the first segment(s) of a chained response (with the chain bit set) but the next chained segment has not arrived yet, computeLength() returns the partial length (equal to readableBytes). Since decode() only waits when payloadLength > readableBytes, it proceeds to call decodePayload() with incomplete data. The command handler processes the partial response and either: 1. Calls ensureALayerDataInBuffer() to read the next DSS header but the buffer is empty: "Needed to have 6 in buffer but only had 0" 2. Calls fireCommandSuccess()/fireCommandFailure() which polls the command from the inflight queue. When the remaining chained segments arrive, inflight.peek() returns the WRONG command: "Invalid correlator ID. Got 2 expected 1" 3. Or inflight.peek() returns null: NullPointerException in decodePayload() 4. Leftover DSS data from command A is fed to command B's handler, which encounters unexpected DRDA codepoints: "Found unknown codepoint: 0x2411" The crash sequence: 1. TCP delivers DSS_1 (chain bit set), DSS_2 not yet received 2. computeLength() returns len(DSS_1) == readableBytes 3. decode() proceeds (payloadLength <= readableBytes) 4. Command processes partial data, polls itself from inflight 5. DSS_2 arrives -> dispatched to wrong command or null -> error This is intermittent because TCP segmentation is non-deterministic - whether the OS delivers all DSS segments in one read() depends on timing, buffer sizes, and network conditions. Fix: After the while loop, check if dssContinues is still true (meaning the chain bit was set on the last DSS we read but the next segment hasn't arrived). If so, return readableBytes + 1 to force decode() to wait for more data. Also fix a secondary bug: use getUnsignedShort() instead of getShort() for reading DSS lengths. The DRDA spec defines DSS lengths as unsigned 16-bit values (0-65535), but getShort() returns a signed Java short (-32768 to 32767), which would produce negative values for segments larger than 32767 bytes. Co-Authored-By: Claude Opus 4.6 --- .../db2client/impl/codec/DB2Decoder.java | 12 +- .../impl/codec/DB2DecoderChainedDssTest.java | 158 ++++++++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 vertx-db2-client/src/test/java/io/vertx/db2client/impl/codec/DB2DecoderChainedDssTest.java diff --git a/vertx-db2-client/src/main/java/io/vertx/db2client/impl/codec/DB2Decoder.java b/vertx-db2-client/src/main/java/io/vertx/db2client/impl/codec/DB2Decoder.java index 1df46b17b..65b30da8f 100644 --- a/vertx-db2-client/src/main/java/io/vertx/db2client/impl/codec/DB2Decoder.java +++ b/vertx-db2-client/src/main/java/io/vertx/db2client/impl/codec/DB2Decoder.java @@ -61,11 +61,17 @@ private int computeLength(ByteBuf in) { dssContinues &= (in.getByte(ridx + index + 3) & 0x40) == 0x40; else dssContinues = false; - short dssLen = 11; // minimum length of DRDA message - if (readableBytes >= index + 2) // julien: is this correct ? thtis checks more space than necessary - dssLen = in.getShort(ridx + index); + int dssLen = 11; // minimum length of DRDA message + if (readableBytes >= index + 2) + dssLen = in.getUnsignedShort(ridx + index); index += dssLen; } + if (dssContinues) { + // Chain bit was set on the last DSS we could fully read, but the next + // chained DSS has not arrived yet. Signal "need more data" so that + // decode() waits instead of processing a partial response. + return readableBytes + 1; + } return index; } diff --git a/vertx-db2-client/src/test/java/io/vertx/db2client/impl/codec/DB2DecoderChainedDssTest.java b/vertx-db2-client/src/test/java/io/vertx/db2client/impl/codec/DB2DecoderChainedDssTest.java new file mode 100644 index 000000000..b23c7e263 --- /dev/null +++ b/vertx-db2-client/src/test/java/io/vertx/db2client/impl/codec/DB2DecoderChainedDssTest.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.db2client.impl.codec; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.Test; + +import java.util.ArrayDeque; + +import static org.junit.Assert.*; + +/** + * Tests for {@link DB2Decoder#computeLength(ByteBuf)} handling of chained + * DRDA DSS segments that arrive split across TCP deliveries. + *

+ * A DRDA response can consist of multiple DSS (Data Stream Structure) segments + * chained together via bit 6 (0x40) of byte 3 in each DSS header. When TCP + * delivers only the first segment(s) of a chained response, the decoder must + * wait for the remaining segments before attempting to process the response. + *

+ * DSS header format (6 bytes): + *

+ *   bytes 0-1: length (unsigned 16-bit, big-endian)
+ *   byte  2  : magic  (0xD0)
+ *   byte  3  : format flags (bit 6 = chain bit: 0x40 = chained, 0x00 = last)
+ *   bytes 4-5: correlation ID
+ * 
+ */ +public class DB2DecoderChainedDssTest { + + private static final byte DSS_MAGIC = (byte) 0xD0; + private static final byte CHAIN = 0x42; // chain bit (0x40) + RPYDSS format (0x02) + private static final byte NO_CHAIN = 0x02; // no chain, RPYDSS format + + private EmbeddedChannel createChannel() { + ArrayDeque> inflight = new ArrayDeque<>(); + return new EmbeddedChannel(new DB2Decoder(inflight)); + } + + private ByteBuf buildDss(int length, boolean chained) { + ByteBuf buf = Unpooled.buffer(length); + buf.writeShort(length); + buf.writeByte(DSS_MAGIC); + buf.writeByte(chained ? CHAIN : NO_CHAIN); + buf.writeShort(1); // correlation ID + buf.writeZero(length - 6); + return buf; + } + + /** + * When a chained DSS arrives but its successor has not been delivered yet, + * the decoder must wait for more data. Before the fix, computeLength() + * returned the length of just the first DSS (which equalled readableBytes), + * causing decode() to pass partial data to decodePayload(). With an empty + * inflight queue this manifested as a NullPointerException. + */ + @Test + public void testChainedDssSplitAcrossTcpDeliveries() { + EmbeddedChannel channel = createChannel(); + + ByteBuf dss1 = buildDss(20, true); + + // Feed only DSS_1 (chain bit set) without the following DSS_2. + // The decoder must NOT attempt to process this partial response. + assertFalse( + "Decoder must not produce output for incomplete chained response", + channel.writeInbound(dss1)); + + // No exception means the decoder correctly waited for more data. + // Before the fix, this would NPE in decodePayload() because + // inflight.peek() returned null. + assertNull("No inbound message expected", channel.readInbound()); + + channel.finishAndReleaseAll(); + } + + /** + * When only a partial DSS header is available (fewer than 4 bytes, so the + * chain bit cannot be read), the decoder must wait for more data. + */ + @Test + public void testPartialDssHeader() { + EmbeddedChannel channel = createChannel(); + + // Only 3 bytes - not enough for a full 4-byte header check + ByteBuf partial = Unpooled.buffer(3); + partial.writeShort(20); // declared length = 20 + partial.writeByte(DSS_MAGIC); + + assertFalse( + "Decoder must wait when DSS header is incomplete", + channel.writeInbound(partial)); + + assertNull("No inbound message expected", channel.readInbound()); + + channel.finishAndReleaseAll(); + } + + /** + * When a chained DSS arrives with its successor partially delivered (only + * part of DSS_2's bytes are in the buffer), the decoder must still wait. + */ + @Test + public void testChainedDssWithPartialSecondSegment() { + EmbeddedChannel channel = createChannel(); + + ByteBuf dss1 = buildDss(20, true); + // DSS_2 declares length 30 but only 10 bytes arrived so far + ByteBuf partialDss2 = Unpooled.buffer(10); + partialDss2.writeShort(30); + partialDss2.writeByte(DSS_MAGIC); + partialDss2.writeByte(NO_CHAIN); + partialDss2.writeShort(1); + partialDss2.writeZero(4); + + ByteBuf combined = Unpooled.wrappedBuffer(dss1, partialDss2); + + assertFalse( + "Decoder must wait when second DSS segment is incomplete", + channel.writeInbound(combined)); + + assertNull("No inbound message expected", channel.readInbound()); + + channel.finishAndReleaseAll(); + } + + /** + * Three-segment chain where only the first two segments have arrived. + * DSS_1 (chain) -> DSS_2 (chain) -> DSS_3 (not yet received). + */ + @Test + public void testThreeSegmentChainWithMissingThird() { + EmbeddedChannel channel = createChannel(); + + ByteBuf dss1 = buildDss(16, true); + ByteBuf dss2 = buildDss(24, true); // chain bit still set + + ByteBuf combined = Unpooled.wrappedBuffer(dss1, dss2); + + assertFalse( + "Decoder must wait when third chained segment is missing", + channel.writeInbound(combined)); + + assertNull("No inbound message expected", channel.readInbound()); + + channel.finishAndReleaseAll(); + } +}