OAuth refresh must survive caller cancellation and not block executor threads
- Status: Todo
- Kind: Bug
- Authors: jp
- Date: 2026-09-23
- Implements: 090
- Label: domain=llm
- Label: llm-provider=anthropic
- Label: package=jp_llm
- Label: type=bug
Two problems in jp_llm::provider::anthropic::resolve that cannot happen yet, because no shipped login creates a CredentialSecret::Oauth record. Setup-token login stores Token, so no refresh ever runs. Both must be fixed before RFD 090 Phase 3 (browser login) ships.
Raised in review of #1151 (comments 4081033143 and 4081035326).
1. A cancelled refresh loses the rotated token
refresh_under_lock holds the store lock across oauth::refresh and persists afterwards. Its future is owned by whoever called resolve. TitleGeneratorTask::run selects cancellation against the title work, which reaches this through model_details. If the cancellation lands after the token endpoint has rotated the refresh token but before its response is persisted, the future and the guard are dropped. The replacement is lost, the next invocation presents the retired token, and the profile is marked for re-login.
Fix: run the lock, recheck, exchange and persist steps as one tokio::spawned task and await its JoinHandle. Dropping a handle does not cancel the task, so the transaction completes whatever happens to the caller.
Test: a mock token endpoint that pauses after accepting the refresh. Cancel the caller while it is paused, release the endpoint, then assert the rotated token was persisted.
2. Outcome writes block executor threads on the refresh lock
record_outcome calls the synchronous CredentialStore::mutate, which blocks in ResourceLocker::lock (no timeout). QuotaWatch::observe does the same from stream polling. A refresh holds that lock across a network await. On a runtime whose worker threads are all parked in these blocking writers (--threads 1 is enough), the refresh can never receive its response or run its timeout, so neither side releases the other.
Fix: route both writes through spawn_blocking, the way acquire already does for the refresh. QuotaWatch::observe becomes async.
Test: on a single-worker runtime, a pending refresh competes with an outcome write, and both complete.