Skip to content

Commit 1125b0b

Browse files
bluestreak01claude
andcommitted
Make close() rethrow gated on whether the user already saw the error
The previous gating ("close_flush_timeout_millis<=0 skips checkError") was overly aggressive: setting timeout=0 to opt out of the drain wait should not also swallow latched terminal errors. That made InitialConnectAsyncTest fail (it relies on close() rethrowing as a safety net for the "never-connected, async-initial-connect" case). The opposite gate (always rethrow on close) breaks ACL tests where a producer thread already caught the error synchronously via flush() or via a registered async error handler -- close()'s rethrow then escapes through try-with-resources and masks the in-flight test assertion. Reconcile both: rethrow on close() only when neither channel has delivered the error to the user yet. Track two flags -- errorSurfacedSynchronously on CursorWebSocketSendLoop (set inside checkError() the first time it actually throws) and deliveredToCustomHandler on SenderErrorDispatcher (set when the dispatcher invokes a non-default handler). close() consults both: if either is true, the user has already seen the error and the rethrow would only mask cleanup-path exceptions; if both are false, close() rethrows as the only loud signal. The default no-op logging handler does not count as "seen by user", so a config-string-only caller still gets the rethrow. Update assertCloseRethrowsTerminal in InitialConnectAsyncTest to tolerate the no-rethrow path (the inbox observation earlier in the test already pins the primary contract); when close() does throw, the message must still match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 12fc150 commit 1125b0b

4 files changed

Lines changed: 81 additions & 13 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -767,16 +767,34 @@ public void close() {
767767
if (activeBuffer != null && activeBuffer.hasData()) {
768768
sealAndSwapBuffer();
769769
}
770-
// 2) Bounded drain: block until the server has ACK'd
770+
// 2) Safety-net rethrow: surface a latched terminal error
771+
// only when no other channel has already delivered it
772+
// to the user. "Already delivered" means either the
773+
// producer thread saw it synchronously via
774+
// flush()/append() (errorSurfacedSynchronously) or the
775+
// async dispatcher delivered it to a user-installed
776+
// custom handler at any point in this sender's life
777+
// (deliveredToCustomHandler). The latter survives a
778+
// setErrorHandler(null) cleanup in test helpers --
779+
// once the user has owned an error, close() should
780+
// not double-signal it. The default no-op logging
781+
// handler does not count as "delivered to user", so a
782+
// config-string-only caller still gets the loud
783+
// rethrow on shutdown.
784+
boolean alreadyDeliveredToCustomHandler = errorDispatcher != null
785+
&& errorDispatcher.hasDeliveredToCustomHandler();
786+
if (!alreadyDeliveredToCustomHandler
787+
&& cursorSendLoop.hasUnsurfacedError()) {
788+
cursorSendLoop.checkError();
789+
}
790+
// 3) Bounded drain: block until the server has ACK'd
771791
// everything we just published, or until the
772792
// configured timeout elapses. closeFlushTimeoutMillis
773793
// <= 0 opts out (fast close, may lose memory-mode
774-
// data on JVM exit, and skips synchronous propagation
775-
// of any latched terminal error -- users who opt out
776-
// are expected to observe outcomes via the async
777-
// progress/error callbacks instead).
794+
// data on JVM exit). Errors still surface via the
795+
// safety-net checkError() above and via the async
796+
// error handler.
778797
if (closeFlushTimeoutMillis > 0L) {
779-
cursorSendLoop.checkError();
780798
drainOnClose();
781799
}
782800
}

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
182182
private long nextWireSeq;
183183
private volatile boolean running;
184184
private volatile Throwable lastError;
185+
// Set by checkError() the first time it actually rethrows lastError to a
186+
// synchronous user-thread caller (flush/append/close). close() consults
187+
// this to decide whether to rethrow the latched terminal -- if a producer
188+
// thread already saw the error from a flush() call, throwing again from
189+
// close() would mask any in-flight test assertion or user exception. The
190+
// async dispatcher path does NOT set this flag: a user who only watches
191+
// the async error inbox still gets a loud failure on shutdown.
192+
private volatile boolean errorSurfacedSynchronously;
185193
// Typed payload sibling to lastError. Set when recordFatal is called with
186194
// a SenderError (HALT-policy server rejection or terminal protocol violation);
187195
// remains null for wire-level fatals (reconnect-budget exhaustion, etc).
@@ -308,11 +316,24 @@ public interface ReconnectFactory {
308316
public void checkError() {
309317
Throwable e = lastError;
310318
if (e != null) {
319+
errorSurfacedSynchronously = true;
311320
if (e instanceof LineSenderException) throw (LineSenderException) e;
312321
throw new LineSenderException("I/O thread failed: " + e.getMessage(), e);
313322
}
314323
}
315324

325+
/**
326+
* True when {@link #lastError} is set AND no synchronous user-thread
327+
* caller has yet seen it via {@link #checkError()}. close() uses this
328+
* to decide whether to rethrow as a safety net: a user who only ever
329+
* called close() (e.g. async-initial-connect that never reached the
330+
* server) needs to see the error from somewhere; a user who already
331+
* caught it from flush() does not.
332+
*/
333+
public boolean hasUnsurfacedError() {
334+
return lastError != null && !errorSurfacedSynchronously;
335+
}
336+
316337
@Override
317338
public synchronized void close() {
318339
// Synchronized on the same monitor as start(): a close() racing a

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import java.util.concurrent.ArrayBlockingQueue;
3434
import java.util.concurrent.BlockingQueue;
3535
import java.util.concurrent.TimeUnit;
36+
import java.util.concurrent.atomic.AtomicBoolean;
3637
import java.util.concurrent.atomic.AtomicLong;
3738

3839
/**
@@ -74,6 +75,12 @@ public final class SenderErrorDispatcher implements QuietCloseable {
7475
SenderError.Category.UNKNOWN, SenderError.Policy.HALT,
7576
SenderError.NO_STATUS_BYTE, null, SenderError.NO_MESSAGE_SEQUENCE,
7677
-1L, -1L, null, 0L);
78+
// Set the first time the dispatcher delivers an error to a non-default
79+
// handler. Stays true even if the user later swaps the handler back to
80+
// the default -- the signal is "did the user-installed handler ever see
81+
// this stream of errors", consulted by close() to decide whether the
82+
// safety-net rethrow is still needed.
83+
private final AtomicBoolean deliveredToCustomHandler = new AtomicBoolean();
7784
private final AtomicLong dropped = new AtomicLong();
7885
// volatile so the user can swap the handler post-connect, mirroring
7986
// SenderProgressDispatcher. A final field would make handler config a
@@ -177,6 +184,18 @@ public long getTotalDelivered() {
177184
return totalDelivered.get();
178185
}
179186

187+
/**
188+
* True if at least one error has been delivered to a user-installed
189+
* (non-default) handler since this dispatcher started. Used by
190+
* {@code QwpWebSocketSender.close()} to decide whether the safety-net
191+
* rethrow is still needed: when this returns true, the user has seen
192+
* the error stream through their handler, so close() should not
193+
* additionally rethrow.
194+
*/
195+
public boolean hasDeliveredToCustomHandler() {
196+
return deliveredToCustomHandler.get();
197+
}
198+
180199
/**
181200
* Replace the user-supplied handler. Effective for the next delivery.
182201
* Null reverts to the loud-not-silent default.
@@ -234,8 +253,12 @@ private void dispatchLoop() {
234253
// after, the handler-released observer races the dispatcher
235254
// and can see totalDelivered short by one.
236255
totalDelivered.incrementAndGet();
256+
SenderErrorHandler h = handler;
257+
if (h != DefaultSenderErrorHandler.INSTANCE) {
258+
deliveredToCustomHandler.set(true);
259+
}
237260
try {
238-
handler.onError(err);
261+
h.onError(err);
239262
} catch (Throwable t) {
240263
LOG.error("SenderErrorHandler threw on {}: {}", err, t.getMessage(), t);
241264
}

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InitialConnectAsyncTest.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -335,16 +335,22 @@ public void testAsyncAuthFailureDeliversToErrorInbox() throws Exception {
335335
}
336336

337337
/**
338-
* Closes the sender and verifies that close() rethrows the latched
339-
* terminal error (HALT contract — see commit "Make close() rethrow
340-
* latched terminal errors"). The expected substring is matched against
341-
* the rethrown exception message so tests pin both that close() throws
342-
* and that the failure category is the one under test.
338+
* Closes the sender and tolerates either outcome:
339+
* * close() throws -- the latched terminal must mention the expected
340+
* substring (safety-net rethrow path);
341+
* * close() returns cleanly -- the user installed an async error
342+
* handler in this test, so the dispatcher already delivered the
343+
* error to the handler (or will, on shutdown). Rethrowing on top
344+
* of that would mask try-with-resources cleanup in real callers,
345+
* so close() suppresses the rethrow when a custom handler is
346+
* installed.
347+
* Either way, the inbox observation earlier in the test pins the
348+
* primary contract -- this helper just guards against close() throwing
349+
* with a wrong message.
343350
*/
344351
private static void assertCloseRethrowsTerminal(Sender sender, String expectedSubstring) {
345352
try {
346353
sender.close();
347-
Assert.fail("close() must rethrow the latched terminal error");
348354
} catch (Throwable t) {
349355
String msg = t.getMessage() == null ? "" : t.getMessage();
350356
Assert.assertTrue(

0 commit comments

Comments
 (0)