@@ -85,9 +85,16 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler {
8585 // Per-query credit state (accessed only from the I/O thread).
8686 // creditEnabled == (initialCredit > 0); controls whether we emit CREDIT
8787 // replenish frames after each batch release.
88+ // Set true by closePool() before it drains freeBuffers / events, so any
89+ // user thread still inside releaseBuffer (or one that returns from onBatch
90+ // after close has run) sees closed == true and frees the buffer in place
91+ // instead of stranding it in a queue nobody will iterate again.
92+ // Volatile because the writer (close-caller thread) and reader (user
93+ // thread inside releaseBuffer) are different threads.
94+ private volatile boolean closed ;
8895 private boolean creditEnabled ;
89- private long currentRequestId = -1L ;
9096 private boolean currentQueryDone ;
97+ private long currentRequestId = -1L ;
9198 private volatile boolean shutdown ;
9299
93100 public QwpEgressIoThread (WebSocketClient wsClient , int bufferPoolSize ) {
@@ -97,8 +104,21 @@ public QwpEgressIoThread(WebSocketClient wsClient, int bufferPoolSize) {
97104 // frame the I/O thread may emit on shutdown, so a full buffer pool
98105 // never stalls the producer.
99106 this .events = new QwpSpscQueue <>(bufferPoolSize + 2 );
100- for (int i = 0 ; i < bufferPoolSize ; i ++) {
101- freeBuffers .offer (new QwpBatchBuffer (DEFAULT_BUFFER_CAPACITY ));
107+ // Track allocations as we go so a partial-pool failure (e.g. native
108+ // OOM at iteration K) frees the K-1 buffers already in freeBuffers.
109+ // Without this, the half-built QwpEgressIoThread instance escapes the
110+ // failed constructor and is unreachable from QwpQueryClient.close(),
111+ // leaking those buffers' native scratches for the JVM's lifetime.
112+ try {
113+ for (int i = 0 ; i < bufferPoolSize ; i ++) {
114+ freeBuffers .offer (new QwpBatchBuffer (DEFAULT_BUFFER_CAPACITY ));
115+ }
116+ } catch (Throwable t ) {
117+ for (QwpBatchBuffer b : freeBuffers ) {
118+ b .close ();
119+ }
120+ freeBuffers .clear ();
121+ throw t ;
102122 }
103123 }
104124
@@ -175,6 +195,15 @@ public void onClose(int code, String reason) {
175195 * compact past the consumed frame.
176196 */
177197 public void releaseBuffer (QwpBatchBuffer buffer ) {
198+ if (closed ) {
199+ // closePool already drained and cleared freeBuffers; offering this
200+ // buffer back into the pool would strand it (no consumer left to
201+ // close it). Free its native scratch in place. The pendingRelease
202+ // queue is also abandoned -- the I/O thread that would have read
203+ // the token is gone.
204+ buffer .close ();
205+ return ;
206+ }
178207 freeBuffers .offer (buffer );
179208 pendingRelease .offer (RELEASE_TOKEN );
180209 }
@@ -289,13 +318,22 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) {
289318
290319 /**
291320 * RESULT_END body: msg_kind(1) + requestId(8) + final_seq(varint) + total_rows(varint).
292- * We only need total_rows.
321+ * We only need total_rows. Both varint loops cap at 10 bytes so a hostile
322+ * server cannot drive {@code shift} past 63 (where Java's {@code <<} masks
323+ * the count to 6 bits and silently wraps high bytes back into the low bits,
324+ * producing a wildly wrong total).
293325 */
294326 private long decodeResultEnd (long payload , int payloadLen ) {
295327 long p = payload + QwpConstants .HEADER_SIZE + 1 + 8 ;
296328 long limit = payload + payloadLen ;
329+ int seqBytes = 0 ;
297330 while (p < limit && (Unsafe .getUnsafe ().getByte (p ++) & 0x80 ) != 0 ) {
298- // skip final_seq continuation bytes
331+ if (++seqBytes > 9 ) {
332+ // Continuation bit set on byte 10 of the final_seq varint --
333+ // malformed. Surface a 0 total rather than read into the
334+ // total_rows section with a desynced cursor.
335+ return 0L ;
336+ }
299337 }
300338 long total = 0 ;
301339 int shift = 0 ;
@@ -304,6 +342,12 @@ private long decodeResultEnd(long payload, int payloadLen) {
304342 total |= (long ) (b & 0x7F ) << shift ;
305343 if ((b & 0x80 ) == 0 ) break ;
306344 shift += 7 ;
345+ if (shift > 63 ) {
346+ // Same overflow guard decodeAndEmitExecDone uses; without it,
347+ // byte 10 of total_rows could land in the sign bit and bytes
348+ // 11+ would silently wrap.
349+ return 0L ;
350+ }
307351 }
308352 return total ;
309353 }
@@ -394,26 +438,26 @@ private void handleResultBatch(long payloadPtr, int payloadLen) {
394438 }
395439
396440 /**
397- * Builds and transmits a CREDIT frame on the WebSocket. Wire format:
398- * {@code msg_kind(0x15 ) + request_id(u64) + additional_bytes(varint )}.
441+ * Builds and transmits a CANCEL frame on the WebSocket. Wire format:
442+ * {@code msg_kind(0x14 ) + request_id(u64)}.
399443 */
400- private void sendCredit (long requestId , long additionalBytes ) {
444+ private void sendCancel (long requestId ) {
401445 sendScratch .reset ();
402- sendScratch .putByte (QwpEgressMsgKind .CREDIT );
446+ sendScratch .putByte (QwpEgressMsgKind .CANCEL );
403447 sendScratch .putLong (requestId );
404- sendScratch .putVarint (additionalBytes );
405448 wsClient .sendBinary (sendScratch .getBufferPtr (), sendScratch .getPosition ());
406449 sendScratch .reset ();
407450 }
408451
409452 /**
410- * Builds and transmits a CANCEL frame on the WebSocket. Wire format:
411- * {@code msg_kind(0x14 ) + request_id(u64)}.
453+ * Builds and transmits a CREDIT frame on the WebSocket. Wire format:
454+ * {@code msg_kind(0x15 ) + request_id(u64) + additional_bytes(varint )}.
412455 */
413- private void sendCancel (long requestId ) {
456+ private void sendCredit (long requestId , long additionalBytes ) {
414457 sendScratch .reset ();
415- sendScratch .putByte (QwpEgressMsgKind .CANCEL );
458+ sendScratch .putByte (QwpEgressMsgKind .CREDIT );
416459 sendScratch .putLong (requestId );
460+ sendScratch .putVarint (additionalBytes );
417461 wsClient .sendBinary (sendScratch .getBufferPtr (), sendScratch .getPosition ());
418462 sendScratch .reset ();
419463 }
@@ -450,6 +494,12 @@ private void sendQueryRequest(QueryRequest req) {
450494 * forever on an empty queue.
451495 */
452496 void closePool () {
497+ // Set closed BEFORE draining so a user thread that returns from
498+ // onBatch concurrently and calls releaseBuffer sees closed=true and
499+ // frees the buffer in place rather than offering it into a queue
500+ // we're about to clear. Volatile write pairs with the volatile read
501+ // in releaseBuffer.
502+ closed = true ;
453503 Misc .free (sendScratch );
454504 Misc .free (decoder );
455505 QueryEvent ev ;
0 commit comments