fix(desktop): actually close relay sockets — plugin:websocket|disconnect does not exist (#1481)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-02 22:55:53 +00:00
committed by GitHub
co-authored by Brain
parent 3e282a2418
commit e70dd1a1e7
6 changed files with 117 additions and 22 deletions
@@ -7,6 +7,7 @@ import {
sortEvents,
type RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
const AUTH_TIMEOUT_MS = 8_000;
const HISTORY_TIMEOUT_MS = 8_000;
@@ -62,9 +63,7 @@ export class ReadOnlyRelayClient {
this.generation++;
if (this.wsId !== null) {
void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch(
() => {},
);
void closeWebSocket(this.wsId, "observer disconnected");
this.wsId = null;
}
+3 -8
View File
@@ -34,6 +34,7 @@ import {
shouldScheduleReconnect,
} from "@/shared/api/relayReconnectPolicy";
import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog";
import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
const RECONNECT_BASE_DELAY_MS = 1_000,
@@ -114,9 +115,7 @@ export class RelayClient {
this.connectionStateEmitter.set("idle");
if (this.wsId !== null) {
void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch(
() => {},
);
void closeWebSocket(this.wsId, "workspace switch");
this.wsId = null;
}
@@ -981,11 +980,7 @@ export class RelayClient {
}
if (this.wsId !== null) {
void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch(
(err) => {
console.warn("[RelayClientSession] disconnect failed:", err);
},
);
void closeWebSocket(this.wsId, "connection reset");
}
this.wsId = null;
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { closeWebSocket } from "./relayWebSocketClose.ts";
test("closeWebSocket sends a Close frame through plugin:websocket|send", async () => {
const calls = [];
await closeWebSocket(42, "workspace switch", async (cmd, args) => {
calls.push({ cmd, args });
});
assert.equal(calls.length, 1);
assert.equal(calls[0].cmd, "plugin:websocket|send");
assert.deepEqual(calls[0].args, {
id: 42,
message: {
type: "Close",
data: { code: 1000, reason: "workspace switch" },
},
});
});
test("closeWebSocket swallows send failures (socket already gone)", async () => {
await closeWebSocket(7, "connection reset", async () => {
throw new Error("WebSocket connection not found");
});
});
// Regression guard: tauri-plugin-websocket registers only `connect` and
// `send` — there is no `disconnect` command. Invoking one rejects silently
// and leaks the socket (relay zombie pile, workspace-switch disconnects).
// Any socket teardown must go through closeWebSocket.
test("no source file invokes the nonexistent plugin:websocket|disconnect command", () => {
const srcRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
);
const offenders = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
if (!/\.(ts|tsx|js|jsx|mjs)$/.test(entry.name)) continue;
if (full === fileURLToPath(import.meta.url)) continue;
if (
fs.readFileSync(full, "utf8").includes("plugin:websocket|disconnect")
) {
offenders.push(path.relative(srcRoot, full));
}
}
};
walk(srcRoot);
assert.deepEqual(
offenders,
[],
"plugin:websocket|disconnect does not exist in tauri-plugin-websocket — use closeWebSocket (Close frame via plugin:websocket|send) instead",
);
});
@@ -0,0 +1,28 @@
import { invoke } from "@tauri-apps/api/core";
/**
* tauri-plugin-websocket 2.4.2 registers only `connect` and `send` there is
* no `disconnect` command, so invoking one rejects and the socket leaks. Close
* the way the plugin's own JS API does: send a Close frame; the plugin's read
* loop drops the connection when the peer echoes the Close (or the TCP read
* stream terminates).
*/
export function closeWebSocket(
id: number,
reason: string,
invokeFn: typeof invoke = invoke,
): Promise<void> {
return invokeFn("plugin:websocket|send", {
id,
message: {
type: "Close",
data: { code: 1000, reason },
},
}).then(
() => undefined,
(err) => {
// Expected when the socket is already gone; greppable for anything else.
console.debug(`closeWebSocket(${id}, ${reason}) rejected:`, err);
},
);
}
-8
View File
@@ -7985,14 +7985,6 @@ export function maybeInstallE2eTauriMocks() {
return sendToMockSocket(
payload as Parameters<typeof sendToMockSocket>[0],
);
case "plugin:websocket|disconnect":
if (isRelayMode(activeConfig)) {
realSockets.get((payload as { id: number }).id)?.close();
realSockets.delete((payload as { id: number }).id);
return;
}
return disconnectMockSocket((payload as { id: number }).id);
case "plugin:window|show":
case "plugin:window|unminimize":
case "plugin:window|set_focus":
+17 -3
View File
@@ -1316,15 +1316,29 @@ test("membership denial can import a different invited key", async ({
// Alice already has a relay profile with a display name, so after the
// identity swap the onboarding gate auto-completes.
// The identity swap must tear down the old relay socket. There is no
// `plugin:websocket|disconnect` command in tauri-plugin-websocket — closing
// is a Close frame sent through `plugin:websocket|send`.
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
command: string;
payload: unknown;
}>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__?.some(
(entry) =>
entry.command === "plugin:websocket|send" &&
(entry.payload as { message?: { type?: string } })?.message
?.type === "Close",
) ?? false,
),
)
.toEqual(expect.arrayContaining(["plugin:websocket|disconnect"]));
.toBe(true);
await expect
.poll(() =>
page.evaluate((storageKey) => {