diff --git a/CHANGELOG.md b/CHANGELOG.md index b67bd0aa..f3371e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/meshchatx.rsm b/meshchatx.rsm index 45d1adb6..55c5017e 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 88ff17da..16e630c5 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -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() { diff --git a/meshchatx/src/frontend/js/WebSocketConnection.js b/meshchatx/src/frontend/js/WebSocketConnection.js index ffb403bc..37a50f31 100644 --- a/meshchatx/src/frontend/js/WebSocketConnection.js +++ b/meshchatx/src/frontend/js/WebSocketConnection.js @@ -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); diff --git a/meshchatx/src/frontend/js/wsConnectionSupport.js b/meshchatx/src/frontend/js/wsConnectionSupport.js index d3e57c88..4e141b0e 100644 --- a/meshchatx/src/frontend/js/wsConnectionSupport.js +++ b/meshchatx/src/frontend/js/wsConnectionSupport.js @@ -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 diff --git a/tests/frontend/AppWsReconnectResync.test.js b/tests/frontend/AppWsReconnectResync.test.js index d728c1d8..1eb45755 100644 --- a/tests/frontend/AppWsReconnectResync.test.js +++ b/tests/frontend/AppWsReconnectResync.test.js @@ -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(); }); }); diff --git a/tests/frontend/WebSocketConnection.test.js b/tests/frontend/WebSocketConnection.test.js index b0026603..a2d72f03 100644 --- a/tests/frontend/WebSocketConnection.test.js +++ b/tests/frontend/WebSocketConnection.test.js @@ -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;