3232import io .questdb .client .network .PlainSocketFactory ;
3333import io .questdb .client .network .Socket ;
3434import io .questdb .client .network .SocketReadinessWaiter ;
35+ import io .questdb .client .std .Unsafe ;
3536import org .junit .Assert ;
3637import 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