Warn after committed session persistence failures
This commit is contained in:
@@ -91,6 +91,8 @@ The pattern existed on `main`, where the resume factory was its main rejection s
|
|||||||
|
|
||||||
**Severity: Low**
|
**Severity: Low**
|
||||||
|
|
||||||
|
**Status: Resolved.** Login, account creation, and partial session metadata refresh now use a success-with-warning policy after reducer commit. Persistence rejection is converted into a `logger.warn` with `safeMessage`, while the method resolves and continues its success path. Focused tests cover all three operations.
|
||||||
|
|
||||||
`login`, `createAccount`, and `partialRefreshSession` synchronously commit reducer state and then await persistence:
|
`login`, `createAccount`, and `partialRefreshSession` synchronously commit reducer state and then await persistence:
|
||||||
|
|
||||||
- `index.tsx:353-370`: login.
|
- `index.tsx:353-370`: login.
|
||||||
@@ -103,10 +105,7 @@ On `main`, persistence was fire-and-forget. Logout and removal currently make th
|
|||||||
|
|
||||||
This requires genuinely broken storage, such as quota exhaustion or a browser security error.
|
This requires genuinely broken storage, such as quota exhaustion or a browser security error.
|
||||||
|
|
||||||
**Recommendation:** Choose and document one policy:
|
**Resolution:** Swallow and warn after the reducer commit. This preserves the successful in-memory operation without misreporting a completed login or account creation as failed; the warning records that durability was lost.
|
||||||
|
|
||||||
1. swallow and log, matching logout/removal; or
|
|
||||||
2. deliberately reject, with callers treating "session established but durability failed" as success with a warning rather than a failed login.
|
|
||||||
|
|
||||||
## Intended but consequential behavior
|
## Intended but consequential behavior
|
||||||
|
|
||||||
@@ -156,11 +155,10 @@ Add a `refreshSession` test with a rejecting `writeSession` to pin the intended
|
|||||||
|
|
||||||
## Remaining recommended order
|
## Remaining recommended order
|
||||||
|
|
||||||
1. Decide and document the B4 persistence-failure policy for login-shaped methods.
|
1. Add a `refreshSession` test with rejecting `writeSession`.
|
||||||
2. Add a `refreshSession` test with rejecting `writeSession`.
|
|
||||||
|
|
||||||
## Verdict
|
## Verdict
|
||||||
|
|
||||||
The core async design is sound. Serialization through `PasswordSession.#sessionPromise`, the per-bundle error channel, arm/kill lifecycle, reducer identity guards, and internally owned persistence lock compose correctly in the reviewed interleavings.
|
The core async design is sound. Serialization through `PasswordSession.#sessionPromise`, the per-bundle error channel, arm/kill lifecycle, reducer identity guards, and internally owned persistence lock compose correctly in the reviewed interleavings.
|
||||||
|
|
||||||
The genuine state-corruption hole from B1 is now closed. B2 is accepted, documented, instrumented, and covered as a conditional-merge tradeoff. B3 is closed; B4 remains as the final lower-severity error-policy issue.
|
The genuine state-corruption hole from B1 is now closed. B2 is accepted, documented, instrumented, and covered as a conditional-merge tradeoff. B3 and B4 are closed. The remaining work is a focused test pinning explicit refresh behavior when `writeSession` rejects.
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ Tab A action: broadcast update notification
|
|||||||
|
|
||||||
Broadcasting after a failed write would tell Tab B to reread localStorage while it still contains `(7, A)`, spreading the stale generation instead of the new one.
|
Broadcasting after a failed write would tell Tab B to reread localStorage while it still contains `(7, A)`, spreading the stale generation instead of the new one.
|
||||||
|
|
||||||
|
Login, account creation, and partial session metadata refresh use a success-with-warning policy once their reducer state has committed. If persistence then fails, the method logs a warning with the error and resolves successfully instead of reporting that the already-completed login or account creation failed. The in-memory session remains usable, but may not survive a reload. The underlying write still throws, and the persisted cache and other tabs are not updated.
|
||||||
|
|
||||||
### Edge case: a failed write leaves a missing lineage link
|
### Edge case: a failed write leaves a missing lineage link
|
||||||
|
|
||||||
If the process survives a failed write, its live `PasswordSession` may advance while authoritative storage remains behind:
|
If the process survives a failed write, its live `PasswordSession` may advance while authoritative storage remains behind:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||||
import {act, render} from '@testing-library/react-native'
|
import {act, render} from '@testing-library/react-native'
|
||||||
|
|
||||||
|
let mockWriteSessionError: Error | undefined
|
||||||
|
const mockAnalyticsLoggerWarn = jest.fn()
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The provider pulls the whole app shell in through `#/state/util` and the
|
* The provider pulls the whole app shell in through `#/state/util` and the
|
||||||
* account factories. These mocks cut the tree back to the session lifecycle
|
* account factories. These mocks cut the tree back to the session lifecycle
|
||||||
@@ -16,7 +19,9 @@ jest.mock('#/state/persisted', () => {
|
|||||||
get: () => defaults.session,
|
get: () => defaults.session,
|
||||||
readLatest: () => defaults.session,
|
readLatest: () => defaults.session,
|
||||||
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
||||||
Promise.resolve(nextSession),
|
mockWriteSessionError
|
||||||
|
? Promise.reject(mockWriteSessionError)
|
||||||
|
: Promise.resolve(nextSession),
|
||||||
onUpdate: () => () => {},
|
onUpdate: () => () => {},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -26,7 +31,14 @@ jest.mock('#/components/dialogs/Context', () => ({
|
|||||||
}))
|
}))
|
||||||
jest.mock('#/analytics', () => ({
|
jest.mock('#/analytics', () => ({
|
||||||
AnalyticsContext: ({children}: {children: React.ReactNode}) => children,
|
AnalyticsContext: ({children}: {children: React.ReactNode}) => children,
|
||||||
useAnalyticsBase: () => ({metric() {}, logger: {debug() {}, error() {}}}),
|
useAnalyticsBase: () => ({
|
||||||
|
metric() {},
|
||||||
|
logger: {
|
||||||
|
debug() {},
|
||||||
|
error() {},
|
||||||
|
warn: (...args: unknown[]) => mockAnalyticsLoggerWarn(...args),
|
||||||
|
},
|
||||||
|
}),
|
||||||
utils: {accountToSessionMetadata: () => ({}), useMeta: () => undefined},
|
utils: {accountToSessionMetadata: () => ({}), useMeta: () => undefined},
|
||||||
}))
|
}))
|
||||||
jest.mock('#/state/shell/onboarding', () => ({
|
jest.mock('#/state/shell/onboarding', () => ({
|
||||||
@@ -81,6 +93,14 @@ function renderProvider(): SessionApiContext {
|
|||||||
return api
|
return api
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockLogin.mockReset()
|
||||||
|
mockCreateAccount.mockReset()
|
||||||
|
mockDisposeBundle.mockReset()
|
||||||
|
mockWriteSessionError = undefined
|
||||||
|
mockAnalyticsLoggerWarn.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Every factory returns an ARMED bundle, so a call whose result is thrown away
|
* Every factory returns an ARMED bundle, so a call whose result is thrown away
|
||||||
* because a newer call superseded it must dispose that bundle. Leaving it armed
|
* because a newer call superseded it must dispose that bundle. Leaving it armed
|
||||||
@@ -88,18 +108,6 @@ function renderProvider(): SessionApiContext {
|
|||||||
* for an account the app is no longer tracking.
|
* for an account the app is no longer tracking.
|
||||||
*/
|
*/
|
||||||
describe('superseded session tasks dispose their bundle', () => {
|
describe('superseded session tasks dispose their bundle', () => {
|
||||||
/*
|
|
||||||
* Without this, a recorded call from an earlier test satisfies a later
|
|
||||||
* assertion. The tagged bundles below are the other half of that guard: two
|
|
||||||
* `{}` literals are structurally equal, so `toHaveBeenCalledWith` could not
|
|
||||||
* tell one test's bundle from the other's even within a cleared mock.
|
|
||||||
*/
|
|
||||||
beforeEach(() => {
|
|
||||||
mockLogin.mockReset()
|
|
||||||
mockCreateAccount.mockReset()
|
|
||||||
mockDisposeBundle.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('disposes the bundle of an aborted login', async () => {
|
it('disposes the bundle of an aborted login', async () => {
|
||||||
const bundle = {tag: 'login-bundle'} as never
|
const bundle = {tag: 'login-bundle'} as never
|
||||||
let resolveLogin!: (value: unknown) => void
|
let resolveLogin!: (value: unknown) => void
|
||||||
@@ -145,3 +153,74 @@ describe('superseded session tasks dispose their bundle', () => {
|
|||||||
expect(mockDisposeBundle).toHaveBeenCalledWith(bundle)
|
expect(mockDisposeBundle).toHaveBeenCalledWith(bundle)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('persistence failures after an in-memory commit', () => {
|
||||||
|
const account = {
|
||||||
|
did: 'did:plc:example',
|
||||||
|
handle: 'alice.test',
|
||||||
|
service: 'https://bsky.social/',
|
||||||
|
accessJwt: 'access-jwt',
|
||||||
|
refreshJwt: 'refresh-jwt',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('treats login as successful and logs a warning', async () => {
|
||||||
|
const error = new Error('storage failed')
|
||||||
|
mockLogin.mockResolvedValueOnce({bundle: {}, account})
|
||||||
|
mockWriteSessionError = error
|
||||||
|
const api = renderProvider()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await api.login({} as never, 'LoginForm')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockAnalyticsLoggerWarn).toHaveBeenCalledWith(
|
||||||
|
'Logged in but session persistence failed',
|
||||||
|
{safeMessage: error},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats account creation as successful and logs a warning', async () => {
|
||||||
|
const error = new Error('storage failed')
|
||||||
|
mockCreateAccount.mockResolvedValueOnce({bundle: {}, account})
|
||||||
|
mockWriteSessionError = error
|
||||||
|
const api = renderProvider()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await api.createAccount({} as never, {} as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockAnalyticsLoggerWarn).toHaveBeenCalledWith(
|
||||||
|
'Account created but session persistence failed',
|
||||||
|
{safeMessage: error},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps refreshed metadata and logs a warning', async () => {
|
||||||
|
const error = new Error('storage failed')
|
||||||
|
const bundle = {
|
||||||
|
pdsClient: {
|
||||||
|
call: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
did: account.did,
|
||||||
|
emailConfirmed: true,
|
||||||
|
emailAuthFactor: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockLogin.mockResolvedValueOnce({bundle, account})
|
||||||
|
const api = renderProvider()
|
||||||
|
await act(async () => {
|
||||||
|
await api.login({} as never, 'LoginForm')
|
||||||
|
})
|
||||||
|
mockWriteSessionError = error
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await api.partialRefreshSession()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockAnalyticsLoggerWarn).toHaveBeenCalledWith(
|
||||||
|
'Session metadata updated but persistence failed',
|
||||||
|
{safeMessage: error},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -353,7 +353,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
disposeBundle(bundle)
|
disposeBundle(bundle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await store.dispatch(
|
await store
|
||||||
|
.dispatch(
|
||||||
{
|
{
|
||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newBundle: bundle,
|
newBundle: bundle,
|
||||||
@@ -367,6 +368,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
.catch(error => {
|
||||||
|
ax.logger.warn('Account created but session persistence failed', {
|
||||||
|
safeMessage: error,
|
||||||
|
})
|
||||||
|
})
|
||||||
ax.metric('account:create:success', metrics, {
|
ax.metric('account:create:success', metrics, {
|
||||||
session: utils.accountToSessionMetadata(account),
|
session: utils.accountToSessionMetadata(account),
|
||||||
})
|
})
|
||||||
@@ -393,7 +399,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
disposeBundle(bundle)
|
disposeBundle(bundle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await store.dispatch(
|
await store
|
||||||
|
.dispatch(
|
||||||
{
|
{
|
||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newBundle: bundle,
|
newBundle: bundle,
|
||||||
@@ -407,6 +414,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
.catch(error => {
|
||||||
|
ax.logger.warn('Logged in but session persistence failed', {
|
||||||
|
safeMessage: error,
|
||||||
|
})
|
||||||
|
})
|
||||||
ax.metric(
|
ax.metric(
|
||||||
'account:loggedIn',
|
'account:loggedIn',
|
||||||
{logContext, withPassword: true},
|
{logContext, withPassword: true},
|
||||||
@@ -615,7 +627,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
/* getSession targets the PDS; only the persisted account fields are patched. */
|
/* getSession targets the PDS; only the persisted account fields are patched. */
|
||||||
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
|
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
|
||||||
if (signal.aborted) return
|
if (signal.aborted) return
|
||||||
await store.dispatch({
|
await store
|
||||||
|
.dispatch({
|
||||||
type: 'partial-refresh-session',
|
type: 'partial-refresh-session',
|
||||||
/*
|
/*
|
||||||
* Read the did off the response rather than the session: the bundle may
|
* Read the did off the response rather than the session: the bundle may
|
||||||
@@ -628,7 +641,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
emailAuthFactor: data.emailAuthFactor,
|
emailAuthFactor: data.emailAuthFactor,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [store, cancelPendingTask])
|
.catch(error => {
|
||||||
|
ax.logger.warn('Session metadata updated but persistence failed', {
|
||||||
|
safeMessage: error,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [store, cancelPendingTask, ax])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotate the session's tokens and hand back the resulting account snapshot.
|
* Rotate the session's tokens and hand back the resulting account snapshot.
|
||||||
|
|||||||
Reference in New Issue
Block a user