From ee6057c4b59fd94e388f809bf06aed8199272f63 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 19 Oct 2026 13:26:03 +0100 Subject: [PATCH] fix(core): keep a device's newest advert when an older one arrives later A session applied gossiped adverts in arrival order, so an older advert forwarded by a hub or gateway after a newer one rolled the device back in the session directory. A device whose two versions kept arriving interleaved alternated between them on every directory event. The directory now keeps the advert with the greater signed snapshot-seconds, the same rule the relay hub applies when registering a device. --- ts/packages/core/src/domain/mesh-session.ts | 10 +++--- ...h-session-advert-verification.unit.test.ts | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/ts/packages/core/src/domain/mesh-session.ts b/ts/packages/core/src/domain/mesh-session.ts index e53a7d8..0208645 100644 --- a/ts/packages/core/src/domain/mesh-session.ts +++ b/ts/packages/core/src/domain/mesh-session.ts @@ -444,11 +444,11 @@ function createSessionCore( frame.peers.map(async (advert) => verifyPeerAdvert(identity, advert)), ); for (const advert of frame.peers.filter((_, i) => verdicts[i] === true)) { - // Latest advert per device wins, order preserved by first insertion -- a re-advert updates in place. - directory.set(deviceIdToHex(advert.device), { - device: advert.device, - advert, - }); + // Newest advert per device wins, order preserved by first insertion -- a re-advert updates in place. Newest is by the device's own signed `snapshot-seconds`, never by arrival: a hub or gateway forwards adverts it holds from earlier, so an older one can arrive after a newer one, and applying it would roll the device back (and, on every later event, the same device alternates between its two versions). + const deviceHex = deviceIdToHex(advert.device); + const held = directory.get(deviceHex)?.advert["snapshot-seconds"]; + if (held !== undefined && advert["snapshot-seconds"] < held) continue; + directory.set(deviceHex, { device: advert.device, advert }); topology.recordAdvert(advert.device); onPeerAdvert?.(advert); } diff --git a/ts/packages/core/test/mesh-session-advert-verification.unit.test.ts b/ts/packages/core/test/mesh-session-advert-verification.unit.test.ts index 7d11219..6094304 100644 --- a/ts/packages/core/test/mesh-session-advert-verification.unit.test.ts +++ b/ts/packages/core/test/mesh-session-advert-verification.unit.test.ts @@ -15,6 +15,7 @@ import { testIdentity, } from "./mesh-session-fixtures.js"; import { + FIXTURE_SNAPSHOT_SECONDS, peerAdvertFor, withBrokenSignature, } from "./relay-hub-test-helpers.js"; @@ -107,6 +108,39 @@ describe("a mesh session verifies gossiped adverts before its directory sees the await session.close(); }); + it("keeps a device's newest advert when an older one arrives after it", async () => { + const { connection, session } = await connectedSession(); + const older = await peerAdvertFor(identityA, FIXTURE_SNAPSHOT_SECONDS); + const newer = await peerAdvertFor(identityA, FIXTURE_SNAPSHOT_SECONDS + 1); + connection.push({ type: "gossip", peers: [newer] }); + connection.push({ type: "gossip", peers: [older] }); + + const event = await nthEvent(session, EVENTS_THROUGH_CONNECT + 2); + expect( + directoryOf(event).map((entry) => entry.advert["snapshot-seconds"]), + "a replayed older advert must not replace the newer one", + ).toEqual([FIXTURE_SNAPSHOT_SECONDS + 1]); + await session.close(); + }); + + it("replaces a device's advert with a newer one", async () => { + const { connection, session } = await connectedSession(); + connection.push({ + type: "gossip", + peers: [await peerAdvertFor(identityA, FIXTURE_SNAPSHOT_SECONDS)], + }); + connection.push({ + type: "gossip", + peers: [await peerAdvertFor(identityA, FIXTURE_SNAPSHOT_SECONDS + 1)], + }); + + const event = await nthEvent(session, EVENTS_THROUGH_CONNECT + 2); + expect( + directoryOf(event).map((entry) => entry.advert["snapshot-seconds"]), + ).toEqual([FIXTURE_SNAPSHOT_SECONDS + 1]); + await session.close(); + }); + it("keeps the valid adverts of a frame that also carries an invalid one", async () => { const { connection, session } = await connectedSession(); const valid = await peerAdvertFor(identityA);