fix: improve WebSocket disconnect handling and UI responsiveness

This commit is contained in:
Ivan 2026-08-14 17:47:50 -05:00
parent 257a1461d4
commit 2521299125
No known key found for this signature in database
7 changed files with 136 additions and 21 deletions

View file

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
## [4.8.4] - 2026-08-14
### Fixed
- **Disconnected banner**: Stopping or restarting the backend no longer hides the red banner. A WebSocket TCP open (Vite proxy flaps, process restart) does not cancel the 2.5s grace window. The banner clears only after the backend sends a frame, usually the heartbeat pong.
## [4.8.3] - 2026-08-14
### Fixed

Binary file not shown.

View file

@ -492,7 +492,7 @@ import { useTheme } from "vuetify";
import SidebarLink from "./SidebarLink.vue";
import DialogUtils from "../js/DialogUtils";
import WebSocketConnection from "../js/WebSocketConnection";
import { formatDisconnectedDuration } from "../js/wsConnectionSupport";
import { formatDisconnectedDuration, WS_DISCONNECT_BANNER_GRACE_MS } from "../js/wsConnectionSupport";
import { applyAuthStatusToGlobalState, fetchAuthStatus } from "../js/authSessionSync.js";
import GlobalState, { mergeGlobalConfig } from "../js/GlobalState";
import { countRelayMentions } from "../js/relayMentionCount.js";
@ -1120,6 +1120,7 @@ export default {
WebSocketConnection.connect();
WebSocketConnection.on("disconnected", this.onWsShellDisconnected);
WebSocketConnection.on("connected", this.onWsShellConnected);
WebSocketConnection.on("ready", this.onWsShellReady);
this.registerShellWsHandlers();
this.startClientHeapMemoryWatch();
GlobalEmitter.on("toast-dismissed", this.onToastDismissedShell);
@ -1232,6 +1233,7 @@ export default {
GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this.onBatterySaverPrefsChangedShell);
WebSocketConnection.off("disconnected", this.onWsShellDisconnected);
WebSocketConnection.off("connected", this.onWsShellConnected);
WebSocketConnection.off("ready", this.onWsShellReady);
this.unregisterShellWsHandlers();
GlobalEmitter.off("identity-switching-start", this.onIdentitySwitchingStartShell);
GlobalEmitter.off("identity-switching-abort", this.onIdentitySwitchingAbortShell);
@ -1430,7 +1432,7 @@ export default {
this.wsDisconnectGraceTimer = setTimeout(() => {
this.wsDisconnectGraceTimer = null;
this._showWsDisconnectedBannerNow();
}, 2500);
}, WS_DISCONNECT_BANNER_GRACE_MS);
},
_tickWsDisconnectedLabel() {
if (!this.wsDisconnectedAt) {
@ -1439,11 +1441,7 @@ export default {
}
this.wsDisconnectedDurationText = formatDisconnectedDuration(Date.now() - this.wsDisconnectedAt);
},
async onWsShellConnected(payload = {}) {
if (!this.shellRunning) {
return;
}
const sawDisconnectBanner = this.wsDisconnectBannerShown;
_clearWsDisconnectedUi() {
if (this.wsDisconnectGraceTimer != null) {
clearTimeout(this.wsDisconnectGraceTimer);
this.wsDisconnectGraceTimer = null;
@ -1458,20 +1456,36 @@ export default {
clearInterval(this.wsDisconnectTickTimer);
this.wsDisconnectTickTimer = null;
}
},
_celebrateWsReconnected() {
this.wsReconnectedBanner = true;
if (this.wsReconnectedHideTimer != null) {
clearTimeout(this.wsReconnectedHideTimer);
}
this.wsReconnectedHideTimer = setTimeout(() => {
this.wsReconnectedBanner = false;
this.wsReconnectedHideTimer = null;
}, 4500);
},
async onWsShellConnected(payload = {}) {
if (!this.shellRunning) {
return;
}
// TCP open is not recovery. Vite proxies and restart flaps can OPEN then
// CLOSE without a backend frame. Keep the grace timer running until ready.
const isReconnect = payload.isReconnect === true;
if (isReconnect) {
await this.resyncShellAfterWebsocketReconnect();
// Only celebrate when the user actually saw a disconnect banner.
if (sawDisconnectBanner) {
this.wsReconnectedBanner = true;
if (this.wsReconnectedHideTimer != null) {
clearTimeout(this.wsReconnectedHideTimer);
}
this.wsReconnectedHideTimer = setTimeout(() => {
this.wsReconnectedBanner = false;
this.wsReconnectedHideTimer = null;
}, 4500);
}
}
},
onWsShellReady() {
if (!this.shellRunning) {
return;
}
const sawDisconnectBanner = this.wsDisconnectBannerShown;
this._clearWsDisconnectedUi();
if (sawDisconnectBanner) {
this._celebrateWsReconnected();
}
},
async resyncShellAfterWebsocketReconnect() {

View file

@ -22,6 +22,7 @@ class WebSocketConnection {
this.destroyed = false;
this._hadSuccessfulOpen = false;
this._pendingReconnectUi = false;
this._sessionReady = false;
this._lastReceivedTime = Date.now();
this._hasEventListeners = false;
this._isForcedReconnect = false;
@ -161,6 +162,7 @@ class WebSocketConnection {
this._reconnectTimeout = null;
}
this._reconnectAttempt = 0;
this._sessionReady = false;
this._stopHeartbeat();
this._startHeartbeat();
const isReconnect = this._pendingReconnectUi;
@ -172,6 +174,7 @@ class WebSocketConnection {
this.ws.addEventListener("close", () => {
this._stopHeartbeat();
this._sessionReady = false;
if (this.destroyed) {
return;
}
@ -225,15 +228,23 @@ class WebSocketConnection {
this.ws.onmessage = (message) => {
this._lastReceivedTime = Date.now();
let isPong = false;
try {
const data = JSON.parse(message.data);
if (data && data.type === "pong") {
this._clearPongTimeout();
return;
isPong = true;
}
} catch {
// non-json: forward
}
if (!this._sessionReady) {
this._sessionReady = true;
this.emit("ready");
}
if (isPong) {
return;
}
this.emit("message", message);
};
}
@ -290,6 +301,7 @@ class WebSocketConnection {
this.initialized = false;
this._hadSuccessfulOpen = false;
this._pendingReconnectUi = false;
this._sessionReady = false;
this._stopHeartbeat();
if (this._reconnectTimeout != null) {
clearTimeout(this._reconnectTimeout);

View file

@ -1,3 +1,6 @@
/** Wait this long after a disconnect before showing the banner. Brief reconnects that get a backend frame stay quiet. */
export const WS_DISCONNECT_BANNER_GRACE_MS = 2500;
/**
* @param {number} attemptIndex 0 = first retry after disconnect
* @param {number} baseMs

View file

@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "../../meshchatx/src/frontend/components/App.vue";
import { WS_DISCONNECT_BANNER_GRACE_MS } from "../../meshchatx/src/frontend/js/wsConnectionSupport";
vi.mock("../../meshchatx/src/frontend/js/csrfToken.js", () => ({
fetchCsrfToken: vi.fn(async () => "refreshed"),
@ -46,8 +47,11 @@ describe("App websocket reconnect shell resync", () => {
resyncShellAfterWebsocketReconnect: App.methods.resyncShellAfterWebsocketReconnect,
onWsShellConnected: App.methods.onWsShellConnected,
onWsShellDisconnected: App.methods.onWsShellDisconnected,
onWsShellReady: App.methods.onWsShellReady,
_showWsDisconnectedBannerNow: App.methods._showWsDisconnectedBannerNow,
_tickWsDisconnectedLabel: App.methods._tickWsDisconnectedLabel,
_clearWsDisconnectedUi: App.methods._clearWsDisconnectedUi,
_celebrateWsReconnected: App.methods._celebrateWsReconnected,
...overrides,
};
}
@ -71,12 +75,17 @@ describe("App websocket reconnect shell resync", () => {
await App.methods.onWsShellConnected.call(ctx, { isReconnect: true });
expect(ctx.wsDisconnected).toBe(false);
expect(ctx.wsDisconnected).toBe(true);
expect(window.api.get).toHaveBeenCalledWith("/api/v1/auth/status", expect.any(Object));
expect(fetchCsrfToken).toHaveBeenCalledTimes(1);
expect(ctx.updatePropagationNodeStatus).toHaveBeenCalled();
expect(ctx.getConfig).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith("websocket-reconnected");
expect(ctx.wsReconnectedBanner).toBe(false);
App.methods.onWsShellReady.call(ctx);
expect(ctx.wsDisconnected).toBe(false);
expect(ctx.wsReconnectedBanner).toBe(true);
emitSpy.mockRestore();
@ -96,6 +105,9 @@ describe("App websocket reconnect shell resync", () => {
expect(ctx.wsReconnectedBanner).toBe(false);
expect(emitSpy).toHaveBeenCalledWith("websocket-reconnected");
App.methods.onWsShellReady.call(ctx);
expect(ctx.wsReconnectedBanner).toBe(false);
emitSpy.mockRestore();
});
@ -113,7 +125,7 @@ describe("App websocket reconnect shell resync", () => {
expect(ctx.wsDisconnected).toBe(false);
expect(ctx.wsDisconnectGraceTimer).not.toBeNull();
await vi.advanceTimersByTimeAsync(2499);
await vi.advanceTimersByTimeAsync(WS_DISCONNECT_BANNER_GRACE_MS - 1);
expect(ctx.wsDisconnected).toBe(false);
await vi.advanceTimersByTimeAsync(2);
@ -121,6 +133,29 @@ describe("App websocket reconnect shell resync", () => {
expect(ctx.wsDisconnectBannerShown).toBe(true);
});
it("keeps disconnect grace across a TCP open that never becomes ready", async () => {
vi.useFakeTimers();
const ctx = makeShellCtx({
wsDisconnected: false,
wsDisconnectedAt: null,
wsDisconnectBannerShown: false,
wsDisconnectGraceTimer: null,
wsDisconnectTickTimer: null,
});
App.methods.onWsShellDisconnected.call(ctx);
await vi.advanceTimersByTimeAsync(1000);
await App.methods.onWsShellConnected.call(ctx, { isReconnect: true });
expect(ctx.wsDisconnected).toBe(false);
expect(ctx.wsDisconnectGraceTimer).not.toBeNull();
App.methods.onWsShellDisconnected.call(ctx);
await vi.advanceTimersByTimeAsync(WS_DISCONNECT_BANNER_GRACE_MS - 1000);
expect(ctx.wsDisconnected).toBe(true);
expect(ctx.wsDisconnectBannerShown).toBe(true);
});
it("does not resync shell on the first websocket connect", async () => {
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
const ctx = makeShellCtx({
@ -135,6 +170,9 @@ describe("App websocket reconnect shell resync", () => {
expect(ctx.updatePropagationNodeStatus).not.toHaveBeenCalled();
expect(emitSpy).not.toHaveBeenCalledWith("websocket-reconnected");
App.methods.onWsShellReady.call(ctx);
expect(ctx.wsReconnectedBanner).toBe(false);
emitSpy.mockRestore();
});
});

View file

@ -115,6 +115,50 @@ describe("WebSocketConnection module", () => {
WebSocketConnection.destroy();
});
it("emits ready on the first backend frame, not on TCP open", async () => {
const MockWS = makeWsImpl();
global.WebSocket = MockWS;
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
const connected = vi.fn();
const ready = vi.fn();
WebSocketConnection.on("connected", connected);
WebSocketConnection.on("ready", ready);
await WebSocketConnection.connect();
await vi.waitUntil(() => connected.mock.calls.length >= 1);
expect(ready).not.toHaveBeenCalled();
WebSocketConnection.ws.onmessage({ data: JSON.stringify({ type: "pong" }) });
expect(ready).toHaveBeenCalledTimes(1);
WebSocketConnection.ws.onmessage({ data: JSON.stringify({ type: "config", config: {} }) });
expect(ready).toHaveBeenCalledTimes(1);
WebSocketConnection.destroy();
});
it("does not emit ready when the socket opens but never receives a frame", async () => {
const SilentWS = makeSilentWsImpl();
global.WebSocket = SilentWS;
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
const connected = vi.fn();
const ready = vi.fn();
WebSocketConnection.on("connected", connected);
WebSocketConnection.on("ready", ready);
await WebSocketConnection.connect();
await vi.waitUntil(() => connected.mock.calls.length >= 1);
await Promise.resolve();
await Promise.resolve();
expect(ready).not.toHaveBeenCalled();
WebSocketConnection.destroy();
});
it("strips pong from message stream", async () => {
const MockWS = makeWsImpl();
global.WebSocket = MockWS;