From 537967033b97b7be4f3885fe92728651740152ad Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 10 Oct 2026 07:40:54 +0200 Subject: [PATCH 1/2] test: relay messages name the binding socket they belong to (#231) --- .../src/__tests__/devtools-relay.test.ts | 15 +++++++ .../src/__tests__/devtools-binding.test.ts | 42 ++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/appduct/src/__tests__/devtools-relay.test.ts b/packages/appduct/src/__tests__/devtools-relay.test.ts index 3912448b..043efdf5 100644 --- a/packages/appduct/src/__tests__/devtools-relay.test.ts +++ b/packages/appduct/src/__tests__/devtools-relay.test.ts @@ -114,4 +114,19 @@ describe("relayPage", () => { expect(page.received()).toEqual([{ kind: "open" }]); }); + + it("tags everything it tells the page with the socket the page asked for", () => { + const { page, daemon } = setup(); + page.call({ kind: "open", socket: 7 }); + + daemon.sockets[0]!.open(); + daemon.sockets[0]!.receive('{"type":"session_ack"}'); + daemon.sockets[0]!.closeFromDaemon(1008, "invalid_token"); + + expect(page.received()).toEqual([ + { kind: "open", socket: 7 }, + { kind: "message", socket: 7, text: '{"type":"session_ack"}' }, + { kind: "close", socket: 7, code: 1008, reason: "invalid_token" }, + ]); + }); }); diff --git a/packages/web/src/__tests__/devtools-binding.test.ts b/packages/web/src/__tests__/devtools-binding.test.ts index b2dd63fc..0183b699 100644 --- a/packages/web/src/__tests__/devtools-binding.test.ts +++ b/packages/web/src/__tests__/devtools-binding.test.ts @@ -19,7 +19,47 @@ describe("the devtools binding transport", () => { it("asks the relay for a daemon socket when opened", () => { const { sent } = setup(); - expect(sent).toEqual([{ kind: "open" }]); + expect(sent).toEqual([{ kind: "open", socket: 1 }]); + }); + + it("tags each daemon socket it asks for, so the relay can say which one it means", () => { + const { devtools, socket, sent } = setup(); + socket.close(1001, "superseded"); + + devtools.transport.open("ws://127.0.0.1:49152", { open() {}, message() {}, close() {}, error() {} }); + + expect(sent.filter((message) => message.kind === "open")).toEqual([ + { kind: "open", socket: 1 }, + { kind: "open", socket: 2 }, + ]); + }); + + it("ignores what the relay says about a socket the page has since replaced", () => { + const { devtools, socket } = setup(); + socket.close(1001, "superseded"); + const events: string[] = []; + devtools.transport.open("ws://127.0.0.1:49152", { + open: () => events.push("open"), + message: (text) => events.push(`message:${text}`), + close: (code, reason) => events.push(`close:${code}:${reason}`), + error: (message) => events.push(`error:${message}`), + }); + + devtools.receive(JSON.stringify({ kind: "open", socket: 1 })); + devtools.receive(JSON.stringify({ kind: "message", socket: 1, text: '{"type":"session_ack"}' })); + devtools.receive(JSON.stringify({ kind: "close", socket: 1, code: 1006, reason: "" })); + devtools.receive(JSON.stringify({ kind: "open", socket: 2 })); + + expect(events).toEqual(["open"]); + }); + + it("delivers an untagged relay message to the open socket, as an older relay sends them", () => { + const { devtools, events } = setup(); + + devtools.receive(JSON.stringify({ kind: "open" })); + devtools.receive(JSON.stringify({ kind: "message", text: '{"type":"y"}' })); + + expect(events).toEqual(["open", 'message:{"type":"y"}']); }); it("reports the socket open once the relay says the daemon socket is", () => { From bc9cda937c765dab4afbaac995006dbc33a89702 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 10 Oct 2026 07:47:34 +0200 Subject: [PATCH 2/2] fix(web): drop relay messages for a binding socket the page replaced --- packages/appduct/src/devtools-relay/relay.ts | 10 ++++++---- .../src/__tests__/devtools-binding.test.ts | 4 ++-- packages/web/src/browser/devtools-binding.ts | 20 +++++++++++++------ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/appduct/src/devtools-relay/relay.ts b/packages/appduct/src/devtools-relay/relay.ts index a9dec41c..f4ab9df3 100644 --- a/packages/appduct/src/devtools-relay/relay.ts +++ b/packages/appduct/src/devtools-relay/relay.ts @@ -46,16 +46,18 @@ export const relayPage = (page: PageChannel, link: RelayLink, openDaemonSocket: }; page.onBindingCall((json) => { - const message = JSON.parse(json) as { kind: string; text?: string; code?: number; reason?: string }; + const message = JSON.parse(json) as { kind: string; socket?: number; text?: string; code?: number; reason?: string }; if (message.kind === "open") { closeCurrent(GOING_AWAY, "superseded"); + // Echoed back, so the page can drop what was sent for a socket it has replaced since. + const tag = { socket: message.socket }; const socket: DaemonSocket = openDaemonSocket(url, { - open: () => current === socket && tellPage({ kind: "open" }), - message: (text) => current === socket && tellPage({ kind: "message", text }), + open: () => current === socket && tellPage({ kind: "open", ...tag }), + message: (text) => current === socket && tellPage({ kind: "message", ...tag, text }), close: (code, reason) => { if (current !== socket) return; current = undefined; - tellPage({ kind: "close", code, reason }); + tellPage({ kind: "close", ...tag, code, reason }); }, }); current = socket; diff --git a/packages/web/src/__tests__/devtools-binding.test.ts b/packages/web/src/__tests__/devtools-binding.test.ts index 0183b699..f0dfb022 100644 --- a/packages/web/src/__tests__/devtools-binding.test.ts +++ b/packages/web/src/__tests__/devtools-binding.test.ts @@ -24,7 +24,7 @@ describe("the devtools binding transport", () => { it("tags each daemon socket it asks for, so the relay can say which one it means", () => { const { devtools, socket, sent } = setup(); - socket.close(1001, "superseded"); + socket.close(1000, "superseded"); devtools.transport.open("ws://127.0.0.1:49152", { open() {}, message() {}, close() {}, error() {} }); @@ -36,7 +36,7 @@ describe("the devtools binding transport", () => { it("ignores what the relay says about a socket the page has since replaced", () => { const { devtools, socket } = setup(); - socket.close(1001, "superseded"); + socket.close(1000, "superseded"); const events: string[] = []; devtools.transport.open("ws://127.0.0.1:49152", { open: () => events.push("open"), diff --git a/packages/web/src/browser/devtools-binding.ts b/packages/web/src/browser/devtools-binding.ts index 4a2a675a..bb9e5dde 100644 --- a/packages/web/src/browser/devtools-binding.ts +++ b/packages/web/src/browser/devtools-binding.ts @@ -5,19 +5,26 @@ import type { SocketEvents, Transport } from "../core/index.js"; * `{ kind }` messages through `window.__appductBinding(json)`; the relay answers by calling * `__APPDUCT__.receive(json)`: * - * - page to relay: `open`, `send` (with `text`), `close` (with `code` and `reason`) - * - relay to page: `open`, `message` (with `text`), `close` (with `code` and `reason`) + * - page to relay: `open` (with `socket`), `send` (with `text`), `close` (with `code` and `reason`) + * - relay to page: `open`, `message` (with `text`), `close` (with `code` and `reason`), each with + * the `socket` of the `open` it answers + * + * `socket` numbers the page's sockets, so a message the relay sent for one the page has since + * replaced is dropped instead of reaching the new socket's handshake. A message without one (an + * older relay, or `attachPage` closing the page's socket) goes to the open socket. */ export const createDevtoolsBindingTransport = ( sendToRelay: (json: string) => void, ): { transport: Transport; receive(json: string): void } => { - let active: { events: SocketEvents; isOpen: boolean } | undefined; + let active: { id: number; events: SocketEvents; isOpen: boolean } | undefined; + let lastId = 0; return { transport: { open(_url, events) { - const entry = { events, isOpen: false }; - sendToRelay(JSON.stringify({ kind: "open" })); + lastId += 1; + const entry = { id: lastId, events, isOpen: false }; + sendToRelay(JSON.stringify({ kind: "open", socket: entry.id })); active = entry; return { send(text) { @@ -38,7 +45,8 @@ export const createDevtoolsBindingTransport = ( receive(json) { const entry = active; if (!entry) return; - const message = JSON.parse(json) as { kind: string; text?: string; code?: number; reason?: string }; + const message = JSON.parse(json) as { kind: string; socket?: number; text?: string; code?: number; reason?: string }; + if (message.socket !== undefined && message.socket !== entry.id) return; if (message.kind === "open") { entry.isOpen = true; entry.events.open();