@@ -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 );
0 commit comments