Skip to content

Commit ea7c1a8

Browse files
glasstigerclaude
andcommitted
Harden token-store cross-process lock edge cases
The FileTokenStore lock-file protocol had four edge cases where the cross-process mutual exclusion could degrade. Each degrades gracefully (at worst a re-prompt on a rotating-refresh-token IdP, never a torn or forged credential), but the fixes are cheap and the comments overstated the guarantees. sweepStaleTempFiles now skips names containing ".lock.": a steal captures a stale lock by renaming it to <hash>.lock.<uuid>.tmp, and ATOMIC_MOVE preserves the stale mtime, so a concurrent save's temp sweep would judge the in-flight capture old and delete it - destroying a lock the stealer may be about to restore to its live owner. stealIfStale now reclaims an empty/unstamped lock after a short grace (EMPTY_LOCK_STEAL_GRACE_MILLIS) instead of the full staleness window. A holder that crashes between the exclusive create and the stamp leaves an empty lock with a fresh mtime that the staleness check protected for the whole window, wedging peers into lock-free refreshes. The grace dwarfs the create-to-stamp gap, so a peer mid-stamp is never pre-empted (which would steal a lock its rightful owner is about to hold). isStale becomes the parameterized isOlderThan. The restore branch now documents honestly that a multi-actor race can momentarily admit two holders - inherent to stealing with a lock file, since a filesystem offers no atomic compare-and-delete. The comments in FileTokenStore, OidcDeviceAuth, and the design doc claimed the under-lock refresh hold is bounded by 4x httpTimeoutMillis; they now note the connection phase (DNS + TCP connect + TLS handshake) is not bounded by httpTimeoutMillis, so lockStaleMillis must clear that on top of the refresh I/O. The default 600s window already absorbs it. Adds regression tests for the empty-lock grace steal and the sweep-skips-capture behaviour; both fail on the unfixed code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b016dd0 commit ea7c1a8

4 files changed

Lines changed: 158 additions & 37 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,26 @@ public final class FileTokenStore implements TokenStore {
9191
// treat a lock older than this as abandoned by a crashed holder and steal it. Must stay comfortably
9292
// above the longest a live holder can hold it (one refresh under the lock) so a live holder is never
9393
// stolen from. That refresh runs send + await + parse, plus a body drain on a parse failure, each
94-
// separately bounded by the client's HTTP timeout, so up to ~4x it; OidcDeviceAuth caps that timeout at
95-
// 120s, so the worst-case hold is ~480s and this 10-minute window stays safely above it
94+
// separately bounded by the client's HTTP timeout (so up to ~4x it; OidcDeviceAuth caps that timeout at
95+
// 120s, hence ~480s), PLUS the connection phase (DNS + TCP connect + TLS handshake), which the HTTP
96+
// timeout does NOT bound and which the OS bounds instead (a black-holed connect is ~tcp-connect-timeout,
97+
// commonly ~2 minutes on Linux). This 10-minute window leaves ample headroom above ~480s + a typical
98+
// connection stall; a pathological DNS/connection hang longer than that headroom can still let a peer
99+
// steal a live holder's lock, degrading (best-effort) to a re-prompt on a rotating-refresh-token IdP
96100
private static final long DEFAULT_LOCK_STALE_MILLIS = 600_000L;
97101
private static final FileAttribute<Set<PosixFilePermission>> DIR_ATTRS =
98102
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"));
99103
// the same owner-only directory permissions as DIR_ATTRS, in the form setPosixFilePermissions wants, so
100104
// ensureDirectory can re-assert them on a directory that already exists with looser permissions
101105
private static final Set<PosixFilePermission> DIR_PERMS = PosixFilePermissions.fromString("rwx------");
106+
// steal an empty/unstamped lock once it has existed at least this long. A validly held lock always
107+
// carries an owner stamp (acquireLock stamps it immediately after the exclusive create); an empty lock is
108+
// therefore either a peer momentarily between its create and its stamp - microseconds, far below this
109+
// grace - or one a holder abandoned by crashing in that tiny window. Stealing on this short grace instead
110+
// of the full staleness window keeps a post-crash empty lock from wedging peers (into lock-free refreshes)
111+
// for the whole staleness window, while the grace stays well above the create->stamp gap so a peer
112+
// mid-stamp is never pre-empted (which would force the rightful holder to degrade)
113+
private static final long EMPTY_LOCK_STEAL_GRACE_MILLIS = 5_000L;
102114
private static final FileAttribute<Set<PosixFilePermission>> FILE_ATTRS =
103115
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"));
104116
private static final char[] HEX = "0123456789abcdef".toCharArray();
@@ -141,14 +153,20 @@ public FileTokenStore(Path directory) {
141153
* and at most 30_000 (30s): {@code getToken()} can wait it out on the
142154
* latency-sensitive flush path, so it is kept short
143155
* @param lockStaleMillis a lock older than this is treated as abandoned by a crashed holder and
144-
* stolen. It MUST exceed the longest a live holder can hold the lock: one
145-
* refresh under the lock runs send + await + parse plus a body drain, each
146-
* bounded by the {@code OidcDeviceAuth} httpTimeoutMillis, so up to ~4x it
147-
* (~480s at the 120s timeout cap). Set it below that and a peer can steal a
148-
* live holder's lock mid-refresh, reopening the cross-process refresh race
149-
* this lock exists to prevent. The store cannot see the client's timeout,
150-
* so sizing this correctly is the caller's responsibility; the default is
151-
* 600_000 (safely above the ~480s worst case).
156+
* stolen. It MUST exceed the longest a live holder can hold the lock, which
157+
* is the under-lock refresh PLUS the connection phase that precedes it: the
158+
* refresh runs send + await + parse plus a body drain, each bounded by the
159+
* {@code OidcDeviceAuth} httpTimeoutMillis (so up to ~4x it, ~480s at the
160+
* 120s timeout cap), but establishing the connection - DNS resolution, the
161+
* TCP connect, and the TLS handshake - is NOT bounded by httpTimeoutMillis;
162+
* the OS bounds it instead (a black-holed connect runs to the OS TCP-connect
163+
* timeout, commonly ~2 minutes). Size this window above ~4x httpTimeoutMillis
164+
* plus a generous connection-stall allowance, or a peer can judge a live but
165+
* connection-stalled holder stale and steal its lock mid-refresh, reopening
166+
* the cross-process refresh race this lock exists to prevent. The store
167+
* cannot see the client's timeout, so sizing this correctly is the caller's
168+
* responsibility; the default is 600_000 (~480s worst-case refresh plus
169+
* ample headroom for a typical connection stall).
152170
*/
153171
public FileTokenStore(Path directory, long lockAcquireBudgetMillis, long lockStaleMillis) {
154172
if (directory == null) {
@@ -591,7 +609,7 @@ private static boolean writeLockHolder(Path lock, String nonce) {
591609
// stamp the lock with the owner nonce. Unlike the staleness mtime (which only needs to be recent),
592610
// this content is what releaseLock checks before deleting, so it must be written reliably; report a
593611
// failure so acquireLock drops an unverifiable lock rather than hold one it cannot safely release.
594-
// Writing also refreshes the mtime, which is what isStale reads
612+
// Writing also refreshes the mtime, which is what the staleness age check reads
595613
try {
596614
Files.write(lock, nonce.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
597615
return true;
@@ -663,10 +681,10 @@ private void ensureDirectory() throws IOException {
663681
}
664682
}
665683

666-
private boolean isStale(Path lock) {
684+
private boolean isOlderThan(Path lock, long thresholdMillis) {
667685
try {
668686
FileTime modified = Files.getLastModifiedTime(lock);
669-
return System.currentTimeMillis() - modified.toMillis() > lockStaleMillis;
687+
return System.currentTimeMillis() - modified.toMillis() > thresholdMillis;
670688
} catch (IOException e) {
671689
return false; // cannot determine the age; do not steal
672690
}
@@ -685,16 +703,27 @@ private void stealIfStale(Path lock) {
685703
// NoSuchFileException and fall back to the wait), then verify what we captured carries the same stamp
686704
// we judged stale. If a peer had already replaced it with a live lock we grabbed that instead, so we
687705
// put it back rather than steal it. This mirrors releaseLock's own-stamp check and shrinks the
688-
// residual race from the whole isStale->delete gap to the gap between the two renames.
706+
// residual race from the whole age-check->delete gap to the gap between the two renames.
689707
final byte[] before;
690708
try {
691709
before = readLockHolder(lock);
692710
} catch (IOException e) {
693711
return; // gone or unreadable; nothing to steal here - the create attempt or a peer settles it
694712
}
695-
// read the stamp first, then check staleness: if a peer replaces the stale lock with a fresh one in
696-
// between, isStale reads the fresh mtime and returns false, so we never proceed against a live lock
697-
if (!isStale(lock)) {
713+
// read the stamp first, then check age: if a peer replaces the lock with a fresh one in between, the
714+
// age check reads the fresh mtime and returns false, so we never proceed against a live lock.
715+
if (before != null) {
716+
// a stamped lock is a (claimed) live holder: steal only once it outlives the full staleness window
717+
if (!isOlderThan(lock, lockStaleMillis)) {
718+
return;
719+
}
720+
} else if (!isOlderThan(lock, Math.min(EMPTY_LOCK_STEAL_GRACE_MILLIS, lockStaleMillis))) {
721+
// an empty/unreadable lock is never a validly-held lock (a holder stamps right after creating): it
722+
// is a peer mid-create/stamp (recovers on its own in microseconds) or one a crash orphaned in that
723+
// gap. Steal it on the short empty-lock grace rather than the full staleness window, so a crash
724+
// orphan stops wedging peers for the whole window; the grace dwarfs the create->stamp gap, so a
725+
// peer mid-stamp is never pre-empted. The capture-verify below still confirms the lock is unchanged
726+
// before completing the steal.
698727
return;
699728
}
700729
final Path captured = lock.resolveSibling(lock.getFileName().toString() + '.' + UUID.randomUUID() + ".tmp");
@@ -722,8 +751,13 @@ private void stealIfStale(Path lock) {
722751
// we captured a live lock a peer recreated in the gap: put it back rather than steal it. Use a plain
723752
// move (not ATOMIC_MOVE, which maps to rename(2) and would replace the target): if a third party
724753
// claimed the now-free path during our capture window, FileAlreadyExistsException leaves their lock
725-
// intact and we drop our captured copy rather than clobber it; a stray .tmp is reclaimed by
726-
// sweepStaleTempFiles on a later save.
754+
// intact and we drop our captured copy rather than clobber it. That drop loses the recreating peer's
755+
// lock file while it still believes it holds the lock, so for that one refresh two holders can run
756+
// concurrently - the inherent residual of stealing with a lock file: a filesystem has no atomic
757+
// "delete/rename only if the content is still X", so the capture-verify shrinks the window to this
758+
// multi-actor race (our steal, a peer recreating, AND a third party claiming the freed path, all
759+
// overlapping) but cannot close it. Best-effort by design: it degrades to one extra refresh - a
760+
// re-prompt on a rotating-refresh-token IdP - never a torn or forged credential (Layer 1 still holds).
727761
try {
728762
Files.move(captured, lock);
729763
} catch (IOException e) {
@@ -740,6 +774,16 @@ private void sweepStaleTempFiles(String hashPrefix) {
740774
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory, hashPrefix + "*.tmp")) {
741775
final long now = System.currentTimeMillis();
742776
for (Path tmp : stream) {
777+
// never sweep a steal-captured lock (<hash>.lock.<uuid>.tmp): it is a cross-process steal in
778+
// progress, not an orphaned write temp, and ATOMIC_MOVE preserves the stale lock's old mtime
779+
// onto it, so the age guard below would judge an in-flight capture sweepable and delete it -
780+
// destroying a lock the stealer may be about to restore to its live owner. A save write temp is
781+
// <hash><random>.tmp and never contains ".lock.", so this only excludes captures. A capture
782+
// orphaned by a crash mid-steal is rare and harmless (the canonical lock path is left free), so
783+
// it is deliberately not reclaimed here.
784+
if (tmp.getFileName().toString().contains(".lock.")) {
785+
continue;
786+
}
743787
try {
744788
if (now - Files.getLastModifiedTime(tmp).toMillis() > lockStaleMillis) {
745789
Files.deleteIfExists(tmp);

core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -132,21 +132,25 @@ public class OidcDeviceAuth implements QuietCloseable {
132132
// tokens fail to parse with "String is too long".
133133
private static final int JSON_LEXER_CACHE_SIZE = 1024;
134134
private static final int JSON_LEXER_MAX_VALUE_BYTES = 1 << 20;
135-
// the worst case a coordinated refresh can hold a FileTokenStore cross-process lock, as a multiple of
136-
// httpTimeoutMillis: one refresh under the lock runs send + await + parse, plus a body drain on a parse
137-
// failure, each separately bounded by httpTimeoutMillis. build() rejects a FileTokenStore whose
138-
// lock-staleness window does not exceed this, so a peer never judges a live holder's lock stale mid-refresh
135+
// the I/O portion of a coordinated refresh, as a multiple of httpTimeoutMillis: the refresh under the lock
136+
// runs send + await + parse, plus a body drain on a parse failure, each separately bounded by
137+
// httpTimeoutMillis. NOTE this multiple does NOT cover the connection phase that precedes the send - DNS
138+
// resolution, the TCP connect, and the TLS handshake are NOT bounded by httpTimeoutMillis (the OS bounds the
139+
// connect instead). build() requires the FileTokenStore staleness window to exceed this multiple as a floor;
140+
// the default window adds ample headroom for a typical connection stall on top of it (see build())
139141
private static final int LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE = 4;
140142
// upper bound on the device code lifetime (the device authorization response's expires_in), so a
141143
// hostile or buggy provider cannot make the client poll for an absurd duration; matches the Python client
142144
private static final int MAX_DEVICE_CODE_TTL_SECONDS = 1800;
143145
// upper bound on the token cache lifetime (the token response's expires_in), so an absurd or hostile
144146
// value cannot overflow the timing arithmetic or make the client trust a token for absurdly long
145147
private static final int MAX_EXPIRES_IN_SECONDS = 3600;
146-
// upper bound on the configurable HTTP request timeout. A token-endpoint round-trip never needs longer,
147-
// and bounding it keeps a refresh held under the FileTokenStore cross-process lock (send + await + parse,
148-
// plus a body drain on a parse failure - each separately bounded by this, so up to ~4x this) safely
149-
// shorter than that store's lock-staleness window, so a slow refresh's live lock is not stolen by a peer
148+
// upper bound on the configurable HTTP request timeout. A token-endpoint round-trip never needs longer, and
149+
// bounding it keeps the I/O portion of a refresh held under the FileTokenStore cross-process lock (send +
150+
// await + parse, plus a body drain on a parse failure - each separately bounded by this, so up to ~4x this)
151+
// a known, bounded multiple, so the store's staleness window can be sized to dominate it. The connection
152+
// phase (DNS + TCP connect + TLS) is bounded by the OS, not by this, and the default staleness window
153+
// leaves headroom for it (see Builder.build())
150154
private static final int MAX_HTTP_TIMEOUT_MILLIS = 120_000;
151155
// upper bound on the poll interval, both the initial value and the growth after a slow_down or 429, so
152156
// a hostile or buggy provider cannot stall the poll loop; matches the Python client
@@ -1585,11 +1589,15 @@ public OidcDeviceAuth build() {
15851589
validateEndpointOrigins(parsedTokenEndpoint, deviceEndpoint, issuerEndpoint);
15861590
ClientTlsConfiguration tls = tlsConfig != null ? tlsConfig : defaultTlsConfig();
15871591
// a FileTokenStore steals a lock older than its staleness window, presuming a crashed holder; that
1588-
// window must exceed the worst-case time a live refresh holds the lock (the refresh under the lock is
1589-
// bounded by httpTimeoutMillis, up to LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE times it), or a peer could steal
1590-
// a live holder's lock mid-refresh and reopen the rotating-refresh-token race the lock prevents. The
1591-
// store cannot see this client's timeout, so enforce the invariant here, where both are known, rather
1592-
// than leave the caller to size it by hand. A non-coordinating TokenStore is exempt - it takes no lock.
1592+
// window must exceed the worst-case time a live refresh holds the lock, or a peer could steal a live
1593+
// holder's lock mid-refresh and reopen the rotating-refresh-token race the lock prevents. Enforce the
1594+
// bounded part of that worst case here, where both values are known: the refresh I/O under the lock is
1595+
// up to LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE x httpTimeoutMillis. This is a FLOOR, not the whole story - the
1596+
// connection phase (DNS + TCP connect + TLS) that precedes the send is bounded by the OS, not by
1597+
// httpTimeoutMillis, so the staleness window must also clear a connection stall on top of this floor.
1598+
// The default 600s window leaves ~120s of headroom over the floor even at the 120s timeout cap, which
1599+
// covers a typical connection stall; a caller raising httpTimeoutMillis should raise lockStaleMillis
1600+
// to keep that headroom. A non-coordinating TokenStore is exempt - it takes no lock.
15931601
if (tokenStore instanceof FileTokenStore) {
15941602
long minStaleMillis = (long) LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE * httpTimeoutMillis;
15951603
long staleMillis = ((FileTokenStore) tokenStore).getLockStaleMillis();

0 commit comments

Comments
 (0)