Add TTFT watchdog to detect and surface LLM stalls

When llama-server is queue-backed it accepts the connection but never
produces tokens; after ~60s Caddy drops the connection and the frontend
clears loading state with no response and no error shown to the user.

Backend: replace the async-for event loop with an explicit __anext__()
iterator wrapped in asyncio.wait_for(). A 30s timeout (TTFT_TIMEOUT_SECONDS)
applies only in the window between on_chat_model_start and the first content
token — tool calls are excluded. On timeout, emits a stalled error event and
logs it rather than waiting for the proxy to kill the connection.

Frontend: separates stall errors from generic errors; shows a "Simba didn't
respond in time. Retry" pill that re-sends the original query.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 16:10:44 -04:00
parent 3875ed4e4f
commit 8fd6fa9ece
5 changed files with 68 additions and 4 deletions
@@ -6,7 +6,7 @@ export type SSEEvent =
| { type: "tool_end"; tool: string }
| { type: "content"; delta: string }
| { type: "response"; message: string }
| { type: "error"; message: string };
| { type: "error"; message: string; stalled?: boolean };
export type SSEEventCallback = (event: SSEEvent) => void;
@@ -61,6 +61,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
stopGeneration,
error,
clearError,
stalled,
retryStalled,
} = useChat({
createConversation,
refreshConversations,
@@ -273,6 +275,20 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
</div>
)}
{stalled && (
<div className="flex justify-center message-enter">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 border border-red-200 text-xs text-red-600">
<span>Simba didn&apos;t respond in time.</span>
<button
onClick={retryStalled}
className="font-medium underline underline-offset-2 hover:text-red-800 transition-colors cursor-pointer"
>
Retry
</button>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
+20 -1
View File
@@ -51,9 +51,12 @@ export function useChat({
const [messagesLoading, setMessagesLoading] = useState(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [stalled, setStalled] = useState(false);
const [streaming, setStreaming] = useState(false);
const [thinking, setThinking] = useState(false);
const stalledQueryRef = useRef<string | null>(null);
const isMountedRef = useRef(true);
// True once tokens for the current assistant reply have started arriving, so
// new content appends to the live bubble instead of creating another one.
@@ -144,6 +147,8 @@ export function useChat({
const imageFile = pendingImage;
setError(null);
setStalled(false);
stalledQueryRef.current = null;
streamingActiveRef.current = false;
setStreaming(false);
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
@@ -239,7 +244,11 @@ export function useChat({
}
} else if (event.type === "error") {
console.error("Stream error:", event.message);
if (isMountedRef.current) {
if (!isMountedRef.current) return;
if (event.stalled) {
stalledQueryRef.current = query;
setStalled(true);
} else {
setError("Simba ran into a problem answering that.");
}
}
@@ -293,6 +302,14 @@ export function useChat({
const clearError = useCallback(() => setError(null), []);
const retryStalled = useCallback(() => {
const q = stalledQueryRef.current;
if (!q) return;
setStalled(false);
stalledQueryRef.current = null;
sendMessage(q, false);
}, [sendMessage]);
return {
messages,
setMessages: updateMessages,
@@ -306,5 +323,7 @@ export function useChat({
stopGeneration,
error,
clearError,
stalled,
retryStalled,
};
}