fix(mobile): drain expo fetch requests before reloading the runtime

Signing out (and the other runtime reloads) crashed the app with SIGSEGV on
the expo.modules.fetch.RequestQueue thread. Expo's native fetch keeps a
closure that owns the JS promise until the response reaches a terminal state;
reloading while a request was pending destroyed that closure against the
torn-down runtime. Sign-out always had requests pending: clearing the cookie
refetches the session queries and popping to the root loads the guest timeline.

Expo also installs its fetch as the global `fetch`, so every request in the app
goes through it. `network-activity.ts` wraps the global fetch (from the
polyfill entry) and the explicit `expo/fetch` imports, tracks each request
until the native response reports `readyForJSFinalization`, and can abort them
all. `reloadApp` cancels the queries, aborts the in-flight requests, waits for
the network to go idle (three seconds at most) and only then reloads. Every
`reloadAppAsync` call site goes through it.

Verified with the Maestro iOS auth journey against production: register, sign
out and log in pass with no crash report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
DIYgod
2026-09-18 19:37:27 +08:00
parent 1ded9fd2b0
commit 7ecbc018e4
14 changed files with 467 additions and 11 deletions
+1 -1
View File
@@ -54,7 +54,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>13</string>
<string>14</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
+6 -2
View File
@@ -13,13 +13,17 @@ import { getAuthStateRevision, getCookie, getLastAuthStateChangeAt } from "./aut
import { getClientId, getSessionId } from "./client-session"
import { getUserAgent } from "./native/user-agent"
import { destination } from "./navigation/biz/Destination"
import { trackFetch } from "./network-activity"
import { proxyEnv } from "./proxy-env"
// Tracked so a runtime reload can wait for the requests to settle first (see `reload-app.ts`).
const trackedFetch = trackFetch(fetch)
export const followClient = new FollowClient({
credentials: "omit",
timeout: 60_000,
baseURL: proxyEnv.API_URL,
fetch: async (input, options = {}) => fetch(input.toString(), options as any) as any,
fetch: async (input, options = {}) => trackedFetch(input.toString(), options as any) as any,
})
export const followApi = followClient.api
@@ -127,7 +131,7 @@ followClient.addResponseInterceptor(async (ctx) => {
/** Whether the API answers at all. Any HTTP status counts; the endpoint needs no session. */
const probeApiReachability = async () => {
await fetch(`${proxyEnv.API_URL}/status/configs`, {
await trackedFetch(`${proxyEnv.API_URL}/status/configs`, {
signal: AbortSignal.timeout(10_000),
})
return true
+2 -1
View File
@@ -25,6 +25,7 @@ import { getUserAgent } from "./native/user-agent"
import { Navigation } from "./navigation/Navigation"
import { getEnvProfile, proxyEnv } from "./proxy-env"
import { queryClient } from "./query-client"
import { reloadApp } from "./reload-app"
import { safeSecureStore } from "./secure-store"
const storagePrefix = "follow_auth"
@@ -253,7 +254,7 @@ export const signOut = async () => {
await refreshSessionQueries()
const dbPath = getDbPath()
await FileSystem.deleteAsync(dbPath, { idempotent: true })
await expo.reloadAppAsync("User sign out")
await reloadApp("User sign out")
}
export const deleteUser = async ({ TOTPCode }: { TOTPCode?: string }) => {
@@ -0,0 +1,190 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import {
abortInFlightRequests,
getInFlightRequestCount,
installGlobalFetchTracking,
trackFetch,
waitForNetworkIdle,
} from "./network-activity"
type Deferred = {
promise: Promise<string>
resolve: (value: string) => void
reject: (error: unknown) => void
}
const createDeferred = (): Deferred => {
let resolve!: Deferred["resolve"]
let reject!: Deferred["reject"]
const promise = new Promise<string>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
/** A fetch stand-in that settles only when told to, and rejects when its signal aborts. */
const createFakeFetch = () => {
const calls: { signal: AbortSignal; deferred: Deferred }[] = []
const fetchImpl = (_input: string, init?: { signal?: AbortSignal | null }) => {
const deferred = createDeferred()
const signal = init!.signal!
if (signal.aborted) {
deferred.reject(new Error("aborted"))
} else {
signal.addEventListener("abort", () => deferred.reject(new Error("aborted")), {
once: true,
})
}
calls.push({ signal, deferred })
return deferred.promise
}
return { calls, fetchImpl }
}
afterEach(() => {
abortInFlightRequests()
vi.useRealTimers()
})
describe("trackFetch", () => {
it("counts a request while it is in flight and settles the idle wait afterwards", async () => {
const { calls, fetchImpl } = createFakeFetch()
const tracked = trackFetch(fetchImpl)
const request = tracked("https://example.test/a")
expect(getInFlightRequestCount()).toBe(1)
const idle = waitForNetworkIdle(1_000)
calls[0]!.deferred.resolve("ok")
await expect(request).resolves.toBe("ok")
await expect(idle).resolves.toBe(true)
expect(getInFlightRequestCount()).toBe(0)
})
it("forwards an abort from the caller's signal", async () => {
const { calls, fetchImpl } = createFakeFetch()
const tracked = trackFetch(fetchImpl)
const outer = new AbortController()
const request = tracked("https://example.test/b", { signal: outer.signal })
outer.abort()
expect(calls[0]!.signal.aborted).toBe(true)
await expect(request).rejects.toThrow("aborted")
expect(getInFlightRequestCount()).toBe(0)
})
it("hands an already aborted signal to the fetch implementation", async () => {
const { calls, fetchImpl } = createFakeFetch()
const tracked = trackFetch(fetchImpl)
const outer = new AbortController()
outer.abort()
const request = tracked("https://example.test/c", { signal: outer.signal })
expect(calls[0]!.signal.aborted).toBe(true)
await expect(request).rejects.toThrow("aborted")
expect(getInFlightRequestCount()).toBe(0)
})
})
describe("trackFetch with a finalizable response", () => {
const createFakeResponse = () => {
const listeners: (() => void)[] = []
return {
response: {
addListener: (_event: string, listener: () => void) => {
listeners.push(listener)
return {
remove: () => {
listeners.splice(listeners.indexOf(listener), 1)
},
}
},
},
finalize: () => {
for (const listener of [...listeners]) listener()
},
listenerCount: () => listeners.length,
}
}
it("keeps the request in flight until the response reports it is finalized", async () => {
const fake = createFakeResponse()
const tracked = trackFetch(async (_input: string) => fake.response)
const response = await tracked("https://example.test/body")
expect(response).toBe(fake.response)
expect(getInFlightRequestCount()).toBe(1)
expect(fake.listenerCount()).toBe(1)
const idle = waitForNetworkIdle(1_000)
fake.finalize()
await expect(idle).resolves.toBe(true)
expect(getInFlightRequestCount()).toBe(0)
expect(fake.listenerCount()).toBe(0)
})
})
describe("installGlobalFetchTracking", () => {
it("wraps the global fetch once and keeps tracking through it", async () => {
const originalFetch = globalThis.fetch
const { calls, fetchImpl } = createFakeFetch()
globalThis.fetch = fetchImpl as unknown as typeof fetch
try {
installGlobalFetchTracking()
const wrapped = globalThis.fetch
installGlobalFetchTracking()
expect(globalThis.fetch).toBe(wrapped)
const request = globalThis.fetch("https://example.test/global")
expect(getInFlightRequestCount()).toBe(1)
calls[0]!.deferred.resolve("ok")
await expect(request).resolves.toBe("ok")
expect(getInFlightRequestCount()).toBe(0)
} finally {
globalThis.fetch = originalFetch
}
})
})
describe("abortInFlightRequests", () => {
it("aborts every tracked request so the network goes idle", async () => {
const { fetchImpl } = createFakeFetch()
const tracked = trackFetch(fetchImpl)
const first = tracked("https://example.test/1")
const second = tracked("https://example.test/2")
expect(getInFlightRequestCount()).toBe(2)
const idle = waitForNetworkIdle(1_000)
abortInFlightRequests()
await expect(first).rejects.toThrow("aborted")
await expect(second).rejects.toThrow("aborted")
await expect(idle).resolves.toBe(true)
expect(getInFlightRequestCount()).toBe(0)
})
})
describe("waitForNetworkIdle", () => {
it("resolves immediately when nothing is in flight", async () => {
expect(getInFlightRequestCount()).toBe(0)
await expect(waitForNetworkIdle(1_000)).resolves.toBe(true)
})
it("gives up after the timeout when a request does not settle in time", async () => {
vi.useFakeTimers()
const deferred = createDeferred()
const tracked = trackFetch(() => deferred.promise)
const request = tracked("https://example.test/slow")
const idle = waitForNetworkIdle(500)
await vi.advanceTimersByTimeAsync(500)
await expect(idle).resolves.toBe(false)
deferred.resolve("late")
await expect(request).resolves.toBe("late")
expect(getInFlightRequestCount()).toBe(0)
})
})
+145
View File
@@ -0,0 +1,145 @@
/**
* Tracks the `expo/fetch` requests that are in flight so the app can settle them before it
* reloads the JS runtime (see `reload-app.ts`).
*
* Expo's native fetch parks a closure that owns the JS promise until the response reaches a
* terminal state. When the runtime is torn down first, that closure is destroyed against the
* dead runtime on the fetch queue and the app crashes with SIGSEGV. Aborting a request makes the
* native side settle it right away, while the runtime is still alive.
*
* Expo installs its fetch as the global `fetch` as well, so `installGlobalFetchTracking` wraps
* that one too; the API client and the TTS service wrap their explicit `expo/fetch` import.
*/
type FetchInit = { signal?: AbortSignal | null } | undefined
/** The shape of an `expo/fetch` response we rely on: an event emitter that says when it is done. */
type FinalizableResponse = {
addListener?: (eventName: string, listener: () => void) => { remove: () => void }
}
/** Emitted by the native response once the task finished, failed or was cancelled. */
const FINALIZATION_EVENT = "readyForJSFinalization"
const inFlight = new Set<AbortController>()
const idleListeners = new Set<() => void>()
const notifyIdle = () => {
if (inFlight.size > 0) {
return
}
const listeners = [...idleListeners]
idleListeners.clear()
for (const listener of listeners) {
listener()
}
}
const release = (controller: AbortController) => {
if (inFlight.delete(controller)) {
notifyIdle()
}
}
/**
* The fetch promise settles once the headers arrive, but the native task, and the closure it
* holds, live on until the body finished. Keep the request in flight until the response says it
* is done; responses without that signal count as done right away.
*/
const releaseWhenFinalized = (controller: AbortController, response: unknown) => {
const emitter = response as FinalizableResponse | null
if (!emitter || typeof emitter.addListener !== "function") {
release(controller)
return
}
try {
const subscription = emitter.addListener(FINALIZATION_EVENT, () => {
subscription.remove()
release(controller)
})
} catch {
release(controller)
}
}
/**
* Wrap a fetch implementation so every request is tracked and can be aborted from
* `abortInFlightRequests`. A signal passed by the caller keeps working.
*/
export const trackFetch = <TInput, TInit extends FetchInit, TResponse>(
fetchImpl: (input: TInput, init?: TInit) => Promise<TResponse>,
): ((input: TInput, init?: TInit) => Promise<TResponse>) => {
return async (input, init) => {
const controller = new AbortController()
const outerSignal = init?.signal ?? null
const forwardAbort = () => controller.abort(outerSignal?.reason)
if (outerSignal?.aborted) {
forwardAbort()
} else {
outerSignal?.addEventListener("abort", forwardAbort, { once: true })
}
inFlight.add(controller)
let response: TResponse
try {
response = await fetchImpl(input, { ...init, signal: controller.signal } as TInit)
} catch (error) {
outerSignal?.removeEventListener("abort", forwardAbort)
release(controller)
throw error
}
outerSignal?.removeEventListener("abort", forwardAbort)
releaseWhenFinalized(controller, response)
return response
}
}
let globalFetchTracked = false
/** Replace the global `fetch` (Expo's own) with the tracked wrapper. Safe to call more than once. */
export const installGlobalFetchTracking = () => {
if (globalFetchTracked || typeof globalThis.fetch !== "function") {
return
}
const tracked = trackFetch(globalThis.fetch)
Object.defineProperty(globalThis, "fetch", {
value: tracked,
configurable: true,
enumerable: true,
writable: true,
})
globalFetchTracked = true
}
export const getInFlightRequestCount = () => inFlight.size
export const abortInFlightRequests = (reason?: unknown) => {
for (const controller of inFlight) {
controller.abort(reason)
}
}
/**
* Resolve once no tracked request is in flight, or after `timeoutMs`. Resolves with whether the
* network went idle in time.
*/
export const waitForNetworkIdle = (timeoutMs: number) =>
new Promise<boolean>((resolve) => {
if (inFlight.size === 0) {
resolve(true)
return
}
const onIdle = () => {
clearTimeout(timer)
resolve(true)
}
const timer = setTimeout(() => {
idleListeners.delete(onIdle)
resolve(false)
}, timeoutMs)
idleListeners.add(onIdle)
})
+2 -2
View File
@@ -1,13 +1,13 @@
import type { env, envProfileMap } from "@follow/shared/env.rn"
import { getEnvProfiles__dangerously } from "@follow/shared/env.rn"
import { createAtomHooks } from "@follow/utils"
import { reloadAppAsync } from "expo"
import { atomWithStorage } from "jotai/utils"
import type { SyncStorage } from "jotai/vanilla/utils/atomWithStorage"
import { cookieKey } from "./auth"
import { getE2EEnvProfile } from "./e2e-config"
import { JotaiPersistSyncStorage } from "./jotai"
import { reloadApp } from "./reload-app"
import { safeSecureStore } from "./secure-store"
const getForcedEnvProfile = (): keyof typeof envProfileMap | null => {
@@ -67,6 +67,6 @@ export const setEnvProfile = (profile: keyof typeof envProfileMap) => {
console.warn("SecureStore access failed during env profile switch:", e)
}
reloadAppAsync()
void reloadApp("Switch env profile")
}
export { getEnvProfile, useEnvProfile }
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { abortInFlightRequests, getInFlightRequestCount, trackFetch } from "./network-activity"
import { reloadApp } from "./reload-app"
const mocks = vi.hoisted(() => ({
reloadAppAsync: vi.fn(async () => {}),
cancelQueries: vi.fn(async () => {}),
}))
vi.mock("expo", () => ({ reloadAppAsync: mocks.reloadAppAsync }))
vi.mock("./query-client", () => ({ queryClient: { cancelQueries: mocks.cancelQueries } }))
beforeEach(() => {
vi.useFakeTimers()
mocks.reloadAppAsync.mockClear()
mocks.cancelQueries.mockClear()
})
afterEach(() => {
abortInFlightRequests()
vi.useRealTimers()
})
describe("reloadApp", () => {
it("cancels queries, aborts in-flight requests and reloads once they settled", async () => {
let aborted = false
const tracked = trackFetch(
(_input: string, init?: { signal?: AbortSignal | null }) =>
new Promise<string>((_, reject) => {
init?.signal?.addEventListener("abort", () => {
aborted = true
reject(new Error("aborted"))
})
}),
)
const pending = tracked("https://example.test/pending").catch(() => "aborted")
const reload = reloadApp("test")
await vi.advanceTimersByTimeAsync(0)
expect(mocks.cancelQueries).toHaveBeenCalledTimes(1)
expect(aborted).toBe(true)
await expect(pending).resolves.toBe("aborted")
expect(mocks.reloadAppAsync).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
await reload
expect(mocks.reloadAppAsync).toHaveBeenCalledWith("test")
expect(getInFlightRequestCount()).toBe(0)
})
it("still reloads after the drain timeout when a request ignores the abort", async () => {
let finish!: (value: string) => void
const tracked = trackFetch(
() =>
new Promise<string>((resolve) => {
finish = resolve
}),
)
const stubborn = tracked("https://example.test/stubborn")
const reload = reloadApp("stuck")
await vi.advanceTimersByTimeAsync(3_000)
expect(mocks.reloadAppAsync).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
await reload
expect(mocks.reloadAppAsync).toHaveBeenCalledWith("stuck")
finish("late")
await expect(stubborn).resolves.toBe("late")
expect(getInFlightRequestCount()).toBe(0)
})
it("reloads right away when the network is already idle", async () => {
expect(getInFlightRequestCount()).toBe(0)
const reload = reloadApp("idle")
await vi.advanceTimersByTimeAsync(150)
await reload
expect(mocks.reloadAppAsync).toHaveBeenCalledWith("idle")
})
})
+23
View File
@@ -0,0 +1,23 @@
import { reloadAppAsync } from "expo"
import { abortInFlightRequests, waitForNetworkIdle } from "./network-activity"
import { queryClient } from "./query-client"
const DRAIN_TIMEOUT_MS = 3_000
const SETTLE_DELAY_MS = 150
/**
* Reload the JS runtime once no `expo/fetch` request is in flight.
*
* Reloading while a request is pending crashes the app: Expo's native fetch keeps a closure that
* owns the JS promise until the response arrives, and destroying it after the runtime is gone
* segfaults on the fetch queue. Cancelling the queries first keeps react-query from retrying,
* aborting the requests makes the native side settle them, and the wait lets that happen.
*/
export const reloadApp = async (reason: string) => {
await queryClient.cancelQueries().catch(() => {})
abortInFlightRequests()
await waitForNetworkIdle(DRAIN_TIMEOUT_MS)
await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS))
await reloadAppAsync(reason)
}
@@ -1,6 +1,8 @@
import { fetch as expoFetch } from "expo/fetch"
import { Directory, File, Paths } from "expo-file-system"
import { trackFetch } from "@/src/lib/network-activity"
import { fetchTtsVoices as fetchTtsVoicesCore, requestTtsBytes } from "./tts-core"
export { DEFAULT_TTS_VOICE, getEntryTtsText, TTS_SERVICE_URL, type TtsVoice } from "./tts-core"
@@ -44,7 +46,7 @@ const createCacheFile = (cacheKey: string): TtsCacheFile => {
const defaultDependencies: TtsDependencies = {
createCacheFile,
fetch: expoFetch,
fetch: trackFetch(expoFetch),
}
export const fetchTtsVoices = async (
@@ -15,6 +15,7 @@ import {
} from "@/src/components/ui/grouped/GroupedList"
import { Switch } from "@/src/components/ui/switch/Switch"
import { getDbPath } from "@/src/database"
import { reloadApp } from "@/src/lib/reload-app"
import { toast } from "@/src/lib/toast"
import { exportLocalDatabase, importOpml } from "../utils"
@@ -80,7 +81,7 @@ export const DataScreen = () => {
onPress: async () => {
const dbPath = getDbPath()
await FileSystem.deleteAsync(dbPath)
await expo.reloadAppAsync("Clear Sqlite Data")
await reloadApp("Clear Sqlite Data")
},
},
],
@@ -0,0 +1,5 @@
import { installGlobalFetchTracking } from "../lib/network-activity"
// Expo's winter runtime (run by Metro before the main module) installs `expo/fetch` as the global
// `fetch`. Wrap it so a runtime reload can wait for every request to settle (see `reload-app.ts`).
installGlobalFetchTracking()
+1
View File
@@ -1,2 +1,3 @@
// import "core-js/proposals/promise-with-resolvers";
import "./promise-with-resolvers"
import "./fetch-tracking"
+2 -1
View File
@@ -8,6 +8,7 @@ import { PlatformActivityIndicator } from "../components/ui/loading/PlatformActi
import { getDbPath } from "../database"
import { BugCuteReIcon } from "../icons/bug_cute_re"
import { useDatabaseMigration } from "../initialize/migration"
import { reloadApp } from "../lib/reload-app"
export const MigrationProvider = ({ children }: { children: ReactNode }) => {
const { success, error } = useDatabaseMigration()
@@ -26,7 +27,7 @@ export const MigrationProvider = ({ children }: { children: ReactNode }) => {
const dbPath = getDbPath()
await deleteAsync(dbPath)
// Reload the app
await expo.reloadAppAsync("Clear Sqlite Data")
await reloadApp("Clear Sqlite Data")
}}
/>
</View>
@@ -29,6 +29,7 @@ import { useNavigation } from "@/src/lib/navigation/hooks"
import { NavigationSitemapRegistry } from "@/src/lib/navigation/sitemap/registry"
import type { NavigationControllerView } from "@/src/lib/navigation/types"
import { setEnvProfile, useEnvProfile } from "@/src/lib/proxy-env"
import { reloadApp } from "@/src/lib/reload-app"
import { toast } from "@/src/lib/toast"
import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog"
import {
@@ -108,7 +109,7 @@ export const DebugScreen: NavigationControllerView = () => {
async onPress() {
const dbPath = getDbPath()
await FileSystem.deleteAsync(dbPath)
await expo.reloadAppAsync("Clear Sqlite Data")
await reloadApp("Clear Sqlite Data")
},
},
])
@@ -121,7 +122,7 @@ export const DebugScreen: NavigationControllerView = () => {
items: [
{
title: "Reload App",
onPress: () => expo.reloadAppAsync("Reload App"),
onPress: () => reloadApp("Reload App"),
},
{
title: "Loading",