Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions frontend/src/hooks/useGenerationStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,12 @@ describe("useGenerationStream", () => {
expect(reconnectDelay(100, () => 1)).toBe(30_000);
});

it("pauses reconnects while offline and resumes when the browser comes online", async () => {
it("keeps retrying a dropped stream while the machine is offline", async () => {
// The backend is loopback-only -- normalizeApiBaseUrl refuses anything else
// in production -- so navigator.onLine says nothing about reaching it.
// Gating reconnects on it parked a dropped stream on "Waiting for
// network..." with no timer and no retry, and a laptop with its adapter
// off lost the rest of a generation that was still running locally.
const hadOwnOnlineProperty = Object.prototype.hasOwnProperty.call(window.navigator, "onLine");
const onlineDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "onLine");
Object.defineProperty(window.navigator, "onLine", { configurable: true, value: false });
Expand All @@ -535,12 +540,12 @@ describe("useGenerationStream", () => {
act(() => {
void result.current.consume({ jobId: "job-offline", threadId: "thread-offline", lastEventId: 0 }, vi.fn().mockResolvedValue(undefined), vi.fn());
});
await waitFor(() => expect(useChatStore.getState().generation.statusText).toContain("paused while offline"));
expect(streamGeneration).toHaveBeenCalledTimes(1);

Object.defineProperty(window.navigator, "onLine", { configurable: true, value: true });
act(() => window.dispatchEvent(new Event("online")));
// No "online" event is ever dispatched: the machine stays offline
// throughout, and the retry has to happen anyway.
await waitFor(() => expect(streamGeneration).toHaveBeenCalledTimes(2));
expect(useChatStore.getState().generation.statusText).toContain("Retrying in");
expect(useChatStore.getState().generation.statusText).not.toContain("offline");
act(() => result.current.stop());
} finally {
if (onlineDescriptor) Object.defineProperty(window.navigator, "onLine", onlineDescriptor);
Expand Down
36 changes: 12 additions & 24 deletions frontend/src/hooks/useGenerationStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,32 +100,20 @@ function delay(milliseconds: number, signal: AbortSignal): Promise<boolean> {
});
}

/**
* Wait before retrying a dropped stream.
*
* Deliberately does not consult `navigator.onLine`. That flag reports whether
* the machine has *network* connectivity, and the Cortex backend is not on the
* network: `normalizeApiBaseUrl` refuses anything but a same-origin path or a
* loopback host in production. Treating "no Wi-Fi" as "cannot reach the
* backend" parked a dropped stream on "Waiting for network..." with no timer
* and no retry, so a laptop with its adapter off -- a plane, a dead router --
* lost the rest of a generation that was still running perfectly one process
* away. Offline is the normal case for a local-first app, not a fault.
*/
function waitForReconnect(jobId: string, attempt: number, signal: AbortSignal): Promise<boolean> {
if (signal.aborted) return Promise.resolve(false);
if (!window.navigator.onLine) {
useChatStore.getState().setStatusText(jobId, "Connection paused while offline. Waiting for network...");
return new Promise((resolve) => {
let settled = false;
const finish = (completed: boolean) => {
if (settled) return;
settled = true;
window.removeEventListener("online", onOnline);
signal.removeEventListener("abort", onAbort);
resolve(completed);
};
const onOnline = () => {
useChatStore.getState().setStatusText(jobId, "Connection restored. Reconnecting...");
finish(true);
};
const onAbort = () => {
finish(false);
};
window.addEventListener("online", onOnline);
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
else if (window.navigator.onLine) onOnline();
});
}
const milliseconds = reconnectDelay(attempt);
useChatStore.getState().setStatusText(
jobId,
Expand Down