()
| 555 | |
| 556 | // ── 8. 401 recovery (OAuth refresh + rebuild) ─────────────────────────── |
| 557 | async function recoverFromAuthFailure(): Promise<void> { |
| 558 | // setOnClose already guards `!authRecoveryInFlight` but that check and |
| 559 | // this set must be atomic against onRefresh — claim synchronously before |
| 560 | // any await. Laptop wake fires both paths ~simultaneously. |
| 561 | if (authRecoveryInFlight) return |
| 562 | authRecoveryInFlight = true |
| 563 | onStateChange?.('reconnecting', 'JWT expired — refreshing') |
| 564 | logForDebugging('[remote-bridge] 401 on SSE — attempting JWT refresh') |
| 565 | try { |
| 566 | // Unconditionally try OAuth refresh — getAccessToken() returns expired |
| 567 | // tokens as non-null strings, so !oauthToken doesn't catch expiry. |
| 568 | // Pass the stale token so handleOAuth401Error's keychain-comparison |
| 569 | // can detect if another tab already refreshed. |
| 570 | const stale = getAccessToken() |
| 571 | if (onAuth401) await onAuth401(stale ?? '') |
| 572 | const oauthToken = getAccessToken() ?? stale |
| 573 | if (!oauthToken || tornDown) { |
| 574 | if (!tornDown) { |
| 575 | onStateChange?.('failed', 'JWT refresh failed: no OAuth token') |
| 576 | } |
| 577 | return |
| 578 | } |
| 579 | |
| 580 | const fresh = await withRetry( |
| 581 | () => |
| 582 | fetchRemoteCredentials( |
| 583 | sessionId, |
| 584 | baseUrl, |
| 585 | oauthToken, |
| 586 | cfg.http_timeout_ms, |
| 587 | ), |
| 588 | 'fetchRemoteCredentials (recovery)', |
| 589 | cfg, |
| 590 | ) |
| 591 | if (!fresh || tornDown) { |
| 592 | if (!tornDown) { |
| 593 | onStateChange?.('failed', 'JWT refresh failed after 401') |
| 594 | } |
| 595 | return |
| 596 | } |
| 597 | // If 401 interrupted the initial flush, writeBatch may have silently |
| 598 | // no-op'd on the closed uploader (ccr.close() ran in the SSE wrapper |
| 599 | // before our setOnClose callback). Reset so the new onConnect re-flushes. |
| 600 | // (v1 scopes initialFlushDone inside the per-transport closure at |
| 601 | // replBridge.ts:1027 so it resets naturally; v2 has it at outer scope.) |
| 602 | initialFlushDone = false |
| 603 | await rebuildTransport(fresh, 'auth_401_recovery') |
| 604 | logForDebugging('[remote-bridge] Transport rebuilt after 401') |
| 605 | } catch (err) { |
| 606 | logForDebugging( |
| 607 | `[remote-bridge] 401 recovery failed: ${errorMessage(err)}`, |
| 608 | { level: 'error' }, |
| 609 | ) |
| 610 | logForDiagnosticsNoPII('error', 'bridge_repl_v2_jwt_refresh_failed') |
| 611 | if (!tornDown) { |
| 612 | onStateChange?.('failed', `JWT refresh failed: ${errorMessage(err)}`) |
| 613 | } |
| 614 | } finally { |
no test coverage detected