WIP cleanup: split branch stash into 8 focused commits #2
@@ -16,6 +16,33 @@ import {
|
||||
type UiMessage,
|
||||
} from "./transcript";
|
||||
|
||||
// Auto-reconnect budget for transport failures (Cloudflare/Traefik culling an
|
||||
// idle SSE stream, transient network flap). The reducer dedupes replayed
|
||||
// events by seq, so re-attaching with resumeFrom is safe even if the server
|
||||
// replays events we already saw.
|
||||
const RECONNECT_MAX_ATTEMPTS = 5;
|
||||
const RECONNECT_BASE_MS = 500;
|
||||
const RECONNECT_MAX_MS = 8000;
|
||||
|
||||
type StreamOutcome = "clean" | "aborted" | "error";
|
||||
|
||||
// Sleep that resolves early if the abort signal fires. Returns true if the
|
||||
// wait was cut short by an abort so callers can bail out of retry loops.
|
||||
function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) return Promise.resolve(true);
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(false);
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(t);
|
||||
resolve(true);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export interface ChatHandle {
|
||||
state: TranscriptState;
|
||||
send: (text: string) => Promise<void>;
|
||||
@@ -43,14 +70,14 @@ export function useChat(
|
||||
const lastSeqRef = useRef(0);
|
||||
lastSeqRef.current = state.lastSeq;
|
||||
|
||||
const readStream = useCallback(
|
||||
// One pass through the SSE stream. Never mutates abortRef — the retry loop
|
||||
// in readStream owns lifecycle so it can share a controller across attempts.
|
||||
const openStream = useCallback(
|
||||
async (
|
||||
body: Record<string, unknown>,
|
||||
controller: AbortController,
|
||||
emit: (action: TranscriptAction) => void,
|
||||
) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
): Promise<StreamOutcome> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/gateway?clawId=${encodeURIComponent(clawId)}`,
|
||||
@@ -61,12 +88,17 @@ export function useChat(
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok || !response.body) {
|
||||
// 4xx from the gateway is definitive (auth, not-found, bad request).
|
||||
// Retrying won't help — surface it to the reducer immediately.
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
emit({
|
||||
kind: "transport_error",
|
||||
message: `gateway returned ${response.status}`,
|
||||
});
|
||||
return;
|
||||
return "clean";
|
||||
}
|
||||
if (!response.ok || !response.body) {
|
||||
return "error";
|
||||
}
|
||||
const parser = createSseParser();
|
||||
const reader = response.body.getReader();
|
||||
@@ -80,18 +112,55 @@ export function useChat(
|
||||
});
|
||||
}
|
||||
if (done) {
|
||||
break;
|
||||
return "clean";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
emit({ kind: "transport_error", message: String(error) });
|
||||
}
|
||||
} catch (_error) {
|
||||
return controller.signal.aborted ? "aborted" : "error";
|
||||
}
|
||||
},
|
||||
[clawId, sessionKey],
|
||||
);
|
||||
|
||||
const readStream = useCallback(
|
||||
async (
|
||||
body: Record<string, unknown>,
|
||||
emit: (action: TranscriptAction) => void,
|
||||
) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
let attempt = 0;
|
||||
let currentBody = body;
|
||||
for (;;) {
|
||||
const outcome = await openStream(currentBody, controller, emit);
|
||||
if (outcome !== "error") {
|
||||
return;
|
||||
}
|
||||
attempt += 1;
|
||||
if (attempt > RECONNECT_MAX_ATTEMPTS) {
|
||||
emit({
|
||||
kind: "transport_error",
|
||||
message: `stream failed after ${RECONNECT_MAX_ATTEMPTS} reconnect attempts`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(
|
||||
RECONNECT_MAX_MS,
|
||||
RECONNECT_BASE_MS * 2 ** (attempt - 1),
|
||||
);
|
||||
const aborted = await sleepUnlessAborted(delay, controller.signal);
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
// Every retry resumes from the last event we saw — the reducer's
|
||||
// seq dedup makes overlap harmless.
|
||||
currentBody = { resumeFrom: lastSeqRef.current };
|
||||
}
|
||||
},
|
||||
[openStream],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
dispatch({ kind: "send", text });
|
||||
|
||||
Reference in New Issue
Block a user