Skip to content

Commit 1582e3a

Browse files
committed
fix(qwp): close half-built WebSocket client when Error escapes connect/upgrade
buildAndConnect caught only HttpClientException and Exception; a JVM Error (OOM/LinkageError) during connect or upgrade escaped with the client's fd and native buffers open. Close quietly (a close failure must not mask the original Error), record no endpoint-health penalty, rethrow. One-shot leak in practice -- every upstream retry loop rethrows Error -- but the resources were unreclaimable by GC. Adds a @testonly clientFactoryOverride seam so the JVM-error cleanup tests can observe close() on a stub client whose connect() throws.
1 parent 370e187 commit 1582e3a

2 files changed

Lines changed: 313 additions & 3 deletions

File tree

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

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ public class QwpWebSocketSender implements Sender {
167167
private QwpTableBuffer.ColumnBuffer cachedTimestampNanosColumn;
168168
// WebSocket client (zero-GC native implementation)
169169
private WebSocketClient client;
170+
// Test seam: when non-null, buildAndConnect obtains its per-attempt
171+
// client here instead of WebSocketClientFactory, so JVM-error cleanup
172+
// tests can observe close() on a client whose connect() throws Error.
173+
// Null in production; set reflectively by tests.
174+
@TestOnly
175+
private volatile java.util.function.Supplier<WebSocketClient> clientFactoryOverride;
170176
// close() drain timeout in millis. Default applied at construction.
171177
// 0 or -1 means "fast close" (skip the drain); otherwise close blocks
172178
// up to this many millis for ackedFsn to catch up to publishedFsn.
@@ -2467,6 +2473,21 @@ public static int effectiveConnectTimeoutMs(boolean background, int configuredMs
24672473
return background && configuredMs <= 0 ? DEFAULT_BACKGROUND_CONNECT_TIMEOUT_MS : configuredMs;
24682474
}
24692475

2476+
/**
2477+
* Builds the per-attempt WebSocket client for {@link #buildAndConnect}.
2478+
* Production path delegates to {@link WebSocketClientFactory}; tests may
2479+
* install {@link #clientFactoryOverride} to substitute a stub.
2480+
*/
2481+
private WebSocketClient newWebSocketClient() {
2482+
java.util.function.Supplier<WebSocketClient> override = clientFactoryOverride;
2483+
if (override != null) {
2484+
return override.get();
2485+
}
2486+
return tlsConfig != null
2487+
? WebSocketClientFactory.newTlsInstance(tlsConfig)
2488+
: WebSocketClientFactory.newPlainTextInstance();
2489+
}
2490+
24702491
private synchronized WebSocketClient buildAndConnect(ReconnectSupplier ctx) {
24712492
// Background (drainer) factories share this connect walk -- endpoint
24722493
// list, hostTracker health and round state -- but must stay INVISIBLE
@@ -2532,9 +2553,7 @@ private synchronized WebSocketClient buildAndConnect(ReconnectSupplier ctx) {
25322553
long attemptNumber = background
25332554
? SenderConnectionEvent.NO_ATTEMPT_NUMBER
25342555
: ++roundConnectAttemptSeq;
2535-
WebSocketClient newClient = tlsConfig != null
2536-
? WebSocketClientFactory.newTlsInstance(tlsConfig)
2537-
: WebSocketClientFactory.newPlainTextInstance();
2556+
WebSocketClient newClient = newWebSocketClient();
25382557
try {
25392558
newClient.setQwpMaxVersion(QwpConstants.VERSION);
25402559
newClient.setQwpClientId(QwpConstants.CLIENT_ID);
@@ -2600,6 +2619,24 @@ private synchronized WebSocketClient buildAndConnect(ReconnectSupplier ctx) {
26002619
attemptNumber, roundSeq, e);
26012620
}
26022621
continue;
2622+
} catch (Error e) {
2623+
// JVM failure (OOM, LinkageError, StackOverflowError) during
2624+
// connect/upgrade. Without this catch the half-built client
2625+
// escaped with its fd and native buffers open -- unreachable
2626+
// by GC, freed only in close(). Close it quietly: under OOM
2627+
// close() itself can throw, and a secondary failure must not
2628+
// mask the original Error. Deliberately NO hostTracker penalty
2629+
// and NO ENDPOINT_ATTEMPT_FAILED event -- a JVM failure is not
2630+
// endpoint health data, and misclassifying it would poison the
2631+
// walk. Rethrow: every retry loop upstream (connectWithRetry,
2632+
// the cursor reconnect loop, BackgroundDrainer) rethrows Error
2633+
// rather than retrying, so this stays a loud one-shot failure.
2634+
try {
2635+
newClient.close();
2636+
} catch (Throwable ignored) {
2637+
// best-effort; the original Error is what must surface
2638+
}
2639+
throw e;
26032640
}
26042641
if (requestDurableAck && !newClient.isServerDurableAckEnabled()) {
26052642
newClient.close();
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/*+*****************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.test.cutlass.qwp.client;
26+
27+
import io.questdb.client.cutlass.http.client.WebSocketClient;
28+
import io.questdb.client.cutlass.line.LineSenderException;
29+
import io.questdb.client.cutlass.qwp.client.QwpHostHealthTracker;
30+
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
31+
import io.questdb.client.std.Unsafe;
32+
import org.junit.Assert;
33+
import org.junit.Test;
34+
35+
import java.lang.reflect.Constructor;
36+
import java.lang.reflect.Field;
37+
import java.lang.reflect.InvocationTargetException;
38+
import java.lang.reflect.Method;
39+
import java.util.ArrayList;
40+
import java.util.Arrays;
41+
import java.util.List;
42+
import java.util.function.Supplier;
43+
44+
/**
45+
* Regression coverage (M10): {@code buildAndConnect}'s connect/upgrade try
46+
* used to catch only {@code HttpClientException} and {@code Exception}, so a
47+
* JVM {@link java.lang.Error} (OOM, LinkageError, StackOverflowError) thrown
48+
* mid-connect escaped with the half-built {@code WebSocketClient} open -- fd
49+
* plus native buffers, unreachable by GC, freed only in {@code close()}. The
50+
* fix adds a {@code catch (Error)} arm that closes the client quietly (a
51+
* close failure under memory pressure must not mask the original Error) and
52+
* rethrows without recording endpoint-health penalties: a JVM failure is not
53+
* endpoint health data.
54+
* <p>
55+
* Uses the same bare-instance pattern as
56+
* {@code CursorWebSocketSendLoopJvmErrorTest}: {@code Unsafe.allocateInstance}
57+
* plus reflective wiring of the fields the connect walk dereferences, with the
58+
* {@code clientFactoryOverride} test seam substituting a stub client whose
59+
* {@code connect()} throws.
60+
*/
61+
public class QwpWebSocketSenderJvmErrorCleanupTest {
62+
63+
@Test
64+
public void testErrorDuringConnectClosesClientAndStopsWalk() throws Exception {
65+
// Two endpoints: an Error on the FIRST connect attempt must close that
66+
// attempt's client and propagate immediately -- no walk to endpoint 2,
67+
// no health penalty. Contrast with the Exception path (below) which
68+
// closes, records a transport error and keeps walking.
69+
QwpWebSocketSender sender = newBareSender();
70+
QwpHostHealthTracker tracker = wireEndpoints(sender, 2);
71+
List<StubClient> built = new ArrayList<>();
72+
OutOfMemoryError oom = new OutOfMemoryError("simulated allocation failure");
73+
installFactory(sender, () -> {
74+
StubClient c = newStubClient();
75+
c.connectError = oom;
76+
built.add(c);
77+
return c;
78+
});
79+
80+
try {
81+
invokeBuildAndConnect(sender);
82+
Assert.fail("a JVM Error must propagate out of buildAndConnect");
83+
} catch (InvocationTargetException ite) {
84+
Assert.assertSame("the original Error must surface", oom, ite.getCause());
85+
}
86+
Assert.assertEquals("Error must stop the walk on the first attempt", 1, built.size());
87+
Assert.assertEquals("half-built client must be closed exactly once",
88+
1, built.get(0).closeCalls);
89+
Assert.assertEquals("a JVM failure is not endpoint health data",
90+
QwpHostHealthTracker.HostState.UNKNOWN, tracker.getState(0));
91+
Assert.assertEquals("unattempted endpoint must stay untouched",
92+
QwpHostHealthTracker.HostState.UNKNOWN, tracker.getState(1));
93+
}
94+
95+
@Test
96+
public void testCloseFailureDoesNotMaskOriginalError() throws Exception {
97+
// Under OOM, close() itself can throw. The cleanup must be
98+
// best-effort: the ORIGINAL Error surfaces, not the close failure.
99+
QwpWebSocketSender sender = newBareSender();
100+
wireEndpoints(sender, 1);
101+
OutOfMemoryError oom = new OutOfMemoryError("simulated allocation failure");
102+
StubClient stub = newStubClient();
103+
stub.connectError = oom;
104+
stub.throwOnClose = true;
105+
installFactory(sender, () -> stub);
106+
107+
try {
108+
invokeBuildAndConnect(sender);
109+
Assert.fail("a JVM Error must propagate out of buildAndConnect");
110+
} catch (InvocationTargetException ite) {
111+
Assert.assertSame("close() failure must not mask the original Error",
112+
oom, ite.getCause());
113+
}
114+
Assert.assertEquals("close must have been attempted", 1, stub.closeCalls);
115+
}
116+
117+
@Test
118+
public void testExceptionPathStillClosesAndWalksAllEndpoints() throws Exception {
119+
// Seam sanity + behavioral contrast: a plain RuntimeException stays on
120+
// the existing path -- close, record a transport penalty, walk the
121+
// next endpoint, and surface LineSenderException once the round is
122+
// exhausted.
123+
QwpWebSocketSender sender = newBareSender();
124+
QwpHostHealthTracker tracker = wireEndpoints(sender, 2);
125+
List<StubClient> built = new ArrayList<>();
126+
installFactory(sender, () -> {
127+
StubClient c = newStubClient();
128+
c.connectRuntimeError = new IllegalStateException("simulated transport failure");
129+
built.add(c);
130+
return c;
131+
});
132+
133+
try {
134+
invokeBuildAndConnect(sender);
135+
Assert.fail("an exhausted round must surface LineSenderException");
136+
} catch (InvocationTargetException ite) {
137+
Assert.assertTrue("expected LineSenderException, got " + ite.getCause(),
138+
ite.getCause() instanceof LineSenderException);
139+
}
140+
Assert.assertEquals("an Exception must keep the walk going", 2, built.size());
141+
for (StubClient c : built) {
142+
Assert.assertEquals("every attempt's client must be closed", 1, c.closeCalls);
143+
}
144+
Assert.assertEquals("Exception path records the transport penalty",
145+
QwpHostHealthTracker.HostState.TRANSPORT_ERROR, tracker.getState(0));
146+
Assert.assertEquals("Exception path records the transport penalty",
147+
QwpHostHealthTracker.HostState.TRANSPORT_ERROR, tracker.getState(1));
148+
}
149+
150+
/**
151+
* Bypasses the real constructor -- no wire client, engine or dispatcher
152+
* needed. The connect walk dereferences only the fields wired below plus
153+
* primitives whose zero-defaults are valid here (field initializers do
154+
* not run under {@code Unsafe.allocateInstance}).
155+
*/
156+
private static QwpWebSocketSender newBareSender() throws Exception {
157+
return (QwpWebSocketSender) Unsafe.getUnsafe()
158+
.allocateInstance(QwpWebSocketSender.class);
159+
}
160+
161+
private static QwpHostHealthTracker wireEndpoints(QwpWebSocketSender sender,
162+
int count) throws Exception {
163+
QwpWebSocketSender.Endpoint[] eps = new QwpWebSocketSender.Endpoint[count];
164+
for (int i = 0; i < count; i++) {
165+
eps[i] = new QwpWebSocketSender.Endpoint("localhost", 9000 + i);
166+
}
167+
setField(sender, "endpoints", Arrays.asList(eps));
168+
QwpHostHealthTracker tracker = new QwpHostHealthTracker(count);
169+
setField(sender, "hostTracker", tracker);
170+
return tracker;
171+
}
172+
173+
private static void installFactory(QwpWebSocketSender sender,
174+
Supplier<WebSocketClient> factory) throws Exception {
175+
setField(sender, "clientFactoryOverride", factory);
176+
}
177+
178+
/**
179+
* Drives the private connect walk through its private foreground
180+
* {@code ReconnectSupplier} (no-arg: abortCheck null means foreground;
181+
* the bare sender's null {@code cursorSendLoop} and false {@code closed}
182+
* make {@code isAborted()} false).
183+
*/
184+
private static void invokeBuildAndConnect(QwpWebSocketSender sender) throws Exception {
185+
Class<?> supplierClass = Class.forName(
186+
"io.questdb.client.cutlass.qwp.client.QwpWebSocketSender$ReconnectSupplier");
187+
Constructor<?> ctor = supplierClass.getDeclaredConstructor(QwpWebSocketSender.class);
188+
ctor.setAccessible(true);
189+
Object ctx = ctor.newInstance(sender);
190+
Method m = QwpWebSocketSender.class.getDeclaredMethod("buildAndConnect", supplierClass);
191+
m.setAccessible(true);
192+
m.invoke(sender, ctx);
193+
}
194+
195+
private static StubClient newStubClient() {
196+
try {
197+
return (StubClient) Unsafe.getUnsafe().allocateInstance(StubClient.class);
198+
} catch (InstantiationException e) {
199+
throw new AssertionError(e);
200+
}
201+
}
202+
203+
private static void setField(Object target, String name, Object value) throws Exception {
204+
Field f = QwpWebSocketSender.class.getDeclaredField(name);
205+
f.setAccessible(true);
206+
f.set(target, value);
207+
}
208+
209+
/**
210+
* Minimal stub: every method the connect walk touches is overridden so no
211+
* base-class state (native buffers, socket) is ever dereferenced --
212+
* instances come from {@code Unsafe.allocateInstance}, so the base
213+
* constructor never ran. Fields rely on zero-defaults; tests assign them
214+
* post-allocation.
215+
*/
216+
private static final class StubClient extends WebSocketClient {
217+
int closeCalls;
218+
Error connectError;
219+
RuntimeException connectRuntimeError;
220+
boolean throwOnClose;
221+
222+
private StubClient() {
223+
// Never invoked -- instances come from Unsafe.allocateInstance.
224+
super(null, null);
225+
}
226+
227+
@Override
228+
public void close() {
229+
closeCalls++;
230+
if (throwOnClose) {
231+
throw new IllegalStateException("simulated close failure under memory pressure");
232+
}
233+
}
234+
235+
@Override
236+
public void connect(CharSequence host, int port) {
237+
if (connectError != null) {
238+
throw connectError;
239+
}
240+
if (connectRuntimeError != null) {
241+
throw connectRuntimeError;
242+
}
243+
}
244+
245+
@Override
246+
public void setConnectTimeout(int connectTimeoutMillis) {
247+
}
248+
249+
@Override
250+
public void setQwpClientId(String clientId) {
251+
}
252+
253+
@Override
254+
public void setQwpMaxVersion(int maxVersion) {
255+
}
256+
257+
@Override
258+
public void setQwpRequestDurableAck(boolean enabled) {
259+
}
260+
261+
@Override
262+
public void upgrade(CharSequence path, int timeout, CharSequence authorizationHeader) {
263+
}
264+
265+
@Override
266+
protected void ioWait(int timeout, int op) {
267+
}
268+
269+
@Override
270+
protected void setupIoWait() {
271+
}
272+
}
273+
}

0 commit comments

Comments
 (0)