@@ -144,6 +144,14 @@ public QwpEgressIoThread(WebSocketClient wsClient, int bufferPoolSize, TerminalF
144144 b .close ();
145145 }
146146 freeBuffers .clear ();
147+ // sendScratch and decoder are field initializers that run BEFORE
148+ // this constructor body. NativeBufferWriter allocates 8 KiB native
149+ // immediately on construction; QwpResultBatchDecoder allocates
150+ // lazily on first decode but still owns native scratch over its
151+ // lifetime. Free both here so a partial-pool failure (native OOM
152+ // mid-loop) doesn't leak them along with the half-built instance.
153+ Misc .free (sendScratch );
154+ Misc .free (decoder );
147155 throw t ;
148156 }
149157 }
@@ -189,8 +197,7 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) {
189197 if (msgKind == QwpEgressMsgKind .RESULT_BATCH ) {
190198 handleResultBatch (payloadPtr , payloadLen );
191199 } else if (msgKind == QwpEgressMsgKind .RESULT_END ) {
192- long total = decodeResultEnd (payloadPtr , payloadLen );
193- events .offer (new QueryEvent ().asEnd (total ));
200+ decodeAndEmitResultEnd (payloadPtr , payloadLen );
194201 currentQueryDone = true ;
195202 } else if (msgKind == QwpEgressMsgKind .EXEC_DONE ) {
196203 decodeAndEmitExecDone (payloadPtr , payloadLen );
@@ -413,16 +420,28 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) {
413420 byte opType = Unsafe .getUnsafe ().getByte (p ++);
414421 long rowsAffected = 0 ;
415422 int shift = 0 ;
423+ boolean terminated = false ;
416424 while (p < limit ) {
417425 byte b = Unsafe .getUnsafe ().getByte (p ++);
418426 rowsAffected |= (long ) (b & 0x7F ) << shift ;
419- if ((b & 0x80 ) == 0 ) break ;
427+ if ((b & 0x80 ) == 0 ) {
428+ terminated = true ;
429+ break ;
430+ }
420431 shift += 7 ;
421432 if (shift > 63 ) {
422433 emitTerminalTransportError ("EXEC_DONE rows_affected varint overflow" );
423434 return ;
424435 }
425436 }
437+ // The loop also exits when {@code p == limit} on a frame that ended
438+ // mid-varint without a terminator byte. Without this guard, a buggy or
439+ // hostile server could truncate the frame and the partial value would
440+ // surface to the user handler as if the EXEC_DONE completed normally.
441+ if (!terminated ) {
442+ emitTerminalTransportError ("EXEC_DONE frame truncated mid rows_affected varint" );
443+ return ;
444+ }
426445 events .offer (new QueryEvent ().asExecDone (opType , rowsAffected ));
427446 }
428447
@@ -431,35 +450,56 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) {
431450 * We only need total_rows. Both varint loops cap at 10 bytes so a hostile
432451 * server cannot drive {@code shift} past 63 (where Java's {@code <<} masks
433452 * the count to 6 bits and silently wraps high bytes back into the low bits,
434- * producing a wildly wrong total).
453+ * producing a wildly wrong total). A frame that ends mid-varint surfaces
454+ * as a terminal transport error rather than a partial-value RESULT_END.
435455 */
436- private long decodeResultEnd (long payload , int payloadLen ) {
456+ private void decodeAndEmitResultEnd (long payload , int payloadLen ) {
437457 long p = payload + QwpConstants .HEADER_SIZE + 1 + 8 ;
438458 long limit = payload + payloadLen ;
439459 int seqBytes = 0 ;
440- while (p < limit && (Unsafe .getUnsafe ().getByte (p ++) & 0x80 ) != 0 ) {
460+ boolean seqTerminated = false ;
461+ while (p < limit ) {
462+ byte b = Unsafe .getUnsafe ().getByte (p ++);
463+ if ((b & 0x80 ) == 0 ) {
464+ seqTerminated = true ;
465+ break ;
466+ }
441467 if (++seqBytes > 9 ) {
442468 // Continuation bit set on byte 10 of the final_seq varint --
443- // malformed. Surface a 0 total rather than read into the
444- // total_rows section with a desynced cursor.
445- return 0L ;
469+ // malformed. Surface as terminal so the caller cannot proceed
470+ // with a desynced cursor.
471+ emitTerminalTransportError ("RESULT_END final_seq varint overflow" );
472+ return ;
446473 }
447474 }
475+ if (!seqTerminated ) {
476+ emitTerminalTransportError ("RESULT_END frame truncated mid final_seq varint" );
477+ return ;
478+ }
448479 long total = 0 ;
449480 int shift = 0 ;
481+ boolean totalTerminated = false ;
450482 while (p < limit ) {
451483 byte b = Unsafe .getUnsafe ().getByte (p ++);
452484 total |= (long ) (b & 0x7F ) << shift ;
453- if ((b & 0x80 ) == 0 ) break ;
485+ if ((b & 0x80 ) == 0 ) {
486+ totalTerminated = true ;
487+ break ;
488+ }
454489 shift += 7 ;
455490 if (shift > 63 ) {
456491 // Same overflow guard decodeAndEmitExecDone uses; without it,
457492 // byte 10 of total_rows could land in the sign bit and bytes
458493 // 11+ would silently wrap.
459- return 0L ;
494+ emitTerminalTransportError ("RESULT_END total_rows varint overflow" );
495+ return ;
460496 }
461497 }
462- return total ;
498+ if (!totalTerminated ) {
499+ emitTerminalTransportError ("RESULT_END frame truncated mid total_rows varint" );
500+ return ;
501+ }
502+ events .offer (new QueryEvent ().asEnd (total ));
463503 }
464504
465505 /**
@@ -572,12 +612,27 @@ private void handleResultBatch(long payloadPtr, int payloadLen) {
572612 // compact the WebSocket recv buffer, overwriting the bytes that the
573613 // user-visible column pointers still reference. User thread's
574614 // releaseBuffer offers the token that unblocks this take.
575- try {
576- pendingRelease .take ();
577- } catch (InterruptedException ie ) {
578- // Shutdown path: leave the batch to the user thread; they'll see
579- // either the in-progress batch or a subsequent close event.
580- return ;
615+ //
616+ // Wait uninterruptibly: if close() interrupts us here we MUST NOT
617+ // unwind back to tryParseFrame, because tryParseFrame's tail
618+ // advances recvReadPos and runs compactRecvBuffer (Vect.memmove)
619+ // over the bytes the user's column pointers still alias inside
620+ // recv-buf. A premature unwind on interrupt is the close()-during-
621+ // onBatch UAF: the user thread's onBatch reads garbage mid-call.
622+ // Stay parked; close()'s 5-second join timeout will abandon the
623+ // I/O thread (the documented timeout-leak path), but only AFTER
624+ // the user's onBatch has finished -- no UAF.
625+ boolean interrupted = false ;
626+ while (true ) {
627+ try {
628+ pendingRelease .take ();
629+ break ;
630+ } catch (InterruptedException ie ) {
631+ interrupted = true ;
632+ }
633+ }
634+ if (interrupted ) {
635+ Thread .currentThread ().interrupt ();
581636 }
582637 // Credit replenish: the user is done with the batch, so the recv-buffer
583638 // bytes are free. Tell the server it can stream {@code payloadLen} more
0 commit comments