feat(core): OIDC sign-in via device flow (RFC 8628) - #52
Open
glasstiger wants to merge 203 commits into
Open
Conversation
Two fixes for the OIDC device flow in the Java client. M2 - Bidi / zero-width Unicode bypassed the display sanitizer. sanitizeForDisplay and OidcAuthException.putSanitized filtered only on Character.isISOControl, which covers C0/C1 and DEL but not the bidirectional overrides (U+202A-202E), isolates (U+2066-2069), marks (U+200E/200F), zero-width characters or the BOM (U+FEFF). Those fields - user_code, verification_uri(_complete), error and error_description - all come from the IdP/settings boundary and reach System.out and the exception messages, so a hostile or MITM'd IdP could embed a right-to-left override and spoof the verification URL a human reads and then opens. The JSON lexer's \uXXXX decoding widens the vector, since an escaped override decodes to the real character before display. Both sanitizers now share OidcAuthException.isUnsafeForDisplay, which also strips the Unicode format category (Cf) plus the explicit bidi/BOM set. The predicate uses hex int literals rather than char escapes, keeping the source strictly ASCII so the file carries none of the characters it guards against. M3 - httpTokenProvider forced a successful sign-in before build(). createLineSender eagerly rebuilt the pending request when a provider was set, calling getToken() at build time. With the documented .httpTokenProvider(auth::getTokenSilently), that threw unless the caller had already signed in, so the natural "construct the sender, sign in, then send" ordering was impossible. The first token pull is now deferred off the build path to the first row (table()). The provider is wired at build but not queried; the initial request is stamped with a token when the first row starts, and the pending flag is cleared only after the pull succeeds, so a not-yet-signed-in provider that throws leaves the stamp pending for a retry. The Sender.httpTokenProvider Javadoc now states the provider is not called at build time. Tests: new bidi/zero-width cases for the challenge fields and the oauth error message (fed as JSON \uXXXX escapes so they exercise the decode-then-display path), and a new LineHttpSenderTokenProviderTest covering the deferred pull and a lazily signing-in provider. Each test was confirmed to fail without its fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three robustness fixes in the OIDC device-flow parser. m3 - A JSON null arrives from the lexer as the literal "null". The token and device parsers used putValue, which stored it verbatim, so "access_token": null became the 4-char token "null" and "error": null was read as an OAuth error code "null". Merged putValue with SettingsDiscoveryParser's null-guarding putNonNull into one shared helper used by all three parsers, so a JSON null is treated as absent everywhere. m4 - Endpoint.parse did not range-check the port, so host:0, host:-1 and host:99999 parsed and flowed to the transport. Added a 1..65535 guard that rejects them with a clear message. m5 - The token-response expires_in was not clamped, unlike the device-auth value, so a TTL near Integer.MAX_VALUE cached the token for ~68 years. storeTokens now applies the same boundedSeconds clamp (the default for a non-positive value, capped at MAX_EXPIRES_IN_SECONDS). The server still enforces the real expiry; this only bounds how long the client trusts its cached copy. Tests: null access_token and null error are rejected/ignored, out-of-range ports are rejected at build, and a clamped token expiry forces a fresh sign-in (observed via a clock-skew margin set above the clamp). Each test was confirmed to fail without its fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-questdb-client into ia_oidc_device_flow
The post-flush reset() eagerly rebuilt the next request and pulled the provider token via httpTokenProvider.getToken() after the current batch had already been sent and accepted. If that pull threw (e.g. OidcDeviceAuth::getTokenSilently when a silent refresh fails) it turned an already-successful flush into a thrown exception and left the shared Request half-built (contentStart == -1, no withContent()), so the next row's data went into the header region - a malformed request, lost rows and a permanently corrupted sender. Route every request's token pull through the same deferred, retriable path the initial request already used: newRequest() no longer pulls the provider token (it marks the request token-pending and builds a valid token-less request), and stampTokenIfPending() pulls it lazily when the first row of a request starts. A failed pull leaves the flag set and the sender untouched, so the next row re-runs the stamp and fully rebuilds the request. Per-request token rotation is unchanged. Rename isInitialTokenPending/stampInitialTokenIfPending to isTokenPending/stampTokenIfPending since the deferral now covers every request, and stamp the token in putRawMessage() too. Add a regression test that fails at the first, successful flush without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getToken() and getTokenSilently() were both synchronized on the instance
monitor, and getToken() holds it for the entire interactive device flow -
up to the device-code lifetime, clamped to one hour. A long-lived Sender
wired with httpTokenProvider(auth::getTokenSilently) therefore stalled on
the flush path for up to an hour whenever another thread ran an
interactive sign-in (e.g. a re-auth after the refresh token died). The
javadoc claimed the opposite ("safe on a request/flush path").
Replace the synchronized methods with a ReentrantLock. getToken() and
clearCache() still acquire it blocking, but getTokenSilently() now uses
tryLock() and fails fast with an OidcAuthException instead of waiting:
while a sign-in is in progress there is no token to serve anyway, so the
caller gets a prompt, retriable exception rather than a wedged flush. The
interactive flow still holds the lock for its whole duration and close()
still sets the volatile cancellation flag before acquiring the lock, so
the no-use-after-free guarantee is unchanged.
Correct the class and getTokenSilently() javadocs, and add a regression
test that fails (getTokenSilently blocks ~10s behind an in-flight
sign-in) without the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
isUnsafeForDisplay() inspected one UTF-16 code unit at a time, so a supplementary-plane (>= U+10000) format or control character - an invisible U+E00xx "tag" char, for instance - arrived as a surrogate pair whose halves are each neither a control nor category Cf and so passed the filter unstripped. Because the JSON lexer reassembles such 😀-style escapes, a hostile or man-in-the-middled identity provider could smuggle invisible/spoofing characters into a user_code, a verification_uri, or an error_description and on into the terminal prompt and exception messages. Judge a Unicode code point instead: isUnsafeForDisplay() takes an int, and both sanitizers (putSanitized for exception messages, sanitizeForDisplay for the prompt) walk the text by code point with Character.codePointAt/charCount, so Character.getType classifies a supplementary char as one character. A legitimate astral character (an emoji) is still preserved. Make the assertNoUnsafeDisplayChars test helper code-point-aware too - it shared the blind spot - and add a regression test that fails (the U+E0001 tag char survives) without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pollOnce() checked for a token before the HTTP status and the OAuth error field, so a response that carried a token alongside an error, or under a non-2xx status, was cached as a valid grant. tryRefresh() had the same flaw: it accepted the refreshed token on token presence alone. Both contradict RFC 6749 - 5.1 makes a grant a 2xx response carrying a token, and 5.2 says an error response must not be treated as a grant. Handle the OAuth error first in pollOnce(), so a token smuggled alongside an error never counts, and accept a token only when the status is 2xx; a token under a non-2xx status goes to the transport- error budget instead of being trusted. Guard tryRefresh() the same way: cache the refreshed token only from a clean 2xx response with no error, otherwise fall back to the interactive flow. The happy path and the existing pending/slow_down/access_denied/empty- body outcomes are unchanged. Add regression tests for a token alongside an error, a token under a non-2xx status, and a refresh that smuggles a token with an error - each fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
newRequest() passed the token from httpTokenProvider.getToken() straight to authToken(), which does not null- or empty-check it. A provider that returned null, "", or whitespace therefore produced a malformed "Authorization: Bearer " header that the server only answered with a 401 far from the cause - no client-side error at all. The HttpTokenProvider contract forbids such a return but nothing enforced it, and httpToken() already rejects a blank token, so the provider path was the weaker spot. Validate the pulled token with Chars.isBlank (as httpToken does) and throw a clear LineSenderException instead. The check sits inside the deferred pull, so a rejected token leaves the stamp pending and the next row retries cleanly, just like a throwing provider does. OidcDeviceAuth never returns a blank token, so this guards custom providers. Add tests that a null, an empty, and a whitespace-only provider token is rejected at first use - each fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JsonLexer.getCharSequence rescanned every decoded value and name from the start to look for a backslash, even though the parse loop already detects one when it sets ignoreNext. Record that in a sawEscape flag (carried across parse() fragments) and resolve escapes only when it is set, so the common no-escape value returns the assembled sink without a second pass. OidcDeviceAuth.Endpoint.parse now rejects a host that contains control characters or whitespace - a smuggled CR/LF would otherwise flow into the outbound Host header. Add the tests these paths lacked: a cross-fragment escape; the lexer's lenient and exotic escape arms (surrogate pairs, \b/\f, unknown and malformed escapes, lone surrogates); the version-probe settings parser reading an escaped key through unescape; HTTP-token-provider rejection for UDP and WebSocket (not just TCP); and the control-character host cases above. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the issuer feature from py-questdb-client (PR #133) onto OidcDeviceAuth, so the device flow keeps working against servers that do not advertise their device-authorization endpoint, and so the device code and refresh token are only sent where the caller pins. The issuer plays three roles: - Discovery fallback: when /settings omits the device (and/or token) endpoint, fromQuestDB(url, issuer) reads it from the issuer's .well-known/openid-configuration document. The discovery origin comes only from the out-of-band issuer (or an explicit discoveryUrl), never from a /settings-supplied value, so a tampered /settings cannot redirect discovery. Without a pin, discovery is refused. - Plaintext-channel pin: a /settings response fetched over plaintext http to a non-loopback host (only reachable with allowInsecureTransport) cannot route credentials to its advertised endpoints without a pin. - Endpoint-origin pin: validateEndpointOrigins, enforced in Builder.build() on every construction path, requires the token and device endpoints to share one origin (RFC 8628 co-location) and, when an issuer is set, to belong to it. Config surface: Builder.issuer(...); new fromQuestDB overloads (url, issuer), (url, issuer, allowInsecure), and a 5-arg master taking issuer, discoveryUrl and a TLS config. Tradeoffs: - The co-location check makes the token and device endpoints share an origin. testPersistentTransportFailureDuringPollingAborts simulated an unreachable token endpoint with a dead second port; it now uses a new MockOidcServer.dropConnection() against a co-located path. - The origin pin compares scheme/host/port and ignores the path, so an identity provider that hosts its endpoints on a different origin than its issuer must be configured without an issuer. This matches the Python client. - allowInsecureTransport still relaxes the identity provider endpoints too (unchanged); the Python client always forces https/loopback for the IdP. Left as-is to avoid changing settled transport behavior. Adds 7 tests and updates the README OIDC section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Endpoint.parse now rejects control characters and whitespace anywhere in the url before splitting it. The host was already checked, but the path was not, so a tampered /settings or discovery document could carry a CR/LF in an endpoint path that the JSON lexer decodes and postForm writes verbatim onto the request line via .url(endpoint.path) - a header-injection / request-smuggling vector that the origin pin (which compares scheme/host/port only) does not catch. Validating the whole url up front also keeps it safe to echo in the parse error messages. fromQuestDB now derives the pin origin from a caller-supplied discoveryUrl when no issuer was resolved. Previously a discoveryUrl pin only took effect when discovery actually ran (an endpoint missing from /settings); when /settings advertised both endpoints the discovery branch was skipped and validateEndpointOrigins ran with a null issuer, so a compromised server could advertise both endpoints at an attacker origin and slip past the pin. The discoveryUrl pin now behaves like the issuer pin on every construction path. Adds regression tests for both: a CR/LF-injected advertised endpoint, path and query cases in Endpoint.parse, and discoveryUrl-pin accept and reject against on- and off-origin endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Endpoint.parse already rejected control characters and whitespace in the url, which kept it safe to echo into the exception messages once it passed validation. That scan did not catch bidi, zero-width or other format characters (U+202E, U+200B, U+FEFF, the Cf category, and the supplementary-plane tag characters), so a tampered /settings or discovery endpoint url could still smuggle one into an OidcAuthException message and reorder, hide or forge the log line it lands in. The url scan now runs per code point and also rejects anything isUnsafeForDisplay flags, so an OIDC url may carry no control, whitespace or display-unsafe character. Every raw url echo in Endpoint.parse, requireSecureTransport and fromQuestDB is therefore safe on screen as well as on the wire, and the rejection message sanitizes the url it reports. Adds a regression test covering a right-to-left override, a zero-width space, the BOM and a supplementary-plane tag character. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
isUnsafeForDisplay now treats an unpaired UTF-16 surrogate as unsafe, so a lone surrogate half - which JsonLexer emits verbatim for a single backslash-u-XXXX escape and which codePointAt surfaces as a SURROGATE code point - is stripped from a user_code, verification_uri or error string before it reaches a terminal or a log line. A valid high+low pair is still reassembled by codePointAt and judged on its real category, so a legitimate emoji survives. The method comment is corrected too: codePointAt in the callers reassembles pairs, not the lexer. close() and the class Javadoc no longer claim an in-flight sign-in is cancelled "promptly". The cancel flag is observed between polls (within about 100ms) but a poll request already in flight is not interrupted, so close() can take up to one HTTP request timeout to return - still far short of the device-code lifetime. The docs now say so. Adds tests: lone high and low surrogates are stripped from the device challenge while an emoji survives; and the private isLoopbackHost classifier (which gates the plaintext-channel MITM pin) is pinned for localhost and the 127.0.0.0/8 block, and against non-loopback and spoofing hosts such as 127.evil.com, localhost.evil.com, 127.1 and 127.0.0.256. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The poll loop now clamps the slow_down-inflated interval to the same MAX_POLL_INTERVAL_SECONDS cap the initial interval already respects, so repeated slow_down responses from the identity provider cannot grow the wait without bound. The device-authorization, token and well-known parsers now reset their current field to FIELD_NONE after each value, matching SettingsDiscoveryParser. The parsers are not currently confusable - in well-formed JSON a name event always sets the field before the next value, array elements arrive as EVT_ARRAY_VALUE, and nested values are filtered by the depth check - so this is a defensive consistency fix that removes a latent field-confusion foot-gun rather than a behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JsonLexer.unescape no longer re-scans the value from the start to re-find the backslash the lexer already flagged via hasEscape; it walks the value once, copying plain characters and resolving escapes in place. That drops the now-dead "no escapes" early return and the separate prefix copy, so an escaped value is traversed about twice (decode then unescape) instead of three times. parseHex4 looks the hex digit up in the shared Numbers.hexNumbers table instead of Character.digit, keeping the same -1-on-non-hex contract. All of this is on the cold error/discovery/auth parse path, never on ingestion. Reorders pollForToken ahead of pollOnce so the private methods stay in alphabetical order; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 4 MiB response-body cap (MAX_RESPONSE_BODY_BYTES) that bounds the OIDC device flow against a hostile or MITM'd server streaming an endless body had no test coverage on the parseBody path. Add an oversizedJson() mode to MockOidcServer that streams a chunked, mostly-whitespace body past the cap, and a test that drives discovery against it and asserts the bounded read aborts with the size-limit error - which also confirms the token-bearing body never reaches the message. The body is whitespace so the lexer keeps consuming until the byte cap trips, instead of hitting its per-value length limit first. Verified both ways: the test passes with the 4 MiB cap and fails when the cap is disabled, where the full body is read and parsing fails with "Unterminated object" instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three small fixes to the OIDC device authorization flow, all in OidcDeviceAuth: - runDeviceFlow now rejects a non-2xx device authorization response. Previously it trusted any body that carried device_code/user_code/ verification_uri and no OAuth error, so a non-2xx response would prompt the user and start polling. It now applies the same 2xx gate pollOnce and tryRefresh already use before trusting a body. - pollForToken checks the device-code deadline at the top of the loop and never sleeps past it, so an expiry that elapses during a sleep times out promptly instead of after one more wasted poll and up to a full extra poll interval. - tryRefresh drops an unreachable branch that rethrew on an OAuth error. postForm only throws on a parse failure here, and a real OAuth error arrives in tokenParser.error (handled by the hasRequiredToken check), so the branch was dead. No behaviour change. Add testNonSuccessDeviceAuthorizationResponseRejected covering the new 2xx gate; it fails without the check (the 403 is accepted, the user is prompted, and polling fails later instead). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A discoveryUrl pins the identity provider, yet fromQuestDB adopted the issuer the discovery document declared about itself and validated the token and device endpoints against that, never against the pinned discoveryUrl origin. A document served at the pinned url could therefore name an attacker issuer, co-locate both endpoints under it, and route the device code and the long-lived refresh token there while the co-location and issuer checks passed trivially - so the discoveryUrl pin did not in fact pin the provider, contradicting its documented guarantee. Reject a document whose own issuer sits on a different origin than the pinned discoveryUrl (RFC 8414 section 3.3), and derive the endpoint pin from the discoveryUrl origin rather than the document's self-declared issuer. An identity provider that serves its discovery document on a different origin than its endpoints must instead be configured with explicit endpoints via OidcDeviceAuth.builder(). The issuer-pinned path is unchanged: it already binds the endpoints to the caller-supplied issuer. testFromQuestDbDiscoveryUrlPinRejectsForeign IssuerInDocument covers the new rejection and fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readResponse copied the response status code into a sink that later appears in OidcAuthException messages. A well-formed status code is bare digits, but the HTTP header parser keeps the status-line token verbatim apart from SP/CR/LF, so a hostile or MITM'd identity provider could splice ESC or other control bytes into it - smuggling ANSI sequences into a log or terminal, or fabricating a leading digit that passes the 2xx success gate. Validate the status code as it is captured: on any non-digit byte, drain the body so the keep-alive connection stays usable, then reject the response with a message that echoes none of its bytes. A clean status is copied digit by digit, so every later [httpStatus=...] echo is bare digits. testNonNumericStatusCodeRejected drives a status code with a spliced ANSI reset and asserts the rejection; it fails without the fix. The new MockOidcServer.raw() helper writes a verbatim response so a test can craft a malformed status line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JsonLexer now resolves JSON string escapes, so the message and errorId fields a QuestDB endpoint returns in a JSON error body arrive at the sender fully decoded. The JSON error parser put them into the LineSenderException verbatim, so a hostile or proxied endpoint could inject real control characters or ANSI escapes that forge a log line or rewrite a terminal when the exception text is printed. Render the server-supplied message, id, code and line through putAsPrintable - the same escaping the column-name errors in this class already use - so a decoded control byte arrives escaped. LineHttpSenderErrorResponseTest flushes against a server returning a chunked JSON error whose message and errorId carry an ESC and a newline, and asserts they reach the exception escaped; it fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plaintext-channel pin refuses /settings-supplied OIDC endpoints fetched over a non-loopback http channel unless the identity provider is pinned out of band, so a tampered response cannot route the device code and refresh token to an attacker. Only its loopback exemption was exercised end to end, because the test mock binds to 127.0.0.1; the firing branch had no integration coverage. Reach the loopback mock through "127.1": the OS resolver expands the short form to 127.0.0.1 so the mock answers, but the loopback classifier deliberately rejects the short form, so the server host is non-loopback and the pin fires. Assert that a plaintext /settings advertising both endpoints without a pin is refused, and that pinning the issuer over the same channel is accepted - proving the pin, not an unrelated rejection, is the gate. The test fails if the firing check is removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Skip testPlaintextSettingsWithAdvertisedEndpointsRequiresPin on Windows: it reaches the loopback mock through the "127.1" short-form address, which Linux/macOS getaddrinfo expands to 127.0.0.1 but Windows getaddrinfo rejects, so discovery cannot connect there. No host string is both reachable at the loopback mock and classified non-loopback on Windows, so the end-to-end firing path cannot run there; the classifier stays covered cross-platform by testLoopbackHostClassifierRejectsNonLoopbackAndSpoofing. Wrap every OidcDeviceAuth construction in try-with-resources so the native JSON lexer and HTTP clients are always released, including the rejection paths where build()/fromQuestDB() throws. Also replace manual StringBuilder fills with String.repeat, switch index loops to enhanced-for, and collapse the split-value test helper to a single lexer cache-limit parameter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
discardUntrustedDirectoryContents() empties the store directory when restrictToOwner() finds it writable by other local users, because an entry that was sitting there may have been planted rather than written by us. Discarding ALL entries rather than only the current key's is deliberate and stays: the verdict is spent by whoever observes it first, so anything left behind is something no later call can distrust. What it must not do is decide "entry" means "any .json". Every file this store writes is named after a 64-character lowercase-hex identity fingerprint -- tokenFile() builds "<hash>.json", writeTemp() asks createTempFile() for "<hash><random>.tmp" -- and nothing outside that shape is ours to delete. The directory being group-writable is what brought us here; it is not a licence to remove an operator's files. The trigger is ordinary rather than exotic. FileTokenStore.at(dir) and questdb.client.oidc.token.store.dir both let an operator name the directory, the class javadoc and the README tell them to use one per application user, and a mkdir under a umask of 002 lands 0775. Point either at a directory that also holds their own JSON and the first getToken() deletes it. The one-shot SLF4J warning that fires alongside is emitted by the same call that does the deleting, so it is a notification rather than a guard -- and the library ships slf4j-api with no binding, so by default it goes nowhere. hasStoreHashPrefix() is the test for "we could have written this", mirroring the hash-prefix scoping sweepTempFiles() already applies. The entry arm additionally requires the exact "<hash>.json" length, since a write temp is the only one of our names that carries an infix. testLoadDiscardsOnlyTheStoresOwnFilesFromAWorldWritableDirectory plants three files a stranger owns -- a plain name, a hex name too short to be a fingerprint, and a foreign temp -- alongside a real entry, then loosens the directory. It asserts the real entry still goes and the other three stay. With the shape test removed it fails on the first: a file the store never wrote must survive: my-important-settings.json No uppercase-hex case: the store renders its digests lowercase, but on a case-insensitive filesystem such a name IS the real entry, so asserting it would test the filesystem rather than the filter. Found by writing it, on APFS. FileTokenStoreTest and OidcDeviceAuthPersistenceTest: 107 tests, green.
PROCESS_LOCKS serializes same-identity critical sections within a JVM, because two OidcDeviceAuth instances sharing one identity would otherwise run the read-refresh-write concurrently and double-POST a rotating refresh token -- which a reuse-detecting provider answers by revoking the whole family. It was keyed on TokenStoreKey.hash() alone. That hash names a CONFIGURATION. computeHash() takes the client id, both endpoints, the scope, the audience and the groups-in-token flag, and no directory at all. So two FileTokenStore instances over per-user directories -- the shape this class's javadoc and the README both prescribe for signing several application users in at once -- minted the same key and queued on one ReentrantLock while touching entirely different files. The cost is the one the comment above PROCESS_LOCKS already rejects a stripe table for: the lock is held across a whole token-endpoint round trip while the caller also holds its OidcDeviceAuth instance lock, and this acquire has no budget (lockAcquireBudgetMillis bounds the FILE lock only). One user's stalled refresh therefore blocked another user's getToken() on the ILP flush path for that holder's entire worst case. processLockIdentity() namespaces the entry by directory as well. Both halves are load-bearing: the fingerprint alone over-serializes as above, the directory alone would let two identities in one directory race. The namespace is normalized once in the constructor rather than per acquire, because "a" and "./a" must not mint two locks over one directory -- under-serializing is the dangerous direction, not the merely slow one. toAbsolutePath().normalize() and not toRealPath(): the directory may not exist yet, since the store creates it lazily, and a key that changed once it did would be worse than one that ignores symlinks. Two stores reaching one directory through different symlinks therefore still get separate in-process locks; the cross-process lock file, which they do share, remains the guard for that shape. testSameIdentityInDifferentDirectoriesDoesNotSerialize is the mirror of testUnrelatedIdentitiesDoNotSerializeOnEachOther along the other axis: that one varies the identity within one directory, this one keeps the identity and varies the directory. Same CyclicBarrier trick -- it trips only when both callers are inside at once, which two callers sharing one lock can never be. With the directory dropped from the key it fails as the head-of-line block it is, taking 20s to time out against 3.6s green: expected null, but was:<java.lang.RuntimeException: java.util.concurrent.TimeoutException> It resolves two distinct subdirectories rather than calling storeDir() twice; that helper returns one fixed path, and two stores over ONE directory are exactly the case that must keep serializing. Found by writing it -- the first draft used storeDir() and failed with the fix in place, for the right reason. FileTokenStoreTest and OidcDeviceAuthPersistenceTest: 108 tests, green.
signIn() checked the interrupt flag once, on entry. Everything after
that guard is network work: the silent refresh is a round trip bounded
by four times httpTimeoutMillis plus an OS connect stall. An interrupt
arriving in that window reached nothing, so the flow went on to launch a
browser and enter a poll loop that runs to the device-code lifetime --
up to 30 minutes -- on a thread whose owner had already asked it to
stop.
Worse, the loop then destroyed the evidence. sleepBetweenPolls used
Os.sleep, which catches InterruptedException, recomputes its deadline
and keeps sleeping; Thread.sleep clears the flag when it throws. So the
caller's cancellation was not merely ignored, it was consumed. signIn()
returned with Thread.interrupted() reading false, and the shutdown path
that raised it -- an ExecutorService.shutdownNow(), a Future.cancel,
QWP's ConnectCancellation -- was left believing the thread was never
cancelled.
This is the outcome the comment above the entry guard already describes
as fixed ("A caller who cancelled got a browser prompt and a thread
parked for half an hour"), and it states the invariant absolutely: "the
flag is the caller's cancellation signal and must survive this call,
exactly as getToken() and FileTokenStore.load()/save() preserve it".
Only a carried interrupt was actually honoured.
Two gaps, both closed:
- a re-check before the interactive phase, because a cancellation
landing during the refresh is the ordinary case rather than a narrow
race -- a caller gives up precisely when a refresh is dragging;
- Thread.sleep in the poll loop, with the flag restored and the step
abandoned, so the loop notices a cancellation and leaves the signal
intact for whoever raised it.
throwIfInterrupted() uses isInterrupted(), never interrupted(), for the
reason above; clearing the flag would make this guard one more cause of
the failure it exists to stop.
close() from another thread remains the documented way to abort an
in-flight sign-in and is unchanged; this is about the caller's own
thread being cancelled, where close() is not a lever the caller has.
Two regression tests, one per gap.
testInterruptDuringTheRefreshDoesNotStartTheDeviceFlow holds a refresh
open until the worker has been interrupted; with the gate removed it
fails on "signIn() must abandon a cancelled sign-in".
testInterruptDuringThePollLoopAbandonsItAndKeepsTheFlag interrupts from
the prompt, which runs on the sign-in thread immediately before the
loop; with Os.sleep restored it does not fail an assertion at all -- it
runs out the 30s test timeout, which is the symptom, shortened only
because the device code in the fixture is good for 1800s.
OidcDeviceAuthTest, OidcDeviceAuthPersistenceTest and
WebSocketCredentialCancellationTest: 174 tests, green.
The only test driving the new accessor through a real Sender was
testADefinitiveStatusFromTheSenderIsNotRetryable, which ends at
assertFalse on a 401. That assertion cannot fail for the reason it
claims to test: the three LineSenderException constructors that carry no
classification also hard-code retryable=false, so it reads the same
whether the sender classifies correctly or has stopped classifying
altogether. Its sibling asserts that same false from those constructors,
which makes the two indistinguishable under the regression.
Both production sites that pass true were therefore unasserted: the
give-up throw after the retry budget is spent on a retryable status
(5xx, 429, 421, 404), and the one after it is spent on a transport
error. Flipping either literal to false failed nothing.
That matters because the accessor is the one the class documentation
tells callers to branch on -- retry flush() on a transient failure,
close or reset() on a permanent one -- and there is no other
programmatic way to make that call. A caller doing what the javadoc says
would, on a lost classification, treat a 503 from a restarting server as
permanent and tear down a healthy sender, discarding the buffered batch.
Two tests, one per site: a chunked 503 with the retry budget spent, and
an unreachable endpoint. No production change; the classification is
correct today and these pin it.
Mutation proof, flipping both literals to false:
Tests run: 5, Failures: 2
testARetryableStatusFromTheSenderIsRetryable
testATransportFailureFromTheSenderIsRetryable
and testADefinitiveStatusFromTheSenderIsNotRetryable passes throughout,
which is the point.
The 503 fixture is chunked because flush0 asserts response.isChunked()
on the error branch and MockOidcServer.json writes Content-Length.
LineSenderExceptionRetryableTest, LineSenderExceptionTest and
LineHttpSenderErrorResponseTest: 27 tests, green.
ResponseHeaders.await(int) passed its timeout parameter straight into
recvOrDie on every pass, so each socket read re-armed the full budget and
the call as a whole had no bound. AbstractResponse.recv and
AbstractChunkedResponse.recv already carry the whole-call deadline this
adds; await() reads the HEAD of the same response over the same socket
and was left out.
That is not a slower version of the same thing, it is unbounded.
recvOrDie returns 0 whenever a read yields no application bytes, a 0
leaves totalBytesReceived unmoved, and an unmoved counter neither
advances the header parser nor fills its 4096-byte buffer -- so the
"header is too large" escape never fires either. The loop simply runs.
It matters because OidcDeviceAuth reads this head from an identity
provider, on the getToken() path an ILP sender built with
httpTokenProvider calls once per flush (postForm at the token endpoint,
fetchJson during discovery). requireSecureIdpEndpoint forces https
there, and a partial TLS record decrypting to no application bytes is
exactly the zero-length read above -- JavaTlsClientSocket.recv returns 0
on BUFFER_UNDERFLOW. A stalled or hostile provider could therefore hang
a producer's flush thread indefinitely, with no exception and no log.
Four places document a bound that this defeated: getToken()'s javadoc
("the send, response wait and body parse ... each bounded by
httpTimeoutMillis"), the HttpTokenProvider contract,
LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE, and Builder.build()'s FileTokenStore
staleness floor, which throws to enforce a multiple of a figure the
response wait did not respect.
The bound reuses the sibling methods' shape exactly: a positive timeout
bounds the whole call, a non-positive one keeps the legacy behaviour.
Against a dribbling peer the shrinking per-pass budget starves ioWait's
poll first, so the throw arrives from there; against reads that yield no
application bytes at all -- which consume no budget -- the loop's own
deadline check fires. Neither can keep running, which is the point.
MockOidcServer gains dribbleHead() for the test: the body-dribbling
dribble() cannot reach this path, because a client only starts reading a
body once the head has parsed. The head it emits stays well-formed
throughout, so the client aborts on its deadline rather than on a parse
error, which would prove nothing. With the fix the test aborts in under
a second; with await() reverted it runs until the 30s test timeout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OidcDeviceAuth built every HTTP client from
DefaultHttpClientConfiguration.INSTANCE, which answers 0 for the connect
timeout and 600s for the request timeout. HttpClient.connect reads both:
it leaves the TCP connect to the OS when the connect timeout is 0, and
it sizes the TLS handshake as
connectTimeout > 0 ? connectTimeout : defaultTimeout
so the handshake alone got a 600s budget -- derived from nothing the
caller set. Neither MAX_HTTP_TIMEOUT_MILLIS (120s) nor the
lockStaleMillis floor Builder.build() enforces could constrain it,
because neither is on that path. requireSecureIdpEndpoint forces https
for the identity provider, so this is the ordinary shape of a refresh,
not an edge case.
The cost is not a slow request. A silent refresh runs inside
FileTokenStore's cross-process lock, whose file is stamped once at
creation and never re-stamped, so a hold that outruns
DEFAULT_LOCK_STALE_MILLIS (600s) is judged abandoned and stolen by a
peer -- stealIfStale's capture-verify confirms the stamp is UNCHANGED,
which is exactly what a live holder's lock looks like. Both holders then
POST the same parent refresh token, and an identity provider with reuse
detection (Auth0's default, Keycloak "Revoke Refresh Token", Okta)
answers by revoking the whole family. On a headless producer ingestion
stops until a human re-runs the device flow. 600s of handshake against a
600s window leaves negative headroom once the TCP connect ahead of it is
counted, and build()'s floor check passed throughout (600_000 >=
120_000), so it read as reassurance.
httpConfig(int) now derives both budgets from one figure: the instance's
httpTimeoutMillis for its own clients, DEFAULT_HTTP_TIMEOUT_MILLIS for
the discovery clients, which run before an instance exists but read
/settings and .well-known from the same untrusted position. That bounds
the TCP connect as well as the handshake, leaving only DNS resolution to
the OS, and makes the "up to LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE x
httpTimeoutMillis" figure -- quoted by the lock-stale floor,
acquireForGetToken's wait cap and getToken()'s javadoc -- true of the
code rather than merely asserted by it.
Six comments and the design doc claimed the connection phase was the
OS's to bound and told readers to size the staleness window for a
connect stall on top of the floor. They now say what the code does, and
the design doc states bounding the connect and handshake as a client
MUST rather than describing the gap as inherent -- it is the contract
the Python client mirrors, and a client that leaves either unbounded
reopens the double-POST above.
Asserted on the configuration, not end to end: a real handshake stall
needs a certificate and the client's test tree has none, which is why
OidcDeviceAuthTlsTest lives in the Enterprise tree. Reverting
httpConfig() to the shared INSTANCE fails both new tests by name
(expected 7777, was 0).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FileTokenStore.inLock caught InterruptedException from both of its
waits -- lockInterruptibly for the in-process lock, and acquireLock's
poll for the cross-process one -- and returned false without
re-asserting the flag. The reasoning was that consuming the signal was
"acting on it", and that leaving it set would break the teardown the
interrupt was sent to enable.
Consuming it is what made the two outcomes indistinguishable. A bare
false is exactly what a refresh that RAN and FAILED returns, and
OidcDeviceAuth acts on the difference:
- signIn() reads false as a failed refresh and answers by starting the
INTERACTIVE device flow. That launches a browser and then polls to
the device-code lifetime (up to 30 minutes) on Os.sleep, which
ignores interrupts -- so the owner that cancelled the thread cannot
get it back and shutdown never completes. The guard meant to stop
this, throwIfInterrupted at the top of the flow, reads the flag
inLock had already cleared. Its own comment says a cancellation
landing inside the refresh is "the common case, not a narrow race".
- getToken() arms refreshFailedAtMillis on a refresh that never
happened. That field is INSTANCE state, so one cancelled caller
failed every other producer sharing the OidcDeviceAuth for the next
five seconds -- with "the cached token expired and could not be
refreshed ... call signIn()", sending an operator to re-authenticate
over a credential that is fine and an identity provider that was
never contacted. ConnectCancellation.cancel() interrupts credential
pulls by design on every close, so this half was routine.
The inconsistency inside the class was the tell: load() and save()
already save and restore the flag around their I/O. Only inLock
swallowed it.
Both catches now re-assert before returning, and each does so past its
own interruptible I/O -- the process-lock arm touches only a
ConcurrentHashMap afterwards, and the acquireLock arm implies nonce ==
null, so the finally skips releaseLock. getToken() checks the flag after
a false return and reports the cancellation instead of arming the latch;
signIn() needed no change once the flag survives. TokenStore's SPI
javadoc now states the obligation, since a third-party store that
consumes the interrupt reopens both failures.
testInLockAbandonsFileLockWaitOnInterrupt asserted the old behaviour
outright and now asserts the new one; its sibling gains the matching
check. Two OidcDeviceAuth tests cover the caller half through a
FakeTokenStore that models a conformant store, so the two halves fail
independently: reverting the store restores 3 FileTokenStoreTest
failures, and reverting getToken() reproduces the misleading
"call signIn()" message verbatim.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
buildManagedSlotSender applies the token provider on the forRecovery leg too, so a startup-recovery delegate now performs a synchronous credential pull before it connects. Neither stop lever could reach it: PoolHousekeeper.stop() and SenderPool.stopStartupRecoveryDriver() both set a flag and join for STOP_TIMEOUT_MILLIS (2s) without interrupting, and the flag is only read BETWEEN recovery steps. The pull is not a 2s-shaped wait. OidcDeviceAuth.getToken() documents up to four times httpTimeoutMillis behind a peer's refresh, and FileTokenStore's in-process lock wait carries no budget at all -- it is held across a whole token-endpoint round trip. close() therefore returned while the recoverer was still parked, holding its store-and-forward slot flock. An immediate reopen then fails with "sf slot already in use", and the detached build's engine, its mmaps and its I/O thread are leaked. That is precisely the window this pool's per-slot ids exist to eliminate, and it is the same hazard the drain_orphans(false) forced on recovery builds was added to avoid -- the comment beside it spells out the consequence in full, then the provider was wired in one line below. Both stop levers now escalate: join, and if the thread is still alive, interrupt and join again. Every wait on that path honours it -- acquireForGetToken polls a timed tryLock, and FileTokenStore's two lock waits abandon and re-assert the flag as of the previous commit, which is what makes the interrupt a usable lever here rather than a signal the store would swallow. The pull throws, runStartupRecoveryStep's caller swallows it (recovery is best-effort by design), and the loop reaches its stop check and releases the flock before close() returns. The residual-window notes in three places claimed a black-holed connect was the only overrun left. They now say what is true: the credential pull is broken by the interrupt, and the connect survives it because it blocks in a syscall no interrupt breaks. The test reuses the existing two-phase recovery harness -- strand unacked frames against a silent server, then open a second pool over the same sf_dir -- with a provider that parks for five minutes. Reverting both escalations fails it by name; close() returns in ~2.5s either way, so the elapsed bound is not what catches it, the un-interrupted provider is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FileTokenStore.load caught ensureDirectory()'s IOException and answered null. load()'s own contract makes null and a throw mean opposite things: Returning null is the definitive answer, and ends the reads for the life of that OidcDeviceAuth. Throwing is not: it reads as a transient fault and is retried. What ensureDirectory() reports there is squarely transient -- Files.createDirectories failing because a home directory is not mounted yet, EIO/ESTALE on an NFS home, a momentarily read-only or full filesystem, or restrictToOwner failing to read back the permissions it just set. Answering null told every later call that a store holding a perfectly good refresh token was empty. Nothing recovers from that inside the process. maybeLoadFromStore latches storeLoadAttempted on a clean return, signIn() clears the two back-off fields but not the latch, and clearCache() sets it. So a momentary mount fault at the first getToken() cost the process its persistence permanently: it re-runs the interactive device flow with a valid refresh token sitting on disk, and for the headless getToken() consumer this feature exists to serve that is a hard failure -- "no token has been obtained yet; call signIn()", which the caller cannot act on -- until a restart. Two things already disagreed with it inside the same class. The very next arm throws for readBounded's IOException, and save() lets this same exception propagate, which is why writes retried while reads latched. load() now throws too, and the retry/back-off machinery built for exactly this fault gets to do its job. The test uses the fixture testInLockDegradesWhenDirectoryUnusable already established -- a regular file standing where the store directory's parent must be. The nearest existing coverage, testTransientStoreLoadFailureIsRetriedNotLatched, drives a throwing FakeTokenStore, so it asserts the CALLER retries a throw and never reached the real store's swallow arm; reverting the change leaves it green and fails this one with "returned null". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lazy_connect and httpTokenProvider were each tested alone; their combination decides who sees a credential failure at startup and nothing drove them together. The contract is that connectivity and credential errors are the caller's problem only DURING initialization, so the two halves must fail in opposite directions, and neither half was pinned. Under lazy_connect the ingest side resolves to ASYNC -- the client is null and no pull happens at build -- and the read pool defaults to min=0, so build() must return and a write must buffer even against a provider that can supply nothing. Getting that wrong is a data-loss shape rather than an inconvenience: a producer that hard-fails at build() instead of buffering drops exactly the rows store-and-forward promised to keep. Without lazy_connect the pools initialize eagerly, so the same provider must fail the build loudly, and the provider's own message has to survive to the caller -- "not signed in yet" is actionable, a transport-shaped wrapper naming the endpoint is not. The deferred read path gets the same treatment: with query_pool_min=0 the first borrowQuery() is where the pull happens, and it must report the failure rather than hand back a client that never authenticated. Both eager cases run against a live server so the failure is unambiguously the credential and not connectivity. The behaviour was already correct; these pin it. The lazy test deliberately asserts no pull count. Whether the async connect thread has attempted one by then is timing, not contract; what is contract is that neither build() nor the write surfaced the failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parseHexLong grew an overflow check to fix three HTTP chunk-framing bugs. The check was right; its location was not. io.questdb.client.std is an exported package, so rejecting a full-width word changed a shipped contract -- ffffffffffffffff read as -1 before and threw after -- to serve one internal caller. The utility had no business making that judgement. It cannot know its digits are a count a remote peer chose; only the caller knows that. And the strictness left it disagreeing with its own neighbours: parseHexInt two methods above still wraps, and so does the server-side io.questdb.std.Numbers of the same name, whose Long256 decoding depends on the wrap. Nothing at a call site would have shown which of the three you were looking at. So parseHexLong goes back to two's-complement accumulation, and AbstractChunkedResponse bounds its own input. The javadoc that promised the rejection now warns about the wrap instead and points at the chunk parser as the worked example. The guard has to run BEFORE the parse. A check on the returned value cannot work: 10000000000000000 wraps to 0, which is indistinguishable from a genuine 0 -- and that residue is the worst of the three, read as the terminal chunk and reporting a truncated body as complete. It counts SIGNIFICANT hex digits, skipping leading zeros, and rejects more than 15. 16^15-1 is about 1.15e18 and always fits a long; a sixteenth digit can push past Long.MAX_VALUE. Counting raw length would reject 00000000000000000001 -- twenty characters, value one -- and break framing against a conformant server that pads, which is a worse failure than the one being prevented. The bound is also strictly stronger than the overflow check it replaces: the smallest thing it turns away is a 2^60-byte chunk, so it rejects absurd-but-representable sizes that merely fitting in a long admits. The three framing tests are unchanged and still pass -- they assert the framing outcome and the "malformed chunk size" message, both produced at the caller either way. Removing the new guard reproduces all three distinct failures: a 30s timeout for the negative residue (the spin), "a terminal chunk (a truncated body reported as complete)" for the zero one, and "a data chunk" for the positive one. NumbersTest now pins the wrap values rather than a rejection, and a new test covers the padded size line, which nothing exercised before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
recv(int) gained a whole-call deadline and documented it. recv() did not,
though it inherits the same bound: both implementations here implement it
as recv(defaultTimeout), and that default -- HttpClientConfiguration
.getTimeout() -- is positive in every configuration this library builds,
since request_timeout is rejected below 1 on the builder and the
configuration-string paths alike. So "using the default timeout" quietly
changed meaning from per-socket-read to whole-call, on an exported
interface that ships a javadoc jar, and said nothing.
Three things were missing, only the first of them new:
- the bound itself, and what to size it against. Each call starts its
own budget, so a large body spread over many calls is unaffected and
only a single call that cannot finish in time aborts. Sizing against
the whole body would be the natural wrong reading.
- the delegation inversion, which is what decides whether the bound
exists at all. The recv(int) default defers DOWN to recv() and
discards its argument; the implementations here do the reverse. An
implementation overriding only recv() is therefore unbounded on both
methods, and one extending AbstractResponse or AbstractChunkedResponse
is bounded on both. recv(int) documents its half of that; recv()
documented none of it.
- the @return contract, which disagreed between the pair. recv(int)
says "or null once the body has been fully read"; recv() said only
"the received fragment", while the one no-arg caller in this library
-- AbstractLineHttpSender's construct-time /settings probe -- loops
on exactly that null. Pre-existing, fixed while the file is open.
Documentation only. The alternative, restoring the legacy shape by
passing a non-positive timeout from recv(), was rejected: it would
preserve an unwritten contract by removing a real guard from the
/settings probe, which reads from a server before any protocol version
has been agreed. No new test -- ResponseTest and ChunkedResponseTest
each already drive the no-arg path and assert the bound fires, and
ExportedApiCompatibilityTest pins the signatures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JsonParser was a bare method signature with no javadoc at all, in an exported package that ships a javadoc jar. Nothing told an implementor what any of the three parameters meant, and this branch then changed one of them: getCharSequence now resolves JSON escapes, so a value written "a\\nb" arrives as four characters rather than five raw ones. A parser carried over from an earlier release that unescapes for itself now decodes twice and turns the \n into a newline -- silent corruption on a minor version bump. The branch's own diff shows the compensating code being removed: ClientInteropTest lost a 68-line unescape() helper. The sharper half is not the escaping, though. getCharSequence returns the unescape sink when a value carried a backslash and the assembly sink when it did not, so the IDENTITY of the returned object now varies with the data. Before this branch it was always the same sink. An implementation that compares tag by identity, or caches the reference, works across escape-free input and then fails on the first value containing an escape -- a failure that depends on payload rather than on code. Also written down while here, neither of them new but neither documented: tag is borrowed for the duration of the call only, and it is null for the four structural events and non-null only for EVT_NAME, EVT_VALUE and EVT_ARRAY_VALUE. An implementor who does not know the second one gets an NPE on the first object it parses. Documentation only, and no test: the behaviour is already pinned from several directions -- testStringEscapesAreDecoded across the escape set, testStringEscapesDecodedAcrossSplitParseCalls and testUnicodeEscapeDecodedAcrossSplitParseCalls for the fragment boundaries, and testStringEscapesExoticAndLenient for the surrogate pair, the backspace/form-feed arms, the lenient malformed-escape arms and a lone unpaired surrogate. What was missing was the contract, not the coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
testSelectFdSetFailureLeaksNothing has been asserting nothing on
Windows, and said so: "construction succeeded, so this test's injected
failure no longer fires and it proved nothing". The guard it was written
for was never exercised.
The injection assumed a capacity of Integer.MAX_VALUE would overflow
FDSet's size computation negative. It does not overflow far enough.
FDSet computes ARRAY_OFFSET + 8 * size in int arithmetic, and
8 * Integer.MAX_VALUE is exactly -8, so the size is ARRAY_OFFSET - 8 --
negative only where ARRAY_OFFSET is 0 or 4. On 64-bit Windows fd_set is
{ u_int fd_count; SOCKET fd_array[]; }, SOCKET is 8 bytes, and
arrayOffset() returns offsetof(fd_set, fd_array[0]) = 8. The size lands
on exactly 0, and Unsafe.allocateMemory(0) does not fail: it returns a
null pointer. Construction completed, the flag stayed false, and the
assertion fired. Only a NEGATIVE size throws IllegalArgumentException.
1 << 28 makes 8 * capacity overflow to exactly Integer.MIN_VALUE, so the
size is around -2^31 whatever arrayOffset() reports -- checked against 0,
4, 8 and 16. The comment now carries that reasoning so the next person
does not reach for Integer.MAX_VALUE again.
Added alongside it: an injection that throws from getSelectFacade().
That reaches the other arm of the same guard, where FDSet is already
constructed and the guard has to free it as well as everything the base
constructor took -- a strictly larger leak surface than FDSet throwing,
and the arm nothing covered. getSelectFacade() sits inside the try for
exactly this case (a caller-supplied configuration that throws), and it
is deterministic, with no arithmetic to rot.
Verified as far as this machine allows: the size arithmetic and
allocateMemory's behaviour at 0 and at negative sizes are JDK-level and
were run here. The tests themselves cannot be: SelectAccessor's natives
are built only from core/src/main/c/windows/select.c, so the class does
not link off Windows and both tests Assume out. CI is the first place
they run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thread.join(long) throws InterruptedException the instant the calling thread's flag is set, without ever checking whether the joined thread exited. QwpQueryClient.close() joins its I/O thread that way, so a caller that merely ARRIVED interrupted took the "could not join" return and skipped closePool() and webSocketClient.close(). Those are the only frees for sendScratch, the decoder and the batch-buffer pool. The leak is permanent, not deferred. close() CAS'd closedFlag on entry, so every later close() returns at the guard, and QueryClientPool.reapIdle() removed the worker from `all` before calling shutdown(), so the pool's own close() never sees it either. Nothing reports it: lastCloseTimedOut stays false because the timeout branch never ran. PoolHousekeeper.stop() reaches this. It interrupts the housekeeper thread to break a recovery build's credential pull, and that same thread runs queryPool.reapIdle() immediately afterwards with the flag still set - the loop checks `stop` before senderPool.reapIdle() and not again between the two reaps. A long-lived application that opens and closes handles leaks a buffer pool and a socket per affected close. close() now clears the caller's flag for the duration of the teardown and restores it in the finally, the interrupt-neutral shape FileTokenStore load()/save() already use. The timeout branch keeps its meaning: with the flag out of the way the join really waits, so a genuinely stuck I/O thread still takes the leak-rather-than-SIGSEGV path, and an interrupt delivered DURING the wait still means "could not join" and still returns. QueryWorker.shutdown() takes the same treatment for its own dispatch-thread join, which a carried flag turned into an immediate throw and left the dispatch thread running alongside client.close(). QwpQueryClientInterruptedCloseLeakTest connects a client to a loopback QWP server, interrupts the calling thread, closes, and asserts under assertMemoryLeak. Reverting both hunks fails it with 401664 bytes leaked under NATIVE_DEFAULT. It also asserts close() hands the cancellation back rather than swallowing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The counters-as-fields fix has two halves and only one of them had a test. BackgroundDrainerDurableAckRetryTest covers the rotating-credential counter with testFlappingCredentialEscalatesAcrossMidDrainRecycles; nothing covered capabilityGapAttempts, so reverting that field to a method local left all 14 BackgroundDrainer suites green. Every other capability-gap test drives ONE connectWithDurableAckRetry() call whose ScriptedWireFactory rejects continuously - (port, 2, Integer.MAX_VALUE) throws on every attempt from the second on. The settle budget is therefore spent inside a single call and the field-vs-local distinction never shows. The shape that needs a field is a flapping cluster: connect accepted, drain, mid-drain durable-ack terminal, run() re-enters connectWithDurableAckRetry(), repeat. One gap sweep per call refills a local budget on every recycle, so 16 consecutive sweeps never accumulate. The drainer then sweeps forever, never writes the .failed sentinel, never reports DATA_LOSS, and holds the slot lock plus one of max_background_drainers workers for the life of the process. testFlappingCapabilityGapEscalatesAcrossMidDrainRecycles drives that shape directly. Only the attempt counter can escalate it, which is what makes it discriminating: the wall-clock half stays deliberately per-call, and with a single gap per call lastCapabilityGapNanos is still 0 when it is charged, so the episode clock never leaves zero however many recycles run. The reconnect budget is Long.MAX_VALUE so the OR's other arm cannot fire either. Verified by simulating the pre-fix local - resetting capabilityGapAttempts at the top of connectWithDurableAckRetry(): the new test fails with "a flapping capability gap must reach the escalation instead of recycling forever", and the other 31 tests in the class still pass, which is the gap itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rotating-credential ride-out quarantines an orphan slot only once BOTH its thresholds are spent: six rejections and a wall-clock dwell. The dwell is anchored at the first rejection of the current run, and the capability-gap, role-reject and transport arms all rewind that anchor on purpose, so an unrelated outage cannot satisfy the floor for free. The attempt counter beside it is only ever cleared by real ack progress. That asymmetry is what the gate wants in the ordinary case and what breaks it in the pathological one. A cluster that ALTERNATES - reject, blip, reject, blip - rewinds the anchor before every rejection, so the elapsed dwell is always ~0, the second disjunct is permanently true, and the AND can never be satisfied. connectWithDurableAckRetry() then never returns: no .failed sentinel, no DATA_LOSS report, the slot lock held, and one worker of a fixed-size BackgroundDrainerPool pinned for the life of the process. That is the outcome MAX_DYNAMIC_CREDENTIAL_AUTH_DWELL_MILLIS already exists to prevent, reached by a route the clamp does not cover. MAX_DYNAMIC_CREDENTIAL_AUTH_ATTEMPTS_PER_EPISODE caps the ride-out on the one quantity nothing rewinds. The attempt counter shares the episode scope the cap needs - only ack progress clears it - so it bounds the ride-out however the rejections are spaced. It sits far above the ordinary threshold (256 vs 6) because it is a backstop, not a policy: the dwell still decides every case that is not pathological, which is why the two arms asserting that an outage does not count toward the dwell keep passing unchanged. testAlternating401AndOutageStillReachesTheEscalation drives strict alternation. Before the cap it does not fail an assertion - it never returns, and dies on the 60s @test timeout parked in the backoff at BackgroundDrainer.connectWithDurableAckRetry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wipeCredentialState() swept the four sinks it knew about and missed the one that sees the token first. JsonLexer assembles every name and value in its own decode sinks before a listener is ever called, so TokenResponseParser's copy is the second copy; wiping the parsers left the originals untouched. Neither JsonLexer.clear() (parse state only) nor close() (frees the native cache without zeroing it) touches those sinks, and both are private with no accessor, so nothing else could reach them. close() hid this. It runs the sweep and then does jsonLexer = Misc.free(jsonLexer), so the field is null by the time testCloseWipesCredentialState's reflective walk looks, and the lexer is unreachable garbage either way. clearCache() is the case that matters: it deliberately keeps the lexer alive so the instance stays usable for a later signIn(), which left the access, id and refresh tokens legible on the heap for the life of the instance - through the very call a caller makes to sign this process out. JsonLexer.wipe() overwrites both decode sinks, and wipeCredentialState() calls it. The call is null-guarded because close() is documented idempotent and frees the field after wiping; a second close() would otherwise NPE, which is how testUseAfterCloseThrowsClearly and two siblings caught the first version of this change. testClearCacheWipesTheLexerDecodeBuffers signs in, asserts the buffers really carry a secret, calls clearCache(), then asserts none of the three tokens survive. It asserts on the SET of secrets rather than one field's position: the sink is reused per value, so which one survives is whichever the parse ended on, plus whatever is still legible in the tail past it - the retention itself. Without the wipe it fails with "clearCache() left REFRESH-LEXER-WIPE-ME legible in the lexer's decode buffers". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The httpTokenProvider javadoc stated twice, unconditionally, that over WebSocket a token must be obtainable when build() runs because the initial handshake pulls it. That is true only of an EAGER initial connect. Under lazy_connect=true - or initial_connect_retry=async - the ingest side resolves to ASYNC, no pull happens at build time, and a provider failure surfaces through the error inbox instead. The branch the text denied is the one this same branch added a test for: QuestDBLazyConnectTest.testLazyConnectBuildsAndWritesDespiteAFailingTokenProvider builds a lazy_connect handle with a provider that always throws and asserts both that build() returns a usable sender and that a write buffers. Its own comment names the stake - a producer that hard-fails at build() instead of buffering drops the rows store-and-forward promised to keep. Getting this wrong points an operator the wrong way on a headless host: the documented remedy for "a token must be obtainable at build()" is a blocking signIn() first, and on a machine with nobody to authorize the device code that parks the producer for the device-code lifetime rather than letting it start and buffer. Both branches are now stated, and both already have tests: testLazyConnectBuildsAndWritesDespiteAFailingTokenProvider for the async case, QuestDBBuilderTest.testEagerBuildAndBorrowQuerySurfaceAProviderFailure for the eager one. Documentation only - no behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TokenStore's contract says entries are keyed by TokenStoreKey, and its javadoc invites a custom store backed by an OS keychain, a secrets manager or a vault. TokenStoreKey had no equals or hashCode, so that read as an invitation to a Map that never hits. The failure is quiet and delayed. OidcDeviceAuth builds its key once per instance and reuses it, so a Map-backed store looks correct for the life of that instance; a second instance for the same identity, or a restart that rebuilds an equal key, misses. The caller then sees an interactive device-flow prompt on every refresh, with a persisted token sitting in their store the whole time. The bundled FileTokenStore is unaffected only because it keys by hash() for the file name. equals compares hash() rather than the fields one by one, so equality means exactly "the same store entry". The hash already folds every identity field through the constructor's null-vs-empty audience normalisation, so two keys that address one entry - and one file - are now also equal and share one Map slot, which field-by-field comparison would have got wrong. hashCode delegates to the same value, so the two are consistent by construction. TokenStore's javadoc now states both routes: the key is a value type and may be used as a Map key directly, or hash() gives the same identity as a stable opaque name for a store that needs one. testKeyIsUsableAsAMapKey covers equality, hashCode agreement, inequality on a changed identity, null and foreign-type comparison, Map get/replace through a rebuilt key, and the normalised-audience case. Without equals it fails on the first assertion with two distinct identities. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parseBody bounds the whole body read against an untrusted identity provider, and nothing exercised the bound. getToken() runs once per ILP flush, so losing it stalls ingestion rather than failing it: a provider that keeps delivering, slowly, holds the producer thread indefinitely. Two bounds can end that read and they are not interchangeable. The per-call recv bound catches a read that makes no progress; parseBody's elapsed deadline catches the case every individual read SUCCEEDS while the read as a whole runs past the budget. Only the second one covers a slow-but-progressing peer, and only the first had tests - MockOidcServer.stall() blocks inside recvOrDie, so it dies one frame lower, and dribble() never completes a chunk-size line, so recv never returns a fragment at all. The test drives parseBody directly rather than over a socket. A socket-level reproduction races the two bounds - the elapsed deadline expires while a recv is waiting out an inter-chunk gap, and whichever wins decides the message - so it would pin whichever fired on the day and flake on the other. The Response here hands back an EMPTY fragment immediately and forever: every recv succeeds, so the recv bound cannot fire; totalBytes never grows, so the 4 MiB cap cannot fire; the lexer is fed nothing, so it cannot throw. The elapsed deadline is the only exit left, which is the line under test. Replacing the deadline check with `if (false)` makes it hang until the 30s @test timeout instead of failing an assertion - the wedge itself. Test only; no production change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
putAsPrintable walks its input a character at a time and appends with put(char). On StringSink that is a capacity check per CHARACTER, where its put(CharSequence) override does one for the whole sequence and then copies in a tight loop. That regressed a real path. The ILP error render used to build its message with put(sink) - the bulk form - and now routes the server's error body through putAsPrintable so a hostile or proxied endpoint cannot splice ANSI or bidi characters into a log line. The escaping is right and stays; what was lost along with it is the bulk copy, over a body the client does not cap, on every failed flush. putAsPrintable now classifies before it copies. It scans for the first display-unsafe code point and, finding none, hands the whole sequence to put(CharSequence); only a sequence that actually carries something unsafe takes the character-by-character escaping loop. A server error body is almost always entirely printable, so the common case is a scan plus one bulk copy instead of N capacity checks and N interface calls. Mixed input pays the extra scan and then takes the same loop as before. That is the rare case and the one where correctness rather than speed is the point. The shape mirrors OidcDeviceAuth.sanitizeForDisplay, which returns its input untouched on the same test. Behaviour is unchanged: the fast path only replaces a per-character copy of text that was already going to be emitted verbatim. Both branches were already covered - the emoji case exercises the fast path, the bidi, lone surrogate and supplementary-format cases the escaping path - and testMessage_putAsPrintableAgreesOnBothPaths adds the guard the split itself creates, pinning that the same text with and without one unsafe code point differs only by that code point's escape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QwpCredentialUnavailableException called itself "an internal marker" while being public in an exported package. One of those had to give, and the code settles it: the type is reachable, so the documentation was the wrong half. It cannot be package-private. It is thrown in io.questdb.client.cutlass.qwp.client and handled in io.questdb.client.cutlass.qwp.client.sf.cursor, and both packages are exported. Moving it somewhere unexported would also buy nothing on the artifact that ships: JDK 8 is the source of truth for this module and cannot compile module-info at all, so exports do not constrain the primary jar. Nor is it unreachable in practice. QwpWebSocketSender.newReconnectFactory() is plain public - not even @testonly - so a caller can drive ReconnectFactory.reconnect() itself, run the endpoint walk, and receive this type with nothing in between to unwrap it. What IS true is the narrower statement the javadoc now makes: no path out of build(), flush() or a row call delivers it. The SYNC and OFF foreground connects both catch it and rethrow providerFailure(), so an ordinary caller sees the provider's own exception; the running background drainer catches it and retries under Invariant B. The docs now say where it can be met, why it is public, and what a caller who meets it should do with it - unwrap it, as the two foreground paths do. providerFailure() also loses the word "marker" and gains its null contract: the constructor dereferences its argument to build the message, so any constructed instance carries a non-null failure. Documentation only; no behaviour or signature changes. ExportedApiCompatibility Test, the WebSocket and query-client token-provider suites and the drainer credential-outage suite all pass, and the javadoc build is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four production sites escape attacker-influenced text before it reaches a log line or a terminal. Two of them had no test that observed the escape. putAsPrintable(char) had none at all. Its only production caller is ConfStringParser, whose test asserted "invalid character" and the position and stopped there - so the overload could emit the raw character, or nothing, and stay green. It is the overload that renders a control char from a connect-string, and before this branch it escaped only the LOW BYTE, which turns U+202E into a full stop: text that looks ordinary rather than escaped. The parser test now asserts the four-hex escape and the absence of the raw char, and Utf16SinkPrintableTest covers the overload directly - one case per class the classifier rejects, both ends of the printable range, and the four-digit width - plus U+2028, U+2029 and the BOM through a sink, which DisplaySafeTest classifies but nothing rendered. QwpWebSocketSender's table-name check was the fourth callsite. Its three siblings are covered - QwpUdpSender and QwpTableBuffer by QwpUdpSenderTest, the ILP names by AbstractLineSender's tests - and this one could regress to a raw concatenation unnoticed. Verified by reverting both: putAsPrintable(char) to the pre-branch low-byte form fails testPutAsPrintableCharUsesFourHexDigits, testPutAsPrintableCharEscapesEveryUnsafeClass and the strengthened testInvalidCtrlCharsInValue; the table-name message back to a concatenation fails testIllegalTableNameIsEscapedInTheMessage. Tests only; no production change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three places pull a token from an HttpTokenProvider, validate it, and write it into an Authorization header, and all three snapshot the pulled value first so the bytes that are checked are the bytes that are sent. Only the ILP sender's copy had a test. The rule exists because HttpTokenProvider explicitly invites a provider to return a reused mutable buffer. Without the snapshot, validateToken scans the live sequence and the header write materialises it a second time; a mutation landing between those two reads passes the check and splices CR/LF into the header. Deleting the snapshot at either uncovered callsite left the whole suite green. Both now have the same coverage the ILP sender has: QwpQueryClient.resolveAuthorizationHeader, driven through getAuthorizationHeaderForTest, and Sender.buildWebSocketAuthHeader's supplier, driven through a real upgrade against TestWebSocketServer and asserted on the header the server recorded. HandOffToken moves out of LineHttpSenderTokenProviderTest and becomes test.tools.HandOffCharSequence rather than being copied twice more. It swaps its contents the instant a full scan completes and materialises current state from toString(), which is what a StringSink-backed buffer does - and is what makes the fix observable rather than merely present. The non-vacuity guard is a pull counter, not a "was it scanned" flag: with the snapshot in place toString() runs BEFORE any scan, so charAt is never called and the hand-off never fires. That is the fix working, so asserting on it would have inverted the test - and did, on the first attempt. Verified by reverting each snapshot in turn: each removal fails its own callsite's test and nothing else. Tests only; no production change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PoolHousekeeper.stop() and SenderPool.stopStartupRecoveryDriver() escalate to Thread.interrupt() when their join times out, to break a recovery build's credential pull. The thread they interrupt is the same one that then runs senderPool.reapIdle() and the startup-recovery step's finally -- both of which close a delegate -- so the flag arrives at close() as an ordinary carried flag rather than an exotic one. That is fatal if unhandled. CountDownLatch.await(t, u) tests Thread.interrupted() before it ever consults the latch, so CursorWebSocketSendLoop.close()'s shutdown await returned having waited 0 ms, close() took the failed-stop branch, and the slot was reported with its store-and-forward flock still held -- precisely the outcome the interrupt was added to prevent. That branch re-asserts the flag, so in a reap sweep every remaining delegate failed the same way: the whole sweep retired its slots and QuestDB.close() returned still holding them, and an immediate reopen of the same sf_dir could fail with "sf slot already in use". QwpWebSocketSender.close() now clears the flag for the duration and restores it on the way out -- the interrupt-neutral shape QwpQueryClient.close(), QueryWorker.shutdown() and FileTokenStore's load()/save() already use. The query half of the pool got this treatment when the escalation landed; the ingest half, which reapIdle() reaches first, did not. An interrupt delivered DURING the close still lands on the await and still takes the failed-stop branch, which is correct: that one really does mean "we could not join". PoolHousekeeper's comment claimed every wait on the pull path is interruptible. It is not -- the token POST's connect, send, await and parse run on the native HTTP client, which no interrupt breaks, and DNS resolution is unbounded -- so the comment now says what the escalation does and does not buy. Two tests drove the failed-stop branch by handing their closer a pending interrupt, which this change deliberately neutralises. They now shrink the loop's bounded-await backstop through setShutdownAwaitTimeoutMillis, the seam written for exactly that: same branch, deterministic, and the class runs in 0.8s instead of 20.5s. Verified both ways: without the production change the new regression test fails with "cursor I/O thread did not stop: close() was interrupted while awaiting shutdown". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An unparseable response head reached flush0's transport arm and was retried like a network failure. HttpHeaderParser rejects a header block past its fixed 4096-byte buffer (an intermediary stacking Set-Cookie and CSP), a malformed Content-Length, or a status line that is not HTTP/1.x. Retrying it is wrong on both counts. The parser only ever runs on bytes that ARRIVED, so an HttpException is positive evidence the server answered -- the same evidence the 2xx drain arm forty lines above treats as decisive when it refuses to let a drain failure re-send a committed batch. And the head is chosen by an intermediary rather than by chance, so the next attempt parses the same block and fails identically: the loop never converges, it just runs to the budget. Measured against a mock returning a 5000-byte head: 16 sends over 10.8s per flush at the default retry budget, where the same trigger sent once before HttpException reached the arm. For a table without DEDUP keys that is fifteen extra copies of every row, and the flush blocks for eleven seconds before reporting "Connection Failed: header is too large" -- a transport failure that never happened. The catch stays, because what it added was right: without it the HttpException escaped flush0 entirely, taking the client.disconnect() that keeps the next flush off a connection holding a half-read response, and leaving flush() throwing a raw HttpException past every caller's catch for the LineSenderException the contract promises. It moves to its own arm that disconnects, reports a non-retryable LineSenderException naming the malformed head, and does not re-send. lastFlushFailed suppresses the close-time re-flush for the same reason: the server already has the rows. The existing test asserted the retry, so it was pinning this rather than guarding against it. It now proves the flush fails once, names the head rather than a transport error, reports isRetryable() false, and leaves the request count at exactly one against a budget a re-send would spend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maybeLoadFromStore() deliberately leaves storeLoadAttempted UNSET when a read THROWS, so a transient fault is retried instead of disabling persistence for the life of the instance. That part is right. What it missed is that the same method runs at the top of getToken(), AHEAD of the cache check, and that adopt() assigns the served kind, the expiry and the ttl unconditionally -- it never compares the file against what is already in memory. So a store that is unavailable across signIn() and readable afterwards undid it. An unmounted home directory, or a container started before its volume attaches, fails the read and the save alike -- one root cause, both through ensureDirectory. The read failure leaves the latch unset, the human authenticates, the save failure is swallowed with a WARN, and then the directory recovers. The next getToken(), which an ILP sender calls once per flush, re-reads the store and installs the PREVIOUS entry over the grant a human just authorized. The failed save is what makes it stick: nothing had rewritten the entry to match memory. persistIfRotated() now latches the flag. Once this instance has produced tokens of its own the on-disk entry is no longer authoritative for it, whether or not the save that follows succeeds -- which is why the latch sits above the rotation check rather than beside the write. Putting it there also covers the refresh-only path through adoptRotatedRefreshToken() with the same line. A store that never yielded a token is unaffected: persistIfRotated() is not reached, so the existing "a failed read must leave the store re-readable" case still re-reads and still serves the persisted token. Verified both ways: without the change the new test reports expected:<ACCESS-FRESH> but was:<ACCESS-STALE>. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adopt() rejects a refresh token carried with NEITHER token kind, treating it as positive evidence of a foreign writer: an attacker who can write the store directory drops in a file whose fingerprint fields are all derivable from public config, and the next silent refresh presents THEIR refresh token. The rejection is right, but its justification rested on a callsite count -- "persistIfRotated runs solely at the tail of storeTokens, so every entry we write carries at least one token kind" -- and that count was wrong. persistIfRotated() has a second caller. Under groupsInToken the served kind is the id token, so a stored entry carrying only an access token takes adopt()'s own served-kind-absent branch, which nulls BOTH kinds and keeps the refresh token. A refresh that then rotates the refresh token but still returns no id token reaches adoptRotatedRefreshToken() -> persistIfRotated() with both null, and the snapshot it writes is exactly the shape adopt() refuses. The client had written a file it would never read back: every restart re-runs the interactive device flow over a live refresh token sitting on disk, which for a headless getToken() consumer is a hard failure rather than a degraded one. persistIfRotated() now declines that shape instead. Skipping the write leaves the previous entry in place, which is the better of the two outcomes: its refresh token is the one the provider just burned, so the next start spends one silent round trip and falls back to an interactive sign-in -- the same end state, without a file that can never be read back. Weakening the rejection was the alternative and is the wrong direction; it is the guard that stops a store-directory writer swapping in an identity. adopt()'s comment now says why the shape cannot arrive from this client rather than asserting a callsite count that a later caller can silently falsify. Verified both ways: without the guard the new test finds the both-null entry on the store after the rotated refresh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
[PR Coverage check]😍 pass : 2201 / 2457 (89.58%) file detail
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds interactive OIDC sign-in to the Java client using the OAuth 2.0 Device Authorization Grant (RFC 8628). A process with no local browser — a remote notebook kernel, a container, a headless job — can sign a human in against QuestDB Enterprise: the user authorizes on any device (laptop or phone) while the process only makes outbound calls to the identity provider.
On first use it prints a verification URL and a short code (and, by default, also tries to open the URL in a local browser); once the user authorizes, the token is cached in memory and refreshed silently on later calls.
What's new
OidcDeviceAuth(io.questdb.client.cutlass.auth) — runs the flow and owns the token:OidcDeviceAuth.fromQuestDB(url)discovers the client id, scope, audience and IdP endpoints from the server's unauthenticated/settings;OidcDeviceAuth.fromQuestDB(url, DiscoveryOptions)adds an identity-provider pin (.issuer(...)), a TLS config, anallowInsecureTransportopt-in, and the prompt (see Discovery and trust below);OidcDeviceAuth.builder()configures the identity provider explicitly.signIn()signs in interactively on first use, then serves a cached token and refreshes it silently;getToken()never prompts and never waits behind an interactive sign-in (safe on a request/flush path);getAuthorizationHeaderValue()returns the fullBearer …value;clearCache()drops the cached token so the nextsignIn()re-signs-in;close()cancels an in-flight sign-in (observed between polls, so it can take up to one HTTP request timeout to return). Calls are serialized by aReentrantLock;getToken()usestryLockand fails fast rather than wait behind an interactive sign-in. Token state is in-memory only by default; pass aTokenStoreto persist it across restarts (see Token persistence below).Senderintegration — newHttpTokenProviderinterface andSender.builder(...).httpTokenProvider(auth::getToken). The sender pulls a freshly refreshed token on every request, so a long-lived sender keeps working as the token rotates — unlike a fixedhttpToken(...), which is captured once and eventually starts returning 401s. Mutually exclusive withhttpToken/httpUsernamePassword. Supported over HTTP and WebSocket transport (a WebSocket sender re-queries the provider on every (re)connect/upgrade); rejected for TCP and UDP. The two transports differ in mechanism but both keep the producer alive across a sustained token outage: over HTTP a failed pull leaves the request token-pending and is retried on the next row; over WebSocket the token must be obtainable whenbuild()runs (the initial handshake fails fast otherwise), after which a pull that keeps failing on later reconnects is retried indefinitely, with the buffered rows held in store-and-forward, until a token is available again — a token outage does not terminate a running WebSocket sender, just as a persistent transport reconnect failure does not (store-and-forward Invariant B). The first pull is deferred off the build path to the first row, so the documentedconstruct → signIn() → sendordering works and a provider that throws leaves the request retriable instead of corrupting the sender.QWP egress query client —
QwpQueryClient.withBearerTokenProvider(HttpTokenProvider)accepts the same on-demand provider, soOidcDeviceAuth::getTokenplugs into the egress query path as well as ingress. The provider is queried at every WebSocket upgrade — the initialconnect()and each failover reconnect — so a long-lived query client follows token rotation; each returned token is validated before it reaches the header, and a provider that throws fails that connection attempt (matching the ingress sender). Mutually exclusive withwithBearerToken/withBasicAuth.DeviceCodePrompt/DeviceAuthorizationChallenge— how the verification URL and user code are shown. The default,DeviceCodePrompt.openBrowser(), prints the instructions toSystem.outand also tries to open the verification URL in the local default browser; the browser open is best-effort (skipped on a headless JVM, without thejava.desktopmodule, or for a non-http(s)URL, and disabled by-Dquestdb.client.oidc.open.browser=false) and never blocks or fails sign-in. UseDeviceCodePrompt.SYSTEM_OUTto print only, or supply your own to render a clickable link or a QR code, e.g. in a notebook.audience—builder().audience(...)/ discovered fromacl.oidc.audience. When set, theaudienceparameter is sent on the device-authorization and refresh requests, for providers that require it to stamp theaudclaim QuestDB expects.The token can be presented to QuestDB over any auth path the server already validates:
Authorization: Bearer <token>._ssowith the token as the password (requiresacl.oidc.pg.token.as.password.enabled=trueon the server).Discovery and trust
fromQuestDB(...)takes the IdP endpoints from the server's unauthenticated/settings, so by default it trusts that server to designate where the user signs in: a spoofed, compromised, or man-in-the-middled server could otherwise redirect the sign-in — and the long-lived refresh token — to an attacker-controlled identity provider. An optionalDiscoveryOptions.issuer(...)pin addresses this, and also covers servers that do not advertise a device-authorization endpoint. The pin separates two sources of endpoints and trusts them differently — an endpoint the untrusted/settingsadvertised is constrained to the issuer, while an endpoint read from the identity provider's own.well-knownis trusted wherever the provider hosts it:.well-knowndiscovery fallback. Current servers do not advertise the device-authorization endpoint. When it (and/or the token endpoint) is missing, a pinned issuer reads it from{issuer}/.well-known/openid-configuration. The discovery origin comes only from the caller-suppliedissuer, never from a/settings-supplied value, so a tampered/settingscannot choose where discovery — and the credential POSTs it resolves — are aimed. Without a pin, discovery is refused rather than guessed.validateEndpointOrigins, enforced on every construction path (discovery and the explicitbuilder()), requires the token and device-authorization endpoints to share one origin (RFC 8628 co-locates them on a single authorization server), so a tampered/settingsor discovery document cannot siphon one of the two credential POSTs off to a different origin./settings-advertised endpoints are pinned to the issuer. An endpoint the untrusted/settingsresponse supplied must sit on the pinned issuer's origin, and — when the issuer has a path — under that path (compared segment by segment, rejecting./.., percent-encoded traversal, and a percent-encoded path separator such as%2for%5c, at every decode level). The path check matters for a path-based provider that shares one origin per tenant (e.g. a Keycloak realm path/realms/<realm>), where the origin check alone cannot stop a tampered/settingsfrom steering credentials to a sibling tenant. The issuer is supplied out of band and cannot be forged..well-knownis neither origin-pinned nor path-scoped: that document is fetched from the pinned issuer origin and is authoritative for wherever the provider hosts its endpoints. This is deliberate — some providers (e.g. Google, Azure AD) serve their token and device endpoints from a different origin or path than the issuer, and discovery against them signs in normally. The co-location pin above still applies./settingsresponse fetched over plaintexthttpto a non-loopback host (only reachable withallowInsecureTransport) is MITM-able, so its advertised endpoints are not trusted to route credentials without an issuer pin.Without a pin, the behaviour against an
httpsserver that advertises its endpoints is unchanged: that server is trusted, as before.Security
httpsis required by default for both the QuestDB server and the IdP endpoints;httpis rejected unless the caller opts in withallowInsecureTransport(true). That opt-in relaxes only the QuestDB/settingslink — the IdP device-authorization and token endpoints always requirehttps(loopback excepted), so the device code and refresh token never cross the network in cleartext (matching the Python client).[httpStatus=…]echo, nor can a short all-digit status (2,5) be misread as a 2xx/5xx class — a malformed-length status falls through to the terminal reject path.verification_uri_complete, treated as absent) rather than shown as a blank line or handed to the browser launcher.0x20–0x7e) before it is cached, placed in theAuthorization: Bearerheader, or used as the PG-wire password — so a tampered or hostile identity provider cannot smuggle a CR/LF into the request the client then sends to the trusted QuestDB server. Only the served kind is checked; a stray character in the unused token kind, which never reaches the wire, no longer aborts an otherwise usable grant. (TheJsonLexerchange below decodes JSON escapes, which is what turns a\r/\nin a token into a real byte rather than two literal characters.)Endpoint.parserejects control characters, whitespace and display-unsafe code points anywhere in the url (so a tampered endpoint cannot inject a CR/LF into the request line or a bidi char into a log line), rejects bracketed IPv6 literals rather than mis-parsing them, rejects userinfo (user@host) — which the HTTP layer would otherwise try to connect to literally — terminates the authority at the first/,?or#so a query or fragment is never folded into the host, and range-checks the port to 1..65535.System.nanoTime) deadline that bounds the whole read — covering both a chunked response whose chunk-size line is dribbled a byte at a time and aContent-Lengthbody dribbled through stalled TLS records, either of which previously could keep a single read running well past the deadline. After such a bounded-read abort the half-read poll connection is dropped so the next poll reconnects on a clean socket, rather than the loop spinning on the stalled response's leftover bytes until the device code expires. The device-code lifetime, the poll interval, and the token TTL are all clamped (defaults applied for absent/zero values, hard caps for absurd ones). A429with no OAuth error is treated as a transient back-off (a429that also carries a terminal error such asaccess_deniedstill aborts on the error); a transient transport failure or5xxduring polling keeps polling until the device-code deadline rather than failing the sign-in (RFC 8628; matches the Python client), while a definitive OAuth error or a terminal4xxaborts immediately. The trade-off is that a persistently flaky network is no longer cut short by a separate error budget — it polls to the device-code deadline.Token persistence (opt-in)
By default token state is in-memory only, so a restarted process re-runs the interactive device flow. Passing a
TokenStorepersists it, so the restarted process resumes from the saved refresh token (one silent token-endpoint round-trip) instead of re-prompting —getToken()then even works as the first call, with no explicitsignIn().TokenStoreSPI (io.questdb.client.cutlass.auth) —load/save/clearkeyed by a non-secretTokenStoreKey(endpoints, client id, scope, audience, groups-in-token mode), plus an optionalinLockhook for cross-process coordination. Wire it in withbuilder().tokenStore(...)orDiscoveryOptions.tokenStore(...). Persistence is best-effort: a store failure logs a warning through SLF4J atWARNand the in-memory token is used regardless — and the library shipsslf4j-apiwith no binding, so that warning, like every other client warning, is discarded unless the application supplies one.FileTokenStore(the default) — one plaintext JSON file per OIDC configuration under${user.home}/.questdb/oidc-tokens/(override withquestdb.client.oidc.token.store.dir), the refresh token protected at rest by file permissions (0600file,0700directory on POSIX) rather than encryption — the same approachgcloud,awsandghtake. The file name is a SHA-256 of that configuration (endpoints, client id, scope, audience, groups-in-token mode), so it leaks neither endpoint nor client id, and different servers, providers or client configurations stay in separate files.FileTokenStore.atDefaultLocation()/FileTokenStore.at(dir).FileTokenStore.at(dir)on a per-user directory, or a per-userquestdb.client.oidc.token.store.dir— rather than a reliance on the name to separate them. The default location is per OS user already, so this only arises inside one OS user: a shared service account, or a process signing in on behalf of several people.O_CREAT|O_EXCLlock file (not an OS advisory lock, which JavaFileLockand Pythonflockcannot share); a process that cannot acquire the lock degrades to a lock-free refresh rather than stall.design/oidc-token-persistence.md): the file name, JSON schema, atomic-write and lock-file protocols are specified so the Python client (and others) can share one file.Supporting changes
JsonLexernow resolves JSON string escape sequences (\",\\,\/,\b \f \n \r \t,\uXXXX; lenient on malformed input), so string values arrive fully decoded. This also reaches the existing ILP error-response parser, which now sees decodedmessage/code/line/errorIdfields.Response.recv(int timeout)— adefaultmethod delegating torecv(), so an implementation of this exported interface written before the overload existed keeps compiling and linking, and keeps its previous behaviour. Both implementations here override it. It bounds the whole read to the timeout in total, not per socket read, so a server that dribbles the body — the chunk-size line of a chunked response, orContent-Lengthbytes behind stalled TLS records — cannot keep a single read running past the caller's deadline. A non-positive timeout keeps the legacy unbounded behaviour. Every ILP flush-path body read now passes an explicit timeout —actualTimeoutMillis, the base request timeout plus the throughput extension, not the rawrequest_timeout— so a tuned-lowrequest_timeoutpaired withrequest_min_throughputcannot abort a large, still-progressing chunked body. That bounds eachrecv()call in total rather than the body cumulatively across calls; the ILP server is trusted, unlike the identity provider, whose readsOidcDeviceAuth.parseBodyadditionally caps by total bytes and one monotonic deadline. A response that completes within the per-call bound is unaffected, while one that dribbles a single fragment for longer than it — previously tolerated as long as each socket read made progress — now aborts. The only no-argrecv()left is the construct-time/settingsprotocol-version probe, whose retry loop already catches the abort. Both flush-path consequences of that newly reachable abort are handled — see the third review round below.AbstractLineHttpSenderplumbs the token provider through with a deferred, retriable per-request pull (a throwing or blank-returning provider leaves the request token-pending for the next row instead of corrupting the half-built request), so the very first send already carries a provider-sourced token. Its error rendering now routes every untrusted server-supplied string throughputAsPrintable— the decoded JSON error body, the[http-status=…]field, and the line-protocol-version detection probe body — escaping control and Unicode format characters (bidi overrides, zero-width joiners, the BOM), so a hostile or proxied endpoint cannot reorder, hide, or forge the text shown in aLineSenderException(or spliced into a log line or terminal).QwpWebSocketSendersources its auth header from the token provider too, re-querying it on every connect/reconnect so a rotating token keeps a long-lived WebSocket sender authenticated.Tradeoffs and limitations
fromQuestDB(...)discovery trusts an endpoint read from the issuer's.well-knownwherever the provider hosts it, so an off-origin provider (e.g. Google) signs in normally through a pinned issuer; only an endpoint the/settingsresponse itself advertised is held to the issuer's origin and path. The explicitbuilder().issuer(...)pin is stricter — a plain sanity check that both supplied endpoints sit on the issuer origin — so an off-origin provider configured that way must have its issuer omitted, or its endpoints supplied to match. This matches the Python client.build()(it fails fast otherwise), after which a pull that keeps failing on later reconnects is retried indefinitely with the buffered rows held in store-and-forward, until a token is available again — a token outage does not terminate a running WebSocket sender (store-and-forward Invariant B). AgetToken()provider that fails only transiently recovers on both transports; a long WebSocket outage grows store-and-forward (and eventually applies backpressure) rather than ending the sender.MockOidcServer.dropConnection()).127.xaddress that the loopback classifier deliberately rejects as non-loopback. That trick relies on the OS resolver expanding the short form (BSDinet_aton, on Linux/macOS), which Windowsgetaddrinfodoes not do, so that one end-to-end test is skipped on Windows; the loopback classifier itself is covered cross-platform.TokenStorebacked by an OS keychain or a secrets manager instead ofFileTokenStore. On Windows POSIX permissions cannot be enforced, so the file currently relies on the user-profile directory's default ACL (owner-only ACL hardening is a follow-up); the client logs a one-line SLF4J warning atWARNthe first time it cannot enforce them — which an application with no SLF4J binding never sees, so this boundary has to be read here rather than watched for at runtime.getToken()runs once per ILP flush and once per WebSocket (re)connect, so a failing credential path would otherwise cost one token-endpoint round trip — or one blocking store read, two stack-trace fills and aWARNline — per flush, on the producer thread and under this instance's lock, which is enough to trip an identity provider's rate limits and lengthen the very outage being retried. So a silent refresh that fails is not re-attempted for 5 s: calls inside that window fail immediately with the cached-token-expired error instead of re-hitting the provider, and only a real attempt re-arms the latch, which an explicitsignIn()orclearCache()clears outright. ATokenStore.loadthat throws is retried once immediately (a one-shot fault, notably a carried interrupt flag, must recover on the next call), then backed off 5 s, doubling to a 60 s cap; a store that simply has nothing to return reports that by returningnulland is unaffected. Both are deliberately short — a stampede guard, not a circuit breaker — but the cost is that a credential which recovers inside a window is picked up on the first call after that window rather than the first call after it recovers.AbstractChunkedResponse, which counts significant hex digits and rejects more than 15 BEFORE parsing — the only form that works, since the worst residue (10000000000000000wrapping to zero, read as the terminal chunk) is indistinguishable from a genuine0afterwards.Numbers.parseHexLongkeeps its two's-complement contract:ffffffffffffffffis still-1, matchingparseHexIntbeside it and the server-sideio.questdb.std.Numbersof the same name, whoseLong256decoding depends on the wrap.io.questdb.client.stdis exported, so that contract is shipped and is deliberately left alone; a caller parsing a count a remote peer chose must bound the digits itself, which is what the chunk parser now does. The javadoc says so and points at it as the worked example.TokenStore.inLockmay now returnfalsewithout running the action. An implementation that waits for its lock must make that wait interruptible and abandon it on an interrupt, because the wait can outlast a caller's shutdown budget (see the second review round below). Thefalsereturn reads as "no refresh happened", whichOidcDeviceAuthalready handled, but a third-partyTokenStoreinherits the new obligation.getToken()may briefly wait to acquire the cross-process lock before a silent refresh (a few seconds at most forFileTokenStore— the acquire budget is capped — then it proceeds without the lock). It still never waits behind an interactive sign-in; this is a quick silent refresh, not an interactive wait.Response.recv(int)bound (see Supporting changes) also tightens existing, non-OIDC ILP flushes. Each flush-response body read is now bounded in total rather than re-arming its timeout per socket read, so a response that legitimately dribbles a single fragment for longer than the per-flush budget (request_timeoutplus therequest_min_throughputextension) — previously tolerated as long as each socket read made progress — now aborts. The bound is perrecv()call, not cumulative across the whole body, so it is a ceiling on one stalled fragment rather than on the response as a whole. A healthy response is unaffected. Making that abort reachable insideflush0's retry scope had two consequences, both fixed in the third review round below rather than accepted: a drain abort after a 2xx no longer re-sends a batch the server had already committed, and a body-read abort under an error status no longer reclassifies a definitive 401/403/405 as a transport failure. The pre-existing ILP-over-HTTP at-least-once window is therefore not widened; the only observable effect is that a pathologically slow single fragment is cut short instead of tolerated indefinitely.Response.recv(int)bound above is the first).HttpHeaderParserrejects a response head past its fixed 4096-byte buffer, a malformedContent-Length, or a non-HTTP/1.x status line. Reaching one needs an intermediary — QuestDB's own/writeanswers 204 with a small head — but where one does, the flush now fails immediately with a non-retryableLineSenderExceptionnaming the malformed head, rather than spending the retry budget re-sending a batch the server had already answered. A healthy response is unaffected.Tests & docs
OidcDeviceAuthTest(~123 cases) +MockOidcServer,BrowserLauncherTest,LineHttpSenderTokenProviderTest,WebSocketTokenProviderTest+TestWebSocketServer,SenderBuilderErrorApiTest,JsonLexerTest,LineHttpSenderErrorResponseTest,DisplaySafeTest,ChunkedResponseTest/ResponseTest,QwpQueryClientTokenProviderTest,FileTokenStoreTest,OidcDeviceAuthPersistenceTest,WebSocketCredentialCancellationTest,SenderPoolSfTokenProviderTest,BackgroundDrainerCredentialOutageReportTest,NumbersTest,HttpClientConstructorLeakTest; runnableOidcDeviceFlowExample/OIDCAuthExample; and README "OIDC Sign-In (Device Flow)" and "Persisting the Token Across Restarts" sections. Coverage includes:.well-knowndiscovery via a pinned issuer; a discovery document that omits the device-authorization endpointbuilder().issuer(...)origin pin rejecting off-origin endpoints; a/settings-advertised endpoint rejected when off the issuer origin; an endpoint discovered from the issuer's.well-knownaccepted even when off the issuer origin (the Google case)%2f), and a percent-encoded backslash (%5c) rejectedhttp(firing path skipped on Windows)audienceparameter discovered from/settingsand sent on the device and refresh requests429and a transient5xx/transport failure keep polling to the deadline; a terminal4xxand an OAuth error fail fast (including a429that also carries a terminal error);slow_downgrowth and the 60 s interval clamp; device-code-lifetime and clock-skew clampsEndpoint.parserejecting a malformed url: userinfo (user@host), a bracketed IPv6 literal, an out-of-range port, and control/whitespace/display-unsafe characters2,5) that must not be read as a 2xx/5xx classverification_uri_completethat sanitizes to empty treated as absentJsonLexerescape decoding, including a\uXXXXescape split across two parse fragments, and the lenient/exotic escape armsgetToken()failing fast while another thread holds the lock in an interactive sign-in or a silent refresh; native-memory cleanup on the error/rejection construction paths/settingsbody and a.well-knownbody under an HTTP error status refused as configuration, and a malformed status rejected without echoing its bytesTokenStorethrowing before its action degrading to exactly one uncoordinated refresh, and throwing after it keeping the completed refreshHttpClientconstructor rollback at four failure points, asserted throughassertMemoryLeak0600/0700permissions and control-char JSON escaping; a corrupt, empty, oversized, schema-version-mismatched, or per-field fingerprint-mismatched file ignored; a tampered far-future expiry clamped and a CR/LF served token rejected on load; a restart serving a valid persisted token (or silently refreshing an expired one) without re-running the device flow; rotating vs non-rotating refresh write behaviour; a swallowed save not replaying a revoked token; the cross-process lock-file protocol (acquire, mutual exclusion, stale-steal, degrade);getToken()degrading to a lock-free refresh when a peer holds the lock; the HTTP-timeout cap; the file-name hash pinned as a cross-language contractReview follow-ups
A level-3 review of this branch surfaced the issues below; all are fixed here, each production fix with a regression test proven to fail without it (see the commit history for detail).
Confirmed defects:
adopt()/storeTokens()accepted a whitespace-only served token (it passedisEmpty()/hasOnlyTokenChars()vacuously), sosignIn()reported success andgetToken()served a blankBearerheader the server only answers with 401, never falling back. Now rejected viaChars.isBlank; a blank served kind is folded to absent soselectToken()surfaces the actionable error.getToken()lock contention: the unconditionaltryLock()failed fast on any lock hold, so concurrent callers sharing oneOidcDeviceAuththrew on every token refresh. It now waits briefly behind a peer's silent refresh (bounded byhttpTimeoutMillis) and fails fast only behind an interactive sign-in.Hardening and coverage:
{"config":[{...}]}can no longer surface fields at the trusted config depth.validateTokenstill re-scans every pulled token by design - a provider may mutate a reused buffer between flushes - but that scan is O(token length), runs once per flush, and is dwarfed by the network round-trip.)parseHex4non-ASCII guard, the raw..issuer-path reject, theFileTokenStoresize caps, the store-and-forward credential-timer reset, and the ILP flush whole-read timeout bound.HttpTokenProvider.getToken()now discloses the OS-bounded connect stall;TokenStore.inLockdocuments its no-reentrancy contract;FileTokenStorestates the concurrent-refresh residual (token-family revocation on a reuse-detecting IdP) instead of understating it.Review follow-ups (second round)
A further review round surfaced the issues below. As before, every production fix carries a regression test shown to fail without it; the commit messages record the counterfactual output for each.
Confirmed defects:
storeTokens()kept the current refresh token whenever a response omitted one, for refresh grants and fresh device grants alike. For a refresh that is right — RFC 6749 §6 makes the field optional and the same authorization is continuing — but a device grant is a new authorization and may be a different human. So user A's refresh fails, user B completes the device flow without a refresh token, B's access token expires, and the next silent refresh presents A's retained token and resumes as A: no prompt, no error, nothing in any log recording that the identity changed. Persisted it is worse — the refresh token is unchanged, sopersistIfRotated()sees no rotation and skips the save, leaving A's whole entry on disk for the next process start to adopt. The omission policy is now grant-specific: a device grant that returns no refresh token clears it, sogetToken()asks for an interactive sign-in instead. Covered end to end, including a restart over the same store.closeTraffic()cannot reach it; the only lever is an interrupt, sent by the send loop's connect cancellation on the foreground path and byBackgroundDrainerPool'sshutdownNow()on the orphan-drainer path. Neither reached the built-in path: the in-process lock was taken withlock(), and the lock file was polled throughOs.sleep, which catchesInterruptedExceptionand keeps sleeping to its own deadline. Since the acquire budget caps at 30 s — the same as the QWP shutdown budget — a sender closing while another same-identity instance held the lock burned the whole budget and then gave up on its I/O thread, delegating teardown of the native client, the cursor engine and the store-and-forward slot lock; on the drainer path it abandoned the drainer still holding the orphan slot's lock.inLocknow takes the process lock interruptibly, polls withThread.sleep, and checks for an interrupt before running the critical section. Every prior test blocked the pull in an interruptible test double, so the shipped path was untested; the new tests block it in a realOidcDeviceAuthover a realFileTokenStorewhose lock a peer holds.val << 4accumulation wraps, and the least harmful — a negative size matches neither the data branch nor the terminator, so the state machine spins, which is at least visible. The other two report success with the wrong bytes:10000000000000000wraps to zero and reads as the terminal chunk, so the caller gets a complete-looking body that is truncated and the connection's framing is lost for the next keep-alive response, while a longer value wraps to a short positive count that mis-frames everything after it. Truncated JSON parses. The size line is chosen by the server, untrusted for a discovery or token response. The size line is now bounded in the chunk parser itself, before the parse, so all three residues are refused at the point the untrusted digits are read — see the chunk-size note in Tradeoffs.credential-unavailablereport was dispatched into a null; at initial connect the exception matched none of the typed arms and landed in the generic transport arm, whose warning says the cluster is unreachable. A revoked token therefore surfaced as a network fault while rows accumulated in store-and-forward, pointing an operator at disk sizing rather than at their credentials. The sink is now wired and the condition named.TERMINALis filtered on the way through: on an orphan loop a terminal is the loop handing the slot back to the drainer to decide, so forwarding it would announce a dead producer for a rotating credential the next sweep accepts, and double-report every quarantine.Exported API compatibility:
module-info.javaexports and that ship a javadoc jar, with no japicmp gate to catch it:Response.recv(int)arrived as an abstract interface method, twoQwpWebSocketSender.connect(..., String, ...)overloads were retyped toSupplier<String>, and the multi-hostAbstractLineHttpSender.createLineSendergained a parameter in place. An existing caller would fail withNoSuchMethodError, an externalResponseimplementation withAbstractMethodError. No affected caller exists in this repo, questdb, or questdb-enterprise, so this is a latent break rather than an observed one. The exact old signatures are restored as delegates; the supplier-backed connect entry points are renamedconnectWithCredentialSupplierrather than left as overloads, because aStringand aSupplier<String>parameter of equal arity make a barenullcredential argument ambiguous and would trade a link error for a compile error. Verified by compiling a caller written against the pre-branch signatures against both the unfixed and fixed classes.Coverage:
SenderPoolapplies the provider on two legs and only the non-SF one was exercised. Unwired, every SF pooled sender's upgrade would go out unauthenticated and take a 401, surfacing later as ring backpressure or a quarantined slot rather than at connect time; on the recovery leg a recovery delegate replays the previous run's data, so it would quarantine the slot and reportDATA_LOSSfor replayable rows..failedsentinel that nothing in production clears, permanently abandoning replayable rows over a token the next pull would have refreshed, while the other direction only delays the operator's signal.isCredentialDynamic()exposes the tag on a built sender, asserted alongside the header the server actually received and the value the drainer's reconnect factory reports.Review follow-ups (third round)
A third review round surfaced the issues below. As before, every production fix carries a regression test shown to fail without it; the commit messages record the counterfactual output for each.
Confirmed defects:
fetchJsonawaited the response headers and went straight to parsing, so both discovery paths read configuration out of a non-2xx body. A discovery document decides where the user signs in and where the long-lived refresh token is POSTed, and an error body can easily carry the keys — an error envelope, a proxy's branded page, a captive portal, a tenant-not-found stub. Black-box proofs constructed a working instance from an HTTP 500/settingsresponse and from an HTTP 404.well-knownresponse. The token and device-authorization paths already gated on status; this one did not.requireSuccessStatusnow runs beforeparseBody: it validates the status is exactly three bare digits before echoing any of it — the header parser copies the status-line token verbatim apart from SP/CR/LF, so a non-digit byte means a malformed or hostile line that must not splice ESC or other control bytes into a message, a log or a terminal — and then requires a leading2. A short all-digit status is malformed too and must not be read as a class by its leading digit, matchingisHttpStatusSuccesselsewhere in the class. On rejection the body is drained within the usual bound so the keep-alive connection stays usable, and the connection is dropped when the drain cannot finish, mirroringreadResponseon the token path. Each call site passes its own message, so a/settingsfailure and a.well-knownfailure are told apart.flush0sit inside thetrywhose only catch treatsHttpClientExceptionas a retryable network error. Base could not throw there for a dribbling-but-progressing server, becauserecv()re-armed its timeout on every socket read; bounding the whole call (see Supporting changes) made it reachable, and the two branches fail differently. On the success branch a 2xx is the commit — the server already has the rows — so draining its body afterwards is only bookkeeping to keep the connection reusable, and an abort there re-sent a batch the server had accepted, with a retry budget that kept trying. The drain is now wrapped: on abort the connection is dropped, since unconsumed bytes would mis-frame the next response, and the flush is reported as the success it was. On the error branch the status is the verdict and the body is only detail for the message; an abort escaping into the catch reclassified a definitive 401, 403 or 405 as a transport failure, burned the whole retry budget against an endpoint that would keep refusing, and finally reported "Connection Failed: timed out" with the real status nowhere in it.throwOnHttpErrorResponsenow wraps its body reads — all four branches at once — and falls back to a status-only exception. Reaching either needs a chunked, slowly dribbled body, which QuestDB's own/writedoes not produce (it answers 204 non-chunked), so exposure is through intermediaries. The pre-existingtestFlushResponseBodyDribbleAbortsOnRequestTimeoutasserted that a dribbled body fails the flush, against a mock that answers 200 — so it was pinning this defect rather than guarding against it. It is reworked rather than left in place: it still proves the whole-read bound, since an unbounded read would hang to the test timeout, and now also proves the batch is sent exactly once, against a retry budget a re-send would visibly spend.TokenStoretook the whole sign-in down with it.TokenStoreis a user-implemented SPI and persistence is documented best-effort, buttryRefreshCoordinatedcalledinLockbare, so a store that threw before running its action refreshed nothing even though the client held a perfectly good refresh token. What the right degrade is depends entirely on whether the refresh already ran, which only the action can report, so the call now tracks whether it entered and completed. Threw before the action: nothing was refreshed, so run one — exactly one — uncoordinated refresh; the point of the lock is that a rotating refresh token must not be POSTed twice, and a reuse-detecting provider answers a replay by revoking the whole family. Threw after the action completed, releasing a lock or closing a handle: the refresh happened and the token is live, so report what the action returned; re-running it is that same double-POST, and throwing tells the caller a completed sign-in failed. The action itself threw: that is the refresh's own failure, not the store's, so it propagates untouched — never swallowed, never replayed.Erroris deliberately not caught; anOutOfMemoryErroris not a store fault to degrade around.FileTokenStorealso let unchecked exceptions escape its own lock bookkeeping —SecurityExceptionfrom a SecurityManager,UnsupportedOperationExceptionfrom a filesystem that cannot carry POSIX permissions — so the acquire path now degrades to lock-free on those as it already did onIOException, and the release path, which runs in afinallyafter the critical section, absorbs them so bookkeeping cannot replace a completed result. The guard above already contains such an escape, so this second half is about the quality of the degrade — coordination is kept for that refresh rather than lost — and about the reference implementation honouring the contractTokenStore.inLockpublishes.Pre-existing defect newly exposed:
HttpClientconstruction leaked when it failed partway. A constructor that fails partway leaves an object nobody can close: it never reaches the caller, so nofinally, no try-with-resources and noclose()ever runs on it, and whatever it had already taken is lost for the life of the process.HttpClient's base constructor takes a socket and two native buffers, then each platform subclass builds its poller, and neither step guarded the earlier ones. What makes it worth fixing is the trigger:epoll_createandkqueuefail on fd exhaustion, and the mallocs fail under memory pressure, so the failure arrives exactly when resources are already scarce, and a caller that retries compounds the loss each time. The root predates this branch; OIDC discovery newly exposes it by building a client per fetch. The base constructor now stages the socket and both buffers in locals, assigns the fields only onceResponseHeadershas succeeded, and frees in reverse order undercatch (Throwable); each platform subclass wraps its poller construction and callssuper.close()before rethrowing. Kqueue already guarded its own constructor this way, so this is the same pattern applied one level out; Epoll, Kqueue and FDSet each free their own allocations on failure already, and what leaked was purely what the caller had taken before calling them.HttpClientConstructorLeakTestcovers four failure points throughassertMemoryLeak— removing the rollback leaks 65536 bytes on the base path and 131072 on the poller path. The base case injects a negative response-buffer size and runs everywhere; the poller cases areAssume-guarded so only the running platform's executes, leaving epoll and FDSet to CI, which the class javadoc records. The pollers are failed through their facades rather than through a failing size, even though a size would need no facade: Kqueue's own failure path callsclose()with its descriptor still zero, so an allocation failure there would close the test JVM's stdin — a facade returning a negative descriptor is the shape fd exhaustion actually takes and touches no real descriptors.Review follow-ups (fourth round)
A fourth review round surfaced the issues below. As before, every production fix carries a regression test shown to fail without it; the commit messages record the counterfactual output for each.
Confirmed defects:
PoolHousekeeper.stop()andSenderPool.stopStartupRecoveryDriver()escalate toThread.interrupt()when their join times out, to break a recovery build's credential pull — and the thread they interrupt is the same one that then runssenderPool.reapIdle()and the startup-recovery step'sfinally, both of which close a delegate.CountDownLatch.await(t, u)testsThread.interrupted()before it ever consults the latch, so the shutdown await returned having waited 0 ms,close()took the failed-stop branch, and the slot was reported with its flock still held — precisely the outcome the interrupt was added to prevent. That branch re-asserts the flag, so in a reap sweep every remaining delegate failed the same way andQuestDB.close()returned still holding their slots.QwpWebSocketSender.close()is now interrupt-neutral, the shapeQwpQueryClient.close()andQueryWorker.shutdown()already use — the query half of the pool got that treatment when the escalation landed; the ingest half, whichreapIdle()reaches first, did not. An interrupt delivered during the close still takes the failed-stop branch, which is correct.PoolHousekeeper's comment claimed every wait on the pull path is interruptible; the token POST's connect, send, await and parse run on the native HTTP client, which no interrupt breaks, so it now says what the escalation does and does not buy.HttpExceptionhad been added toflush0's transport catch, which was right about the disconnect and the exception type — uncaught it escapedflush0entirely, leaving the next flush on a connection holding a half-read response and throwing a rawHttpExceptionpast every caller'scatch (LineSenderException). But it must not be retried.HttpHeaderParseronly runs on bytes that arrived, so the exception is positive evidence the server answered — the same evidence the 2xx drain arm treats as decisive — and the head is chosen by an intermediary, so the next attempt parses the same block and fails identically. Measured against a mock returning a 5000-byte head: 16 sends over 10.8 s per flush at the default retry budget, where the same trigger sent once before. It now disconnects, reports a non-retryableLineSenderExceptionnaming the malformed head, and does not re-send. The existing test asserted the retry, so it was pinning this rather than guarding against it.maybeLoadFromStore()deliberately leaves its latch unset when a read throws, so a transient fault is retried — but it runs at the top ofgetToken(), ahead of the cache check, andadopt()assigns the served kind, the expiry and the ttl unconditionally, never comparing the file against what is already in memory. A store directory unavailable acrosssignIn()and readable afterwards (an unmounted home, a container started before its volume attaches) therefore undid it: the read failed, the human authenticated, the save failed the same way and was swallowed, and the nextgetToken()— one per ILP flush — installed the previous entry over the grant just obtained.persistIfRotated()now latches the flag, above the rotation check so it holds whether or not the save succeeds, and covering the refresh-only path throughadoptRotatedRefreshToken()with the same line. A store that never yielded a token is unaffected.adopt()refuses to read back.adopt()rejects a refresh token carried with neither token kind as positive evidence of a foreign writer — the guard that stops someone who can write the store directory swapping in their own refresh token. Its justification rested on a callsite count ("persistIfRotatedruns solely at the tail ofstoreTokens"), and that count was wrong: undergroupsInToken, a stored entry carrying only an access token takesadopt()'s own served-kind-absent branch, which nulls both kinds and keeps the refresh token, so a refresh that rotates the refresh token but still returns no id token reachesadoptRotatedRefreshToken()→persistIfRotated()with both null. The file it wrote was one it would never read back, so every restart re-ran the device flow over a live refresh token on disk.persistIfRotated()now declines that shape; the previous entry stays, its burned refresh token costs one silent round trip on the next start, and the rejection keeps its teeth.Tandem
OSS: questdb/questdb#7331
Ent: https://github.com/questdb/questdb-enterprise/pull/1090