Skip to content

Commit 1417242

Browse files
committed
fix(app): handle iOS backgrounding - resync session and reconnect SSE on resume
- Add visibilitychange listener in session.tsx to resync when tab becomes visible - Refactor global-sdk.tsx SSE consumption with auto-reconnect on clean disconnect - Use exponential backoff (1s-30s) and pause reconnects while hidden - Add visibility tracking utility to detect recent backgrounding - Gate error toasts in prompt-input.tsx when failure likely due to backgrounding - Check if message landed before removing optimistic UI on resume Fixes anomalyco#10721
1 parent ec72014 commit 1417242

File tree

5 files changed

+341
-27
lines changed

5 files changed

+341
-27
lines changed

packages/app/src/components/prompt-input.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/
5858
import { Binary } from "@opencode-ai/util/binary"
5959
import { showToast } from "@opencode-ai/ui/toast"
6060
import { base64Encode } from "@opencode-ai/util/encode"
61+
import { wasRecentlyBackgrounded } from "@/utils/visibility"
6162

6263
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
6364
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -1258,10 +1259,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
12581259
command: text,
12591260
})
12601261
.catch((err) => {
1261-
showToast({
1262-
title: language.t("prompt.toast.shellSendFailed.title"),
1263-
description: errorMessage(err),
1264-
})
1262+
// Suppress toast if failure likely due to iOS backgrounding
1263+
if (!wasRecentlyBackgrounded()) {
1264+
showToast({
1265+
title: language.t("prompt.toast.shellSendFailed.title"),
1266+
description: errorMessage(err),
1267+
})
1268+
}
12651269
restoreInput()
12661270
})
12671271
return
@@ -1290,10 +1294,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
12901294
})),
12911295
})
12921296
.catch((err) => {
1293-
showToast({
1294-
title: language.t("prompt.toast.commandSendFailed.title"),
1295-
description: errorMessage(err),
1296-
})
1297+
// Suppress toast if failure likely due to iOS backgrounding
1298+
if (!wasRecentlyBackgrounded()) {
1299+
showToast({
1300+
title: language.t("prompt.toast.commandSendFailed.title"),
1301+
description: errorMessage(err),
1302+
})
1303+
}
12971304
restoreInput()
12981305
})
12991306
return
@@ -1591,11 +1598,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
15911598
})
15921599
}
15931600

1594-
void send().catch((err) => {
1601+
void send().catch(async (err) => {
15951602
pending.delete(session.id)
15961603
if (sessionDirectory === projectDirectory) {
15971604
sync.set("session_status", session.id, { type: "idle" })
15981605
}
1606+
// If we just resumed from background, the request may have actually succeeded
1607+
// Resync to check before showing error and removing optimistic message
1608+
if (wasRecentlyBackgrounded()) {
1609+
await sync.session.sync(session.id).catch(() => {})
1610+
// Check if message actually landed
1611+
const messages = sync.data.message[session.id] ?? []
1612+
const found = messages.find((m) => m.id === messageID)
1613+
if (found) return // Message was delivered, don't show error
1614+
}
15991615
showToast({
16001616
title: language.t("prompt.toast.promptSendFailed.title"),
16011617
description: errorMessage(err),

packages/app/src/context/global-sdk.tsx

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -67,28 +67,83 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
6767
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
6868
}
6969

70-
void (async () => {
71-
const events = await eventSdk.global.event()
72-
let yielded = Date.now()
73-
for await (const event of events.stream) {
74-
const directory = event.directory ?? "global"
75-
const payload = event.payload
76-
const k = key(directory, payload)
77-
if (k) {
78-
const i = coalesced.get(k)
79-
if (i !== undefined) {
80-
queue[i] = undefined
70+
// SSE reconnection with exponential backoff (fixes iOS backgrounding)
71+
const connectSSE = async () => {
72+
const BASE_DELAY = 1000
73+
const MAX_DELAY = 30000
74+
let attempt = 0
75+
76+
while (!abort.signal.aborted) {
77+
try {
78+
const events = await eventSdk.global.event()
79+
attempt = 0 // Reset on successful connection
80+
81+
let yielded = Date.now()
82+
for await (const event of events.stream) {
83+
const directory = event.directory ?? "global"
84+
const payload = event.payload
85+
const k = key(directory, payload)
86+
if (k) {
87+
const i = coalesced.get(k)
88+
if (i !== undefined) {
89+
queue[i] = undefined
90+
}
91+
coalesced.set(k, queue.length)
92+
}
93+
queue.push({ directory, payload })
94+
schedule()
95+
96+
if (Date.now() - yielded < 8) continue
97+
yielded = Date.now()
98+
await new Promise<void>((resolve) => setTimeout(resolve, 0))
8199
}
82-
coalesced.set(k, queue.length)
100+
101+
// Stream ended cleanly (e.g., iOS backgrounding) - reconnect
102+
if (abort.signal.aborted) break
103+
} catch {
104+
// Connection error - will retry with backoff
105+
if (abort.signal.aborted) break
83106
}
84-
queue.push({ directory, payload })
85-
schedule()
86107

87-
if (Date.now() - yielded < 8) continue
88-
yielded = Date.now()
89-
await new Promise<void>((resolve) => setTimeout(resolve, 0))
108+
// Exponential backoff before reconnecting
109+
attempt++
110+
const delay = Math.min(BASE_DELAY * 2 ** (attempt - 1), MAX_DELAY)
111+
112+
// Wait for delay, but also listen for abort
113+
await new Promise<void>((resolve) => {
114+
const timeout = setTimeout(resolve, delay)
115+
const cleanup = () => {
116+
clearTimeout(timeout)
117+
resolve()
118+
}
119+
abort.signal.addEventListener("abort", cleanup, { once: true })
120+
})
121+
122+
// If hidden, wait until visible before reconnecting (saves resources)
123+
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
124+
await new Promise<void>((resolve) => {
125+
const handler = () => {
126+
if (document.visibilityState === "visible") {
127+
document.removeEventListener("visibilitychange", handler)
128+
resolve()
129+
}
130+
}
131+
document.addEventListener("visibilitychange", handler)
132+
// Also resolve if aborted
133+
abort.signal.addEventListener(
134+
"abort",
135+
() => {
136+
document.removeEventListener("visibilitychange", handler)
137+
resolve()
138+
},
139+
{ once: true },
140+
)
141+
})
142+
}
90143
}
91-
})()
144+
}
145+
146+
void connectSSE()
92147
.finally(flush)
93148
.catch(() => undefined)
94149

packages/app/src/pages/session.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,25 @@ export default function Page() {
551551
sync.session.sync(params.id)
552552
})
553553

554+
// Resync session when tab becomes visible after being backgrounded (iOS fix)
555+
onMount(() => {
556+
let lastHidden = 0
557+
const handleVisibility = () => {
558+
if (document.visibilityState === "hidden") {
559+
lastHidden = Date.now()
560+
return
561+
}
562+
// Only resync if we were hidden for more than 1 second
563+
const wasHiddenLongEnough = lastHidden > 0 && Date.now() - lastHidden > 1000
564+
if (!wasHiddenLongEnough) return
565+
const sessionID = params.id
566+
if (!sessionID) return
567+
sync.session.sync(sessionID)
568+
}
569+
document.addEventListener("visibilitychange", handleVisibility)
570+
onCleanup(() => document.removeEventListener("visibilitychange", handleVisibility))
571+
})
572+
554573
createEffect(() => {
555574
if (!view().terminal.opened()) {
556575
setUi("autoCreated", false)
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
2+
3+
// Test the visibility tracking logic by simulating visibilitychange events
4+
// The module registers a listener on document.visibilitychange
5+
6+
describe("visibility module", () => {
7+
describe("initial state", () => {
8+
test("wasRecentlyBackgrounded returns false when never hidden", async () => {
9+
const { wasRecentlyBackgrounded } = await import("./visibility")
10+
expect(wasRecentlyBackgrounded()).toBe(false)
11+
})
12+
13+
test("getLastHiddenAt returns 0 when never hidden", async () => {
14+
const { getLastHiddenAt } = await import("./visibility")
15+
expect(getLastHiddenAt()).toBe(0)
16+
})
17+
18+
test("isHidden returns false when document is visible", async () => {
19+
const { isHidden } = await import("./visibility")
20+
// In test environment, document.visibilityState is "visible" by default
21+
expect(isHidden()).toBe(false)
22+
})
23+
})
24+
25+
describe("visibilitychange event handling", () => {
26+
test("responds to visibilitychange events", async () => {
27+
const { getLastHiddenAt, wasRecentlyBackgrounded } = await import("./visibility")
28+
29+
// Simulate hiding the page
30+
Object.defineProperty(document, "visibilityState", {
31+
value: "hidden",
32+
writable: true,
33+
configurable: true,
34+
})
35+
document.dispatchEvent(new Event("visibilitychange"))
36+
37+
const hiddenAt = getLastHiddenAt()
38+
expect(hiddenAt).toBeGreaterThan(0)
39+
40+
// Small delay then simulate showing
41+
await new Promise((r) => setTimeout(r, 50))
42+
43+
Object.defineProperty(document, "visibilityState", {
44+
value: "visible",
45+
writable: true,
46+
configurable: true,
47+
})
48+
document.dispatchEvent(new Event("visibilitychange"))
49+
50+
// Should now be "recently backgrounded" since we just came back
51+
expect(wasRecentlyBackgrounded(5000)).toBe(true)
52+
expect(wasRecentlyBackgrounded(10)).toBe(true) // Very short threshold
53+
})
54+
55+
test("wasRecentlyBackgrounded respects threshold", async () => {
56+
const { wasRecentlyBackgrounded } = await import("./visibility")
57+
58+
// Simulate hide
59+
Object.defineProperty(document, "visibilityState", {
60+
value: "hidden",
61+
writable: true,
62+
configurable: true,
63+
})
64+
document.dispatchEvent(new Event("visibilitychange"))
65+
66+
// Wait longer than a small threshold
67+
await new Promise((r) => setTimeout(r, 100))
68+
69+
// Simulate show
70+
Object.defineProperty(document, "visibilityState", {
71+
value: "visible",
72+
writable: true,
73+
configurable: true,
74+
})
75+
document.dispatchEvent(new Event("visibilitychange"))
76+
77+
// With 5000ms threshold, should still be true (we're within 5s of becoming visible)
78+
expect(wasRecentlyBackgrounded(5000)).toBe(true)
79+
80+
// Wait past the threshold
81+
await new Promise((r) => setTimeout(r, 150))
82+
83+
// With 100ms threshold, should now be false (more than 100ms since visible)
84+
expect(wasRecentlyBackgrounded(100)).toBe(false)
85+
})
86+
})
87+
88+
describe("isHidden reflects document state", () => {
89+
test("returns true when document is hidden", async () => {
90+
const { isHidden } = await import("./visibility")
91+
92+
Object.defineProperty(document, "visibilityState", {
93+
value: "hidden",
94+
writable: true,
95+
configurable: true,
96+
})
97+
98+
expect(isHidden()).toBe(true)
99+
})
100+
101+
test("returns false when document is visible", async () => {
102+
const { isHidden } = await import("./visibility")
103+
104+
Object.defineProperty(document, "visibilityState", {
105+
value: "visible",
106+
writable: true,
107+
configurable: true,
108+
})
109+
110+
expect(isHidden()).toBe(false)
111+
})
112+
})
113+
})
114+
115+
describe("iOS backgrounding simulation", () => {
116+
test("simulates iOS Safari background/foreground cycle", async () => {
117+
const { wasRecentlyBackgrounded, getLastHiddenAt, isHidden } = await import("./visibility")
118+
119+
// Initial state - app is visible
120+
Object.defineProperty(document, "visibilityState", {
121+
value: "visible",
122+
writable: true,
123+
configurable: true,
124+
})
125+
126+
// User switches to another app (iOS backgrounds Safari)
127+
Object.defineProperty(document, "visibilityState", {
128+
value: "hidden",
129+
writable: true,
130+
configurable: true,
131+
})
132+
document.dispatchEvent(new Event("visibilitychange"))
133+
134+
expect(isHidden()).toBe(true)
135+
const hiddenTimestamp = getLastHiddenAt()
136+
expect(hiddenTimestamp).toBeGreaterThan(0)
137+
138+
// Simulate iOS memory pressure / time passing (30 seconds)
139+
// In real iOS, the network connections would be terminated here
140+
await new Promise((r) => setTimeout(r, 50)) // Shortened for test speed
141+
142+
// User returns to Safari
143+
Object.defineProperty(document, "visibilityState", {
144+
value: "visible",
145+
writable: true,
146+
configurable: true,
147+
})
148+
document.dispatchEvent(new Event("visibilitychange"))
149+
150+
expect(isHidden()).toBe(false)
151+
expect(wasRecentlyBackgrounded(5000)).toBe(true)
152+
153+
// The hidden timestamp should be unchanged
154+
expect(getLastHiddenAt()).toBe(hiddenTimestamp)
155+
})
156+
157+
test("error suppression logic - recently backgrounded", async () => {
158+
const { wasRecentlyBackgrounded } = await import("./visibility")
159+
160+
// Simulate background then foreground
161+
Object.defineProperty(document, "visibilityState", {
162+
value: "hidden",
163+
writable: true,
164+
configurable: true,
165+
})
166+
document.dispatchEvent(new Event("visibilitychange"))
167+
168+
await new Promise((r) => setTimeout(r, 20))
169+
170+
Object.defineProperty(document, "visibilityState", {
171+
value: "visible",
172+
writable: true,
173+
configurable: true,
174+
})
175+
document.dispatchEvent(new Event("visibilitychange"))
176+
177+
// Simulate error occurring right after resume
178+
// This is when iOS backgrounding typically causes network errors
179+
const shouldSuppressError = wasRecentlyBackgrounded(5000)
180+
expect(shouldSuppressError).toBe(true)
181+
})
182+
})
183+
184+
describe("exports", () => {
185+
test("all exports are functions", async () => {
186+
const visibility = await import("./visibility")
187+
188+
expect(typeof visibility.wasRecentlyBackgrounded).toBe("function")
189+
expect(typeof visibility.getLastHiddenAt).toBe("function")
190+
expect(typeof visibility.isHidden).toBe("function")
191+
})
192+
})

0 commit comments

Comments
 (0)