From 09c667252cfeaff34eef4adba58d1ea6fea76d28 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:39:25 +0000 Subject: [PATCH] Deliver broadcasts to all hosts, not just the local one An event broadcast from an instance to all hosts only reached the host running that instance. The host side of an instance connection has the host's own address as its source, so a broadcast aimed at hosts counts as addressed to it, and a link that a message is addressed to handles it and stops. The event was delivered locally and never routed onwards. A broadcast is addressed to every link of the type it targets, so arriving at one of them does not mean it has arrived everywhere. Keep routing such a message after handling it locally. Only links that route are affected, as instances and controls have no router and would otherwise throw on every broadcast they receive. The only links whose source type matches a broadcast target and which route are the host and its instance connections, both matching a broadcast to hosts, so add the missing case for that to the host router. It forwards to the controller, which already passes broadcasts on to every other host, and stops when the message came from the controller to begin with. Drop the exclusion this bug had in the routing test matrix, which now covers it along with the plugin filtering variants. Fixes #575 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/src/Host.ts | 6 ++++++ packages/lib/src/link/link.ts | 10 ++++++++++ test/integration/routing.js | 2 -- test/lib/link/link.js | 36 +++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index b99392dc..0d3a4185 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -182,6 +182,12 @@ export class HostRouter { if (this.host !== origin && (!plugin || this.host.serverPlugins.has(plugin))) { this.host.connector.forward(message); } + } else if (dst.id === lib.Address.host) { + // Sent up to the controller, which passes it on to the other + // hosts. Nothing left to do when it came from there. + if (this.host !== origin && (!plugin || this.host.serverPlugins.has(plugin))) { + this.host.connector.forward(message); + } } else if (dst.id === lib.Address.control) { if (this.host !== origin) { if (!plugin || this.host.serverPlugins.has(plugin)) { diff --git a/packages/lib/src/link/link.ts b/packages/lib/src/link/link.ts index b7bc8c02..a1ed20ca 100644 --- a/packages/lib/src/link/link.ts +++ b/packages/lib/src/link/link.ts @@ -225,6 +225,16 @@ export class Link { return; } + // A broadcast is addressed to every link of the type it targets, so + // being addressed to this one does not mean it stops here. + if ( + message.type === "event" + && message.dst.type === libData.Address.broadcast + && this.router + ) { + this._routeMessage(message, entry); + } + if (message.type === "request") { this._processRequest( message as libData.MessageRequest, diff --git a/test/integration/routing.js b/test/integration/routing.js index a4672305..2f40745a 100644 --- a/test/integration/routing.js +++ b/test/integration/routing.js @@ -235,8 +235,6 @@ describe("Integration of link routing", function() { }; for (const dstName of dsts[dst]) { if (srcName === dstName) { continue; } - // TODO remove when broadcasting from instance to hosts is fixed, see #575 - if (srcName === "instanceA1" && dstName === "hostB") { continue; } let called; handle(get(dstName), Event, async () => { called = true; }); const should = canReach(dstName); diff --git a/test/lib/link/link.js b/test/lib/link/link.js index c8bd06c4..4690944a 100644 --- a/test/lib/link/link.js +++ b/test/lib/link/link.js @@ -254,6 +254,42 @@ describe("lib/link/link", function() { await assert.rejects(pending, { message: "Session Lost" }); }); + describe("Broadcast handling", function() { + // A broadcast is addressed to every link of the type it targets, + // so a link it reaches has to pass it on as well as handle it, + // see #575. + function mockRouter(record) { + return { forwardMessage: () => { record.routed = true; return true; } }; + } + + it("should route a broadcast addressed to this link as well as handle it", function() { + const record = {}; + let handled = false; + testLink.router = mockRouter(record); + testLink.handle(SimpleEvent, async () => { handled = true; }); + testConnector.emit("message", new lib.MessageEvent(1, dst, addr("allControls"), "SimpleEvent")); + assert(record.routed, "broadcast was not routed onwards"); + assert(handled, "broadcast was not handled locally"); + }); + + it("should handle a broadcast on a link which does not route", function() { + // Instances have no router, so routing unconditionally would + // throw here and the event would never be handled. + let handled = false; + testLink.handle(SimpleEvent, async () => { handled = true; }); + testConnector.emit("message", new lib.MessageEvent(1, dst, addr("allControls"), "SimpleEvent")); + assert(handled, "broadcast was not handled locally"); + }); + + it("should not route a non broadcast addressed to this link", function() { + const record = {}; + testLink.router = mockRouter(record); + testLink.handle(SimpleEvent, async () => {}); + testConnector.emit("message", new lib.MessageEvent(1, dst, src, "SimpleEvent")); + assert(!record.routed, "message addressed to this link was routed onwards"); + }); + }); + describe(".send()", function() { it("should send request to the other side of the link", async function() { let message = events.once(testConnector, "send");