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:
Samuel Newman
2026-08-03 10:48:06 +03:00
parent b96d85387d
commit 374e1ba3de
2 changed files with 96 additions and 22 deletions
+6 -2
View File
@@ -24,6 +24,7 @@ import {type BskyAppAgent} from './bridge-agent'
import {configureModerationForAccount} from './moderation'
import {
buildBundle,
finishPreparation,
makeSessionHooks,
type OnSessionChange,
registerBundleKillSwitch,
@@ -125,9 +126,12 @@ export async function createSessionBundleAndCreateAccount(
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
await Promise.all([gates, aa])
// 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()
return {account, bundle}
}
+90 -20
View File
@@ -4,8 +4,8 @@ import {
type SessionData,
} from '@atproto/lex-password-session'
import {networkRetry} from '#/lib/async/retry'
import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {logger} from '#/logger'
import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data'
import {features} from '#/analytics'
import {
@@ -127,10 +127,24 @@ export function makeSessionHooks(
if (!armed) {
return
}
const did = getDid()
onSessionChange(getBundle(), did, event, sessionData)
if (event !== 'update') {
addSessionErrorLog(did, event)
/*
* A hook must never throw. PasswordSession awaits its hooks inside the
* assignment to its internal session promise, so a synchronous throw here
* 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 = {
@@ -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
* network resume (one retry); still-valid stored tokens take a synchronous
* no-network fast path. Hooks are armed only after the prepare tail resolves.
* network resume; still-valid stored tokens take a synchronous no-network fast
* path. Hooks are armed only after the prepare tail resolves.
*/
export async function createSessionBundleAndResume(
storedAccount: SessionAccount,
@@ -200,10 +256,18 @@ export async function createSessionBundleAndResume(
let session: PasswordSession
const sessionData = sessionAccountToSessionData(storedAccount)
if (isSessionExpired(storedAccount)) {
// The arm latch swallows resume's initial onUpdated event.
session = await networkRetry(1, () =>
PasswordSession.resume(sessionData, hooks),
)
/*
* The arm latch swallows resume's initial onUpdated event.
*
* 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 {
// Sync fast path: trust the stored tokens, no network.
session = new PasswordSession(sessionData, hooks)
@@ -228,14 +292,17 @@ export async function createSessionBundleAndResume(
*/
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
await Promise.all([gates, aa])
// Preparation may auto-refresh the session while hooks are still disarmed.
const account =
sessionDataToSessionAccount(
session.session,
session.session.service,
storedAccount.pdsUrl,
) ?? storedAccount
const account = await finishPreparation(
bundle,
Promise.all([gates, aa]),
() =>
sessionDataToSessionAccount(
session.session,
session.session.service,
storedAccount.pdsUrl,
) ?? storedAccount,
)
hooks.arm()
return {account, bundle}
}
@@ -286,9 +353,12 @@ export async function createSessionBundleAndLogin(
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
await Promise.all([gates, aa])
// 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()
return {account, bundle}
}