Skip to content

fix(frontend): keep a local generation streaming while the machine is offline - #258

Merged
dovvnloading merged 1 commit into
mainfrom
fix/offline-does-not-stall-local-stream
Sep 7, 2026
Merged

fix(frontend): keep a local generation streaming while the machine is offline#258
dovvnloading merged 1 commit into
mainfrom
fix/offline-does-not-stall-local-stream

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

The problem

Turning off Wi-Fi can cost the rest of a running generation.

If an SSE stream drops while navigator.onLine is false, the UI parks on:

Connection paused while offline. Waiting for network...

with no timer and no retry. On a machine that stays offline — a plane, a dead router, an adapter switched off — it never reconnects, and the answer being generated one process away is lost to the UI.

Root cause

if (!window.navigator.onLine) {
  useChatStore.getState().setStatusText(jobId, "Connection paused while offline. Waiting for network...");
  return new Promise((resolve) => {
    ...
    window.addEventListener("online", onOnline);   // the only way out
  });
}

The promise settles on an online event or an abort. Nothing else.

navigator.onLine reports whether the machine has network connectivity. The Cortex backend is not on the network — normalizeApiBaseUrl enforces that in production:

if (production && !isLoopbackHost(parsed.hostname)) {
  throw new Error("The Cortex API base URL must be relative or loopback-only.");
}

So the flag says nothing whatsoever about whether the backend is reachable. Worse, for a Windows-first, local-first desktop assistant running against local models, offline is the ordinary operating condition, not a fault to wait out. This is the one app where the check is guaranteed wrong.

The fix

The branch is gone. A dropped stream always retries on the existing backoff, which is what a loopback connection needs, and the status text says what is actually happening (Connection interrupted. Retrying in Ns...).

The abort path, the backoff schedule and every other behaviour are untouched — this only removes a special case that could never be correct here.

The old test was pinning the defect

"pauses reconnects while offline and resumes when the browser comes online" asserted both halves of the broken behaviour: that the stream did not retry while offline, and that it only resumed once online fired. It could not survive a correct implementation, so it is replaced rather than kept.

The new test keeps the machine offline for its whole duration and never dispatches an online event, then requires the retry 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");

Against the unfixed code it fails, timing out after ~1 s with the retry never attempted.

Verification

Check Result
npm test -- --run 266 passed, 31 files
npm run typecheck clean
npm run lint clean
python -m pytest -q 906 passed

Compatibility and rollback

Frontend only, confined to one function. No API contract, stored data, or migration. Reverting the commit restores the previous behaviour exactly.

Limits

A genuinely unreachable backend (the process died) now retries on the backoff until the user stops it, rather than waiting on a network event that would never have been the right signal anyway. That is the pre-existing behaviour for every other disconnect reason, and it is bounded — reconnectDelay caps at 30 s per attempt.

This does not add a "backend is gone" state distinct from "connection interrupted". Detecting that properly means probing the health endpoint rather than reading a browser flag, which is a larger change than removing an incorrect one.

🤖 Generated with Claude Code

… offline

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>
@dovvnloading
dovvnloading merged commit 22e09bf into main Sep 7, 2026
7 checks passed
@dovvnloading
dovvnloading deleted the fix/offline-does-not-stall-local-stream branch September 7, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant