mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user