mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
fix: improve WebSocket disconnect handling and UI responsiveness
This commit is contained in:
parent
257a1461d4
commit
2521299125
7 changed files with 136 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue