Summary
A chat WebSocket that dies while the browser tab is hidden is never detected, because the one listener written to recover that situation is guarded on a flag only a detected disconnect can set.
The user-visible result is a chat that spins forever on a message the daemon never received, and a reload that shows nothing — neither the question nor an answer, because the frame never left the machine.
Mechanism
All line numbers are crates/librefang-api/dashboard/src/pages/ChatPage.tsx at a394401b0.
#4063 added a visibilitychange / online listener so a user returning to the tab is not stuck on a dead socket until they refresh:
const wakeUp = () => {
if (authErrorRef.current) return;
if (!gaveUpRef.current) return; // :302
...
connect();
};
gaveUpRef.current = true is assigned in exactly one place — :263, inside ws.onclose, and only after retriesRef.current >= WS_MAX_RETRIES.
So the wake-up can only fire for a socket whose death the browser already noticed and retried to exhaustion.
A half-open socket never fires onclose — that is its defining property.
When the link dies while the tab is hidden (laptop suspend, wifi roaming to another access point, a NAT mapping expiring), the browser is never told.
No onclose, therefore no retries, therefore gaveUpRef stays false, therefore wakeUp returns at :302 having done nothing.
readyState still reports OPEN, which is what the send path branches on at :965, so the next message is written into a socket whose bytes go nowhere.
Nothing is persisted because nothing arrived, which is why a reload shows no trace of the message.
The intent of #4063 was right and the guard is what leaves it short: it covers the case where the browser noticed, and not the case where it could not.
Reproduction
- Open a chat and let the socket connect.
- Kill the link in a way that produces no FIN or RST — suspend the machine, or drop the connection at a middlebox — so the browser is never notified.
- Return to the tab and send a message.
Expected: the client notices the socket is unusable and reconnects, or at minimum does not silently swallow the message.
Actual: readyState is OPEN, the frame is written and lost, the chat shows a streaming bubble forever, and a reload shows neither the question nor an answer.
Note on what is already there
crates/librefang-api/src/ws.rs:1706 already answers {"type":"ping"} with {"type":"pong"}, and no client has ever sent it — zero occurrences across dashboard/src, and in Rust only the definition itself.
A fix does not need a protocol addition, only a caller.
Related
#3854 — the reconnect policy this listener belongs to, and #3930 which implemented it.
#4063 — added the visibilitychange / online wake-up whose guard is the subject here.
#8257 — a different path to a stuck turn (agent switch tears the socket down); it does not involve gaveUpRef or the visibility listeners.
- Server side: neither end sends a periodic ping, so a dead peer is also undetectable from the daemon. Tracked separately.
Summary
A chat WebSocket that dies while the browser tab is hidden is never detected, because the one listener written to recover that situation is guarded on a flag only a detected disconnect can set.
The user-visible result is a chat that spins forever on a message the daemon never received, and a reload that shows nothing — neither the question nor an answer, because the frame never left the machine.
Mechanism
All line numbers are
crates/librefang-api/dashboard/src/pages/ChatPage.tsxata394401b0.#4063added avisibilitychange/onlinelistener so a user returning to the tab is not stuck on a dead socket until they refresh:gaveUpRef.current = trueis assigned in exactly one place —:263, insidews.onclose, and only afterretriesRef.current >= WS_MAX_RETRIES.So the wake-up can only fire for a socket whose death the browser already noticed and retried to exhaustion.
A half-open socket never fires
onclose— that is its defining property.When the link dies while the tab is hidden (laptop suspend, wifi roaming to another access point, a NAT mapping expiring), the browser is never told.
No
onclose, therefore no retries, thereforegaveUpRefstaysfalse, thereforewakeUpreturns at:302having done nothing.readyStatestill reportsOPEN, which is what the send path branches on at:965, so the next message is written into a socket whose bytes go nowhere.Nothing is persisted because nothing arrived, which is why a reload shows no trace of the message.
The intent of
#4063was right and the guard is what leaves it short: it covers the case where the browser noticed, and not the case where it could not.Reproduction
Expected: the client notices the socket is unusable and reconnects, or at minimum does not silently swallow the message.
Actual:
readyStateisOPEN, the frame is written and lost, the chat shows a streaming bubble forever, and a reload shows neither the question nor an answer.Note on what is already there
crates/librefang-api/src/ws.rs:1706already answers{"type":"ping"}with{"type":"pong"}, and no client has ever sent it — zero occurrences acrossdashboard/src, and in Rust only the definition itself.A fix does not need a protocol addition, only a caller.
Related
#3854— the reconnect policy this listener belongs to, and#3930which implemented it.#4063— added thevisibilitychange/onlinewake-up whose guard is the subject here.#8257— a different path to a stuck turn (agent switch tears the socket down); it does not involvegaveUpRefor the visibility listeners.