Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Paul Frazee and committed by
GitHub
3c05a084 94741cdd

+100 -48
+29
src/lib/async/retry.ts
··· 1 + import {isNetworkError} from 'lib/strings/errors' 2 + 3 + export async function retry<P>( 4 + retries: number, 5 + cond: (err: any) => boolean, 6 + fn: () => Promise<P>, 7 + ): Promise<P> { 8 + let lastErr 9 + while (retries > 0) { 10 + try { 11 + return await fn() 12 + } catch (e: any) { 13 + lastErr = e 14 + if (cond(e)) { 15 + retries-- 16 + continue 17 + } 18 + throw e 19 + } 20 + } 21 + throw lastErr 22 + } 23 + 24 + export async function networkRetry<P>( 25 + retries: number, 26 + fn: () => Promise<P>, 27 + ): Promise<P> { 28 + return retry(retries, isNetworkError, fn) 29 + }
+6 -18
src/state/models/log.ts
··· 1 1 import {makeAutoObservable} from 'mobx' 2 2 import {XRPCError, XRPCInvalidResponseError} from '@atproto/xrpc' 3 - import {isObj, hasProp} from 'lib/type-guards' 3 + 4 + const MAX_ENTRIES = 300 4 5 5 6 interface LogEntry { 6 7 id: string ··· 28 29 entries: LogEntry[] = [] 29 30 30 31 constructor() { 31 - makeAutoObservable(this, {serialize: false, hydrate: false}) 32 - } 33 - 34 - serialize(): unknown { 35 - return { 36 - entries: this.entries.slice(-100), 37 - } 38 - } 39 - 40 - hydrate(v: unknown) { 41 - if (isObj(v)) { 42 - if (hasProp(v, 'entries') && Array.isArray(v.entries)) { 43 - this.entries = v.entries.filter( 44 - e => isObj(e) && hasProp(e, 'id') && typeof e.id === 'string', 45 - ) 46 - } 47 - } 32 + makeAutoObservable(this) 48 33 } 49 34 50 35 private add(entry: LogEntry) { 51 36 this.entries.push(entry) 37 + while (this.entries.length > MAX_ENTRIES) { 38 + this.entries = this.entries.slice(50) 39 + } 52 40 } 53 41 54 42 debug(summary: string, details?: any) {
-4
src/state/models/root-store.ts
··· 78 78 serialize(): unknown { 79 79 return { 80 80 appInfo: this.appInfo, 81 - log: this.log.serialize(), 82 81 session: this.session.serialize(), 83 82 me: this.me.serialize(), 84 83 shell: this.shell.serialize(), ··· 92 91 if (appInfoParsed.success) { 93 92 this.setAppInfo(appInfoParsed.data) 94 93 } 95 - } 96 - if (hasProp(v, 'log')) { 97 - this.log.hydrate(v.log) 98 94 } 99 95 if (hasProp(v, 'me')) { 100 96 this.me.hydrate(v.me)
+55 -25
src/state/models/session.ts
··· 7 7 } from '@atproto/api' 8 8 import normalizeUrl from 'normalize-url' 9 9 import {isObj, hasProp} from 'lib/type-guards' 10 + import {networkRetry} from 'lib/async/retry' 10 11 import {z} from 'zod' 11 12 import {RootStoreModel} from './root-store' 12 13 ··· 35 36 } 36 37 37 38 export class SessionModel { 39 + // DEBUG 40 + // emergency log facility to help us track down this logout issue 41 + // remove when resolved 42 + // -prf 43 + private _log(message: string, details?: Record<string, any>) { 44 + details = details || {} 45 + details.state = { 46 + data: this.data, 47 + accounts: this.accounts.map( 48 + a => 49 + `${!!a.accessJwt && !!a.refreshJwt ? '✅' : '❌'} ${a.handle} (${ 50 + a.service 51 + })`, 52 + ), 53 + isResumingSession: this.isResumingSession, 54 + } 55 + this.rootStore.log.debug(message, details) 56 + } 57 + 38 58 /** 39 59 * Currently-active session 40 60 */ ··· 115 135 async attemptSessionResumption() { 116 136 const sess = this.currentSession 117 137 if (sess) { 118 - this.rootStore.log.debug( 119 - 'SessionModel:attemptSessionResumption found stored session', 120 - ) 138 + this._log('SessionModel:attemptSessionResumption found stored session') 121 139 this.isResumingSession = true 122 140 try { 123 141 return await this.resumeSession(sess) ··· 127 145 }) 128 146 } 129 147 } else { 130 - this.rootStore.log.debug( 148 + this._log( 131 149 'SessionModel:attemptSessionResumption has no session to resume', 132 150 ) 133 151 } ··· 137 155 * Sets the active session 138 156 */ 139 157 setActiveSession(agent: AtpAgent, did: string) { 140 - this.rootStore.log.debug('SessionModel:setActiveSession') 158 + this._log('SessionModel:setActiveSession') 141 159 this.data = { 142 160 service: agent.service.toString(), 143 161 did, ··· 155 173 session?: AtpSessionData, 156 174 addedInfo?: AdditionalAccountData, 157 175 ) { 158 - this.rootStore.log.debug('SessionModel:persistSession', { 176 + this._log('SessionModel:persistSession', { 159 177 service, 160 178 did, 161 179 event, 162 180 hasSession: !!session, 163 181 }) 164 182 165 - // upsert the account in our listing 166 183 const existingAccount = this.accounts.find( 167 184 account => account.service === service && account.did === did, 168 185 ) 186 + 187 + // fall back to any pre-existing access tokens 188 + let refreshJwt = session?.refreshJwt || existingAccount?.refreshJwt 189 + let accessJwt = session?.accessJwt || existingAccount?.accessJwt 190 + if (event === 'expired') { 191 + // only clear the tokens when they're known to have expired 192 + refreshJwt = undefined 193 + accessJwt = undefined 194 + } 195 + 169 196 const newAccount = { 170 197 service, 171 198 did, 172 - refreshJwt: session?.refreshJwt, 173 - accessJwt: session?.accessJwt, 199 + refreshJwt, 200 + accessJwt, 201 + 174 202 handle: session?.handle || existingAccount?.handle || '', 175 203 displayName: addedInfo 176 204 ? addedInfo.displayName ··· 198 226 * Clears any session tokens from the accounts; used on logout. 199 227 */ 200 228 private clearSessionTokens() { 201 - this.rootStore.log.debug('SessionModel:clearSessionTokens') 229 + this._log('SessionModel:clearSessionTokens') 202 230 this.accounts = this.accounts.map(acct => ({ 203 231 service: acct.service, 204 232 handle: acct.handle, ··· 236 264 * Attempt to resume a session that we still have access tokens for. 237 265 */ 238 266 async resumeSession(account: AccountData): Promise<boolean> { 239 - this.rootStore.log.debug('SessionModel:resumeSession') 267 + this._log('SessionModel:resumeSession') 240 268 if (!(account.accessJwt && account.refreshJwt && account.service)) { 241 - this.rootStore.log.debug( 269 + this._log( 242 270 'SessionModel:resumeSession aborted due to lack of access tokens', 243 271 ) 244 272 return false ··· 252 280 }) 253 281 254 282 try { 255 - await agent.resumeSession({ 256 - accessJwt: account.accessJwt, 257 - refreshJwt: account.refreshJwt, 258 - did: account.did, 259 - handle: account.handle, 260 - }) 283 + await networkRetry(3, () => 284 + agent.resumeSession({ 285 + accessJwt: account.accessJwt || '', 286 + refreshJwt: account.refreshJwt || '', 287 + did: account.did, 288 + handle: account.handle, 289 + }), 290 + ) 261 291 const addedInfo = await this.loadAccountInfo(agent, account.did) 262 292 this.persistSession( 263 293 account.service, ··· 266 296 agent.session, 267 297 addedInfo, 268 298 ) 269 - this.rootStore.log.debug('SessionModel:resumeSession succeeded') 299 + this._log('SessionModel:resumeSession succeeded') 270 300 } catch (e: any) { 271 - this.rootStore.log.debug('SessionModel:resumeSession failed', { 301 + this._log('SessionModel:resumeSession failed', { 272 302 error: e.toString(), 273 303 }) 274 304 return false ··· 290 320 identifier: string 291 321 password: string 292 322 }) { 293 - this.rootStore.log.debug('SessionModel:login') 323 + this._log('SessionModel:login') 294 324 const agent = new AtpAgent({service}) 295 325 await agent.login({identifier, password}) 296 326 if (!agent.session) { ··· 308 338 ) 309 339 310 340 this.setActiveSession(agent, did) 311 - this.rootStore.log.debug('SessionModel:login succeeded') 341 + this._log('SessionModel:login succeeded') 312 342 } 313 343 314 344 async createAccount({ ··· 324 354 handle: string 325 355 inviteCode?: string 326 356 }) { 327 - this.rootStore.log.debug('SessionModel:createAccount') 357 + this._log('SessionModel:createAccount') 328 358 const agent = new AtpAgent({service}) 329 359 await agent.createAccount({ 330 360 handle, ··· 348 378 349 379 this.setActiveSession(agent, did) 350 380 this.rootStore.shell.setOnboarding(true) 351 - this.rootStore.log.debug('SessionModel:createAccount succeeded') 381 + this._log('SessionModel:createAccount succeeded') 352 382 } 353 383 354 384 /** 355 385 * Close all sessions across all accounts. 356 386 */ 357 387 async logout() { 358 - this.rootStore.log.debug('SessionModel:logout') 388 + this._log('SessionModel:logout') 359 389 // TODO 360 390 // need to evaluate why deleting the session has caused errors at times 361 391 // -prf
+10 -1
src/view/com/auth/withAuthRequired.tsx
··· 22 22 23 23 function Loading() { 24 24 const pal = usePalette('default') 25 + 26 + const [isTakingTooLong, setIsTakingTooLong] = React.useState(false) 27 + React.useEffect(() => { 28 + const t = setTimeout(() => setIsTakingTooLong(true), 15e3) 29 + return () => clearTimeout(t) 30 + }, [setIsTakingTooLong]) 31 + 25 32 return ( 26 33 <View style={[styles.loading, pal.view]}> 27 34 <ActivityIndicator size="large" /> 28 35 <Text type="2xl" style={[styles.loadingText, pal.textLight]}> 29 - Firing up the grill... 36 + {isTakingTooLong 37 + ? "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..." 38 + : 'Connecting...'} 30 39 </Text> 31 40 </View> 32 41 )