From 012faead6383b4d8b453c8c04b082a3492db9fc7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:58:16 +0100 Subject: [PATCH 1/3] test(core): cover multi-device relay pairing addressing on mesh-session Adds a deviceC fixture and RED tests proving mesh-session's relay handling must read to-device/from-device per frame rather than track a single most-recently-established pairing: concurrent relay pairings must each attribute an inbound request to its real sender, a response must address back to that sender even after a different pairing was established in between, and a previously-paired target must not trigger a redundant relay-connect after pairing with someone else. Also updates the existing fromDevice/relay-connect assertions to match the wire-mesh#30 hub, which always stamps from-device on every forwarded relay-data frame. --- .../core/test/mesh-session-fixtures.ts | 1 + .../test/mesh-session-routing.unit.test.ts | 151 +++++++++++++++++- 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/ts/packages/core/test/mesh-session-fixtures.ts b/ts/packages/core/test/mesh-session-fixtures.ts index 40b3403..71143cb 100644 --- a/ts/packages/core/test/mesh-session-fixtures.ts +++ b/ts/packages/core/test/mesh-session-fixtures.ts @@ -11,6 +11,7 @@ import { deviceIdFromFillHex } from "./hex.js"; export const deviceA = deviceIdFromFillHex("11"); export const deviceB = deviceIdFromFillHex("22"); +export const deviceC = deviceIdFromFillHex("33"); export const testIdentityDeviceId = deviceIdFromFillHex("ee"); export const testIdentity: IdentityPort = { diff --git a/ts/packages/core/test/mesh-session-routing.unit.test.ts b/ts/packages/core/test/mesh-session-routing.unit.test.ts index 901f7e9..0b4e310 100644 --- a/ts/packages/core/test/mesh-session-routing.unit.test.ts +++ b/ts/packages/core/test/mesh-session-routing.unit.test.ts @@ -20,6 +20,7 @@ import { TEST_INCOMING_REQUEST_ID, deviceA, deviceB, + deviceC, fakeTransport, nthEvent, testClock, @@ -80,6 +81,7 @@ describe("relay routing", () => { }); const relayData = frameAt(connection.sent, LAST_SENT); + expect((relayData as RelayDataFrame)["to-device"]).toEqual(deviceA); const inner = unwrapRelayData(relayData) as ManageRequestFrame; expect(inner.type).toBe("manage-request"); expect(inner.command).toEqual(testCommand); @@ -136,7 +138,7 @@ describe("relay routing", () => { await expect(second).rejects.toThrow(); }); - it("dispatches a relay-data frame wrapping a manage-request into incomingManageRequests, with fromDevice set from the establishing relay-inbound", async () => { + it("dispatches a relay-data frame wrapping a manage-request into incomingManageRequests, with fromDevice set from the frame's own from-device field", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); await session.connect("ws://node", ["core/management"]); @@ -157,6 +159,7 @@ describe("relay routing", () => { connection.push({ type: "relay-data", payload: messageFromFrame(wrapped), + "from-device": deviceA, } satisfies RelayDataFrame); const incoming = await incomingDone; @@ -167,6 +170,7 @@ describe("relay routing", () => { await incoming.respond({ result: "ok" }); const sentResponse = frameAt(connection.sent, LAST_SENT); + expect((sentResponse as RelayDataFrame)["to-device"]).toEqual(deviceA); const innerResponse = unwrapRelayData(sentResponse); expect(innerResponse).toEqual({ type: "manage-response", @@ -176,6 +180,151 @@ describe("relay routing", () => { await session.close(); }); + it("attributes fromDevice from the relay-data frame's own from-device field, not from whichever relay pairing was most recently established", async () => { + // Regression test for wire-mesh#170: a single-value "most recent pairing" tracker collapses concurrent relay pairings, mis-attributing every inbound request to whichever peer paired last regardless of who actually sent it. Two pairings are established (A first, then B) and a request stamped from-device: A must still be attributed to A, not silently reassigned to B because it was established more recently. + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + + const incomingDone = (async (): Promise => { + const iterator = session.incomingManageRequests[Symbol.asyncIterator](); + const result = await iterator.next(); + return result.value as IncomingManageRequest; + })(); + + connection.push({ type: "relay-inbound", "source-device": deviceA }); + connection.push({ type: "relay-inbound", "source-device": deviceB }); + const wrapped: ManageRequestFrame = { + type: "manage-request", + "request-id": TEST_INCOMING_REQUEST_ID, + command: testCommand, + scope: testScope, + }; + connection.push({ + type: "relay-data", + payload: messageFromFrame(wrapped), + "from-device": deviceA, + } satisfies RelayDataFrame); + + const incoming = await incomingDone; + expect(incoming.fromDevice).toEqual(deviceA); + await session.close(); + }); + + it("exposes toDevice from the relay-data frame's own to-device field, for a caller fronting more than one local device to route on", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + + const incomingDone = (async (): Promise => { + const iterator = session.incomingManageRequests[Symbol.asyncIterator](); + const result = await iterator.next(); + return result.value as IncomingManageRequest; + })(); + + const wrapped: ManageRequestFrame = { + type: "manage-request", + "request-id": TEST_INCOMING_REQUEST_ID, + command: testCommand, + scope: testScope, + }; + connection.push({ + type: "relay-data", + payload: messageFromFrame(wrapped), + "from-device": deviceA, + "to-device": deviceB, + } satisfies RelayDataFrame); + + const incoming = await incomingDone; + expect(incoming.toDevice).toEqual(deviceB); + await session.close(); + }); + + it("does not resend relay-connect for a previously-paired target after pairing with a different target in between", async () => { + // Regression test for wire-mesh#170: the old single-value pairing tracker treated pairing with a new target as replacing the old one, so returning to an already-paired device sent a redundant relay-connect. relay-hub.ts has tracked a real multiplexed adjacency map (multiple simultaneous pairings per connection) since #30; the session side must hold onto every pairing it has established, not just the latest. + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const firstDone = nthEvent(session, EVENTS_THROUGH_FIRST_RELAY_REQUEST); + const first = session.sendManageRequest(testCommand, testScope, deviceA); + await firstDone; + const secondDone = nthEvent(session, EVENTS_PER_RELAY_REQUEST_NEW_TARGET); + const second = session.sendManageRequest(testCommand, testScope, deviceB); + await secondDone; + const thirdDone = nthEvent(session, EVENTS_PER_RELAY_REQUEST_SAME_TARGET); + const third = session.sendManageRequest(testCommand, testScope, deviceA); + await thirdDone; + + const relayConnects = connection.sent.filter( + (frame) => frame.type === "relay-connect", + ); + expect(relayConnects).toEqual([ + { type: "relay-connect", "target-device": deviceA }, + { type: "relay-connect", "target-device": deviceB }, + ]); + await session.close(); + await expect(first).rejects.toThrow(); + await expect(second).rejects.toThrow(); + await expect(third).rejects.toThrow(); + }); + + it("addresses a response back to the request's own source device via to-device, even after a different relay pairing was established in between", async () => { + // Regression test for wire-mesh#170: two concurrent inbound requests from two different peers relayed through the same hub connection must each get their response addressed back to the actual sender, not to whichever pairing is currently "most recent" from the hub's own fallback perspective. + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + + const incoming: IncomingManageRequest[] = []; + const collectIncoming = (async (): Promise => { + for await (const request of session.incomingManageRequests) { + incoming.push(request); + if (incoming.length === 2) { + return; + } + } + })(); + + connection.push({ type: "relay-inbound", "source-device": deviceA }); + connection.push({ + type: "relay-data", + payload: messageFromFrame({ + type: "manage-request", + "request-id": 1, + command: testCommand, + scope: testScope, + } satisfies ManageRequestFrame), + "from-device": deviceA, + } satisfies RelayDataFrame); + + connection.push({ type: "relay-inbound", "source-device": deviceC }); + connection.push({ + type: "relay-data", + payload: messageFromFrame({ + type: "manage-request", + "request-id": 2, + command: testCommand, + scope: testScope, + } satisfies ManageRequestFrame), + "from-device": deviceC, + } satisfies RelayDataFrame); + + await collectIncoming; + const [fromA, fromC] = incoming; + if (fromA === undefined || fromC === undefined) { + throw new Error("expected two incoming manage-requests"); + } + + await fromA.respond({ result: "ok" }); + const responseToA = frameAt(connection.sent, LAST_SENT); + expect((responseToA as RelayDataFrame)["to-device"]).toEqual(deviceA); + + await fromC.respond({ result: "ok" }); + const responseToC = frameAt(connection.sent, LAST_SENT); + expect((responseToC as RelayDataFrame)["to-device"]).toEqual(deviceC); + + await session.close(); + }); + it("resolves a pending sendManageRequest from a relay-data frame wrapping the matching manage-response", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); From 12ef20c78250f9e8f12a168a8b010cbcfae5d19e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:58:22 +0100 Subject: [PATCH 2/3] fix(core): address concurrent relay pairings by device, not by recency mesh-session tracked at most one active relay pairing in a single relayPeerDevice field, shared across every device this connection was ever paired with. Establishing a new pairing silently overwrote it, so an inbound request was always attributed to whichever peer paired most recently rather than whoever actually sent it, and a response was addressed back the same wrong way -- relay-hub.ts has forwarded real per-frame to-device/from-device addressing since wire-mesh#30, but mesh-session never read or wrote either field. Replaces the single field with a relayPairings map of every pairing this connection currently holds, used only to avoid a redundant relay-connect for an already-paired target. Outbound relay-data is now always explicitly addressed: sendManageRequest stamps to-device with its own targetDevice, and respond() echoes back whatever from-device the original request actually carried. Inbound attribution (fromDevice and the new toDevice, for a caller fronting more than one local device behind one hub connection) is read solely from each relay-data frame's own fields, never guessed from pairing state. --- ts/packages/core/src/domain/mesh-session.ts | 53 +++++++++++++-------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/ts/packages/core/src/domain/mesh-session.ts b/ts/packages/core/src/domain/mesh-session.ts index 980c424..61761fa 100644 --- a/ts/packages/core/src/domain/mesh-session.ts +++ b/ts/packages/core/src/domain/mesh-session.ts @@ -17,6 +17,7 @@ import { type ManageResponseFrame, type PeerAdvert, type ProtocolVersion, + type RelayDataFrame, type RevocationAnnounceFrame, type RevocationEntry, } from "../generated/protocol.js"; @@ -81,8 +82,10 @@ export interface IncomingManageRequest { command: ManageCommand; scope: CapabilityScope; token?: CapabilityToken; - /** The device-id of the peer this request was relayed on behalf of, present only when the request arrived wrapped in a relay-data frame rather than directly over this session's own connection. A caller that needs to address a further request back to the same peer (one not sent via respond(), which already routes back correctly on its own) passes this as sendManageRequest's targetDevice. */ + /** The device-id of the peer this request was relayed on behalf of, read directly from the enclosing relay-data-frame's own `from-device` field (stamped by the hub on every frame it forwards, wire-mesh#30) -- present only when the request arrived wrapped in a relay-data frame that carried one. A caller that needs to address a further request back to the same peer (one not sent via respond(), which already routes back correctly on its own) passes this as sendManageRequest's targetDevice. Never inferred from which relay pairing happens to be most recently established: a connection can hold several concurrent pairings (wire-mesh#30's own multiplexed adjacency map), so only the frame's own per-message addressing can say who actually sent it. */ fromDevice?: DeviceId; + /** The device-id this request's relay-data frame was explicitly addressed to, read from its own `to-device` field -- present only when the request arrived relay-wrapped and the frame carried one. A caller fronting more than one locally-addressable device behind a single hub connection (a gateway advertising several local peers through the same relay pairing) uses this to decide whether the request is for this device or should be routed on to a different local peer it also advertises; this session has no such routing logic of its own, since it represents exactly one identity. */ + toDevice?: DeviceId; respond: (outcome: ManageOutcome) => Promise; } @@ -192,8 +195,8 @@ function createSessionCore( let attempt = 0; let currentToken: CapabilityToken | null = null; let nextRequestId = 0; - // The device-id this session's relay-hub connection is currently paired with, in either role: set when this session sends its own relay-connect (initiator role), or when it receives a relay-inbound naming who is now paired with it (target role). relay-hub pairs at most one device per connection at a time -- a fresh relay-connect re-pairs totally -- so a single field is enough to track it, in whichever role this session is currently playing. - let relayPeerDevice: DeviceId | null = null; + // Every device this session's own connection currently holds a relay pairing with, keyed by hex device-id, in either role: added when this session sends its own relay-connect (initiator role) or when it receives a relay-inbound naming who is now paired with it (target role). relay-hub has supported multiple simultaneous pairings per connection since wire-mesh#30 (a symmetric adjacency map, not a single slot) -- this mirrors that on the session side, so establishing a pairing with a new target never discards an already-established one with a different target. Used only to avoid a redundant relay-connect for an already-paired target (ensureRelayPairing); outbound relay-data is always addressed explicitly via targetDevice/fromDevice rather than read back out of this map, and inbound attribution comes solely from each frame's own to-device/from-device fields -- never from this map -- so a stale or merely-most-recent entry here can never mis-attribute a message. + const relayPairings = new Map(); const pendingManageRequests = new Map< number, { @@ -266,15 +269,20 @@ function createSessionCore( }; } - /** Sends a frame, wrapping it as relay-data first when viaRelay is set -- the single choke point every outbound manage-request/manage-response passes through, so a consumer of sendManageRequest/respond never needs its own relay-wrapping logic. */ - async function transmit(frame: Frame, viaRelay: boolean): Promise { + /** Sends a frame, wrapping it as relay-data first when viaRelay is set -- the single choke point every outbound manage-request/manage-response passes through, so a consumer of sendManageRequest/respond never needs its own relay-wrapping logic. When relaying, toDevice is stamped onto the outer relay-data-frame's own `to-device` field so the hub addresses it to the correct pairing directly (wire-mesh#30) rather than falling back to whichever pairing it last saw -- the one case this is omitted is a response to a request that itself arrived with no from-device to echo back, which is left to that same hub fallback exactly as an unaddressed relay-data always has been. */ + async function transmit( + frame: Frame, + viaRelay: boolean, + toDevice?: DeviceId, + ): Promise { if (connection === null) { throw new Error("not connected"); } if (viaRelay) { - const relayFrame: Frame = { + const relayFrame: RelayDataFrame = { type: "relay-data", payload: messageFromFrame(frame), + ...(toDevice !== undefined ? { "to-device": toDevice } : {}), }; await connection.send(relayFrame); return; @@ -294,16 +302,17 @@ function createSessionCore( function applyManageRequest( frame: ManageRequestFrame, viaRelay: boolean, + fromDevice?: DeviceId, + toDevice?: DeviceId, ): void { const requestId = frame["request-id"]; - const fromDevice = - viaRelay && relayPeerDevice !== null ? relayPeerDevice : undefined; const incoming: IncomingManageRequest = { requestId, command: frame.command, scope: frame.scope, ...(frame.token !== undefined ? { token: frame.token } : {}), ...(fromDevice !== undefined ? { fromDevice } : {}), + ...(toDevice !== undefined ? { toDevice } : {}), respond: async (outcome: ManageOutcome): Promise => { const response: ManageResponseFrame = { type: "manage-response", @@ -311,7 +320,7 @@ function createSessionCore( outcome, }; frameLog.push({ direction: "sent", frame: response }); - await transmit(response, viaRelay); + await transmit(response, viaRelay, fromDevice); emit(); }, }; @@ -330,7 +339,12 @@ function createSessionCore( if (inner.type === "manage-response") { applyManageResponse(inner); } else { - applyManageRequest(inner, true); + applyManageRequest( + inner, + true, + frame["from-device"], + frame["to-device"], + ); } return; } @@ -350,8 +364,11 @@ function createSessionCore( onPeerAdvert?.(advert); } } else if (frame.type === "relay-inbound") { - // The target-role side of a relay-connect pairing learns who dialed it only via this frame -- there is no ack frame for relay-connect itself, so an initiator simply proceeds to relay-data right after sending it. - relayPeerDevice = frame["source-device"]; + // The target-role side of a relay-connect pairing learns who dialed it only via this frame -- there is no ack frame for relay-connect itself, so an initiator simply proceeds to relay-data right after sending it. Added to relayPairings rather than replacing a single tracked value, since this connection may already hold other established pairings (wire-mesh#30's own multiplexed adjacency map) that must not be discarded. + relayPairings.set( + deviceIdToHex(frame["source-device"]), + frame["source-device"], + ); } else if (frame.type === "manage-response") { applyManageResponse(frame); } else if (frame.type === "manage-request") { @@ -363,15 +380,13 @@ function createSessionCore( } } - /** Establishes a relay-connect pairing to targetDevice if this session isn't already paired with it -- a no-op when it already is, whether that pairing was established by this session's own prior relay-connect (initiator role) or learned from an incoming relay-inbound (target role, replying back to whoever dialed it). relay-connect has no ack frame: the initiator proceeds to relay-data right after sending it. */ + /** Establishes a relay-connect pairing to targetDevice if this session isn't already paired with it -- a no-op when it already is, whether that pairing was established by this session's own prior relay-connect (initiator role) or learned from an incoming relay-inbound (target role, replying back to whoever dialed it). Pairing with a new target never tears down an existing pairing with a different one: this connection can hold several simultaneously (wire-mesh#30's own multiplexed adjacency map), so a later request back to an already-paired target must not re-send relay-connect for it. relay-connect has no ack frame: the initiator proceeds to relay-data right after sending it. */ async function ensureRelayPairing(targetDevice: DeviceId): Promise { if (connection === null) { throw new Error("not connected"); } - if ( - relayPeerDevice !== null && - deviceIdToHex(relayPeerDevice) === deviceIdToHex(targetDevice) - ) { + const key = deviceIdToHex(targetDevice); + if (relayPairings.has(key)) { return; } const relayConnect: Frame = { @@ -380,7 +395,7 @@ function createSessionCore( }; frameLog.push({ direction: "sent", frame: relayConnect }); await connection.send(relayConnect); - relayPeerDevice = targetDevice; + relayPairings.set(key, targetDevice); emit(); } @@ -636,7 +651,7 @@ function createSessionCore( pendingManageRequests.set(requestId, { resolve, reject }); }); frameLog.push({ direction: "sent", frame }); - await transmit(frame, targetDevice !== undefined); + await transmit(frame, targetDevice !== undefined, targetDevice); emit(); if (timeoutMs === undefined) { return outcome; From d30dd8a823d192d6b0abfb7b154ad53aa29ba4a0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 20:05:33 +0100 Subject: [PATCH 3/3] refactor(core): extract relay pairing and relay-data wrapping helpers mesh-session.ts's relay-connect/relay-data handling had grown past the project's own 800-line file budget once addressing was fixed to read to-device/from-device per frame instead of a single tracked pairing. Pulls two genuinely reusable pieces out into their own small modules rather than trimming comments to fit: relay-pairing.ts owns the "which devices is this connection already paired with" set (used only to avoid a redundant relay-connect), and frame-codec.ts's new wrapRelayData mirrors its own tryDecodeFrame for the outbound side of the same relay-data envelope. applyManageRequest now takes the relay-data-frame itself rather than two separately-threaded fields, since it already carries both to-device and from-device. No behaviour change. --- ts/packages/core/src/adapters/frame-codec.ts | 19 ++++++++- ts/packages/core/src/domain/mesh-session.ts | 44 +++++++------------- ts/packages/core/src/domain/relay-pairing.ts | 19 +++++++++ 3 files changed, 53 insertions(+), 29 deletions(-) create mode 100644 ts/packages/core/src/domain/relay-pairing.ts diff --git a/ts/packages/core/src/adapters/frame-codec.ts b/ts/packages/core/src/adapters/frame-codec.ts index 0539bc8..318157e 100644 --- a/ts/packages/core/src/adapters/frame-codec.ts +++ b/ts/packages/core/src/adapters/frame-codec.ts @@ -1,13 +1,30 @@ // The CBOR frame codec shared by every message-based Connection adapter (WebSocket, WebRTC DataChannel, or any future one): one CBOR frame per message, no length prefix, with schema validation distinguishing an undecodable payload (connection-level failure) from a decodable-but-unrecognised frame (dropped, connection survives). Distinct from tcp-transport.ts's own inline codec, which frames a byte *stream* with a length prefix -- a different transport shape, not a duplicate of this one. import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2"; -import { frameSchema, type Frame } from "../generated/protocol.js"; +import { + frameSchema, + type DeviceId, + type Frame, + type RelayDataFrame, +} from "../generated/protocol.js"; export function messageFromFrame(frame: Frame): Uint8Array { // A fresh whole-buffer view over a plain ArrayBuffer: the WebSocket/DataChannel send signatures require it, and it matches the fresh-buffer discipline the other adapters apply to anything crossing a runtime boundary. return new Uint8Array(encode(frame, cdeEncodeOptions)); } +/** Wraps frame as a relay-data-frame's own opaque payload, stamping to-device when the caller knows which established pairing to address it to (wire-mesh#30) -- the outbound counterpart to tryDecodeFrame's own doc comment below, which describes the inbound side of the same relay-data envelope. Omitting toDevice leaves the frame unaddressed, which the receiving hub then routes via its own most-recently-established-pairing fallback. */ +export function wrapRelayData( + frame: Frame, + toDevice?: DeviceId, +): RelayDataFrame { + return { + type: "relay-data", + payload: messageFromFrame(frame), + ...(toDevice !== undefined ? { "to-device": toDevice } : {}), + }; +} + /** A frame that fails schema validation, caught separately from a decode failure so it can be dropped without disconnecting. */ export class SchemaInvalidFrameError extends Error { constructor(message: string) { diff --git a/ts/packages/core/src/domain/mesh-session.ts b/ts/packages/core/src/domain/mesh-session.ts index 61761fa..84a85da 100644 --- a/ts/packages/core/src/domain/mesh-session.ts +++ b/ts/packages/core/src/domain/mesh-session.ts @@ -23,10 +23,11 @@ import { } from "../generated/protocol.js"; import { SUPPORTED_PROTOCOL_VERSION, negotiate } from "./handshake.js"; import { deviceIdToHex } from "./device-id.js"; +import { createRelayPairings } from "./relay-pairing.js"; import type { Clock } from "../ports/clock.js"; import type { IdentityPort } from "../ports/identity.js"; import type { Connection, Transport } from "../ports/transport.js"; -import { messageFromFrame, tryDecodeFrame } from "../adapters/frame-codec.js"; +import { tryDecodeFrame, wrapRelayData } from "../adapters/frame-codec.js"; const MS_PER_SECOND = 1000; @@ -195,8 +196,8 @@ function createSessionCore( let attempt = 0; let currentToken: CapabilityToken | null = null; let nextRequestId = 0; - // Every device this session's own connection currently holds a relay pairing with, keyed by hex device-id, in either role: added when this session sends its own relay-connect (initiator role) or when it receives a relay-inbound naming who is now paired with it (target role). relay-hub has supported multiple simultaneous pairings per connection since wire-mesh#30 (a symmetric adjacency map, not a single slot) -- this mirrors that on the session side, so establishing a pairing with a new target never discards an already-established one with a different target. Used only to avoid a redundant relay-connect for an already-paired target (ensureRelayPairing); outbound relay-data is always addressed explicitly via targetDevice/fromDevice rather than read back out of this map, and inbound attribution comes solely from each frame's own to-device/from-device fields -- never from this map -- so a stale or merely-most-recent entry here can never mis-attribute a message. - const relayPairings = new Map(); + // Every device this session's own connection currently holds a relay pairing with, in either role: added when this session sends its own relay-connect (initiator role) or when it receives a relay-inbound naming who is now paired with it (target role). See relay-pairing.ts for why establishing a pairing with a new target never discards an already-established one with a different target, and why this is never consulted for addressing -- only for ensureRelayPairing's own "already paired" check. + const relayPairings = createRelayPairings(); const pendingManageRequests = new Map< number, { @@ -279,12 +280,7 @@ function createSessionCore( throw new Error("not connected"); } if (viaRelay) { - const relayFrame: RelayDataFrame = { - type: "relay-data", - payload: messageFromFrame(frame), - ...(toDevice !== undefined ? { "to-device": toDevice } : {}), - }; - await connection.send(relayFrame); + await connection.send(wrapRelayData(frame, toDevice)); return; } await connection.send(frame); @@ -299,13 +295,14 @@ function createSessionCore( } } + /** relayFrame is present only for a manage-request that arrived wrapped in relay-data, and is that same outer relay-data-frame -- its own to-device/from-device fields carry whatever addressing it received. See IncomingManageRequest's own fromDevice/toDevice doc comments for what each means and why neither is ever guessed from pairing state. */ function applyManageRequest( frame: ManageRequestFrame, - viaRelay: boolean, - fromDevice?: DeviceId, - toDevice?: DeviceId, + relayFrame?: RelayDataFrame, ): void { const requestId = frame["request-id"]; + const fromDevice = relayFrame?.["from-device"]; + const toDevice = relayFrame?.["to-device"]; const incoming: IncomingManageRequest = { requestId, command: frame.command, @@ -320,7 +317,7 @@ function createSessionCore( outcome, }; frameLog.push({ direction: "sent", frame: response }); - await transmit(response, viaRelay, fromDevice); + await transmit(response, relayFrame !== undefined, fromDevice); emit(); }, }; @@ -339,12 +336,7 @@ function createSessionCore( if (inner.type === "manage-response") { applyManageResponse(inner); } else { - applyManageRequest( - inner, - true, - frame["from-device"], - frame["to-device"], - ); + applyManageRequest(inner, frame); } return; } @@ -364,15 +356,12 @@ function createSessionCore( onPeerAdvert?.(advert); } } else if (frame.type === "relay-inbound") { - // The target-role side of a relay-connect pairing learns who dialed it only via this frame -- there is no ack frame for relay-connect itself, so an initiator simply proceeds to relay-data right after sending it. Added to relayPairings rather than replacing a single tracked value, since this connection may already hold other established pairings (wire-mesh#30's own multiplexed adjacency map) that must not be discarded. - relayPairings.set( - deviceIdToHex(frame["source-device"]), - frame["source-device"], - ); + // The target-role side of a relay-connect pairing learns who dialed it only via this frame -- there is no ack frame for relay-connect itself, so an initiator simply proceeds to relay-data right after sending it. + relayPairings.add(frame["source-device"]); } else if (frame.type === "manage-response") { applyManageResponse(frame); } else if (frame.type === "manage-request") { - applyManageRequest(frame, false); + applyManageRequest(frame); } else if (frame.type === "revocation-announce") { for (const entry of frame.entries) { emitRevocationEntry(entry); @@ -385,8 +374,7 @@ function createSessionCore( if (connection === null) { throw new Error("not connected"); } - const key = deviceIdToHex(targetDevice); - if (relayPairings.has(key)) { + if (relayPairings.has(targetDevice)) { return; } const relayConnect: Frame = { @@ -395,7 +383,7 @@ function createSessionCore( }; frameLog.push({ direction: "sent", frame: relayConnect }); await connection.send(relayConnect); - relayPairings.set(key, targetDevice); + relayPairings.add(targetDevice); emit(); } diff --git a/ts/packages/core/src/domain/relay-pairing.ts b/ts/packages/core/src/domain/relay-pairing.ts new file mode 100644 index 0000000..d47d9ec --- /dev/null +++ b/ts/packages/core/src/domain/relay-pairing.ts @@ -0,0 +1,19 @@ +// Tracks every relay pairing a MeshSession's own connection currently holds, keyed by device hex -- the session-side counterpart to relay-hub.ts's own multiplexed adjacency map (wire-mesh#30): a connection can hold simultaneous pairings with several remote devices, so pairing with a new target must never discard an already-established pairing with a different one. Deliberately holds no addressing logic of its own -- outbound relay-data is always addressed explicitly via the caller's own known target/source device (see mesh-session.ts's transmit/applyManageRequest), and inbound attribution is read solely from each frame's own to-device/from-device fields, never guessed from this set. This exists only to answer "have we already relay-connected to this device", so ensureRelayPairing never sends a redundant relay-connect for a target it is already paired with. + +import type { DeviceId } from "../generated/protocol.js"; +import { deviceIdToHex } from "./device-id.js"; + +export interface RelayPairings { + has: (device: DeviceId) => boolean; + add: (device: DeviceId) => void; +} + +export function createRelayPairings(): RelayPairings { + const established = new Map(); + return { + has: (device) => established.has(deviceIdToHex(device)), + add: (device) => { + established.set(deviceIdToHex(device), device); + }, + }; +}