@@ -81,6 +81,11 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler {
8181 // Single-slot request queue (Phase-1 allows one in-flight query).
8282 private final BlockingQueue <QueryRequest > requests = new ArrayBlockingQueue <>(1 );
8383 private final NativeBufferWriter sendScratch = new NativeBufferWriter ();
84+ // Notified once when the I/O thread detects a transport- or protocol-level
85+ // terminal failure (onClose, truncated/unknown frames, send/receive
86+ // throwing). Distinct from per-query QUERY_ERROR, which leaves the
87+ // connection healthy and is delivered only through the events queue.
88+ private final TerminalFailureListener terminalFailureListener ;
8489 private final WebSocketClient wsClient ;
8590 // Per-query credit state (accessed only from the I/O thread).
8691 // creditEnabled == (initialCredit > 0); controls whether we emit CREDIT
@@ -98,7 +103,12 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler {
98103 private volatile boolean shutdown ;
99104
100105 public QwpEgressIoThread (WebSocketClient wsClient , int bufferPoolSize ) {
106+ this (wsClient , bufferPoolSize , null );
107+ }
108+
109+ public QwpEgressIoThread (WebSocketClient wsClient , int bufferPoolSize , TerminalFailureListener terminalFailureListener ) {
101110 this .wsClient = wsClient ;
111+ this .terminalFailureListener = terminalFailureListener ;
102112 this .freeBuffers = new ArrayBlockingQueue <>(bufferPoolSize );
103113 // +2 reserves slots for a trailing RESULT_END and a synthetic error
104114 // frame the I/O thread may emit on shutdown, so a full buffer pool
@@ -153,7 +163,7 @@ public static QueryEvent decodeError(long payload, int payloadLen) {
153163 @ Override
154164 public void onBinaryMessage (long payloadPtr , int payloadLen ) {
155165 if (payloadLen < QwpConstants .HEADER_SIZE + 1 ) {
156- emitError ((byte ) 0 , "server sent truncated frame" );
166+ emitTerminalError ((byte ) 0 , "server sent truncated frame" );
157167 // Stop the receive loop; the framing is broken and any further bytes
158168 // would be misinterpreted relative to the expected message boundary.
159169 currentQueryDone = true ;
@@ -173,13 +183,14 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) {
173183 decodeAndEmitError (payloadPtr , payloadLen );
174184 currentQueryDone = true ;
175185 } else {
176- emitError ((byte ) 0 , "unknown msg_kind 0x" + Integer .toHexString (msgKind & 0xFF ));
186+ emitTerminalError ((byte ) 0 , "unknown msg_kind 0x" + Integer .toHexString (msgKind & 0xFF ));
177187 currentQueryDone = true ;
178188 }
179189 }
180190
181191 @ Override
182192 public void onClose (int code , String reason ) {
193+ notifyTerminalFailure ((byte ) 0 , "server closed connection: code=" + code + " reason=" + reason );
183194 if (!currentQueryDone ) {
184195 emitError ((byte ) 0 , "server closed connection: code=" + code + " reason=" + reason );
185196 currentQueryDone = true ;
@@ -244,16 +255,22 @@ public void run() {
244255 }
245256 }
246257 } catch (Throwable t ) {
258+ notifyTerminalFailure ((byte ) 0 , "I/O thread failure: " + t .getMessage ());
247259 emitErrorBlocking ((byte ) 0 , "I/O thread failure: " + t .getMessage ());
248260 } finally {
249261 // Wake any user thread blocked on events.take(). Without this, a close()
250262 // (or any abnormal exit) while a user thread is mid-execute() would let
251263 // takeEvent() block forever -- once the I/O thread is gone, no further
252264 // events arrive on the queue.
253265 if (!currentQueryDone ) {
254- emitErrorBlocking (( byte ) 0 , shutdown
266+ String msg = shutdown
255267 ? "I/O thread shut down with query in flight"
256- : "I/O thread terminated with query in flight" );
268+ : "I/O thread terminated with query in flight" ;
269+ if (!shutdown ) {
270+ // Shutdown is a user-driven close; don't mark the client terminal for it.
271+ notifyTerminalFailure ((byte ) 0 , msg );
272+ }
273+ emitErrorBlocking ((byte ) 0 , msg );
257274 currentQueryDone = true ;
258275 }
259276 }
@@ -297,7 +314,7 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) {
297314 long p = payload + QwpConstants .HEADER_SIZE + 1 + 8 ;
298315 long limit = payload + payloadLen ;
299316 if (p + 1 > limit ) {
300- emitError ((byte ) 0 , "EXEC_DONE frame truncated before op_type" );
317+ emitTerminalError ((byte ) 0 , "EXEC_DONE frame truncated before op_type" );
301318 return ;
302319 }
303320 byte opType = Unsafe .getUnsafe ().getByte (p ++);
@@ -309,7 +326,7 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) {
309326 if ((b & 0x80 ) == 0 ) break ;
310327 shift += 7 ;
311328 if (shift > 63 ) {
312- emitError ((byte ) 0 , "EXEC_DONE rows_affected varint overflow" );
329+ emitTerminalError ((byte ) 0 , "EXEC_DONE rows_affected varint overflow" );
313330 return ;
314331 }
315332 }
@@ -390,6 +407,15 @@ private void emitErrorBlocking(byte status, String message) {
390407 }
391408 }
392409
410+ // Emits a per-query error event AND latches the client terminal-failure
411+ // state. Use for transport- or protocol-level faults where the WebSocket
412+ // is no longer usable; per-query QUERY_ERROR must still go through
413+ // plain emitError, since the connection remains healthy after it.
414+ private void emitTerminalError (byte status , String message ) {
415+ notifyTerminalFailure (status , message );
416+ emitError (status , message );
417+ }
418+
393419 private void handleResultBatch (long payloadPtr , int payloadLen ) {
394420 QwpBatchBuffer buf ;
395421 try {
@@ -413,7 +439,9 @@ private void handleResultBatch(long payloadPtr, int payloadLen) {
413439 decoder .decode (buf , payloadPtr , payloadLen );
414440 } catch (QwpDecodeException e ) {
415441 freeBuffers .offer (buf );
416- emitError ((byte ) 0 , "decode failure: " + e .getMessage ());
442+ // A decode failure leaves the client-side decoder out of step with
443+ // the server's byte stream: the next frame cannot be trusted.
444+ emitTerminalError ((byte ) 0 , "decode failure: " + e .getMessage ());
417445 currentQueryDone = true ;
418446 return ;
419447 }
@@ -437,6 +465,17 @@ private void handleResultBatch(long payloadPtr, int payloadLen) {
437465 }
438466 }
439467
468+ private void notifyTerminalFailure (byte status , String message ) {
469+ if (terminalFailureListener != null ) {
470+ try {
471+ terminalFailureListener .onTerminalFailure (status , message );
472+ } catch (Throwable ignored ) {
473+ // Listener must not bring down the I/O thread. A first-failure-wins
474+ // CAS in the listener cannot throw in practice; defensive anyway.
475+ }
476+ }
477+ }
478+
440479 /**
441480 * Builds and transmits a CANCEL frame on the WebSocket. Wire format:
442481 * {@code msg_kind(0x14) + request_id(u64)}.
@@ -517,6 +556,11 @@ void closePool() {
517556 events .offer (new QueryEvent ().asError ((byte ) 0 , "QwpQueryClient closed" ));
518557 }
519558
559+ @ FunctionalInterface
560+ public interface TerminalFailureListener {
561+ void onTerminalFailure (byte status , String message );
562+ }
563+
520564 private static final class QueryRequest {
521565 final long initialCredit ;
522566 final long requestId ;
0 commit comments