Skip to content

fix(credential_pool): lock the public pool query surface (#62614 salvage) - #70154

Merged
teknium1 merged 3 commits into
mainfrom
salvage/62614-pool-query-locks
Jul 23, 2026
Merged

fix(credential_pool): lock the public pool query surface (#62614 salvage)#70154
teknium1 merged 3 commits into
mainfrom
salvage/62614-pool-query-locks

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Salvage of #62614 by @solyanviktor-star: every public CredentialPool state method now runs under self._lock. Previously has_available / peek / current / entries / has_credentials (plus the management surface: reset_statuses / remove_index / resolve_target / add_entry) read — and in has_available's case pruned and persisted — shared pool state with no lock, racing against select / mark_exhausted_and_rotate / refresh, which all mutate under the lock. Gateway and auxiliary threads call these queries concurrently with rotation on every turn.

The lock is a non-reentrant threading.Lock, so the fix introduces _current_unlocked() and reroutes every in-lock caller — one lock boundary, unlocked helpers inside.

The merge hazard we caught (and fixed)

The PR predates our #69843 salvage, which added try_refresh_matching() — it calls self.current() inside with self._lock:. Git merges the two changes with zero textual conflict, but the merged tree deadlocks the moment try_refresh_matching runs with no/unmatched hint (live callers: the 401 recovery path and tools/xai_http.py). Fixed with _current_unlocked() and covered in the no-deadlock test so the class of bug can't silently return.

Changes

  • agent/credential_pool.py (contributor, 2 commits cherry-picked): lock acquisition in the 4 query methods + 5 management methods; _current_unlocked() helper; all in-lock current() callers rerouted.
  • agent/credential_pool.py (follow-up): try_refresh_matching no-hint branch uses _current_unlocked() — the silent-merge deadlock.
  • Tests (contributor): parametrized 9-method matrix with an instrumented-lock probe (proves the worker actually reaches self._lock before asserting it blocks — no spurious passes) + no-deadlock smoke test. Follow-up: try_refresh_matching added to the no-deadlock test.

Validation

  • 372 targeted tests green across 6 files (test_credential_pool.py, test_credential_pool_routing.py, test_credential_pool_interrupt.py, test_codex_xai_oauth_recovery.py, test_anthropic_adapter.py, test_auth_codex_quota_probe.py).
  • Live E2E with a real 4-entry pool: (1) no-hint try_refresh_matching completes in <5s on a background thread (hangs forever pre-follow-up on the merged tree); (2) 4 reader threads + 1 rotator hammering the pool for 3s — no hang, no exceptions.

Closes #62614's scope.

Credit

Locking design, both commits, and the instrumented test matrix by @solyanviktor-star (#62614), cherry-picked with authorship preserved. The try_refresh_matching deadlock fix (a post-PR merge hazard) added on top.

Infographic

infographic

@teknium1
teknium1 force-pushed the salvage/62614-pool-query-locks branch from 17222c0 to e90659d Compare July 23, 2026 15:39
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 7a4acc3

all good!

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Jul 23, 2026
solyanviktor-star and others added 3 commits July 23, 2026 09:22
…rent/entries

`has_available()`, `peek()`, `current()` and `entries()` read (and, via
`_available_entries()`, mutate and persist) `self._entries` without holding
`self._lock`, while every other entry point — `select()`,
`mark_exhausted_and_rotate()`, `acquire_lease()`, `try_refresh_current()` —
guards the exact same access with the lock.

`_available_entries()` is not read-only: it prunes aged-out DEAD manual
entries (rebinding `self._entries` at the prune step) and calls `_persist()`
(writes auth.json). The gateway runs platform adapters in threads and cron
runs jobs in a ThreadPoolExecutor, so a status probe via `has_available()`
or `peek()` can race a concurrent `select()`/rotation: torn iteration of
`self._entries`, interleaved auth.json writes, or a lost token rotation.

Fix: take `self._lock` in all four query methods. Because the lock is
non-reentrant and `peek()` composes `current()` + `_available_entries()`,
add a lock-free `_current_unlocked()` helper and route the already-locked
internal callers (`_select_unlocked`, `mark_exhausted_and_rotate`,
`_try_refresh_current_unlocked`) through it to avoid self-deadlock.

Added regression tests: a no-deadlock check (peek re-entrancy) and a
lock-held-blocks-the-call check for each of the four methods.
… pool surface

Follow-up to review feedback:

- Acquire self._lock in the remaining public pool-state methods:
  has_credentials, reset_statuses, remove_index, resolve_target, and
  add_entry. All of them read or rebind self._entries (and the mutating
  ones persist auth.json), so they now hold the same lock as select()
  and the query methods. None are called from within the lock, so no
  unlocked helpers are needed.
- Make the blocking test deterministic: an instrumented lock records the
  acquire attempt, and the test first waits for the worker to actually
  reach self._lock before asserting it blocks. Previously an unlocked
  method could pass if the worker thread was scheduled late.
- Extend the lock test matrix to all nine public methods; the five newly
  locked ones fail the test without this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ranch

Follow-up to the #62614 salvage: try_refresh_matching (added by the
#69843 salvage after this PR's base) calls self.current() while already
holding the now-locking non-reentrant pool lock — a guaranteed deadlock
that git merges silently (no textual conflict). Use _current_unlocked()
and cover the method in the no-deadlock test.
@teknium1
teknium1 force-pushed the salvage/62614-pool-query-locks branch from e90659d to 7a4acc3 Compare July 23, 2026 16:22
@teknium1
teknium1 merged commit d9165d7 into main Jul 23, 2026
37 checks passed
@teknium1
teknium1 deleted the salvage/62614-pool-query-locks branch July 23, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants