Skip to content

Commit 0f1da89

Browse files
committed
fix(qwp): Error escaping the post-upgrade success tail leaks the connected client
The connect walk's catch (Error) arm (1582e3a, cc5e9cc) guarded only connect/upgrade and the failure arms. After a successful upgrade, the durable-ack check, success classification, dispatchConnectionEvent and applyServerBatchSizeLimit ran unguarded before return: an Error thrown there (the success dispatch allocates the event plus a deque node, and on a clean first connect lazy-starts the dispatcher thread) escaped with the fully connected client's fd and native buffers unreachable by GC. Wrap the durable-ack-check-to-return tail in the same quiet-close / rethrow contract as the connect/upgrade arm, extracted into closeQuietlyOnError so the two arms cannot drift. close() is CAS-gated, so re-closing after the durable-ack arm's own close is a no-op. Tests bracket the guarded window at both ends -- first expression (durable-ack check) and last call (getServerMaxBatchSize) -- so narrowing the try block later trips one of the two.
1 parent cba62ac commit 0f1da89

2 files changed

Lines changed: 203 additions & 65 deletions

File tree

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

Lines changed: 92 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2631,6 +2631,20 @@ public static int effectiveConnectTimeoutMs(boolean background, int configuredMs
26312631
* Production path delegates to {@link WebSocketClientFactory}; tests may
26322632
* install {@link #clientFactoryOverride} to substitute a stub.
26332633
*/
2634+
/**
2635+
* Best-effort close for a client being abandoned because a JVM Error is
2636+
* about to be rethrown: under OOM {@code close()} itself can throw, and a
2637+
* secondary failure must not mask the original Error. {@code close()} is
2638+
* CAS-gated, so re-closing an already-closed client is a no-op.
2639+
*/
2640+
private static void closeQuietlyOnError(WebSocketClient client) {
2641+
try {
2642+
client.close();
2643+
} catch (Throwable ignored) {
2644+
// best-effort; the original Error is what must surface
2645+
}
2646+
}
2647+
26342648
private WebSocketClient newWebSocketClient() {
26352649
java.util.function.Supplier<WebSocketClient> override = clientFactoryOverride;
26362650
if (override != null) {
@@ -2851,77 +2865,90 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
28512865
// walk. Rethrow: every retry loop upstream (connectWithRetry,
28522866
// the cursor reconnect loop, BackgroundDrainer) rethrows Error
28532867
// rather than retrying, so this stays a loud one-shot failure.
2854-
try {
2855-
newClient.close();
2856-
} catch (Throwable ignored) {
2857-
// best-effort; the original Error is what must surface
2858-
}
2868+
closeQuietlyOnError(newClient);
28592869
throw e;
28602870
}
2861-
if (requestDurableAck && !newClient.isServerDurableAckEnabled()) {
2862-
newClient.close();
2863-
hostTracker.recordRoleReject(idx, false, !background);
2864-
QwpDurableAckMismatchException ackErr = new QwpDurableAckMismatchException(
2865-
ep.host, ep.port, null);
2866-
if (terminalUpgradeError == null) {
2867-
terminalUpgradeError = ackErr;
2871+
// Guard the post-upgrade tail: from here until newClient is
2872+
// returned, an escaping JVM Error would leak the CONNECTED
2873+
// client's fd and native buffers -- the same class the
2874+
// connect/upgrade catch (Error) arm above closes over. The
2875+
// success-event dispatch is the realistic trigger: it allocates
2876+
// the SenderConnectionEvent plus a deque node, and on a clean
2877+
// first connect it is also the dispatcher's first offer(), which
2878+
// lazy-starts the dispatcher thread (Thread.start() can itself
2879+
// fail with OOM). Same contract as the arm above: close quietly
2880+
// (a secondary failure must not mask the original Error) and
2881+
// rethrow. close() is CAS-gated, so re-closing after the
2882+
// durable-ack arm's own close is a no-op.
2883+
try {
2884+
if (requestDurableAck && !newClient.isServerDurableAckEnabled()) {
2885+
newClient.close();
2886+
hostTracker.recordRoleReject(idx, false, !background);
2887+
QwpDurableAckMismatchException ackErr = new QwpDurableAckMismatchException(
2888+
ep.host, ep.port, null);
2889+
if (terminalUpgradeError == null) {
2890+
terminalUpgradeError = ackErr;
2891+
}
2892+
lastError = ackErr;
2893+
if (!background) {
2894+
dispatchConnectionEvent(
2895+
SenderConnectionEvent.Kind.ENDPOINT_ATTEMPT_FAILED,
2896+
ep.host, ep.port, null, SenderConnectionEvent.NO_PORT,
2897+
attemptNumber, roundSeq, ackErr);
2898+
}
2899+
continue;
28682900
}
2869-
lastError = ackErr;
2870-
if (!background) {
2871-
dispatchConnectionEvent(
2872-
SenderConnectionEvent.Kind.ENDPOINT_ATTEMPT_FAILED,
2873-
ep.host, ep.port, null, SenderConnectionEvent.NO_PORT,
2874-
attemptNumber, roundSeq, ackErr);
2901+
hostTracker.recordSuccess(idx, !background);
2902+
ctx.previousIdx = idx;
2903+
if (background) {
2904+
// Walk bookkeeping only: recordSuccess feeds the shared health
2905+
// tracker and ctx.previousIdx arms this factory's own
2906+
// mid-stream-failure handling on its next reconnect. No
2907+
// lifecycle event, no CONNECTED/RECONNECTED/FAILED_OVER
2908+
// classification state, no producer batch re-sizing -- the
2909+
// drainer's lifecycle is observable via
2910+
// BackgroundDrainerListener and the drainer counters, never
2911+
// the foreground connection-event stream.
2912+
return newClient;
28752913
}
2876-
continue;
2877-
}
2878-
hostTracker.recordSuccess(idx, !background);
2879-
ctx.previousIdx = idx;
2880-
if (background) {
2881-
// Walk bookkeeping only: recordSuccess feeds the shared health
2882-
// tracker and ctx.previousIdx arms this factory's own
2883-
// mid-stream-failure handling on its next reconnect. No
2884-
// lifecycle event, no CONNECTED/RECONNECTED/FAILED_OVER
2885-
// classification state, no producer batch re-sizing -- the
2886-
// drainer's lifecycle is observable via
2887-
// BackgroundDrainerListener and the drainer counters, never
2888-
// the foreground connection-event stream.
2889-
return newClient;
2890-
}
2891-
int previousLiveIdx = currentEndpointIdx;
2892-
currentEndpointIdx = idx;
2893-
// Classify the success. CONNECTED only fires once per sender
2894-
// lifetime; subsequent successes are RECONNECTED (same endpoint
2895-
// as before) or FAILED_OVER (different endpoint). hasEverConnected
2896-
// is set after the classification so the very first success picks
2897-
// CONNECTED before flipping the flag.
2898-
SenderConnectionEvent.Kind successKind;
2899-
String prevHost = null;
2900-
int prevPort = SenderConnectionEvent.NO_PORT;
2901-
if (!hasEverConnected) {
2902-
successKind = SenderConnectionEvent.Kind.CONNECTED;
2903-
hasEverConnected = true;
2904-
} else if (previousLiveIdx == idx) {
2905-
successKind = SenderConnectionEvent.Kind.RECONNECTED;
2906-
} else {
2907-
successKind = SenderConnectionEvent.Kind.FAILED_OVER;
2908-
if (previousLiveIdx >= 0) {
2909-
Endpoint prevEp = endpoints.get(previousLiveIdx);
2910-
prevHost = prevEp.host;
2911-
prevPort = prevEp.port;
2914+
int previousLiveIdx = currentEndpointIdx;
2915+
currentEndpointIdx = idx;
2916+
// Classify the success. CONNECTED only fires once per sender
2917+
// lifetime; subsequent successes are RECONNECTED (same endpoint
2918+
// as before) or FAILED_OVER (different endpoint). hasEverConnected
2919+
// is set after the classification so the very first success picks
2920+
// CONNECTED before flipping the flag.
2921+
SenderConnectionEvent.Kind successKind;
2922+
String prevHost = null;
2923+
int prevPort = SenderConnectionEvent.NO_PORT;
2924+
if (!hasEverConnected) {
2925+
successKind = SenderConnectionEvent.Kind.CONNECTED;
2926+
hasEverConnected = true;
2927+
} else if (previousLiveIdx == idx) {
2928+
successKind = SenderConnectionEvent.Kind.RECONNECTED;
2929+
} else {
2930+
successKind = SenderConnectionEvent.Kind.FAILED_OVER;
2931+
if (previousLiveIdx >= 0) {
2932+
Endpoint prevEp = endpoints.get(previousLiveIdx);
2933+
prevHost = prevEp.host;
2934+
prevPort = prevEp.port;
2935+
}
29122936
}
2937+
dispatchConnectionEvent(
2938+
successKind, ep.host, ep.port, prevHost, prevPort,
2939+
attemptNumber, roundSeq, null);
2940+
// Refresh the cap-derived state before returning the new client so
2941+
// the producer thread observes the new endpoint's advertised
2942+
// X-QWP-Max-Batch-Size from the next sendRow onwards. Skipping this
2943+
// on a mid-stream failover leaves the sender sized for the prior
2944+
// endpoint's cap; an oversize row then escapes the producer-side
2945+
// guard and trips a wire-level ws-close[1009] downstream.
2946+
applyServerBatchSizeLimit(newClient.getServerMaxBatchSize());
2947+
return newClient;
2948+
} catch (Error e) {
2949+
closeQuietlyOnError(newClient);
2950+
throw e;
29132951
}
2914-
dispatchConnectionEvent(
2915-
successKind, ep.host, ep.port, prevHost, prevPort,
2916-
attemptNumber, roundSeq, null);
2917-
// Refresh the cap-derived state before returning the new client so
2918-
// the producer thread observes the new endpoint's advertised
2919-
// X-QWP-Max-Batch-Size from the next sendRow onwards. Skipping this
2920-
// on a mid-stream failover leaves the sender sized for the prior
2921-
// endpoint's cap; an oversize row then escapes the producer-side
2922-
// guard and trips a wire-level ws-close[1009] downstream.
2923-
applyServerBatchSizeLimit(newClient.getServerMaxBatchSize());
2924-
return newClient;
29252952
}
29262953
// Round walked every endpoint without a success. Surface
29272954
// ALL_ENDPOINTS_UNREACHABLE before any of the typed throws so a

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
* rethrows without recording endpoint-health penalties: a JVM failure is not
5353
* endpoint health data.
5454
* <p>
55+
* Also covers the sibling gap (M5): the post-upgrade SUCCESS tail (durable-ack
56+
* check, success-event dispatch, cap re-sizing) used to run unguarded, so an
57+
* Error there leaked the fully connected client. A second {@code catch (Error)}
58+
* guard now closes it under the same quiet-close / rethrow contract.
59+
* <p>
5560
* Uses the same bare-instance pattern as
5661
* {@code CursorWebSocketSendLoopJvmErrorTest}: {@code Unsafe.allocateInstance}
5762
* plus reflective wiring of the fields the connect walk dereferences, with the
@@ -114,6 +119,90 @@ public void testCloseFailureDoesNotMaskOriginalError() throws Exception {
114119
Assert.assertEquals("close must have been attempted", 1, stub.closeCalls);
115120
}
116121

122+
@Test
123+
public void testErrorInSuccessTailClosesConnectedClient() throws Exception {
124+
// M5 regression: connect and upgrade SUCCEED, then a JVM Error strikes
125+
// in the post-upgrade success tail (event dispatch / cap re-sizing,
126+
// here injected via getServerMaxBatchSize()). The tail guard must
127+
// close the fully CONNECTED client and rethrow the original Error.
128+
// recordSuccess bookkeeping stands -- the connect itself was real
129+
// health data; only the leak is fixed.
130+
QwpWebSocketSender sender = newBareSender();
131+
QwpHostHealthTracker tracker = wireEndpoints(sender, 2);
132+
List<StubClient> built = new ArrayList<>();
133+
OutOfMemoryError oom = new OutOfMemoryError("simulated allocation failure");
134+
installFactory(sender, () -> {
135+
StubClient c = newStubClient();
136+
c.serverMaxBatchSizeError = oom;
137+
built.add(c);
138+
return c;
139+
});
140+
141+
try {
142+
invokeBuildAndConnect(sender);
143+
Assert.fail("a JVM Error must propagate out of buildAndConnect");
144+
} catch (InvocationTargetException ite) {
145+
Assert.assertSame("the original Error must surface", oom, ite.getCause());
146+
}
147+
Assert.assertEquals("Error must stop the walk on the first attempt", 1, built.size());
148+
Assert.assertEquals("connected client must be closed exactly once",
149+
1, built.get(0).closeCalls);
150+
Assert.assertEquals("success bookkeeping preceded the Error and stands",
151+
QwpHostHealthTracker.HostState.HEALTHY, tracker.getState(0));
152+
Assert.assertEquals("unattempted endpoint must stay untouched",
153+
QwpHostHealthTracker.HostState.UNKNOWN, tracker.getState(1));
154+
}
155+
156+
@Test
157+
public void testSuccessTailCloseFailureDoesNotMaskOriginalError() throws Exception {
158+
// Same contract as the connect/upgrade arm: the tail guard's cleanup
159+
// is best-effort -- a close() failure under memory pressure must not
160+
// mask the original Error.
161+
QwpWebSocketSender sender = newBareSender();
162+
wireEndpoints(sender, 1);
163+
OutOfMemoryError oom = new OutOfMemoryError("simulated allocation failure");
164+
StubClient stub = newStubClient();
165+
stub.serverMaxBatchSizeError = oom;
166+
stub.throwOnClose = true;
167+
installFactory(sender, () -> stub);
168+
169+
try {
170+
invokeBuildAndConnect(sender);
171+
Assert.fail("a JVM Error must propagate out of buildAndConnect");
172+
} catch (InvocationTargetException ite) {
173+
Assert.assertSame("close() failure must not mask the original Error",
174+
oom, ite.getCause());
175+
}
176+
Assert.assertEquals("close must have been attempted", 1, stub.closeCalls);
177+
}
178+
179+
@Test
180+
public void testErrorAtSuccessTailEntryClosesConnectedClient() throws Exception {
181+
// Brackets the tail guard from the front: the durable-ack check is
182+
// the FIRST expression after the connect/upgrade try, while the
183+
// getServerMaxBatchSize() injection above hits the LAST call before
184+
// return. Together they pin the guard to the whole post-upgrade
185+
// tail -- narrowing the try block later trips one of the two.
186+
QwpWebSocketSender sender = newBareSender();
187+
QwpHostHealthTracker tracker = wireEndpoints(sender, 1);
188+
setField(sender, "requestDurableAck", true);
189+
OutOfMemoryError oom = new OutOfMemoryError("simulated allocation failure");
190+
StubClient stub = newStubClient();
191+
stub.durableAckCheckError = oom;
192+
installFactory(sender, () -> stub);
193+
194+
try {
195+
invokeBuildAndConnect(sender);
196+
Assert.fail("a JVM Error must propagate out of buildAndConnect");
197+
} catch (InvocationTargetException ite) {
198+
Assert.assertSame("the original Error must surface", oom, ite.getCause());
199+
}
200+
Assert.assertEquals("connected client must be closed exactly once",
201+
1, stub.closeCalls);
202+
Assert.assertEquals("the Error preceded recordSuccess; no health data recorded",
203+
QwpHostHealthTracker.HostState.UNKNOWN, tracker.getState(0));
204+
}
205+
117206
@Test
118207
public void testExceptionPathStillClosesAndWalksAllEndpoints() throws Exception {
119208
// Seam sanity + behavioral contrast: a plain RuntimeException stays on
@@ -221,6 +310,8 @@ private static final class StubClient extends WebSocketClient {
221310
int closeCalls;
222311
Error connectError;
223312
RuntimeException connectRuntimeError;
313+
Error durableAckCheckError;
314+
Error serverMaxBatchSizeError;
224315
boolean throwOnClose;
225316

226317
private StubClient() {
@@ -246,6 +337,26 @@ public void connect(CharSequence host, int port) {
246337
}
247338
}
248339

340+
@Override
341+
public boolean isServerDurableAckEnabled() {
342+
// First expression of the guarded success tail (evaluated only
343+
// when requestDurableAck is wired true).
344+
if (durableAckCheckError != null) {
345+
throw durableAckCheckError;
346+
}
347+
return true;
348+
}
349+
350+
@Override
351+
public int getServerMaxBatchSize() {
352+
// Success-tail injection point: called by applyServerBatchSizeLimit
353+
// after a successful connect+upgrade, inside the tail Error guard.
354+
if (serverMaxBatchSizeError != null) {
355+
throw serverMaxBatchSizeError;
356+
}
357+
return 0;
358+
}
359+
249360
@Override
250361
public void setConnectTimeout(int connectTimeoutMillis) {
251362
}

0 commit comments

Comments
 (0)