Skip to content

Commit 22e09bf

Browse files
dovvnloadingclaude
andauthored
fix(frontend): keep a local generation streaming while the machine is offline (#258)
Turning off Wi-Fi could cost the rest of a running generation. `waitForReconnect` treated `navigator.onLine === false` as "cannot reach the backend": it set "Connection paused while offline. Waiting for network..." and returned a promise that settled only on an `online` event. No timer, no retry. On a machine that stays offline -- a plane, a dead router, an adapter switched off -- the stream never reconnected, and the answer being generated one process away was lost to the UI. `navigator.onLine` reports whether the machine has network connectivity. The Cortex backend is not on the network: `normalizeApiBaseUrl` refuses anything but a same-origin path or a loopback host in production, and throws otherwise. So the flag says nothing about whether the backend is reachable, and offline is the ordinary operating condition for a local-first desktop app rather than a fault to wait out. The branch is gone. A dropped stream now always retries on the existing backoff, which is what a loopback connection needs, and the status text says what is actually happening. The test that covered the old behaviour asserted the pause and the resume, so it was pinning the defect; it now asserts the opposite -- with the machine offline for the whole test and no `online` event ever dispatched, the retry still has to happen. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ebc6661 commit 22e09bf

2 files changed

Lines changed: 22 additions & 29 deletions

File tree

frontend/src/hooks/useGenerationStream.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,12 @@ describe("useGenerationStream", () => {
521521
expect(reconnectDelay(100, () => 1)).toBe(30_000);
522522
});
523523

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

541-
Object.defineProperty(window.navigator, "onLine", { configurable: true, value: true });
542-
act(() => window.dispatchEvent(new Event("online")));
544+
// No "online" event is ever dispatched: the machine stays offline
545+
// throughout, and the retry has to happen anyway.
543546
await waitFor(() => expect(streamGeneration).toHaveBeenCalledTimes(2));
547+
expect(useChatStore.getState().generation.statusText).toContain("Retrying in");
548+
expect(useChatStore.getState().generation.statusText).not.toContain("offline");
544549
act(() => result.current.stop());
545550
} finally {
546551
if (onlineDescriptor) Object.defineProperty(window.navigator, "onLine", onlineDescriptor);

frontend/src/hooks/useGenerationStream.ts

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -100,32 +100,20 @@ function delay(milliseconds: number, signal: AbortSignal): Promise<boolean> {
100100
});
101101
}
102102

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

0 commit comments

Comments
 (0)