@@ -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