()
| 528 | |
| 529 | // ── 8. 401 recovery (OAuth refresh + rebuild) ─────────────────────────── |
| 530 | async function recoverFromAuthFailure(): Promise<void> { |
| 531 | // setOnClose already guards `!authRecoveryInFlight` but that check and |
| 532 | // this set must be atomic against onRefresh — claim synchronously before |
| 533 | // any await. Laptop wake fires both paths ~simultaneously. |
| 534 | if (authRecoveryInFlight) return |
| 535 | authRecoveryInFlight = true |
| 536 | onStateChange?.('reconnecting', 'JWT expired — refreshing') |
| 537 | logForDebugging('[remote-bridge] 401 on SSE — attempting JWT refresh') |
| 538 | try { |
| 539 | // Unconditionally try OAuth refresh — getAccessToken() returns expired |
| 540 | // tokens as non-null strings, so !oauthToken doesn't catch expiry. |
| 541 | // Pass the stale token so handleOAuth401Error's keychain-comparison |
| 542 | // can detect if another tab already refreshed. |
| 543 | const stale = getAccessToken() |
| 544 | if (onAuth401) await onAuth401(stale ?? '') |
| 545 | const oauthToken = getAccessToken() ?? stale |
| 546 | if (!oauthToken || tornDown) { |
| 547 | if (!tornDown) { |
| 548 | onStateChange?.('failed', 'JWT refresh failed: no OAuth token') |
| 549 | } |
| 550 | return |
| 551 | } |
| 552 | |
| 553 | const fresh = await withRetry( |
| 554 | () => |
| 555 | fetchRemoteCredentials( |
| 556 | sessionId, |
| 557 | baseUrl, |
| 558 | oauthToken, |
| 559 | cfg.http_timeout_ms, |
| 560 | ), |
| 561 | 'fetchRemoteCredentials (recovery)', |
| 562 | cfg, |
| 563 | ) |
| 564 | if (!fresh || tornDown) { |
| 565 | if (!tornDown) { |
| 566 | onStateChange?.('failed', 'JWT refresh failed after 401') |
| 567 | } |
| 568 | return |
| 569 | } |
| 570 | // If 401 interrupted the initial flush, writeBatch may have silently |
| 571 | // no-op'd on the closed uploader (ccr.close() ran in the SSE wrapper |
| 572 | // before our setOnClose callback). Reset so the new onConnect re-flushes. |
| 573 | // (v1 scopes initialFlushDone inside the per-transport closure at |
| 574 | // replBridge.ts:1027 so it resets naturally; v2 has it at outer scope.) |
| 575 | initialFlushDone = false |
| 576 | await rebuildTransport(fresh, 'auth_401_recovery') |
| 577 | logForDebugging('[remote-bridge] Transport rebuilt after 401') |
| 578 | } catch (err) { |
| 579 | logForDebugging( |
| 580 | `[remote-bridge] 401 recovery failed: ${errorMessage(err)}`, |
| 581 | { level: 'error' }, |
| 582 | ) |
| 583 | logForDiagnosticsNoPII('error', 'bridge_repl_v2_jwt_refresh_failed') |
| 584 | if (!tornDown) { |
| 585 | onStateChange?.('failed', `JWT refresh failed: ${errorMessage(err)}`) |
| 586 | } |
| 587 | } finally { |
no test coverage detected