fix: improve session resume resilience, permission timeout, and stream idle handling

1. Resume pre-check and degradation (claude-client.ts):
   - Check working_directory exists on disk before attempting resume;
     if missing, skip resume, clear sdk_session_id, notify frontend
   - On resume peek failure, clear sdk_session_id to prevent repeated
     retries on the same broken session
   - Change iterator wrap condition from sdkSessionId to shouldResume
     so skipped resumes don't enter the peek path

2. Per-request independent permission timeout (permission-registry.ts):
   - Replace lazy cleanup (cleanupExpired on next register) with a
     dedicated setTimeout per permission request
   - Auto-deny after 5 minutes with clearTimeout on resolve/abort
   - Extract denyAndRemove helper for abort and timeout paths

3. Permission wait visibility (StreamingMessage.tsx):
   - Always show StreamingStatusBar during streaming (remove
     !pendingPermission guard)
   - Display "Waiting for authorization: <tool>" with elapsed timer
     when a permission request is pending, instead of hiding the bar

4. Stream idle timeout (ChatView.tsx):
   - Add 330s idle timeout (slightly above 5-min permission timeout
     to avoid races) using setInterval that checks lastEventTime
   - Every SSE callback calls markActive() to reset the timer
   - On idle timeout, abort the stream and show a readable error
   - Clean up interval in both catch and finally blocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-24 21:39:09 +08:00
1 parent 7b9de0ab23
commit edd55a9b0f
4 files changed
+152 -69

No files matched your search

+87 -40
View File
@@ -216,6 +216,20 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
let accumulated = '';
// Stream idle timeout: abort if no SSE events arrive for this duration.
// Set slightly above the 5-minute permission timeout (300s) to avoid races.
const STREAM_IDLE_TIMEOUT_MS = 330_000;
let lastEventTime = Date.now();
let isIdleTimeout = false;
const idleCheckTimer = setInterval(() => {
if (Date.now() - lastEventTime >= STREAM_IDLE_TIMEOUT_MS) {
clearInterval(idleCheckTimer);
isIdleTimeout = true;
controller.abort();
}
}, 10_000);
const markActive = () => { lastEventTime = Date.now(); };
try {
const response = await fetch('/api/chat', {
method: 'POST',
@@ -240,11 +254,13 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const result = await consumeSSEStream(reader, {
onText: (acc) => {
markActive();
accumulated = acc;
accumulatedRef.current = acc;
setStreamingContent(acc);
},
onToolUse: (tool) => {
markActive();
setStreamingToolOutput('');
setToolUses((prev) => {
if (prev.some((t) => t.id === tool.id)) return prev;
@@ -254,6 +270,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
});
},
onToolResult: (res) => {
markActive();
setStreamingToolOutput('');
setToolResults((prev) => {
const next = [...prev, res];
@@ -265,15 +282,18 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
window.dispatchEvent(new Event('refresh-file-tree'));
},
onToolOutput: (data) => {
markActive();
setStreamingToolOutput((prev) => {
const next = prev + (prev ? '\n' : '') + data;
return next.length > 5000 ? next.slice(-5000) : next;
});
},
onToolProgress: (toolName, elapsed) => {
markActive();
setStatusText(`Running ${toolName}... (${elapsed}s)`);
},
onStatus: (text) => {
markActive();
if (text?.startsWith('Connected (')) {
setStatusText(text);
setTimeout(() => setStatusText(undefined), 2000);
@@ -281,21 +301,28 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
setStatusText(text);
}
},
onResult: () => { /* token usage captured by consumeSSEStream */ },
onResult: () => {
markActive();
/* token usage captured by consumeSSEStream */
},
onPermissionRequest: (permData) => {
markActive();
setPendingPermission(permData);
setPermissionResolved(null);
setPendingApprovalSessionId(sessionId);
},
onToolTimeout: (toolName, elapsedSeconds) => {
markActive();
toolTimeoutRef.current = { toolName, elapsedSeconds };
},
onModeChanged: (sdkMode) => {
markActive();
// Map SDK permissionMode to UI mode
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
handleModeChange(uiMode);
},
onError: (acc) => {
markActive();
accumulated = acc;
accumulatedRef.current = acc;
setStreamingContent(acc);
@@ -341,56 +368,75 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
setMessages((prev) => [...prev, assistantMessage]);
}
} catch (error) {
clearInterval(idleCheckTimer);
if (error instanceof DOMException && error.name === 'AbortError') {
const timeoutInfo = toolTimeoutRef.current;
if (timeoutInfo) {
// Tool execution timed out — save partial content and auto-retry
// Stream idle timeout — no SSE events for too long
if (isIdleTimeout) {
const idleSecs = Math.round(STREAM_IDLE_TIMEOUT_MS / 1000);
const errContent = accumulated.trim()
? accumulated.trim() + `\n\n**Error:** Stream idle timeout — no response for ${idleSecs}s. The connection may have dropped.`
: `**Error:** Stream idle timeout — no response for ${idleSecs}s. The connection may have dropped.`;
const errMessage: Message = {
id: 'temp-error-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: errContent,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, errMessage]);
} else {
const timeoutInfo = toolTimeoutRef.current;
if (timeoutInfo) {
// Tool execution timed out — save partial content and auto-retry
if (accumulated.trim()) {
const partialMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: accumulated.trim() + `\n\n*(tool ${timeoutInfo.toolName} timed out after ${timeoutInfo.elapsedSeconds}s)*`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, partialMessage]);
}
// Clean up before auto-retry
toolTimeoutRef.current = null;
setIsStreaming(false);
setStreamingSessionId('');
setStreamingContent('');
accumulatedRef.current = '';
toolUsesRef.current = [];
toolResultsRef.current = [];
setToolUses([]);
setToolResults([]);
setStreamingToolOutput('');
setStatusText(undefined);
setPendingPermission(null);
setPermissionResolved(null);
setPendingApprovalSessionId('');
abortControllerRef.current = null;
// Auto-retry: send a follow-up message telling the model to adjust strategy
setTimeout(() => {
sendMessageRef.current?.(
`The previous tool "${timeoutInfo.toolName}" timed out after ${timeoutInfo.elapsedSeconds} seconds. Please try a different approach to accomplish the task. Avoid repeating the same operation that got stuck.`
);
}, 500);
return; // Skip the normal finally cleanup since we did it above
}
// User manually stopped generation — add partial content
if (accumulated.trim()) {
const partialMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: accumulated.trim() + `\n\n*(tool ${timeoutInfo.toolName} timed out after ${timeoutInfo.elapsedSeconds}s)*`,
content: accumulated.trim() + '\n\n*(generation stopped)*',
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, partialMessage]);
}
// Clean up before auto-retry
toolTimeoutRef.current = null;
setIsStreaming(false);
setStreamingSessionId('');
setStreamingContent('');
accumulatedRef.current = '';
toolUsesRef.current = [];
toolResultsRef.current = [];
setToolUses([]);
setToolResults([]);
setStreamingToolOutput('');
setStatusText(undefined);
setPendingPermission(null);
setPermissionResolved(null);
setPendingApprovalSessionId('');
abortControllerRef.current = null;
// Auto-retry: send a follow-up message telling the model to adjust strategy
setTimeout(() => {
sendMessageRef.current?.(
`The previous tool "${timeoutInfo.toolName}" timed out after ${timeoutInfo.elapsedSeconds} seconds. Please try a different approach to accomplish the task. Avoid repeating the same operation that got stuck.`
);
}, 500);
return; // Skip the normal finally cleanup since we did it above
}
// User manually stopped generation — add partial content
if (accumulated.trim()) {
const partialMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: accumulated.trim() + '\n\n*(generation stopped)*',
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, partialMessage]);
}
} else {
const errMsg = error instanceof Error ? error.message : 'Unknown error';
@@ -405,6 +451,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
setMessages((prev) => [...prev, errorMessage]);
}
} finally {
clearInterval(idleCheckTimer);
toolTimeoutRef.current = null;
setIsStreaming(false);
setStreamingSessionId('');
+5 -3
View File
@@ -460,9 +460,11 @@ export function StreamingMessage({
</div>
)}
{/* Status bar during streaming */}
{isStreaming && !pendingPermission && <StreamingStatusBar statusText={
statusText || getRunningCommandSummary()
{/* Status bar during streaming — show permission wait status when awaiting authorization */}
{isStreaming && <StreamingStatusBar statusText={
pendingPermission && !permissionResolved
? `Waiting for authorization: ${pendingPermission.toolName}`
: statusText || getRunningCommandSummary()
} onForceStop={onForceStop} />}
</MessageContent>
</AIMessage>
+37 -4
View File
@@ -385,8 +385,27 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
queryOptions.mcpServers = toSdkMcpConfig(mcpServers);
}
// Resume session if we have an SDK session ID from a previous conversation turn
if (sdkSessionId) {
// Resume session if we have an SDK session ID from a previous conversation turn.
// Pre-check: verify working_directory exists before attempting resume.
// Resume depends on session context (cwd/project scope), so if the
// original working_directory no longer exists, resume will fail.
let shouldResume = !!sdkSessionId;
if (shouldResume && workingDirectory && !fs.existsSync(workingDirectory)) {
console.warn(`[claude-client] Working directory "${workingDirectory}" does not exist, skipping resume`);
shouldResume = false;
if (sessionId) {
try { updateSdkSessionId(sessionId, ''); } catch { /* best effort */ }
}
controller.enqueue(formatSSE({
type: 'status',
data: JSON.stringify({
notification: true,
title: 'Session fallback',
message: 'Original working directory no longer exists. Starting fresh conversation.',
}),
}));
}
if (shouldResume) {
queryOptions.resume = sdkSessionId;
}
@@ -542,7 +561,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
});
// Wrap the iterator so we can detect resume failures on the first message
if (sdkSessionId) {
if (shouldResume) {
try {
// Peek at the first message to verify resume works
const iter = conversation[Symbol.asyncIterator]();
@@ -558,7 +577,21 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
}
})() as ReturnType<typeof query>;
} catch (resumeError) {
console.warn('[claude-client] Resume failed, retrying without resume:', resumeError instanceof Error ? resumeError.message : resumeError);
const errMsg = resumeError instanceof Error ? resumeError.message : String(resumeError);
console.warn('[claude-client] Resume failed, retrying without resume:', errMsg);
// Clear stale sdk_session_id so future messages don't retry this broken resume
if (sessionId) {
try { updateSdkSessionId(sessionId, ''); } catch { /* best effort */ }
}
// Notify frontend about the fallback
controller.enqueue(formatSSE({
type: 'status',
data: JSON.stringify({
notification: true,
title: 'Session fallback',
message: 'Previous session could not be resumed. Starting fresh conversation.',
}),
}));
// Remove resume and try again as a fresh conversation
delete queryOptions.resume;
conversation = query({
+23 -22
View File
@@ -4,7 +4,8 @@ interface PendingPermission {
resolve: (result: PermissionResult) => void;
createdAt: number;
abortSignal?: AbortSignal;
toolInput: Record<string, unknown>; // Original tool input for updatedInput in allow response
toolInput: Record<string, unknown>;
timer: ReturnType<typeof setTimeout>;
}
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -22,50 +23,49 @@ function getMap(): Map<string, PendingPermission> {
}
/**
* Lazily clean up expired entries (older than TIMEOUT_MS).
* Helper to deny and remove a pending permission entry.
*/
function cleanupExpired() {
function denyAndRemove(id: string, message: string) {
const map = getMap();
const now = Date.now();
for (const [id, entry] of map) {
if (now - entry.createdAt > TIMEOUT_MS) {
entry.resolve({ behavior: 'deny', message: 'Permission request timed out' });
map.delete(id);
}
}
const entry = map.get(id);
if (!entry) return;
clearTimeout(entry.timer);
entry.resolve({ behavior: 'deny', message });
map.delete(id);
}
/**
* Register a pending permission request.
* Returns a Promise that resolves when the user responds.
* Returns a Promise that resolves when the user responds or after TIMEOUT_MS.
*/
export function registerPendingPermission(
id: string,
toolInput: Record<string, unknown>,
abortSignal?: AbortSignal,
): Promise<PermissionResult> {
// Lazily clean up expired entries on each registration
cleanupExpired();
const map = getMap();
return new Promise<PermissionResult>((resolve) => {
// Per-request independent timer: auto-deny after TIMEOUT_MS
const timer = setTimeout(() => {
if (map.has(id)) {
console.warn(`[permission-registry] Permission request ${id} timed out after ${TIMEOUT_MS / 1000}s`);
resolve({ behavior: 'deny', message: 'Permission request timed out' });
map.delete(id);
}
}, TIMEOUT_MS);
map.set(id, {
resolve,
createdAt: Date.now(),
abortSignal,
toolInput,
timer,
});
// Auto-deny if the abort signal fires (client disconnect / stop button)
if (abortSignal) {
const onAbort = () => {
if (map.has(id)) {
resolve({ behavior: 'deny', message: 'Request aborted' });
map.delete(id);
}
};
abortSignal.addEventListener('abort', onAbort, { once: true });
abortSignal.addEventListener('abort', () => denyAndRemove(id, 'Request aborted'), { once: true });
}
});
}
@@ -82,7 +82,8 @@ export function resolvePendingPermission(
const entry = map.get(id);
if (!entry) return false;
// SDK requires updatedInput when allowing — inject the original tool input
clearTimeout(entry.timer);
if (result.behavior === 'allow' && !result.updatedInput) {
result = { ...result, updatedInput: entry.toolInput };
}