Skip to content

Commit 7ace601

Browse files
committed
fix(ws): in-callback close() no longer corrupts recv state on the unwinding parse path
CursorWebSocketSendLoop's NACK-recycle (handleServerRejection / handlePreSendRejection / onClose -> failPaced()/fail() -> connectLoop -> swapClient -> oldClient.close()) closes the client synchronously while the I/O thread is still inside that client's tryParseFrame. Control then unwinds into the post-handler tail, which advanced recvReadPos and compacted the recv buffer on the just-closed client: recvPos went negative, and only the accident of disconnect() zeroing positions plus compactRecvBuffer's remaining > 0 guard stood between that and a Vect.memmove on the freed recvBufPtr. Make in-callback close a supported contract instead: tryParseFrame's tail detects closed and returns PARSE_OK without touching recv state (the frame was fully dispatched), covering every present and future frame handler rather than just the cursor send loop. Pin the restored invariant with an assert in compactRecvBuffer. Red/green: without the guard the new tests observe recvPos=-6/-4 after an in-callback close from onBinaryMessage/onClose; with it, positions stay at disconnect()'s reset. NACK-recycle e2e suites unaffected.
1 parent 0f1da89 commit 7ace601

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,12 @@ private void checkConnected() {
911911
private void compactRecvBuffer() {
912912
if (recvReadPos > 0) {
913913
int remaining = recvPos - recvReadPos;
914+
// recvPos >= recvReadPos always holds here: a handler-initiated
915+
// close() (which zeroes recvPos under our feet) is caught in
916+
// tryParseFrame's tail before this method is reached. If this
917+
// assert fires, someone reintroduced a post-callback touch of
918+
// recv state on a closed/disconnected client.
919+
assert remaining >= 0 : "recv buffer positions out of order [recvPos=" + recvPos + ", recvReadPos=" + recvReadPos + ']';
914920
if (remaining > 0) {
915921
Vect.memmove(recvBufPtr, recvBufPtr + recvReadPos, remaining);
916922
}
@@ -1258,6 +1264,20 @@ private int tryParseFrame(WebSocketFrameHandler handler) {
12581264
break;
12591265
}
12601266

1267+
// A handler callback above may have close()d this client:
1268+
// CursorWebSocketSendLoop's NACK/close recycle swaps in a new
1269+
// client and synchronously closes this one (swapClient), then
1270+
// control unwinds back here. close() -> disconnect() has already
1271+
// zeroed recvPos/recvReadPos and freed recvBufPtr -- advancing
1272+
// the read position or compacting would corrupt that state
1273+
// (negative recvPos today; a memmove on freed memory if the
1274+
// zeroing ever moved). The frame was fully dispatched, so
1275+
// in-callback close is a supported contract: report success
1276+
// and touch nothing. Same-thread, so the closed read is exact.
1277+
if (closed.get()) {
1278+
return PARSE_OK;
1279+
}
1280+
12611281
// Advance read position
12621282
recvReadPos += consumed;
12631283

core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientTest.java

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import io.questdb.client.network.PlainSocketFactory;
3333
import io.questdb.client.network.Socket;
3434
import io.questdb.client.network.SocketReadinessWaiter;
35+
import io.questdb.client.std.Unsafe;
3536
import org.junit.Assert;
3637
import org.junit.Test;
3738

@@ -127,6 +128,78 @@ public void testExtractMaxBatchSizeParsesPositive() throws Exception {
127128
Assert.assertEquals(16 * 1024 * 1024, invokeExtractMaxBatchSize(response));
128129
}
129130

131+
/**
132+
* A frame handler may close() the client from inside its callback:
133+
* CursorWebSocketSendLoop's NACK-recycle path (handleServerRejection /
134+
* handlePreSendRejection -> failPaced()/fail() -> connectLoop ->
135+
* swapClient -> oldClient.close()) runs synchronously on the I/O thread
136+
* while that thread is still inside this client's tryParseFrame. The
137+
* post-callback tail must detect the close and touch no recv state:
138+
* before the guard, it left recvPos negative on the closed client and
139+
* was one close()-reorder away from a memmove on freed memory.
140+
*/
141+
@Test
142+
public void testInCallbackCloseFromBinaryHandlerLeavesStateCoherent() throws Exception {
143+
assertMemoryLeak(() -> {
144+
// Unmasked server->client binary frame: FIN|BINARY, len=4, "abcd"
145+
byte[] frame = {(byte) 0x82, 0x04, 'a', 'b', 'c', 'd'};
146+
try (FrameFeedWebSocketClient client = new FrameFeedWebSocketClient(frame)) {
147+
setUpgradedTrue(client);
148+
149+
boolean received = client.tryReceiveFrame(new WebSocketFrameHandler() {
150+
@Override
151+
public void onBinaryMessage(long payloadPtr, int payloadLen) {
152+
Assert.assertEquals(4, payloadLen);
153+
client.close();
154+
}
155+
156+
@Override
157+
public void onClose(int code, String reason) {
158+
}
159+
});
160+
161+
Assert.assertTrue("frame must be reported as received", received);
162+
Assert.assertEquals("recvPos must stay at disconnect()'s reset, not go negative",
163+
0, getIntField(client, "recvPos"));
164+
Assert.assertEquals(0, getIntField(client, "recvReadPos"));
165+
}
166+
});
167+
}
168+
169+
/**
170+
* Same contract for the CLOSE-frame branch: onClose handlers routinely
171+
* recycle the connection (every WS close is reconnect-eligible), which
172+
* closes this client before tryParseFrame's tail runs.
173+
*/
174+
@Test
175+
public void testInCallbackCloseFromCloseHandlerLeavesStateCoherent() throws Exception {
176+
assertMemoryLeak(() -> {
177+
// Unmasked server->client close frame: FIN|CLOSE, len=2, code=1000
178+
byte[] frame = {(byte) 0x88, 0x02, 0x03, (byte) 0xE8};
179+
try (FrameFeedWebSocketClient client = new FrameFeedWebSocketClient(frame)) {
180+
setUpgradedTrue(client);
181+
182+
boolean received = client.tryReceiveFrame(new WebSocketFrameHandler() {
183+
@Override
184+
public void onBinaryMessage(long payloadPtr, int payloadLen) {
185+
Assert.fail("unexpected binary frame");
186+
}
187+
188+
@Override
189+
public void onClose(int code, String reason) {
190+
Assert.assertEquals(1000, code);
191+
client.close();
192+
}
193+
});
194+
195+
Assert.assertTrue("frame must be reported as received", received);
196+
Assert.assertEquals("recvPos must stay at disconnect()'s reset, not go negative",
197+
0, getIntField(client, "recvPos"));
198+
Assert.assertEquals(0, getIntField(client, "recvReadPos"));
199+
}
200+
});
201+
}
202+
130203
@Test
131204
public void testRecvOrTimeoutPropagatesNonTimeoutError() throws Exception {
132205
assertMemoryLeak(() -> {
@@ -258,6 +331,24 @@ private static int invokeExtractMaxBatchSize(String response) throws Exception {
258331
return (int) m.invoke(null, response);
259332
}
260333

334+
private static int getIntField(Object obj, String name) {
335+
try {
336+
Class<?> clazz = obj.getClass();
337+
while (clazz != null) {
338+
try {
339+
Field field = clazz.getDeclaredField(name);
340+
field.setAccessible(true);
341+
return field.getInt(obj);
342+
} catch (NoSuchFieldException e) {
343+
clazz = clazz.getSuperclass();
344+
}
345+
}
346+
throw new NoSuchFieldException(name);
347+
} catch (Exception e) {
348+
throw new AssertionError(e);
349+
}
350+
}
351+
261352
private static void setUpgradedTrue(Object obj) throws Exception {
262353
Class<?> clazz = obj.getClass();
263354
while (clazz != null) {
@@ -273,6 +364,94 @@ private static void setUpgradedTrue(Object obj) throws Exception {
273364
throw new NoSuchFieldException("upgraded");
274365
}
275366

367+
/**
368+
* WebSocketClient over a socket pre-loaded with one server frame;
369+
* sends always succeed. Used to drive a real tryReceiveFrame ->
370+
* tryParseFrame -> handler dispatch on the test thread.
371+
*/
372+
private static class FrameFeedWebSocketClient extends WebSocketClient {
373+
374+
FrameFeedWebSocketClient(byte[] frame) {
375+
super(DefaultHttpClientConfiguration.INSTANCE, (nf, log) -> new FrameFeedSocket(frame));
376+
}
377+
378+
@Override
379+
protected void ioWait(int timeout, int op) {
380+
// no-op: recv delivers data on the first call
381+
}
382+
383+
@Override
384+
protected void setupIoWait() {
385+
// no-op
386+
}
387+
}
388+
389+
/**
390+
* Socket that serves a fixed byte sequence from recv() and reports
391+
* every send() as fully written (so close-frame sends succeed).
392+
*/
393+
private static class FrameFeedSocket implements Socket {
394+
private final byte[] data;
395+
private int readPos;
396+
397+
FrameFeedSocket(byte[] data) {
398+
this.data = data;
399+
}
400+
401+
@Override
402+
public void close() {
403+
}
404+
405+
@Override
406+
public int getFd() {
407+
return 0;
408+
}
409+
410+
@Override
411+
public boolean isClosed() {
412+
return false;
413+
}
414+
415+
@Override
416+
public void of(int fd) {
417+
}
418+
419+
@Override
420+
public int recv(long bufferPtr, int bufferLen) {
421+
int n = Math.min(data.length - readPos, bufferLen);
422+
for (int i = 0; i < n; i++) {
423+
Unsafe.getUnsafe().putByte(bufferPtr + i, data[readPos + i]);
424+
}
425+
readPos += n;
426+
return n;
427+
}
428+
429+
@Override
430+
public int send(long bufferPtr, int bufferLen) {
431+
return bufferLen;
432+
}
433+
434+
@Override
435+
public void startTlsSession(CharSequence peerName, SocketReadinessWaiter waiter) {
436+
throw new UnsupportedOperationException();
437+
}
438+
439+
@Override
440+
public boolean supportsTls() {
441+
return false;
442+
}
443+
444+
@Override
445+
public int tlsIO(int readinessFlags) {
446+
return 0;
447+
}
448+
449+
@Override
450+
public boolean wantsTlsWrite() {
451+
return false;
452+
}
453+
}
454+
276455
/**
277456
* Minimal Socket that always returns 0 from recv() (no data available),
278457
* triggering the ioWait path in recvOrTimeout().

0 commit comments

Comments
 (0)