Logout bug hunt (#294)

* Stop storing the log on disk

* Add more info to the session logging

* Only clear session tokens from storage when they've expired

* Retry session resumption a few times if it's a network issue

* Improvements to the 'connecting' screen
zio/stable
Paul Frazee 2023-03-15 15:05:13 -05:00 committed by GitHub
parent 94741cdded
commit 3c05a08482
5 changed files with 100 additions and 48 deletions

View File

@ -0,0 +1,29 @@
import {isNetworkError} from 'lib/strings/errors'
export async function retry<P>(
retries: number,
cond: (err: any) => boolean,
fn: () => Promise<P>,
): Promise<P> {
let lastErr
while (retries > 0) {
try {
return await fn()
} catch (e: any) {
lastErr = e
if (cond(e)) {
retries--
continue
}
throw e
}
}
throw lastErr
}
export async function networkRetry<P>(
retries: number,
fn: () => Promise<P>,
): Promise<P> {
return retry(retries, isNetworkError, fn)
}

View File

@ -1,6 +1,7 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc'
import {isObj, hasProp} from 'lib/type-guards'
const MAX_ENTRIES = 300
interface LogEntry { interface LogEntry {
id: string id: string
@ -28,27 +29,14 @@ export class LogModel {
entries: LogEntry[] = [] entries: LogEntry[] = []
constructor() { constructor() {
makeAutoObservable(this, {serialize: false, hydrate: false}) makeAutoObservable(this)
}
serialize(): unknown {
return {
entries: this.entries.slice(-100),
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'entries') && Array.isArray(v.entries)) {
this.entries = v.entries.filter(
e => isObj(e) && hasProp(e, 'id') && typeof e.id === 'string',
)
}
}
} }
private add(entry: LogEntry) { private add(entry: LogEntry) {
this.entries.push(entry) this.entries.push(entry)
while (this.entries.length > MAX_ENTRIES) {
this.entries = this.entries.slice(50)
}
} }
debug(summary: string, details?: any) { debug(summary: string, details?: any) {

View File

@ -78,7 +78,6 @@ export class RootStoreModel {
serialize(): unknown { serialize(): unknown {
return { return {
appInfo: this.appInfo, appInfo: this.appInfo,
log: this.log.serialize(),
session: this.session.serialize(), session: this.session.serialize(),
me: this.me.serialize(), me: this.me.serialize(),
shell: this.shell.serialize(), shell: this.shell.serialize(),
@ -93,9 +92,6 @@ export class RootStoreModel {
this.setAppInfo(appInfoParsed.data) this.setAppInfo(appInfoParsed.data)
} }
} }
if (hasProp(v, 'log')) {
this.log.hydrate(v.log)
}
if (hasProp(v, 'me')) { if (hasProp(v, 'me')) {
this.me.hydrate(v.me) this.me.hydrate(v.me)
} }

View File

@ -7,6 +7,7 @@ import {
} from '@atproto/api' } from '@atproto/api'
import normalizeUrl from 'normalize-url' import normalizeUrl from 'normalize-url'
import {isObj, hasProp} from 'lib/type-guards' import {isObj, hasProp} from 'lib/type-guards'
import {networkRetry} from 'lib/async/retry'
import {z} from 'zod' import {z} from 'zod'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
@ -35,6 +36,25 @@ interface AdditionalAccountData {
} }
export class SessionModel { export class SessionModel {
// DEBUG
// emergency log facility to help us track down this logout issue
// remove when resolved
// -prf
private _log(message: string, details?: Record<string, any>) {
details = details || {}
details.state = {
data: this.data,
accounts: this.accounts.map(
a =>
`${!!a.accessJwt && !!a.refreshJwt ? '✅' : '❌'} ${a.handle} (${
a.service
})`,
),
isResumingSession: this.isResumingSession,
}
this.rootStore.log.debug(message, details)
}
/** /**
* Currently-active session * Currently-active session
*/ */
@ -115,9 +135,7 @@ export class SessionModel {
async attemptSessionResumption() { async attemptSessionResumption() {
const sess = this.currentSession const sess = this.currentSession
if (sess) { if (sess) {
this.rootStore.log.debug( this._log('SessionModel:attemptSessionResumption found stored session')
'SessionModel:attemptSessionResumption found stored session',
)
this.isResumingSession = true this.isResumingSession = true
try { try {
return await this.resumeSession(sess) return await this.resumeSession(sess)
@ -127,7 +145,7 @@ export class SessionModel {
}) })
} }
} else { } else {
this.rootStore.log.debug( this._log(
'SessionModel:attemptSessionResumption has no session to resume', 'SessionModel:attemptSessionResumption has no session to resume',
) )
} }
@ -137,7 +155,7 @@ export class SessionModel {
* Sets the active session * Sets the active session
*/ */
setActiveSession(agent: AtpAgent, did: string) { setActiveSession(agent: AtpAgent, did: string) {
this.rootStore.log.debug('SessionModel:setActiveSession') this._log('SessionModel:setActiveSession')
this.data = { this.data = {
service: agent.service.toString(), service: agent.service.toString(),
did, did,
@ -155,22 +173,32 @@ export class SessionModel {
session?: AtpSessionData, session?: AtpSessionData,
addedInfo?: AdditionalAccountData, addedInfo?: AdditionalAccountData,
) { ) {
this.rootStore.log.debug('SessionModel:persistSession', { this._log('SessionModel:persistSession', {
service, service,
did, did,
event, event,
hasSession: !!session, hasSession: !!session,
}) })
// upsert the account in our listing
const existingAccount = this.accounts.find( const existingAccount = this.accounts.find(
account => account.service === service && account.did === did, account => account.service === service && account.did === did,
) )
// fall back to any pre-existing access tokens
let refreshJwt = session?.refreshJwt || existingAccount?.refreshJwt
let accessJwt = session?.accessJwt || existingAccount?.accessJwt
if (event === 'expired') {
// only clear the tokens when they're known to have expired
refreshJwt = undefined
accessJwt = undefined
}
const newAccount = { const newAccount = {
service, service,
did, did,
refreshJwt: session?.refreshJwt, refreshJwt,
accessJwt: session?.accessJwt, accessJwt,
handle: session?.handle || existingAccount?.handle || '', handle: session?.handle || existingAccount?.handle || '',
displayName: addedInfo displayName: addedInfo
? addedInfo.displayName ? addedInfo.displayName
@ -198,7 +226,7 @@ export class SessionModel {
* Clears any session tokens from the accounts; used on logout. * Clears any session tokens from the accounts; used on logout.
*/ */
private clearSessionTokens() { private clearSessionTokens() {
this.rootStore.log.debug('SessionModel:clearSessionTokens') this._log('SessionModel:clearSessionTokens')
this.accounts = this.accounts.map(acct => ({ this.accounts = this.accounts.map(acct => ({
service: acct.service, service: acct.service,
handle: acct.handle, handle: acct.handle,
@ -236,9 +264,9 @@ export class SessionModel {
* Attempt to resume a session that we still have access tokens for. * Attempt to resume a session that we still have access tokens for.
*/ */
async resumeSession(account: AccountData): Promise<boolean> { async resumeSession(account: AccountData): Promise<boolean> {
this.rootStore.log.debug('SessionModel:resumeSession') this._log('SessionModel:resumeSession')
if (!(account.accessJwt && account.refreshJwt && account.service)) { if (!(account.accessJwt && account.refreshJwt && account.service)) {
this.rootStore.log.debug( this._log(
'SessionModel:resumeSession aborted due to lack of access tokens', 'SessionModel:resumeSession aborted due to lack of access tokens',
) )
return false return false
@ -252,12 +280,14 @@ export class SessionModel {
}) })
try { try {
await agent.resumeSession({ await networkRetry(3, () =>
accessJwt: account.accessJwt, agent.resumeSession({
refreshJwt: account.refreshJwt, accessJwt: account.accessJwt || '',
refreshJwt: account.refreshJwt || '',
did: account.did, did: account.did,
handle: account.handle, handle: account.handle,
}) }),
)
const addedInfo = await this.loadAccountInfo(agent, account.did) const addedInfo = await this.loadAccountInfo(agent, account.did)
this.persistSession( this.persistSession(
account.service, account.service,
@ -266,9 +296,9 @@ export class SessionModel {
agent.session, agent.session,
addedInfo, addedInfo,
) )
this.rootStore.log.debug('SessionModel:resumeSession succeeded') this._log('SessionModel:resumeSession succeeded')
} catch (e: any) { } catch (e: any) {
this.rootStore.log.debug('SessionModel:resumeSession failed', { this._log('SessionModel:resumeSession failed', {
error: e.toString(), error: e.toString(),
}) })
return false return false
@ -290,7 +320,7 @@ export class SessionModel {
identifier: string identifier: string
password: string password: string
}) { }) {
this.rootStore.log.debug('SessionModel:login') this._log('SessionModel:login')
const agent = new AtpAgent({service}) const agent = new AtpAgent({service})
await agent.login({identifier, password}) await agent.login({identifier, password})
if (!agent.session) { if (!agent.session) {
@ -308,7 +338,7 @@ export class SessionModel {
) )
this.setActiveSession(agent, did) this.setActiveSession(agent, did)
this.rootStore.log.debug('SessionModel:login succeeded') this._log('SessionModel:login succeeded')
} }
async createAccount({ async createAccount({
@ -324,7 +354,7 @@ export class SessionModel {
handle: string handle: string
inviteCode?: string inviteCode?: string
}) { }) {
this.rootStore.log.debug('SessionModel:createAccount') this._log('SessionModel:createAccount')
const agent = new AtpAgent({service}) const agent = new AtpAgent({service})
await agent.createAccount({ await agent.createAccount({
handle, handle,
@ -348,14 +378,14 @@ export class SessionModel {
this.setActiveSession(agent, did) this.setActiveSession(agent, did)
this.rootStore.shell.setOnboarding(true) this.rootStore.shell.setOnboarding(true)
this.rootStore.log.debug('SessionModel:createAccount succeeded') this._log('SessionModel:createAccount succeeded')
} }
/** /**
* Close all sessions across all accounts. * Close all sessions across all accounts.
*/ */
async logout() { async logout() {
this.rootStore.log.debug('SessionModel:logout') this._log('SessionModel:logout')
// TODO // TODO
// need to evaluate why deleting the session has caused errors at times // need to evaluate why deleting the session has caused errors at times
// -prf // -prf

View File

@ -22,11 +22,20 @@ export const withAuthRequired = <P extends object>(
function Loading() { function Loading() {
const pal = usePalette('default') const pal = usePalette('default')
const [isTakingTooLong, setIsTakingTooLong] = React.useState(false)
React.useEffect(() => {
const t = setTimeout(() => setIsTakingTooLong(true), 15e3)
return () => clearTimeout(t)
}, [setIsTakingTooLong])
return ( return (
<View style={[styles.loading, pal.view]}> <View style={[styles.loading, pal.view]}>
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
<Text type="2xl" style={[styles.loadingText, pal.textLight]}> <Text type="2xl" style={[styles.loadingText, pal.textLight]}>
Firing up the grill... {isTakingTooLong
? "This is taking too long. There may be a problem with your internet or with the service, but we're going to try a couple more times..."
: 'Connecting...'}
</Text> </Text>
</View> </View>
) )