Skip to content

Commit 418a44b

Browse files
ericdalloeca-agent
andcommitted
Fix sticky reconnect banner and iOS browser detection
- Resolve `reconnectSSE()` on the first event of any kind on a fresh SSE stream, not only on `session:connected`. The server does not always re-emit `session:connected` on a reconnect for an already- known session, so live data was streaming through `handleSSEEvent` while the promise never resolved and the banner stuck indefinitely. - Make `retryNow()` perform `health()` + `reconnectSSE()` immediately instead of going through the per-attempt `setTimeout`. The button now triggers the work synchronously; on failure it falls back to the scheduled exponential-backoff loop. - Add a `visibilitychange` listener in `SSEClient` that resets the heartbeat timer when the page returns to the foreground. iOS WebKit coalesces fetch-stream chunks while a tab is backgrounded, which was tripping the 35 s heartbeat for backgrounded iPhone sessions even though the underlying TCP connection was healthy. - Drive the reconnect-banner countdown from local state via `setInterval`, so the user sees actual seconds tick down instead of a frozen `1s` value. - Replace `max-width: 100vw` with `max-width: 100%` on `html, body`. iOS Safari computes `100vw` against the layout viewport and can leak overflow during rubber-banding. - Detect iOS first in `detectBrowser()` and return `'safari'` for any iOS browser. Chrome on iOS is a WebKit skin (UA contains both `CriOS/` and `Chrome/`), and was being routed through the Chromium Local Network Access path with a `targetAddressSpace` option that iOS silently ignores. Addresses user reports on iPhone Chrome of: persistent "Connection lost" banner during healthy streaming, unresponsive Retry Now button, and missing Safari-specific mixed-content guidance. Bumps eca-webview submodule for the related mobile chat-overflow and trash-icon fixes. 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca-agent <git@eca.dev>
1 parent bb7fcb5 commit 418a44b

6 files changed

Lines changed: 165 additions & 17 deletions

File tree

src/bridge/sse.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ export class SSEClient {
3838
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
3939
private heartbeatTimeoutMs: number;
4040
private running = false;
41+
/**
42+
* Listener that resets the heartbeat timer when the page returns to the
43+
* foreground. iOS WebKit (and Safari in low-power mode) coalesces fetch
44+
* stream chunks while a tab is backgrounded, which can starve the
45+
* heartbeat for tens of seconds even when the underlying TCP connection
46+
* is healthy. Without this, switching back to the tab on iPhone reliably
47+
* trips a false-positive disconnect.
48+
*/
49+
private visibilityHandler: (() => void) | null = null;
4150

4251
constructor(
4352
url: string,
@@ -81,6 +90,7 @@ export class SSEClient {
8190
}
8291

8392
this.reader = response.body.getReader();
93+
this.installVisibilityHandler();
8494
this.resetHeartbeatTimer();
8595
this.readLoop();
8696
}
@@ -94,12 +104,38 @@ export class SSEClient {
94104
/** Release resources without changing the `running` flag. */
95105
private cleanUp(): void {
96106
this.clearHeartbeatTimer();
107+
this.removeVisibilityHandler();
97108
this.abortController?.abort();
98109
this.reader?.cancel().catch(() => {});
99110
this.reader = null;
100111
this.abortController = null;
101112
}
102113

114+
// ---------------------------------------------------------------------------
115+
// Visibility (iOS background-tab tolerance)
116+
// ---------------------------------------------------------------------------
117+
118+
private installVisibilityHandler(): void {
119+
if (typeof document === 'undefined' || this.visibilityHandler) return;
120+
this.visibilityHandler = () => {
121+
if (this.running && !document.hidden) {
122+
// Page returned to foreground. Reset the heartbeat clock so the
123+
// backlog of throttled chunks can flush without us declaring the
124+
// connection dead. Real disconnects will still trip the timer
125+
// afterward because no chunks will arrive at all.
126+
this.resetHeartbeatTimer();
127+
}
128+
};
129+
document.addEventListener('visibilitychange', this.visibilityHandler);
130+
}
131+
132+
private removeVisibilityHandler(): void {
133+
if (this.visibilityHandler && typeof document !== 'undefined') {
134+
document.removeEventListener('visibilitychange', this.visibilityHandler);
135+
}
136+
this.visibilityHandler = null;
137+
}
138+
103139
// ---------------------------------------------------------------------------
104140
// Heartbeat
105141
// ---------------------------------------------------------------------------

src/bridge/transport.ts

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,10 @@ export class WebBridge {
291291

292292
/**
293293
* Manually trigger a reconnection attempt (e.g. from a "Retry Now" button).
294-
* Resets the attempt counter and starts a fresh reconnection cycle.
294+
*
295+
* Performs health check + SSE reconnect IMMEDIATELY (no per-attempt delay)
296+
* so the user sees instant feedback. If it fails, falls back to the
297+
* scheduled exponential-backoff loop.
295298
*/
296299
retryNow(): void {
297300
if (this.disposed) return;
@@ -302,21 +305,62 @@ export class WebBridge {
302305
this.reconnectTimer = null;
303306
}
304307

305-
this.reconnectAttempt = 0;
308+
this.reconnectAttempt = 1;
306309
this.reconnecting = true;
307-
this.attemptReconnect();
310+
311+
// Show "reconnecting now" with no countdown — we are acting this very tick.
312+
this.notifyReconnection({
313+
status: 'reconnecting',
314+
attempt: this.reconnectAttempt,
315+
retryNow: () => this.retryNow(),
316+
});
317+
318+
void (async () => {
319+
try {
320+
await this.api.health();
321+
if (this.disposed) return;
322+
323+
await this.reconnectSSE();
324+
if (this.disposed) return;
325+
326+
console.log('[Bridge] Reconnected via Retry Now');
327+
this.reconnecting = false;
328+
this.reconnectAttempt = 0;
329+
330+
await this.syncAfterReconnect();
331+
332+
this.notifyReconnection({
333+
status: 'reconnected',
334+
attempt: 0,
335+
});
336+
} catch (err) {
337+
console.warn('[Bridge] Retry Now failed, falling back to scheduled reconnect:', err);
338+
if (!this.disposed) {
339+
// Reset to 0 so attemptReconnect's first scheduled try is attempt #1
340+
this.reconnectAttempt = 0;
341+
this.attemptReconnect();
342+
}
343+
}
344+
})();
308345
}
309346

310347
/**
311348
* Re-open the SSE stream (without the full connect() ceremony).
312349
* Rejects if the handshake times out or the connection fails.
350+
*
351+
* The handshake is considered successful as soon as the FIRST event of any
352+
* kind arrives on the new stream — not just `session:connected`. The server
353+
* does not always re-emit `session:connected` on a fresh /events connection
354+
* for an already-known session; if we waited only for that, live data could
355+
* stream through `handleSSEEvent` (so chat appears to be working) while the
356+
* promise never resolved and the "Connection lost" banner stuck forever.
313357
*/
314358
private reconnectSSE(): Promise<void> {
315359
return new Promise<void>((resolve, reject) => {
316-
const timeout = setTimeout(
317-
() => reject(new Error('SSE reconnect timeout')),
318-
SSE_CONNECT_TIMEOUT_MS,
319-
);
360+
let resolved = false;
361+
const timeout = setTimeout(() => {
362+
if (!resolved) reject(new Error('SSE reconnect timeout'));
363+
}, SSE_CONNECT_TIMEOUT_MS);
320364

321365
// Disconnect old SSE if it still exists
322366
this.sse?.disconnect();
@@ -325,17 +369,23 @@ export class WebBridge {
325369
this.api.sseUrl(),
326370
this.api.authPassword,
327371
(event) => {
328-
if (event.event === 'session:connected' && !this.connected) {
372+
// First event on the new stream — promote to "connected" and resolve.
373+
if (!resolved) {
374+
resolved = true;
329375
clearTimeout(timeout);
330-
this.handleSessionConnected(event);
331376
this.connected = true;
332377
resolve();
378+
}
379+
380+
if (event.event === 'session:connected') {
381+
this.handleSessionConnected(event);
333382
} else {
334383
this.handleSSEEvent(event);
335384
}
336385
},
337386
(error) => {
338-
if (!this.connected) {
387+
if (!resolved) {
388+
resolved = true;
339389
clearTimeout(timeout);
340390
reject(error);
341391
} else {
@@ -354,8 +404,11 @@ export class WebBridge {
354404
);
355405

356406
this.sse.connect().catch((err) => {
357-
clearTimeout(timeout);
358-
reject(err);
407+
if (!resolved) {
408+
resolved = true;
409+
clearTimeout(timeout);
410+
reject(err);
411+
}
359412
});
360413
});
361414
}

src/bridge/utils.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ export type BrowserKind = 'chrome' | 'firefox' | 'safari' | 'other';
1919
*
2020
* Order matters: Chrome's UA also contains "Safari", and many browsers
2121
* (Edge, Opera, Brave) contain "Chrome", which is fine — they all
22-
* inherit Chrome's LNA behaviour.
22+
* inherit Chrome's LNA behaviour. iOS is special-cased FIRST because
23+
* Apple requires every iOS browser (including Chrome — UA "CriOS/...
24+
* Chrome/... Safari/...") to use the WebKit rendering engine. iOS
25+
* Chrome therefore has zero LNA support and must be treated as Safari
26+
* for mixed-content guidance.
2327
*/
2428
export function detectBrowser(): BrowserKind {
29+
if (isIosDevice()) return 'safari';
2530
const ua = navigator.userAgent;
2631
// All Chromium-based browsers (Chrome, Edge, Brave, Opera, Arc…)
2732
// include "Chrome/" in their UA and inherit LNA support.
@@ -32,6 +37,25 @@ export function detectBrowser(): BrowserKind {
3237
return 'other';
3338
}
3439

40+
/**
41+
* True when the current device is an iPhone, iPad, or iPod (regardless of
42+
* which "browser" is running — they all use WebKit on Apple's platform).
43+
*
44+
* Also detects iPadOS 13+ "Desktop Mode", which advertises itself as
45+
* macOS but exposes touch via `navigator.maxTouchPoints > 1` and runs
46+
* on the WebKit engine.
47+
*/
48+
function isIosDevice(): boolean {
49+
if (typeof navigator === 'undefined') return false;
50+
const ua = navigator.userAgent;
51+
if (/iPad|iPhone|iPod/.test(ua)) return true;
52+
// iPadOS 13+ pretends to be macOS in its UA. Touch support + Apple
53+
// vendor string is the official escape hatch.
54+
return navigator.platform === 'MacIntel'
55+
&& typeof navigator.maxTouchPoints === 'number'
56+
&& navigator.maxTouchPoints > 1;
57+
}
58+
3559
/** RFC 1918 private addresses (192.168.x, 10.x, 172.16-31.x). */
3660
const PRIVATE_NETWORK_RE =
3761
/^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i;

src/components/AppLayout.css

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
/* Defensive viewport clip — prevents iOS Safari's horizontal rubber-band
22
* if any descendant briefly overflows the viewport during reflow. The
33
* webview's own overflow rules (pre, code, message wrappers) are the
4-
* primary fix; this is a belt-and-suspenders for the browser shell. */
4+
* primary fix; this is a belt-and-suspenders for the browser shell.
5+
*
6+
* `max-width: 100%` (not `100vw`) on purpose: on iOS Safari `100vw` is
7+
* computed against the layout viewport and excludes the visual-viewport
8+
* inset from rubber-banding, so it can leak a fraction of a pixel of
9+
* overflow on narrow phone screens. `100%` clamps to the actual containing
10+
* block and behaves identically on desktop. */
511
html,
612
body {
7-
max-width: 100vw;
13+
max-width: 100%;
814
overflow-x: hidden;
915
}
1016

src/pages/RemoteSession.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,13 +230,42 @@ function ReconnectionBanner({ state }: { state: ReconnectionState }) {
230230
}
231231

232232
// --- Reconnecting ---
233+
return <ReconnectingBannerBody state={state} />;
234+
}
235+
236+
/**
237+
* Reconnecting banner body — splits out so we can drive a real-time countdown
238+
* via local state. The previous implementation rendered a static seconds value
239+
* from props, so the user saw "1s" frozen for the full backoff window and the
240+
* banner appeared dead even when reconnection was actively progressing.
241+
*/
242+
function ReconnectingBannerBody({ state }: { state: ReconnectionState }) {
243+
const [remainingMs, setRemainingMs] = useState<number>(state.nextRetryMs ?? 0);
244+
245+
useEffect(() => {
246+
if (!state.nextRetryMs || state.nextRetryMs <= 0) {
247+
setRemainingMs(0);
248+
return;
249+
}
250+
const start = Date.now();
251+
const total = state.nextRetryMs;
252+
setRemainingMs(total);
253+
const interval = setInterval(() => {
254+
const remaining = Math.max(0, total - (Date.now() - start));
255+
setRemainingMs(remaining);
256+
if (remaining <= 0) clearInterval(interval);
257+
}, 250);
258+
return () => clearInterval(interval);
259+
// Re-run whenever a new attempt arrives (each schedule emits a fresh nextRetryMs)
260+
}, [state.nextRetryMs, state.attempt]);
261+
233262
return (
234263
<div className="reconnect-banner reconnect-banner--reconnecting">
235264
<div className="reconnect-banner__spinner" />
236265
<span className="reconnect-banner__text">
237266
Connection lost · Reconnecting
238267
{state.attempt > 1 ? ` (attempt ${state.attempt})` : ''}
239-
{state.nextRetryMs ? ` · ${Math.ceil(state.nextRetryMs / 1000)}s` : '…'}
268+
{remainingMs > 0 ? ` · ${Math.ceil(remainingMs / 1000)}s` : '…'}
240269
</span>
241270
{state.retryNow && (
242271
<button className="reconnect-banner__btn" onClick={state.retryNow}>

0 commit comments

Comments
 (0)