Skip to content

Commit 8ab6b6d

Browse files
committed
fix(auth): serialize keyring migration under a reentrant credential lock
load_tokens()'s keyring read-copy-delete migration ran outside _credential_transaction_locks, so it could race persist_login/persist_logout and overwrite a newer file token with the older keyring value. Serialize it under the credential lock. The lock is now reentrant per thread (fcntl.flock / msvcrt.locking deny a second same-file acquisition within one process), so callers already inside a credential transaction — e.g. persist_login loading the previous token at the same key (oauth.py:804) — re-enter instead of self-deadlocking. Adds reentrancy and migration-under-held-lock regression tests.
1 parent 5b808da commit 8ab6b6d

2 files changed

Lines changed: 85 additions & 1 deletion

File tree

src/pythinker_code/auth/oauth.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,13 @@ def _migrate_keyring_token(ref: OAuthRef) -> OAuthToken | None:
641641

642642
def load_tokens(ref: OAuthRef) -> OAuthToken | None:
643643
if ref.storage == "keyring":
644-
return _migrate_keyring_token(ref)
644+
# Serialize the read-copy-delete keyring migration with credential writes
645+
# so it cannot race persist_login/persist_logout and overwrite a newer
646+
# file token with the older keyring value. The lock is reentrant per
647+
# thread, so callers already inside a credential transaction (e.g.
648+
# persist_login loading the previous token) do not deadlock.
649+
with _credential_transaction_locks([ref.key]):
650+
return _migrate_keyring_token(ref)
645651
return _load_from_file(ref.key)
646652

647653

@@ -719,11 +725,36 @@ def _config_transaction_lock() -> Generator[None]:
719725
) from exc
720726

721727

728+
# Per-thread reentrancy guard for the credential lock. fcntl.flock (Unix) and
729+
# msvcrt.locking (Windows) deny a second acquisition of the same lock file within
730+
# one process, so a thread that already holds a key's lock must reference that
731+
# hold instead of re-acquiring it — otherwise nested acquisitions (e.g.
732+
# load_tokens() migrating a keyring credential while inside a persist transaction)
733+
# would self-deadlock.
734+
_HELD_CREDENTIAL_KEYS = threading.local()
735+
736+
737+
def _held_credential_key_counts() -> dict[str, int]:
738+
counts: dict[str, int] | None = getattr(_HELD_CREDENTIAL_KEYS, "counts", None)
739+
if counts is None:
740+
counts = {}
741+
_HELD_CREDENTIAL_KEYS.counts = counts
742+
return counts
743+
744+
722745
@contextmanager
723746
def _credential_transaction_locks(keys: list[str]) -> Generator[None]:
747+
held = _held_credential_key_counts()
748+
bumped: list[str] = []
724749
locks: list[_CrossProcessLock] = []
725750
try:
726751
for key in sorted(set(keys)):
752+
if held.get(key, 0) > 0:
753+
# Reentrant: this thread already holds `key`'s cross-process lock;
754+
# re-acquiring the same file lock would be denied by the OS.
755+
held[key] += 1
756+
bumped.append(key)
757+
continue
727758
try:
728759
lock = _CrossProcessLock(key)
729760
acquired = lock.acquire_with_retry_sync()
@@ -736,8 +767,18 @@ def _credential_transaction_locks(keys: list[str]) -> Generator[None]:
736767
"Could not acquire the OAuth credential lock; no changes were made."
737768
)
738769
locks.append(lock)
770+
held[key] = 1
771+
bumped.append(key)
739772
yield
740773
finally:
774+
# Decrement only what this invocation bumped (correct even on a partial
775+
# acquisition), and release only the OS locks it actually acquired.
776+
for key in bumped:
777+
count = held.get(key, 0)
778+
if count <= 1:
779+
held.pop(key, None)
780+
else:
781+
held[key] = count - 1
741782
for lock in reversed(locks):
742783
lock.release()
743784

tests/auth/test_oauth_persist.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,49 @@ async def test_replacement_with_same_credential_key_keeps_new_token(
511511
assert caller == committed
512512

513513

514+
def test_credential_transaction_locks_are_reentrant_same_thread(tmp_path, monkeypatch) -> None:
515+
"""A thread already holding a credential key's lock can re-enter it without
516+
deadlocking — fcntl.flock / msvcrt.locking deny a second same-file
517+
acquisition within one process, so reentrancy is tracked in Python. Guards
518+
the keyring-migration self-deadlock (load_tokens inside a persist transaction).
519+
"""
520+
monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path))
521+
key = "oauth/reentrant-provider"
522+
counts = oauth._held_credential_key_counts()
523+
assert counts.get(key, 0) == 0
524+
with oauth._credential_transaction_locks([key]):
525+
assert counts.get(key, 0) == 1
526+
with oauth._credential_transaction_locks([key]):
527+
# Reentrant no-op acquisition; must not block on the same file lock.
528+
assert counts.get(key, 0) == 2
529+
# Inner exit decrements but the outer hold (and OS lock) remains.
530+
assert counts.get(key, 0) == 1
531+
assert counts.get(key, 0) == 0
532+
533+
534+
def test_load_tokens_keyring_migration_reentrant_under_held_lock(tmp_path, monkeypatch) -> None:
535+
"""The keyring read-copy-delete migration in load_tokens() is serialized under
536+
the credential lock (so it cannot race persist), and re-enters safely when the
537+
caller already holds that key's lock (the persist_login -> load_tokens path).
538+
Pre-fix this path self-deadlocked on the non-reentrant cross-process lock.
539+
"""
540+
monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path))
541+
key = "oauth/keyring-migrate"
542+
migrated_token = _token("keyring-access", "keyring-refresh")
543+
monkeypatch.setattr(oauth, "_load_from_keyring", lambda _k: migrated_token)
544+
deleted: list[str] = []
545+
monkeypatch.setattr(oauth, "_delete_from_keyring", deleted.append)
546+
ref = OAuthRef(storage="keyring", key=key)
547+
548+
with oauth._credential_transaction_locks([key]):
549+
result = oauth.load_tokens(ref) # would self-deadlock without reentrancy
550+
551+
assert result == migrated_token
552+
assert deleted == [key] # keyring entry removed as part of the migration
553+
# Token now lives on the authoritative file copy.
554+
assert oauth.load_tokens(OAuthRef(storage="file", key=key)) == migrated_token
555+
556+
514557
@pytest.mark.asyncio
515558
async def test_old_token_rollback_failure_preserves_committed_new_pair(
516559
monkeypatch: pytest.MonkeyPatch, tmp_path

0 commit comments

Comments
 (0)