@@ -928,14 +928,20 @@ public void close() {
928928 // SCHEMA_MISMATCH HALT) from users who only call close() and
929929 // never call flush() afterwards.
930930 Throwable terminalError = null ;
931- // Snapshot the latched terminal error that the user thread has
932- // ALREADY caught (via flush()/at()) before close() ran. If
933- // flushPendingRows/drainOnClose below also rethrow the same
931+ // Snapshot the exact terminal error instance that a user- thread
932+ // API call ALREADY caught (via flush()/at()) before close() ran.
933+ // If flushPendingRows/drainOnClose below also rethrow the same
934934 // instance, dropping it at the final rethrow avoids
935935 // try-with-resources self-suppression: Throwable.addSuppressed
936936 // raises IllegalArgumentException when primary == suppressed.
937- Throwable alreadyOwnedByUser = (cursorSendLoop != null && !cursorSendLoop .hasUnsurfacedError ())
938- ? cursorSendLoop .getLastError () : null ;
937+ // Must stay this single read: the snapshot needs the identity of
938+ // the error the user already owns, and only
939+ // getSynchronouslySurfacedError() holds it. Deriving it from two
940+ // separate latch reads races the I/O thread -- a terminal latched
941+ // between the reads would be adopted as user-owned and silently
942+ // dropped (see CloseOwnershipRaceTest).
943+ Throwable alreadyOwnedByUser = cursorSendLoop != null
944+ ? cursorSendLoop .getSynchronouslySurfacedError () : null ;
939945
940946 try {
941947 // Only drain when both the engine and the I/O loop are wired
@@ -953,35 +959,42 @@ public void close() {
953959 if (activeBuffer != null && activeBuffer .hasData ()) {
954960 sealAndSwapBuffer ();
955961 }
956- // 2) Safety-net rethrow: surface a latched terminal error
957- // only when no other channel has already delivered it
958- // to the user. "Already delivered" means either the
959- // producer thread saw it synchronously via
960- // flush()/append() (errorSurfacedSynchronously) or the
961- // async dispatcher delivered it to a user-installed
962- // custom handler at any point in this sender's life
963- // (deliveredToCustomHandler). The latter survives a
964- // setErrorHandler(null) cleanup in test helpers --
965- // once the user has owned an error, close() should
966- // not double-signal it. The default no-op logging
967- // handler does not count as "delivered to user", so a
968- // config-string-only caller still gets the loud
969- // rethrow on shutdown.
970- boolean alreadyDeliveredToCustomHandler = errorDispatcher != null
971- && errorDispatcher .hasDeliveredToCustomHandler ();
972- if (!alreadyDeliveredToCustomHandler
973- && cursorSendLoop .hasUnsurfacedError ()) {
974- cursorSendLoop .checkError ();
962+ // 2) Safety-net rethrow: surface the latched terminal
963+ // error only when no other channel has already
964+ // delivered THIS terminal to the user. "Already
965+ // delivered" means either the producer thread saw it
966+ // synchronously via flush()/append() (checkUnsurfacedError
967+ // is silent in that case) or the async dispatcher
968+ // actually delivered the latched terminal to a
969+ // user-installed custom handler
970+ // (hasDeliveredTerminalToCustomHandler, checked here).
971+ // The test is terminal-specific on purpose: an earlier
972+ // routine DROP_AND_CONTINUE rejection delivered to the
973+ // handler must NOT suppress a later genuine HALT
974+ // terminal (the "any error ever" flag did, silently
975+ // losing it). It also stays false when the terminal
976+ // reached only the default handler after a
977+ // setErrorHandler(null) revert, or is still
978+ // queued/abandoned behind a slow handler -- so a
979+ // config-string-only caller, and a reverting caller,
980+ // both still get the loud rethrow on shutdown.
981+ boolean terminalOwnedByCustomHandler = errorDispatcher != null
982+ && errorDispatcher .hasDeliveredTerminalToCustomHandler ();
983+ if (!terminalOwnedByCustomHandler ) {
984+ cursorSendLoop .checkUnsurfacedError ();
975985 }
976986 // 3) Bounded drain: block until the server has ACK'd
977987 // everything we just published, or until the
978988 // configured timeout elapses. closeFlushTimeoutMillis
979989 // <= 0 opts out (fast close, may lose memory-mode
980- // data on JVM exit). Errors still surface via the
981- // safety-net checkError() above and via the async
982- // error handler.
990+ // data on JVM exit). Pass the same ownership flag the
991+ // step-2 safety net used: when the custom handler
992+ // already owns THIS terminal, the drain must stop on it
993+ // without re-throwing (re-throwing would double-signal
994+ // an error the user already handled). Otherwise the
995+ // drain keeps the loud safety net and surfaces it.
983996 if (closeFlushTimeoutMillis > 0L ) {
984- drainOnClose ();
997+ drainOnClose (terminalOwnedByCustomHandler );
985998 }
986999 }
9871000 } catch (Throwable t ) {
@@ -2593,7 +2606,7 @@ private void checkConnectionError() {
25932606 error .fillInStackTrace ();
25942607 throw error ;
25952608 }
2596- // Poll the cursor I/O loop's lastError too. Without this, a fatal
2609+ // Poll the cursor I/O loop's terminalError too. Without this, a fatal
25972610 // wire / server-rejection error recorded by the I/O thread would
25982611 // only surface on the next flush() / close() — every row-level
25992612 // method (table, longColumn, atNow, etc.) routes through
@@ -2673,8 +2686,30 @@ private void dispatchConnectionEvent(
26732686 * SF-mode users can recover the unacked tail by reopening a sender on
26742687 * the same SF directory; memory-mode users have no recovery path and
26752688 * must treat this as fatal.
2689+ * <p>
2690+ * A latched terminal error means the server will never ACK up to
2691+ * {@code target}, so the drain must stop on it regardless. Whether it is
2692+ * also re-thrown from close() is a separate surfacing policy that mirrors
2693+ * the step-2 safety net in {@link #close()}:
2694+ * <ul>
2695+ * <li>{@code errorOwnedByCustomHandler == true}: a custom error handler
2696+ * has already delivered this terminal to the user, so stop silently —
2697+ * re-throwing here would double-signal it (the M3 drainOnClose
2698+ * double-signal).</li>
2699+ * <li>{@code errorOwnedByCustomHandler == false}: re-throw via
2700+ * {@code checkError()} to preserve the loud safety net (a
2701+ * config-string-only caller's only channel). The throw also breaks the
2702+ * loop; an error a synchronous {@code flush()}/{@code at()} caller
2703+ * already owns is then suppressed by close()'s
2704+ * {@code terminalError == alreadyOwnedByUser} check, so it is not
2705+ * double-signalled either.</li>
2706+ * </ul>
2707+ *
2708+ * @param errorOwnedByCustomHandler whether the async dispatcher has
2709+ * already delivered a terminal to a
2710+ * user-installed handler
26762711 */
2677- private void drainOnClose () {
2712+ private void drainOnClose (boolean errorOwnedByCustomHandler ) {
26782713 if (closeFlushTimeoutMillis <= 0L ) {
26792714 return ;
26802715 }
@@ -2684,7 +2719,15 @@ private void drainOnClose() {
26842719 }
26852720 long deadlineNanos = System .nanoTime () + closeFlushTimeoutMillis * 1_000_000L ;
26862721 while (cursorEngine .ackedFsn () < target ) {
2687- cursorSendLoop .checkError ();
2722+ // Stop on a latched terminal (acks will never reach target);
2723+ // surface it only when no other channel already delivered it.
2724+ if (errorOwnedByCustomHandler ) {
2725+ if (cursorSendLoop .getTerminalError () != null ) {
2726+ return ;
2727+ }
2728+ } else {
2729+ cursorSendLoop .checkError ();
2730+ }
26882731 if (System .nanoTime () >= deadlineNanos ) {
26892732 long acked = cursorEngine .ackedFsn ();
26902733 LOG .warn ("close() drain timed out after {}ms [target={} acked={}], pending data may be lost" ,
0 commit comments