From 6a22eaa3a2d008ae1cf72b145824d81e8db6957a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 02:13:38 +0100 Subject: [PATCH] feat(core): gossip this side's own currently-hosted public/private rooms WireMeshTransport's periodic gossip tick (previously presence-only) now also carries an optional room/hosted extension listing this side's own currently-advertised public/private rooms, generalising the presence-readvertise mechanism into readvertiseGossip so both facts ride the same gossip frame per tick rather than a separate frame each. This is the write half of P3.8's room-discovery replacement for createRoom's own broadcastPatch: a peer's listKnownDevices() can now read another device's hosted-rooms advert directly, the same way it already reads presence/status, with no bespoke per-fact transport event needed. Merging a gossip-discovered room into listRooms is deliberately left for its own follow-up, since it needs to decide what a not-yet-joined room's synthesized shape should look like. --- src/core/wire-mesh-transport.ts | 57 ++++--- .../hosted-rooms-gossip.integration.test.ts | 141 ++++++++++++++++++ 2 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 src/test/hosted-rooms-gossip.integration.test.ts diff --git a/src/core/wire-mesh-transport.ts b/src/core/wire-mesh-transport.ts index d243e7ca..a18195b3 100644 --- a/src/core/wire-mesh-transport.ts +++ b/src/core/wire-mesh-transport.ts @@ -72,6 +72,17 @@ const LISTENER_ID_LENGTH = 8; /** The domain-qualified gossip extension key this transport reads/writes presence under, per wire-mesh's own gossip-extension-namespacing convention (spec/CONVENTIONS.md): `/`, never a bare name a second application's own extension could collide with. */ const PRESENCE_GOSSIP_KEY = "presence/status"; +/** The domain-qualified gossip extension key this transport writes this side's own currently-hosted public/private rooms under -- the write half of P3.8's room-discovery replacement for createRoom's own broadcastPatch (agent-comms#48). Same namespacing convention as PRESENCE_GOSSIP_KEY. */ +const HOSTED_ROOMS_GOSSIP_KEY = "room/hosted"; + +/** The lightweight, gossip-safe shape a room advertises itself under: enough for a peer to display "this device hosts a discoverable room here" without exposing anything membership- or grant-related. Deliberately excludes secret rooms (never worth advertising at all) and every CRDT membership field a real Room carries -- a gossip-discovered entry is a hint pointing at a room to join, not a substitute for the real Room object join/admission still produces. */ +export interface HostedRoomAdvert { + path: string; + name: string; + type: "public" | "private"; + description: string; +} + /** This project's own namespaced domain (registrant "exadev.io", local name "agent-comms-v1"), matching namespaced-domain-id's "/" shape -- registry/core-domains.md's own recommended pattern for a third party. Exported for test use only: a security test simulating a hostile client that skips connect_request needs to construct a well-formed frame under the same domain/verb/scope this transport itself listens on, rather than duplicating these as separately-maintained magic strings that could silently drift from the real values. */ export const DOMAIN = "exadev.io/agent-comms-v1"; @@ -207,10 +218,13 @@ export class WireMeshTransport implements MeshTransport { private readonly pendingConnectionTimeoutMs: number; - /** Reads this side's own current AgentStatus for the next presence re-advertisement tick -- a pull, not a push, so MeshStore never needs to reach into this transport's internals on every status change (see updateAgent/setAgentOffline, which patch MeshStore's own agents map and let the next tick pick it up). undefined when no presence source was wired in (every existing construction site that predates this feature), in which case the interval below is never even started. */ + /** Reads this side's own current AgentStatus for the next gossip re-advertisement tick -- a pull, not a push, so MeshStore never needs to reach into this transport's internals on every status change (see updateAgent/setAgentOffline, which patch MeshStore's own agents map and let the next tick pick it up). undefined when no presence source was wired in (every existing construction site that predates this feature). */ private readonly getCurrentPresence: (() => AgentStatus | undefined) | undefined; - private presenceInterval: ReturnType | undefined; + /** Reads this side's own currently-hosted public/private rooms for the next gossip re-advertisement tick, the same pull-not-push shape getCurrentPresence already established -- createRoom/destroyRoom patch MeshStore's own rooms map and let the next tick pick it up, rather than pushing an update here on every mutation. undefined when no hosted-rooms source was wired in. */ + private readonly getHostedRooms: + (() => readonly HostedRoomAdvert[]) | undefined; + private gossipInterval: ReturnType | undefined; constructor( events: Readonly, @@ -219,6 +233,7 @@ export class WireMeshTransport implements MeshTransport { pendingConnectionTimeoutMs: number = DEFAULT_PENDING_CONNECTION_TIMEOUT_MS, getCurrentPresence?: () => AgentStatus | undefined, presenceReadvertiseIntervalMs: number = PRESENCE_READVERTISE_INTERVAL_MS, + getHostedRooms?: () => readonly HostedRoomAdvert[], ) { this.events = events; this.wireTransport = createTlsTransport({ @@ -232,26 +247,30 @@ export class WireMeshTransport implements MeshTransport { }); this.pendingConnectionTimeoutMs = pendingConnectionTimeoutMs; this.getCurrentPresence = getCurrentPresence; - if (getCurrentPresence !== undefined) { - this.presenceInterval = setInterval(() => { - this.readvertisePresence(); + this.getHostedRooms = getHostedRooms; + if (getCurrentPresence !== undefined || getHostedRooms !== undefined) { + this.gossipInterval = setInterval(() => { + this.readvertiseGossip(); }, presenceReadvertiseIntervalMs); - this.presenceInterval.unref(); + this.gossipInterval.unref(); } } - /** Re-sends this side's own current presence status onto every live session's gossip self-advert. A session that fails to send (mid-disconnect, most likely -- watchForDisconnect will independently notice and clean it up) is reported via onError and skipped, not allowed to stop the tick from reaching the rest of allSessions: a periodic broadcast to N peers is N independent operations, not one atomic unit. A no-op tick (nothing to advertise, because getCurrentPresence returned undefined, or no sessions exist yet) is expected and silent. */ - private readvertisePresence(): void { + /** Re-sends this side's own current presence status and currently-hosted rooms, together, onto every live session's gossip self-advert -- one gossip frame per tick carrying whichever of the two sources is wired in, rather than a separate frame per fact. A session that fails to send (mid-disconnect, most likely -- watchForDisconnect will independently notice and clean it up) is reported via onError and skipped, not allowed to stop the tick from reaching the rest of allSessions: a periodic broadcast to N peers is N independent operations, not one atomic unit. A no-op tick (neither source wired in, or no sessions exist yet) is expected and silent. */ + private readvertiseGossip(): void { + const extensions: Record = {}; const status = this.getCurrentPresence?.(); - if (status === undefined) return; + if (status !== undefined) extensions[PRESENCE_GOSSIP_KEY] = status; + const hostedRooms = this.getHostedRooms?.(); + if (hostedRooms !== undefined) + extensions[HOSTED_ROOMS_GOSSIP_KEY] = hostedRooms; + if (Object.keys(extensions).length === 0) return; for (const session of this.allSessions) { - session - .sendGossipUpdate({ [PRESENCE_GOSSIP_KEY]: status }) - .catch((error: unknown) => { - this.events.onError?.( - error instanceof Error ? error : new Error(String(error)), - ); - }); + session.sendGossipUpdate(extensions).catch((error: unknown) => { + this.events.onError?.( + error instanceof Error ? error : new Error(String(error)), + ); + }); } } @@ -799,9 +818,9 @@ export class WireMeshTransport implements MeshTransport { async shutdown(): Promise { this.shutDown = true; - if (this.presenceInterval !== undefined) { - clearInterval(this.presenceInterval); - this.presenceInterval = undefined; + if (this.gossipInterval !== undefined) { + clearInterval(this.gossipInterval); + this.gossipInterval = undefined; } this.dataDials.clear(); diff --git a/src/test/hosted-rooms-gossip.integration.test.ts b/src/test/hosted-rooms-gossip.integration.test.ts new file mode 100644 index 00000000..0e24a876 --- /dev/null +++ b/src/test/hosted-rooms-gossip.integration.test.ts @@ -0,0 +1,141 @@ +/** + * WireMeshTransport's periodic gossip tick also carries this side's own currently-hosted public/private rooms (a `room/hosted` extension on peer-advert's own open tail, the same convention presence/status already established), so a peer's `listKnownDevices()` can read another device's advertised rooms without a bespoke per-fact event. This is the write side of P3.8's room-discovery replacement for createRoom's own broadcastPatch (agent-comms#48's own 2026-09-14 investigation): the read side (merging a gossip-discovered room into listRooms) is deliberately not built here -- it needs its own design pass, per that issue's established pattern, once this primitive exists to build it against. + */ + +import { test, describe, expect } from "vitest"; +import { generateIdentity } from "../core/identity.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import { + WireMeshTransport, + type HostedRoomAdvert, +} from "../core/wire-mesh-transport.js"; +import type { TransportEvents } from "../core/transport.js"; +import { waitFor } from "./test-transport.js"; + +const SHORT_INTERVAL_MS = 50; + +function inertEvents(): TransportEvents { + return { + onMessage: () => undefined, + onPeerConnected: () => undefined, + onPeerDisconnected: () => undefined, + onIntroduction: () => undefined, + onConnectionRequest: () => undefined, + onPeerList: () => undefined, + onPeerJoined: () => undefined, + onBecomeCoordinator: () => undefined, + onRevocationAnnounce: () => undefined, + onPresenceAdvert: () => undefined, + }; +} + +describe("WireMeshTransport hosted-rooms gossip", () => { + test("a peer's listKnownDevices reflects another device's currently-hosted public/private rooms", async () => { + const identityA = generateIdentity(); + const identityB = generateIdentity(); + const peerIdA = deviceIdToHex( + await toIdentityPort(identityA).then((p) => p.deviceId), + ); + const peerIdB = deviceIdToHex( + await toIdentityPort(identityB).then((p) => p.deviceId), + ); + + let hostedByA: readonly HostedRoomAdvert[] = [ + { + path: `${peerIdA}/general`, + name: "general", + type: "public", + description: "chat", + }, + ]; + + const transportA = new WireMeshTransport( + inertEvents(), + identityA, + undefined, + undefined, + undefined, + SHORT_INTERVAL_MS, + () => hostedByA, + ); + const transportB = new WireMeshTransport(inertEvents(), identityB); + + try { + await transportA.startDataServer(); + await transportB.connectToPeer( + { + id: peerIdA, + port: transportA.dataPort, + startedAt: new Date().toISOString(), + }, + peerIdB, + ); + + await waitFor(() => { + const advert = transportB + .listKnownDevices() + .find((entry) => entry.deviceId === peerIdA)?.advert["room/hosted"]; + return ( + Array.isArray(advert) && + advert.length === 1 && + (advert[0] as HostedRoomAdvert).name === "general" + ); + }, "B observes A's advertised hosted room"); + + hostedByA = []; + + await waitFor(() => { + const advert = transportB + .listKnownDevices() + .find((entry) => entry.deviceId === peerIdA)?.advert["room/hosted"]; + return Array.isArray(advert) && advert.length === 0; + }, "B observes A no longer hosting any room after the next tick"); + } finally { + await transportB.shutdown(); + await transportA.shutdown(); + } + }); + + test("a session with no hosted-rooms source configured never advertises the room/hosted key", async () => { + const identityA = generateIdentity(); + const identityB = generateIdentity(); + const peerIdA = deviceIdToHex( + await toIdentityPort(identityA).then((p) => p.deviceId), + ); + const peerIdB = deviceIdToHex( + await toIdentityPort(identityB).then((p) => p.deviceId), + ); + + const transportA = new WireMeshTransport(inertEvents(), identityA); + const transportB = new WireMeshTransport(inertEvents(), identityB); + + try { + await transportA.startDataServer(); + await transportB.connectToPeer( + { + id: peerIdA, + port: transportA.dataPort, + startedAt: new Date().toISOString(), + }, + peerIdB, + ); + + await waitFor( + () => + transportB + .listKnownDevices() + .some((entry) => entry.deviceId === peerIdA), + "B's known-devices view includes A", + ); + + const known = transportB + .listKnownDevices() + .find((entry) => entry.deviceId === peerIdA); + expect(known?.advert["room/hosted"]).toBeUndefined(); + } finally { + await transportB.shutdown(); + await transportA.shutdown(); + } + }); +});