Skip to content

Commit 941b447

Browse files
committed
review
1 parent b665b8c commit 941b447

8 files changed

Lines changed: 1486 additions & 454 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)