fix: renew a session that died under an open tab, instead of eating the message typed into it
Nightly Build / build (push) Successful in 7m34s

Sessions live in the server's RAM, so a restart logs everyone out while the
browser keeps sending a cookie nobody recognises. Nothing noticed: every gated
API call answered 401 into a component that shrugged, and the chat socket was
refused at the upgrade — which reaches `onclose` looking exactly like a flaky
network, so the loop retried every 2 s forever behind "Not connected —
reconnecting, please retry", against a server that would never accept it again.

Retrying was not even the expensive part. `_send()` cleared the composer and
dropped the attachment chips *before* testing the socket, so a long message was
already destroyed by the time the error bubble appeared. The connection test now
comes first and everything below it is unreachable while the socket is down, so
the text stays where the user left it; `/new` and `/clear` move above the guard
because they go over HTTP and reconnect the socket themselves, which is when
they are most wanted.

Detection is one module (`lib/session-expiry.js`) reporting a fact — `auth-expired`,
and `auth-restored` on the way back — with nothing in it that touches the DOM. A
`window.fetch` wrapper flags any 401 from a gated `/api` path, a wrapper rather
than a helper each call site opts into because the components call `fetch`
directly in dozens of places and a seam that must be remembered is one the next
page will forget; `auth/*` and `setup/*` are excluded, where 401 is the normal
answer. The socket's own path asks `probeSession()` before retrying, since it
cannot tell a refusal from a blip. The native mobile shell is guarded inside the
report, so no future caller can reintroduce a web login form there.

The answer is a modal over the page the user is already on, not the login
screen: bouncing to it would throw away everything the page was holding —
including the half-written message this commit exists to save. One password
field, prefilled with the last username this browser logged in as, not
dismissible (with no session nothing on the page works, and a dialog you can
wave away leaves a UI that silently fails every action). On success the chat
reconnects on `auth-restored` and reconciles like any other disconnection.

Known gap: pages that failed a fetch during the outage keep their stale data
until navigated to again. Only the chat re-arms itself.
This commit is contained in:
2026-08-04 13:02:39 +01:00
parent 6cb4ea0ce8
commit ff298f1aef
10 changed files with 378 additions and 10 deletions
+55 -10
View File
@@ -1,6 +1,7 @@
import { html, nothing } from 'lit';
import { LightElement } from './base.js';
import { t } from './i18n.js';
import { isSessionExpired, notifySessionExpired, probeSession } from './session-expiry.js';
// Slash commands handled entirely server-side: they reply with a `Done` and never
// echo back as a `user_message`, so they are the only commands rendered
@@ -109,10 +110,12 @@ export class ChatSession extends LightElement {
// Each entry: { name, path, mimetype, filesize, uploading? }. While an upload
// is in flight the entry has `uploading: true` and no `path` yet.
this._attachments = [];
this._onAuthRestored = this._onAuthRestored.bind(this);
}
async connectedCallback() {
super.connectedCallback();
window.addEventListener('auth-restored', this._onAuthRestored);
// Fire-and-forget: availability of a transcription provider determines
// whether the mic button is rendered at all.
this._checkTranscribe();
@@ -120,6 +123,11 @@ export class ChatSession extends LightElement {
this._connectWS();
}
disconnectedCallback() {
super.disconnectedCallback?.();
window.removeEventListener('auth-restored', this._onAuthRestored);
}
// ── Source identity — override in subclass ────────────────────────────────────
// Static default source for this component. Subclasses override (e.g. 'mobile').
@@ -223,7 +231,38 @@ export class ChatSession extends LightElement {
}
};
ws.onmessage = (ev) => this._handleServerMsg(JSON.parse(ev.data));
ws.onclose = () => { this._reconnecting = true; setTimeout(() => this._connectWS(), 2000); };
ws.onclose = () => { this._reconnecting = true; this._scheduleReconnect(); };
}
/**
* Reconnect after an unexpected close — unless we were dropped because the
* server no longer knows this browser. Sessions live in the server's RAM
* (blueprint §9), so a restart refuses the WS upgrade, and a refused upgrade
* reaches `onclose` looking exactly like a flaky network: the loop used to
* retry every 2 s forever behind "Not connected", against a server that would
* never accept it again. So ask first, and let the re-login dialog take it
* from there — the socket comes back on `auth-restored`.
*/
async _scheduleReconnect() {
if (isSessionExpired()) return; // the dialog is already up
if ((await probeSession()) === 'expired') {
notifySessionExpired();
// A shell that handles auth itself (native mobile) ignores the report;
// there is no dialog coming, so keep retrying as before.
if (isSessionExpired()) return;
}
setTimeout(() => this._connectWS(), 2000);
}
/**
* A new session was obtained without leaving the page: reconnect and reconcile
* like any other unexpected disconnection (the `_reconnecting` flag is what
* makes `onopen` re-sync tool state that advanced while we were away).
*/
_onAuthRestored() {
if (this._ws && this._ws.readyState !== WebSocket.CLOSED) return;
this._reconnecting = true;
this._connectWS();
}
/**
@@ -665,14 +704,26 @@ export class ChatSession extends LightElement {
// Don't send while an attachment is still streaming to disk, or its path
// would be missing from the message.
if (this._attachments.some(a => a.uploading)) return;
this._clearInput();
// Handled entirely over HTTP (and it reconnects the socket itself), so it must
// stay available precisely when the socket is down.
if (content === '/new' || content === '/clear') {
this._clearInput();
this._attachments = [];
await this._startNewSession();
return;
}
// Nothing below this point may run while the socket is down: everything from
// here on is destructive to what the user typed (the input is cleared, the
// attachment chips are dropped). Bailing first is what keeps a long message
// recoverable — the composer still holds it, so retrying after the automatic
// reconnect is one Enter, not a retype.
if (this._ws?.readyState !== WebSocket.OPEN) {
this._pushError(t('chat.not_connected'));
return;
}
this._clearInput();
// Strip client-only fields; the server persists these as message metadata.
const attachments = this._attachments.map(({ name, path, mimetype, filesize }) =>
({ name, path, mimetype, filesize }));
@@ -692,13 +743,7 @@ export class ChatSession extends LightElement {
this._push({ kind: 'user', content, attachments });
}
this._waiting = true;
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify({ content, attachments }));
} else {
this._pushError('Not connected — reconnecting, please retry.');
this._waiting = false;
}
this._ws.send(JSON.stringify({ content, attachments }));
}
// ── Attachments ────────────────────────────────────────────────────────────