Skip to content

Commit 82b8bab

Browse files
glasstigerclaude
andcommitted
Fix OIDC cancelRow crash and SF drainer terminal
The httpTokenProvider work introduced two defects. cancelRow() could segfault the JVM. With a provider configured, newRequest() defers the token and leaves the request at the header stage, so contentStart stays at its -1 sentinel and no row bytes are buffered. cancelRow() then ran trimContentToLen(0), which set the write pointer to -1, and the next buffer write faulted in Unsafe.putByte. cancelRow() now returns early while the token is pending (nothing is buffered yet), and trimContentToLen refuses the -1 sentinel as a defensive guard. The store-and-forward background drainer could terminate a producer on a transient credential outage. It bounded a token-provider failure by reconnect_max_duration_millis and then latched a SECURITY_ERROR terminal, dropping a producer that store-and-forward had promised to keep alive. A failing provider (IdP unreachable, a silent refresh failing, an interactive sign-in in progress) is a transient outage like any other, so the running drainer now retries it indefinitely with capped backoff, per Invariant B. The foreground/SYNC initial connect still fails fast, because a connectivity error is only the caller's problem during initialization. This restores the file's own field comment, which already declared reconnect_max_duration_millis "NOT consulted by the background loop". The crash gets a regression test proven to segfault without the guard. The drainer tests now assert the sender survives a persistent provider outage and recovers, replacing a catch-all that asserted nothing about the terminal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 82ae4af commit 82b8bab

5 files changed

Lines changed: 149 additions & 106 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/http/client/HttpClient.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,13 @@ public String toString() {
548548
}
549549

550550
public void trimContentToLen(int contentLen) {
551+
if (contentStart < 0) {
552+
// withContent() has not started a content section yet, so contentStart is the -1 sentinel
553+
// and contentStart + contentLen would be a negative, invalid write pointer that the next
554+
// write would segfault on. Nothing has been written into a content section, so there is
555+
// nothing to trim.
556+
return;
557+
}
551558
ptr = contentStart + contentLen;
552559
}
553560

core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,15 @@ public DirectByteSlice bufferView() {
469469
@Override
470470
public void cancelRow() {
471471
validateNotClosed();
472+
if (isTokenPending) {
473+
// newRequest() left the request at the header stage with the provider token deferred, so
474+
// withContent() has not run and contentStart is still -1 (getContentLength() reads 0): no row
475+
// bytes were written, so there is nothing to trim. trimContentToLen(0) would set the write
476+
// pointer to contentStart + 0 == -1 and the next buffer write would segfault. Just reset the
477+
// row state and leave the token pending for the next row.
478+
state = RequestState.EMPTY;
479+
return;
480+
}
472481
request.trimContentToLen(rowBookmark);
473482
state = RequestState.EMPTY;
474483
}

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

Lines changed: 23 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,18 +1138,16 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
11381138
// INVARIANT B: a store-and-forward drainer must NEVER terminate on a
11391139
// wall-clock reconnect budget. A replica-only / all-endpoints-replica
11401140
// window is TRANSIENT -- a replica gets promoted, a primary reappears --
1141-
// so this background loop retries for as long as it is running, backing
1142-
// off between attempts. The ONLY terminal conditions are a genuinely
1143-
// non-retriable upgrade (auth / non-421 upgrade / durable-ack capability
1144-
// gap), which return directly below, a credential the client cannot
1145-
// ACQUIRE (QwpCredentialUnavailableException -- see its catch below), or
1146-
// the sender being stopped. SF exhaustion is surfaced to the PRODUCER as
1147-
// append backpressure, never here. reconnect_max_duration_millis is
1148-
// intentionally NOT consulted for a TRANSPORT outage: for those it bounds
1149-
// only the blocking (non-lazy) initial connect in
1150-
// QwpWebSocketSender.buildAndConnect, never this background loop. It does
1151-
// bound an uninterrupted run of credential-acquisition failures, which no
1152-
// amount of retrying can clear.
1141+
// and so is a token-provider failure -- the IdP becomes reachable again,
1142+
// or the user completes an interactive sign-in -- so this background loop
1143+
// retries all of them for as long as it is running, backing off between
1144+
// attempts. The ONLY terminal conditions are a genuinely non-retriable
1145+
// upgrade (auth / non-421 upgrade / durable-ack capability gap), which
1146+
// return directly below, or the sender being stopped. SF exhaustion is
1147+
// surfaced to the PRODUCER as append backpressure, never here.
1148+
// reconnect_max_duration_millis is intentionally NOT consulted anywhere
1149+
// in this background loop: it bounds only the blocking (non-lazy) initial
1150+
// connect in QwpWebSocketSender.buildAndConnect, never this loop.
11531151
long backoffMillis = reconnectInitialBackoffMillis;
11541152
if (paceFirstAttemptMillis > 0 && running) {
11551153
// NACK-initiated recycle against a reachable server: pace the
@@ -1170,11 +1168,6 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
11701168
int attempts = 0;
11711169
long lastLogNanos = 0L;
11721170
Throwable lastReconnectError = initial;
1173-
// Start of the current UNINTERRUPTED run of credential-acquisition failures; 0 when none is in
1174-
// flight. Any other outcome (a connect, or a transport-class failure) clears it, so only a
1175-
// sustained inability to obtain a credential burns the budget and terminates -- a token blip
1176-
// between transport errors still recovers.
1177-
long credentialFailingSinceNanos = 0L;
11781171
while (running) {
11791172
attempts++;
11801173
totalReconnectAttempts.incrementAndGet();
@@ -1271,52 +1264,26 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
12711264
dispatchError(err);
12721265
return;
12731266
} catch (QwpCredentialUnavailableException e) {
1274-
// The token provider threw instead of returning a credential. Unlike a transport outage
1275-
// (Invariant B), retrying alone cannot clear this: the loop cannot conjure a token the
1276-
// provider will not hand over. But a silent refresh can fail transiently, so retry while
1277-
// one still might recover -- bounded by the reconnect budget -- and only terminate once
1278-
// credential acquisition has failed for that whole uninterrupted window. Terminating is
1279-
// what keeps a dead credential ("not signed in") from reconnect-looping forever behind
1280-
// throttled logs: it surfaces the provider's own message to the producer via checkError()
1281-
// and to the async handler, exactly as a server-side 401/403 does above.
1282-
long now = System.nanoTime();
1283-
if (credentialFailingSinceNanos == 0L) {
1284-
credentialFailingSinceNanos = now;
1285-
}
1286-
// Compare in millis, not nanos: the builder puts no upper bound on the budget, and a
1287-
// millis-to-nanos multiply of a large one overflows to a negative bound -- which would
1288-
// terminate on the FIRST credential blip instead of never.
1289-
if ((now - credentialFailingSinceNanos) / 1_000_000L > reconnectMaxDurationMillis) {
1290-
LOG.error("token provider failed for {}ms during {} -- won't retry: {}",
1291-
reconnectMaxDurationMillis, phase, e.getMessage());
1292-
long fromFsn = engine.ackedFsn() + 1L;
1293-
long toFsn = Math.max(fromFsn, engine.publishedFsn());
1294-
SenderError err = new SenderError(
1295-
SenderError.Category.SECURITY_ERROR,
1296-
SenderError.Policy.TERMINAL,
1297-
SenderError.NO_STATUS_BYTE,
1298-
"token-provider-failed: " + e.getMessage(),
1299-
SenderError.NO_MESSAGE_SEQUENCE,
1300-
fromFsn,
1301-
toFsn,
1302-
null,
1303-
System.nanoTime()
1304-
);
1305-
totalServerErrors.incrementAndGet();
1306-
recordFatal(new LineSenderServerException(err));
1307-
dispatchError(err);
1308-
return;
1309-
}
1267+
// The token provider threw instead of returning a credential (a failed silent refresh, an
1268+
// interactive sign-in in progress on another thread, or not signed in yet). In the RUNNING
1269+
// background drainer this is a TRANSIENT outage like any other under Invariant B: the provider
1270+
// hands over a token again once the IdP is reachable or the user finishes signing in, and the
1271+
// un-acked rows stay safe in on-disk SF meanwhile. So retry indefinitely with capped backoff --
1272+
// NEVER bound by a wall-clock budget and NEVER latch a terminal, which would drop a producer
1273+
// that store-and-forward promised to keep alive on a recoverable fault. The foreground/SYNC
1274+
// initial connect still fails fast with the provider's own exception (connectWithRetry, and the
1275+
// OFF-mode connect in QwpWebSocketSender), because a connectivity error is only the caller's to
1276+
// see DURING initialization, not after the drainer is running.
13101277
lastReconnectError = e;
1278+
long now = System.nanoTime();
13111279
if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) {
1312-
LOG.warn("{} attempt {}: the token provider failed ({}); retrying within the "
1313-
+ "credential budget -- if this persists the sender terminates",
1280+
LOG.warn("{} attempt {}: the token provider failed ({}); retrying with capped backoff -- "
1281+
+ "the sender keeps buffering to SF and recovers once a token is available",
13141282
phase, attempts, e.getMessage());
13151283
lastLogNanos = now;
13161284
}
13171285
// fall through to the shared capped-backoff block
13181286
} catch (QwpRoleMismatchException | QwpIngressRoleRejectedException e) {
1319-
credentialFailingSinceNanos = 0L;
13201287
// Role mismatch: every reachable endpoint role-rejected the
13211288
// upgrade -- right now they are all replicas / primary-catchup.
13221289
// This is a TRANSIENT failover window (a replica is promotable),
@@ -1355,7 +1322,6 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
13551322
recordFatal(e);
13561323
throw (Error) e;
13571324
}
1358-
credentialFailingSinceNanos = 0L;
13591325
lastReconnectError = e;
13601326
long now = System.nanoTime();
13611327
if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) {

core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderTokenProviderTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,49 @@ public void testBuildSucceedsWhenProviderHasNotSignedInYet() throws Exception {
8787
});
8888
}
8989

90+
@Test(timeout = 30_000)
91+
public void testCancelRowWithPendingTokenDoesNotCorruptRequest() throws Exception {
92+
assertMemoryLeak(() -> {
93+
// Regression: with an httpTokenProvider, newRequest() defers the token and leaves the request at the
94+
// header stage (withContent() not yet run), so the native contentStart is still the -1 sentinel and
95+
// no row bytes are buffered. cancelRow() must be a safe no-op in that window: trimContentToLen(0)
96+
// would otherwise set the write pointer to contentStart + 0 == -1, and the next buffer write (the
97+
// deferred Authorization header on the following row) would segfault the JVM. The window is entered
98+
// after build() and again after every flush (reset() re-arms the pending token); a rejected table
99+
// name - validateTableName() runs BEFORE the token is stamped - is a mainstream way to reach a
100+
// cancelRow() with the token still pending.
101+
try (MockOidcServer server = new MockOidcServer((method, path, body) -> MockOidcServer.json(204, ""))) {
102+
AtomicInteger calls = new AtomicInteger();
103+
HttpTokenProvider provider = () -> "TOKEN-" + calls.incrementAndGet();
104+
try (Sender sender = Sender.builder(Sender.Transport.HTTP)
105+
.address("127.0.0.1:" + server.port())
106+
.protocolVersion(Sender.PROTOCOL_VERSION_V1)
107+
.disableAutoFlush()
108+
.httpTokenProvider(provider)
109+
.build()) {
110+
// (1) cancelRow immediately after build(), token pending, nothing buffered: before the fix the
111+
// write pointer went to -1 and the following row's write segfaulted the JVM
112+
sender.cancelRow();
113+
Assert.assertEquals("cancelRow must not pull the deferred token", 0, calls.get());
114+
115+
// the sender is still usable: a real row buffers, flushes, and carries the token to the wire
116+
sender.table("t").longColumn("v", 1L).atNow();
117+
sender.flush();
118+
119+
// (2) after a flush the token is pending again; cancelRow in that window must also be a safe
120+
// no-op, and the next row must still send its (rotated) token
121+
sender.cancelRow();
122+
sender.table("t").longColumn("v", 2L).atNow();
123+
sender.flush();
124+
}
125+
List<String> auth = server.requestAuthHeaders();
126+
Assert.assertEquals("both flushes must reach the server", 2, auth.size());
127+
Assert.assertEquals("Bearer TOKEN-1", auth.get(0));
128+
Assert.assertEquals("Bearer TOKEN-2", auth.get(1));
129+
}
130+
});
131+
}
132+
90133
@Test(timeout = 30_000)
91134
public void testChangedProviderTokenIsRevalidated() throws Exception {
92135
assertMemoryLeak(() -> {

0 commit comments

Comments
 (0)