Skip to content

Commit 70adbf1

Browse files
committed
fix(ws): document in-callback close contract; cover fragmented-dispatch close
Review hardening on top of the in-callback close guard: - WebSocketFrameHandler javadoc now states the contract where implementors read it, including the sharp edge: close() frees the buffers the payload pointers point into, so payload must be consumed before closing. - New test pins the CONTINUATION branch: resetFragmentState() runs after the handler returns and before the closed-client guard, and must only write fields (close() has already freed fragmentBufPtr). - Test-file standards: inner classes and reflection helpers in alphabetical order; getIntField declares throws Exception like its siblings. Reviewed and accepted without change: in-callback disconnect() (zeroes positions without setting closed) has no callers anywhere in-tree and is caught loudly by the compactRecvBuffer assert under -ea; the guard's closed.get() is one volatile load per parsed frame, noise next to the recv syscall and frame parse it sits behind.
1 parent 7ace601 commit 70adbf1

2 files changed

Lines changed: 114 additions & 61 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@
3232
* <p>
3333
* Thread safety: Callbacks are invoked from the thread that called receiveFrame().
3434
* Implementations must handle their own synchronization if accessed from multiple threads.
35+
* <p>
36+
* In-callback close: a callback may {@code close()} the client that is
37+
* delivering it — connection recycling does exactly this (see
38+
* CursorWebSocketSendLoop, whose NACK/close handling swaps in a new client
39+
* and synchronously closes the old one before the callback returns). The
40+
* client detects the close when the callback returns, touches no further
41+
* receive state, and reports the frame as received. Note that
42+
* {@code close()} frees the buffers the payload pointers point into: read
43+
* everything you need from the payload BEFORE closing.
3544
*/
3645
public interface WebSocketFrameHandler {
3746

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

Lines changed: 105 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,54 @@ public void onClose(int code, String reason) {
200200
});
201201
}
202202

203+
/**
204+
* Fragmented-message variant: the CONTINUATION branch runs
205+
* resetFragmentState() after the handler returns and before the
206+
* closed-client guard. Pin that an in-callback close from the
207+
* reassembled-message dispatch stays safe there too: close() frees
208+
* fragmentBufPtr, so resetFragmentState() must only write fields.
209+
*/
210+
@Test
211+
public void testInCallbackCloseFromFragmentedMessageLeavesStateCoherent() throws Exception {
212+
assertMemoryLeak(() -> {
213+
// Two unmasked server->client frames: non-FIN BINARY "ab",
214+
// then FIN CONTINUATION "cd" -> one reassembled message "abcd"
215+
byte[] frames = {
216+
0x02, 0x02, 'a', 'b',
217+
(byte) 0x80, 0x02, 'c', 'd'
218+
};
219+
try (FrameFeedWebSocketClient client = new FrameFeedWebSocketClient(frames)) {
220+
setUpgradedTrue(client);
221+
final int[] messages = {0};
222+
WebSocketFrameHandler handler = new WebSocketFrameHandler() {
223+
@Override
224+
public void onBinaryMessage(long payloadPtr, int payloadLen) {
225+
messages[0]++;
226+
Assert.assertEquals(4, payloadLen);
227+
client.close();
228+
}
229+
230+
@Override
231+
public void onClose(int code, String reason) {
232+
Assert.fail("unexpected close frame");
233+
}
234+
};
235+
236+
// First call consumes the initial fragment: parsed and
237+
// buffered, no dispatch yet.
238+
Assert.assertTrue(client.tryReceiveFrame(handler));
239+
Assert.assertEquals(0, messages[0]);
240+
241+
// Second call reassembles and dispatches; the handler
242+
// closes the client in-callback.
243+
Assert.assertTrue(client.tryReceiveFrame(handler));
244+
Assert.assertEquals(1, messages[0]);
245+
Assert.assertEquals(0, getIntField(client, "recvPos"));
246+
Assert.assertEquals(0, getIntField(client, "recvReadPos"));
247+
}
248+
});
249+
}
250+
203251
@Test
204252
public void testRecvOrTimeoutPropagatesNonTimeoutError() throws Exception {
205253
assertMemoryLeak(() -> {
@@ -325,30 +373,26 @@ public void testSendPingDoesNotClobberSendBuffer() throws Exception {
325373
});
326374
}
327375

376+
private static int getIntField(Object obj, String name) throws Exception {
377+
Class<?> clazz = obj.getClass();
378+
while (clazz != null) {
379+
try {
380+
Field field = clazz.getDeclaredField(name);
381+
field.setAccessible(true);
382+
return field.getInt(obj);
383+
} catch (NoSuchFieldException e) {
384+
clazz = clazz.getSuperclass();
385+
}
386+
}
387+
throw new NoSuchFieldException(name);
388+
}
389+
328390
private static int invokeExtractMaxBatchSize(String response) throws Exception {
329391
Method m = WebSocketClient.class.getDeclaredMethod("extractMaxBatchSize", String.class);
330392
m.setAccessible(true);
331393
return (int) m.invoke(null, response);
332394
}
333395

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-
352396
private static void setUpgradedTrue(Object obj) throws Exception {
353397
Class<?> clazz = obj.getClass();
354398
while (clazz != null) {
@@ -365,38 +409,10 @@ private static void setUpgradedTrue(Object obj) throws Exception {
365409
}
366410

367411
/**
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).
412+
* Minimal Socket that always returns 0 from recv() (no data available),
413+
* triggering the ioWait path in recvOrTimeout().
392414
*/
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-
}
415+
private static class FakeSocket implements Socket {
400416

401417
@Override
402418
public void close() {
@@ -418,17 +434,12 @@ public void of(int fd) {
418434

419435
@Override
420436
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;
437+
return 0;
427438
}
428439

429440
@Override
430441
public int send(long bufferPtr, int bufferLen) {
431-
return bufferLen;
442+
return 0;
432443
}
433444

434445
@Override
@@ -453,10 +464,16 @@ public boolean wantsTlsWrite() {
453464
}
454465

455466
/**
456-
* Minimal Socket that always returns 0 from recv() (no data available),
457-
* triggering the ioWait path in recvOrTimeout().
467+
* Socket that serves a fixed byte sequence from recv() and reports
468+
* every send() as fully written (so close-frame sends succeed).
458469
*/
459-
private static class FakeSocket implements Socket {
470+
private static class FrameFeedSocket implements Socket {
471+
private final byte[] data;
472+
private int readPos;
473+
474+
FrameFeedSocket(byte[] data) {
475+
this.data = data;
476+
}
460477

461478
@Override
462479
public void close() {
@@ -478,12 +495,17 @@ public void of(int fd) {
478495

479496
@Override
480497
public int recv(long bufferPtr, int bufferLen) {
481-
return 0;
498+
int n = Math.min(data.length - readPos, bufferLen);
499+
for (int i = 0; i < n; i++) {
500+
Unsafe.getUnsafe().putByte(bufferPtr + i, data[readPos + i]);
501+
}
502+
readPos += n;
503+
return n;
482504
}
483505

484506
@Override
485507
public int send(long bufferPtr, int bufferLen) {
486-
return 0;
508+
return bufferLen;
487509
}
488510

489511
@Override
@@ -507,6 +529,28 @@ public boolean wantsTlsWrite() {
507529
}
508530
}
509531

532+
/**
533+
* WebSocketClient over a socket pre-loaded with canned server frames;
534+
* sends always succeed. Used to drive a real tryReceiveFrame ->
535+
* tryParseFrame -> handler dispatch on the test thread.
536+
*/
537+
private static class FrameFeedWebSocketClient extends WebSocketClient {
538+
539+
FrameFeedWebSocketClient(byte[] frames) {
540+
super(DefaultHttpClientConfiguration.INSTANCE, (nf, log) -> new FrameFeedSocket(frames));
541+
}
542+
543+
@Override
544+
protected void ioWait(int timeout, int op) {
545+
// no-op: recv delivers data on the first call
546+
}
547+
548+
@Override
549+
protected void setupIoWait() {
550+
// no-op
551+
}
552+
}
553+
510554
/**
511555
* WebSocketClient subclass with a fake socket that always returns 0
512556
* from recv(), forcing the ioWait path in recvOrTimeout().

0 commit comments

Comments
 (0)