dispose the bundle and fail loudly when prep destroys the session
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {type BskyAppAgent} from './bridge-agent'
|
|||||||
import {configureModerationForAccount} from './moderation'
|
import {configureModerationForAccount} from './moderation'
|
||||||
import {
|
import {
|
||||||
buildBundle,
|
buildBundle,
|
||||||
|
finishPreparation,
|
||||||
makeSessionHooks,
|
makeSessionHooks,
|
||||||
type OnSessionChange,
|
type OnSessionChange,
|
||||||
registerBundleKillSwitch,
|
registerBundleKillSwitch,
|
||||||
@@ -125,9 +126,12 @@ export async function createSessionBundleAndCreateAccount(
|
|||||||
|
|
||||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||||
|
|
||||||
await Promise.all([gates, aa])
|
|
||||||
// Preparation may auto-refresh the session while hooks are still disarmed.
|
// Preparation may auto-refresh the session while hooks are still disarmed.
|
||||||
const account = snapshotNewAccount(session, email)
|
const account = await finishPreparation(
|
||||||
|
bundle,
|
||||||
|
Promise.all([gates, aa]),
|
||||||
|
() => snapshotNewAccount(session, email),
|
||||||
|
)
|
||||||
hooks.arm()
|
hooks.arm()
|
||||||
return {account, bundle}
|
return {account, bundle}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
type SessionData,
|
type SessionData,
|
||||||
} from '@atproto/lex-password-session'
|
} from '@atproto/lex-password-session'
|
||||||
|
|
||||||
import {networkRetry} from '#/lib/async/retry'
|
|
||||||
import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data'
|
import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data'
|
||||||
import {features} from '#/analytics'
|
import {features} from '#/analytics'
|
||||||
import {
|
import {
|
||||||
@@ -127,10 +127,24 @@ export function makeSessionHooks(
|
|||||||
if (!armed) {
|
if (!armed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const did = getDid()
|
/*
|
||||||
onSessionChange(getBundle(), did, event, sessionData)
|
* A hook must never throw. PasswordSession awaits its hooks inside the
|
||||||
if (event !== 'update') {
|
* assignment to its internal session promise, so a synchronous throw here
|
||||||
addSessionErrorLog(did, event)
|
* leaves that promise permanently rejected: every later request fails, and
|
||||||
|
* because the session is never marked destroyed, disposeBundle cannot even
|
||||||
|
* see that the bundle is dead. The dispatch path reaches reducer side
|
||||||
|
* effects and event emitters, so treat it as capable of throwing.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
const did = getDid()
|
||||||
|
onSessionChange(getBundle(), did, event, sessionData)
|
||||||
|
if (event !== 'update') {
|
||||||
|
addSessionErrorLog(did, event)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e instanceof Error ? e : String(e), {
|
||||||
|
message: `session: onSessionChange threw for a '${event}' event`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hooks: PasswordSessionOptions = {
|
const hooks: PasswordSessionOptions = {
|
||||||
@@ -180,10 +194,52 @@ export function createPublicSessionBundle(): PublicSessionBundle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the prepare tail shared by the asynchronous factories.
|
||||||
|
*
|
||||||
|
* Preparation does real network work, so it can both reject and - when a
|
||||||
|
* request gets a 401 and the session's own refresh then fails definitively -
|
||||||
|
* destroy the session underneath us.
|
||||||
|
*
|
||||||
|
* A session destroyed during preparation is fatal rather than recoverable. The
|
||||||
|
* hooks are still disarmed at that point, so the session's `expired` event was
|
||||||
|
* swallowed and nothing will ever tell the reducer to log the account out;
|
||||||
|
* returning the bundle anyway would leave the app looking signed in over a
|
||||||
|
* session that can only make unauthenticated requests. Failing instead matches
|
||||||
|
* what `CredentialSession.resumeSession` did on a revoked token, and every
|
||||||
|
* caller already handles a rejected factory. Checking `destroyed` first also
|
||||||
|
* keeps `PasswordSession`'s `Logged out` getter throw from escaping as the
|
||||||
|
* opaque rejection a caller would surface, so `snapshot` only ever runs against
|
||||||
|
* a live session.
|
||||||
|
*
|
||||||
|
* Both failure modes dispose: the bundle is fully built by this point, and a
|
||||||
|
* still-live session left behind would keep its refresh and dispatch paths
|
||||||
|
* alive with nothing tracking it. (Disposal is a no-op for the destroyed case,
|
||||||
|
* where the session already refuses to refresh and the bridge agent already
|
||||||
|
* reads as logged out - but the two paths are indistinguishable to the caller,
|
||||||
|
* so both go through it.)
|
||||||
|
*/
|
||||||
|
export async function finishPreparation<T>(
|
||||||
|
bundle: SessionBundle,
|
||||||
|
preparation: Promise<unknown>,
|
||||||
|
snapshot: () => T,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
await preparation
|
||||||
|
if (bundle.session.destroyed) {
|
||||||
|
throw new Error('Session was revoked while it was being prepared')
|
||||||
|
}
|
||||||
|
return snapshot()
|
||||||
|
} catch (e) {
|
||||||
|
disposeBundle(bundle)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resume a stored account into a {@link SessionBundle}. Expired sessions take a
|
* Resume a stored account into a {@link SessionBundle}. Expired sessions take a
|
||||||
* network resume (one retry); still-valid stored tokens take a synchronous
|
* network resume; still-valid stored tokens take a synchronous no-network fast
|
||||||
* no-network fast path. Hooks are armed only after the prepare tail resolves.
|
* path. Hooks are armed only after the prepare tail resolves.
|
||||||
*/
|
*/
|
||||||
export async function createSessionBundleAndResume(
|
export async function createSessionBundleAndResume(
|
||||||
storedAccount: SessionAccount,
|
storedAccount: SessionAccount,
|
||||||
@@ -200,10 +256,18 @@ export async function createSessionBundleAndResume(
|
|||||||
let session: PasswordSession
|
let session: PasswordSession
|
||||||
const sessionData = sessionAccountToSessionData(storedAccount)
|
const sessionData = sessionAccountToSessionData(storedAccount)
|
||||||
if (isSessionExpired(storedAccount)) {
|
if (isSessionExpired(storedAccount)) {
|
||||||
// The arm latch swallows resume's initial onUpdated event.
|
/*
|
||||||
session = await networkRetry(1, () =>
|
* The arm latch swallows resume's initial onUpdated event.
|
||||||
PasswordSession.resume(sessionData, hooks),
|
*
|
||||||
)
|
* There is deliberately no network retry here: `resume` rejects only when
|
||||||
|
* the session is definitively invalid, and it swallows everything else -
|
||||||
|
* a failed refresh reports through `onUpdateFailure` and resolves with the
|
||||||
|
* stale tokens. So an offline cold start now stays signed in with dead
|
||||||
|
* tokens (requests fail until connectivity returns) rather than throwing
|
||||||
|
* the way the old `CredentialSession.resumeSession` did, and retrying a
|
||||||
|
* definitive rejection would only repeat a request that cannot succeed.
|
||||||
|
*/
|
||||||
|
session = await PasswordSession.resume(sessionData, hooks)
|
||||||
} else {
|
} else {
|
||||||
// Sync fast path: trust the stored tokens, no network.
|
// Sync fast path: trust the stored tokens, no network.
|
||||||
session = new PasswordSession(sessionData, hooks)
|
session = new PasswordSession(sessionData, hooks)
|
||||||
@@ -228,14 +292,17 @@ export async function createSessionBundleAndResume(
|
|||||||
*/
|
*/
|
||||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||||
|
|
||||||
await Promise.all([gates, aa])
|
|
||||||
// Preparation may auto-refresh the session while hooks are still disarmed.
|
// Preparation may auto-refresh the session while hooks are still disarmed.
|
||||||
const account =
|
const account = await finishPreparation(
|
||||||
sessionDataToSessionAccount(
|
bundle,
|
||||||
session.session,
|
Promise.all([gates, aa]),
|
||||||
session.session.service,
|
() =>
|
||||||
storedAccount.pdsUrl,
|
sessionDataToSessionAccount(
|
||||||
) ?? storedAccount
|
session.session,
|
||||||
|
session.session.service,
|
||||||
|
storedAccount.pdsUrl,
|
||||||
|
) ?? storedAccount,
|
||||||
|
)
|
||||||
hooks.arm()
|
hooks.arm()
|
||||||
return {account, bundle}
|
return {account, bundle}
|
||||||
}
|
}
|
||||||
@@ -286,9 +353,12 @@ export async function createSessionBundleAndLogin(
|
|||||||
|
|
||||||
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||||
|
|
||||||
await Promise.all([gates, aa])
|
|
||||||
// Preparation may auto-refresh the session while hooks are still disarmed.
|
// Preparation may auto-refresh the session while hooks are still disarmed.
|
||||||
const account = sessionDataToSessionAccountOrThrow(session)
|
const account = await finishPreparation(
|
||||||
|
bundle,
|
||||||
|
Promise.all([gates, aa]),
|
||||||
|
() => sessionDataToSessionAccountOrThrow(session),
|
||||||
|
)
|
||||||
hooks.arm()
|
hooks.arm()
|
||||||
return {account, bundle}
|
return {account, bundle}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user