Skip to content

Commit 010340f

Browse files
IM.codesclaude
andcommitted
fix: loadingOlder timeout + evidence-based stream retry reset
- useTimeline: 10s timeout on older-history requests — auto-resets loadingOlder if response never arrives (packet loss, disconnect) - useTimeline: reset loadingOlder on WS disconnected event - useTimeline: consolidate older state cleanup into resetOlderState() - ws-client: retryCount now resets only when data actually flows (confirmStreamRecovery on raw frame), not on resubscribe attempt Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 825922b commit 010340f

2 files changed

Lines changed: 32 additions & 14 deletions

File tree

web/src/hooks/useTimeline.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,19 +68,15 @@ export function useTimeline(
6868
if (!sessionId) {
6969
setEvents([]);
7070
setLoading(false);
71-
setLoadingOlder(false);
72-
loadingOlderRef.current = false;
73-
olderRequestIdRef.current = null;
71+
resetOlderState();
7472
epochRef.current = 0;
7573
seqRef.current = 0;
7674
historyLoadedRef.current = null;
7775
return;
7876
}
7977

8078
setRefreshing(false);
81-
setLoadingOlder(false);
82-
loadingOlderRef.current = false;
83-
olderRequestIdRef.current = null;
79+
resetOlderState();
8480
historyLoadedRef.current = null;
8581

8682
let cancelled = false;
@@ -165,15 +161,26 @@ export function useTimeline(
165161
});
166162
}, [sessionId]);
167163

164+
const olderTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
165+
const resetOlderState = useCallback(() => {
166+
loadingOlderRef.current = false;
167+
olderRequestIdRef.current = null;
168+
if (olderTimeoutRef.current) { clearTimeout(olderTimeoutRef.current); olderTimeoutRef.current = null; }
169+
setLoadingOlder(false);
170+
}, []);
171+
168172
// Load older events (backward pagination)
169173
const loadOlderEvents = useCallback(() => {
170174
if (!ws?.connected || !sessionId || loadingOlderRef.current) return;
171175
const cached = eventsCache.get(sessionId);
172176
if (!cached || cached.length === 0) return;
173177
const oldestTs = Math.min(...cached.map((e) => e.ts));
174-
loadingOlderRef.current = true; // Synchronous guard — prevents duplicate requests from rapid clicks
178+
loadingOlderRef.current = true;
175179
setLoadingOlder(true);
176180
olderRequestIdRef.current = ws.sendTimelineHistoryRequest(sessionId, 500, undefined, oldestTs);
181+
// Timeout: if response never arrives (packet loss, disconnect), reset after 10s
182+
if (olderTimeoutRef.current) clearTimeout(olderTimeoutRef.current);
183+
olderTimeoutRef.current = setTimeout(resetOlderState, 10_000);
177184
}, [ws, sessionId, loadingOlder]);
178185

179186
// Append a single event, dedup by eventId
@@ -273,13 +280,11 @@ export function useTimeline(
273280

274281
// Handle backward pagination response
275282
if (msg.requestId && msg.requestId === olderRequestIdRef.current) {
276-
olderRequestIdRef.current = null;
277-
loadingOlderRef.current = false;
283+
resetOlderState();
278284
if (msg.events.length > 0) {
279285
mergeEvents(msg.events);
280286
sharedDb?.putEvents(msg.events).catch(() => {});
281287
}
282-
setLoadingOlder(false);
283288
return;
284289
}
285290

@@ -331,6 +336,10 @@ export function useTimeline(
331336
historyRequestIdRef.current = ws.sendTimelineHistoryRequest(sessionId, 500, afterTs);
332337
}
333338
}
339+
// ── Browser WS disconnected: reset in-flight pagination to prevent stuck state ──
340+
if (msg.type === 'session.event' && (msg as { event: string }).event === 'disconnected') {
341+
if (loadingOlderRef.current) resetOlderState();
342+
}
334343
// ── Browser WS reconnected: fill gaps since last seen seq ──
335344
if (msg.type === 'session.event' && (msg as { event: string }).event === 'connected') {
336345
if (ws && sessionId && epochRef.current > 0) {

web/src/ws-client.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -419,9 +419,19 @@ export class WsClient {
419419
if (data.length < 3 + nameLen) return;
420420
const sessionName = new TextDecoder().decode(data.slice(3, 3 + nameLen));
421421
const ptyData = data.slice(3 + nameLen);
422+
// Data flowing → stream recovered. Reset retry counter so future resets get full budget.
423+
this.confirmStreamRecovery(sessionName);
422424
this._terminalRawHandlers.get(sessionName)?.forEach((h) => h(ptyData));
423425
}
424426

427+
/** Called when data arrives for a session — confirms stream is healthy, resets retry budget. */
428+
private confirmStreamRecovery(session: string): void {
429+
const state = this.resetState.get(session);
430+
if (state && state.retryCount > 0) {
431+
state.retryCount = 0;
432+
}
433+
}
434+
425435
/**
426436
* Handle terminal.stream_reset: schedule resubscribe with exponential backoff.
427437
* Cooldown: 3+ resets within 60s → 30s pause before retrying.
@@ -468,10 +478,9 @@ export class WsClient {
468478
if (state.retryTimer) clearTimeout(state.retryTimer);
469479
state.retryTimer = setTimeout(() => {
470480
const s = this.resetState.get(session);
471-
if (s) {
472-
s.retryTimer = null;
473-
s.retryCount = 0; // Reset on resubscribe — next reset starts fresh
474-
}
481+
if (s) s.retryTimer = null;
482+
// retryCount is NOT reset here — only reset when data actually flows
483+
// (confirmStreamRecovery called from handleRawFrame on first received frame)
475484
if (!this._destroyed && this._connected) {
476485
this.subscribeTerminal(session);
477486
}

0 commit comments

Comments
 (0)