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);
},
);
}