From 6bde895752b4d82f982aba25fc6a8ca5b3393aed Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 9 Sep 2026 04:53:56 +0100 Subject: [PATCH 1/2] fix(core): keep the certificate serial a minimal DER integer Clearing the sign bit on the random serial number can leave a leading zero byte, which is not a minimal DER INTEGER encoding; OpenSSL rejects such certificates as illegal padding when they are loaded, so tls.createServer failed for roughly one generated identity in 128 and the bridge intermittently could not start. Pin a masked-to-zero first byte to one, and load a batch of generated certificates in the test suite to catch a reintroduction. --- package.json | 2 +- src/core/identity.ts | 2 ++ src/test/identity-cert.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/test/identity-cert.test.ts diff --git a/package.json b/package.json index 07ea2c0..efc1a95 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "generate:server-json": "tsx scripts/sync-release-metadata.ts", "publish:mcp-registry": "tsx scripts/publish-mcp-registry.ts", "lint": "eslint .", - "test": "node --test dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js", + "test": "node --test dist/test/coordinator-socket-error.integration.test.js dist/test/identity-store.test.js dist/test/identity-cert.test.js", "test:visibility": "node --test dist/test/visibility.integration.test.js", "test:delivery": "node dist/test/delivery-receipt.runner.js", "test:all": "pnpm test && pnpm test:delivery", diff --git a/src/core/identity.ts b/src/core/identity.ts index 326b9af..2b0cc30 100644 --- a/src/core/identity.ts +++ b/src/core/identity.ts @@ -266,6 +266,8 @@ export function generateIdentity(): PeerIdentity { const serial = Buffer.from(serialBytes); const firstSerialByte = serial[0]; if (firstSerialByte !== undefined) serial[0] = firstSerialByte & 0x7f; + // DER INTEGERs are minimally encoded: a leading zero byte is only legal when the following byte's high bit is set. Clearing the sign bit above can leave 0x00 here, which OpenSSL rejects as illegal padding when the certificate is loaded (tls.createServer then fails despite retries), so pin it to a minimal non-zero value. + if (serial[0] === 0) serial[0] = 1; // Validity period: now through CERTIFICATE_VALIDITY_MS from now const now = new Date(); diff --git a/src/test/identity-cert.test.ts b/src/test/identity-cert.test.ts new file mode 100644 index 0000000..4f321f7 --- /dev/null +++ b/src/test/identity-cert.test.ts @@ -0,0 +1,25 @@ +/** + * Regression test: every generated certificate must be loadable by tls.createServer. + * + * The hand-rolled DER serial number could begin with a zero byte whenever clearing the sign bit produced 0x00 — a non-minimal INTEGER, which OpenSSL rejects as "illegal padding" when the certificate is loaded. That made tls.createServer fail (even across its retries) for roughly one bridge start in 128. Loading a batch of generated identities catches a reintroduction with high probability while never failing once the encoding is minimal. + */ + +import * as tls from "node:tls"; +import * as assert from "node:assert/strict"; +import { test } from "node:test"; +import { generateIdentity } from "../core/identity.js"; + +// ~90% detection odds against the original 1-in-128 defect per run. +const IDENTITIES_TO_LOAD = 300; + +void test("generated certificates are loadable by tls.createServer", () => { + for (let i = 0; i < IDENTITIES_TO_LOAD; i++) { + const identity = generateIdentity(); + const server = tls.createServer( + { key: identity.privateKey, cert: identity.certificate }, + () => {}, + ); + server.close(); + assert.equal(identity.fingerprint.length > 0, true); + } +}); From b75023e4b77500d91afbf1e7b5f488fb93c77462 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 9 Sep 2026 04:53:59 +0100 Subject: [PATCH 2/2] fix(core): stop dropping broadcasts sent while peer connections are still dialling State patches broadcast in the window between store.init() returning and the TLS data connections registering had nowhere to go and were silently dropped, so a bridge that registers immediately after init() (as every production bridge does through ensureRegistered) could stay invisible in established peers' list_agents, and a restarted bridge with a persisted identity could stay offline on peers that missed its re-activation upsert even though delivery routing to it worked. Two windows caused the loss. connectToCoordinator() resolved once the introduction was sent, before the coordinator answered with the peer list, so the post-join dials had not even started when the first broadcast fired; and broadcasts to a dialling peer were discarded because the dial is fire-and-forget. The handshake now resolves on the peer list (with the existing connect timeout as a degraded fallback) and each transport queues broadcasts for dialling peers, flushing them in order when the connection registers from either side, bounded so a dial that never completes cannot grow the queue unbounded. The restart-continuity test no longer needs its settle-delay workaround, and a regression test pins immediate-registration visibility. Found while testing the persistent-identity work; also exposed an intermittent invalid-certificate bug fixed separately. --- src/core/tcp-transport.ts | 71 ++++++++++++++- src/core/tls-transport.ts | 71 ++++++++++++++- src/core/ws-transport.ts | 68 ++++++++++++++- src/test/broadcast-window.integration.test.ts | 86 +++++++++++++++++++ src/test/identity-restart.integration.test.ts | 5 -- 5 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 src/test/broadcast-window.integration.test.ts diff --git a/src/core/tcp-transport.ts b/src/core/tcp-transport.ts index 54a6fa6..602ec01 100644 --- a/src/core/tcp-transport.ts +++ b/src/core/tcp-transport.ts @@ -40,6 +40,12 @@ const CONNECT_TIMEOUT_MS = 1000; // Async socket write helper (not exported) // --------------------------------------------------------------------------- +/** + * Safety valve so a dial that never completes cannot grow its outbound queue + * unbounded; oldest entries are dropped first (#23). + */ +const MAX_PENDING_PER_PEER = 100; + function writeAsync(socket: net.Socket, data: string): Promise { return new Promise((resolve, reject) => { if (socket.destroyed) { @@ -80,12 +86,19 @@ export class TcpTransport implements MeshTransport { // -- Coordinator client socket (connection to the coordinator) -- private coordinatorSocket: net.Socket | undefined; + // -- Coordinator introduction handshake (resolved on the peer list) -- + private resolveCoordinatorHandshake: (() => void) | undefined; + private coordinatorHandshakeTimer: ReturnType | undefined; + // -- Peer data connections (peer ID → socket + buffer) -- private peerConnections = new Map< string, { socket: net.Socket; buffer: MessageBuffer } >(); + // -- Messages queued for peers whose dial is still in flight (#23) -- + private pendingOutbound = new Map(); + // -- All sockets accepted by the data server (for shutdown cleanup) -- private dataServerSockets = new Set(); @@ -196,7 +209,16 @@ export class TcpTransport implements MeshTransport { }); clearTimeout(timer); - resolve(); + // Resolve on the coordinator's peer list rather than on sending the + // introduction: MeshStore.init() then returns only after the post-join + // peer dials have started, so the first broadcasts are queued for the + // dialling peers instead of dropped (#23). A timeout keeps today's + // degraded behaviour for a coordinator that never answers. + this.resolveCoordinatorHandshake = resolve; + this.coordinatorHandshakeTimer = setTimeout(() => { + this.resolveCoordinatorHandshake = undefined; + resolve(); + }, CONNECT_TIMEOUT_MS); }); const timer = setTimeout(() => { @@ -387,6 +409,10 @@ export class TcpTransport implements MeshTransport { async connectToPeer(peer: PeerInfo, ownPeerId: string): Promise { if (this.peerConnections.has(peer.id)) return; + // Queue broadcasts until the connection registers: messages sent in the + // dial window previously had nowhere to go and were silently dropped. + this.pendingOutbound.set(peer.id, this.pendingOutbound.get(peer.id) ?? []); + await new Promise((resolve) => { const socket = net.createConnection( { port: peer.port, host: COORDINATOR_HOST }, @@ -398,6 +424,8 @@ export class TcpTransport implements MeshTransport { const pong: MeshMessage = { method: "pong", peerId: ownPeerId }; socket.write(encode(pong)); + void this.flushPending(peer.id, socket); + // Wire up ongoing message handling socket.on("data", (data) => { const items = buffer.append(data.toString()); @@ -428,6 +456,7 @@ export class TcpTransport implements MeshTransport { socket.on("close", onDisconnect); socket.on("error", () => { + this.pendingOutbound.delete(peer.id); onDisconnect(); socket.destroy(); resolve(); @@ -513,15 +542,41 @@ export class TcpTransport implements MeshTransport { }), ); } + for (const queue of this.pendingOutbound.values()) { + queue.push(message); + if (queue.length > MAX_PENDING_PER_PEER) queue.shift(); + } await Promise.all(writes); } + /** Send messages queued while the peer's connection was being dialled. */ + private async flushPending( + peerId: string, + socket: net.Socket, + ): Promise { + const queue = this.pendingOutbound.get(peerId); + this.pendingOutbound.delete(peerId); + if (queue === undefined) return; + for (const message of queue) { + const sent = await writeAsync(socket, encode(message)).then( + () => true, + () => false, + ); + if (!sent) return; // connection is dying; close/error listeners clean up + } + } + // ----------------------------------------------------------------------- // MeshTransport — Shutdown / unref // ----------------------------------------------------------------------- shutdown(): Promise { this.shutDown = true; + if (this.coordinatorHandshakeTimer !== undefined) { + clearTimeout(this.coordinatorHandshakeTimer); + this.coordinatorHandshakeTimer = undefined; + } + this.resolveCoordinatorHandshake = undefined; // Destroy the coordinator client socket this.coordinatorSocket?.unref(); @@ -598,7 +653,10 @@ export class TcpTransport implements MeshTransport { if (this.shutDown) return; if (msg.method === "peer_list") { + // Fire onPeerList first: it starts the post-join dials (and their + // broadcast queues) before init() resolves. this.events.onPeerList(msg.peers); + this.completeCoordinatorHandshake(); } else if (msg.method === "peer_joined") { this.events.onPeerJoined(msg.peer); } else if (msg.method === "become_coordinator") { @@ -606,6 +664,16 @@ export class TcpTransport implements MeshTransport { } } + /** Complete connectToCoordinator's handshake after the peer list arrives. */ + private completeCoordinatorHandshake(): void { + if (this.coordinatorHandshakeTimer !== undefined) { + clearTimeout(this.coordinatorHandshakeTimer); + this.coordinatorHandshakeTimer = undefined; + } + this.resolveCoordinatorHandshake?.(); + this.resolveCoordinatorHandshake = undefined; + } + // ----------------------------------------------------------------------- // Internal — Data message dispatch // ----------------------------------------------------------------------- @@ -717,6 +785,7 @@ export class TcpTransport implements MeshTransport { if (!this.peerConnections.has(peerId)) { this.peerConnections.set(peerId, { socket, buffer }); } + void this.flushPending(peerId, socket); const handle: ConnectionHandle = { id: peerId }; const info: PeerInfo = { id: peerId, diff --git a/src/core/tls-transport.ts b/src/core/tls-transport.ts index f5f3592..b17c755 100644 --- a/src/core/tls-transport.ts +++ b/src/core/tls-transport.ts @@ -66,6 +66,12 @@ function retryCreateTlsServer( // Async socket write helper (not exported) // --------------------------------------------------------------------------- +/** + * Safety valve so a dial that never completes cannot grow its outbound queue + * unbounded; oldest entries are dropped first (#23). + */ +const MAX_PENDING_PER_PEER = 100; + function writeAsync( socket: net.Socket | tls.TLSSocket, data: string, @@ -109,6 +115,13 @@ export class TlsTransport { // -- Coordinator client socket (TLS connection to the coordinator) -- private coordinatorSocket: tls.TLSSocket | undefined; + // -- Messages queued for peers whose dial is still in flight (#23) -- + private pendingOutbound = new Map(); + + // -- Coordinator introduction handshake (resolved on the peer list) -- + private resolveCoordinatorHandshake: (() => void) | undefined; + private coordinatorHandshakeTimer: ReturnType | undefined; + // -- Peer data connections (peer ID → socket + buffer) -- private peerConnections = new Map< string, @@ -249,7 +262,16 @@ export class TlsTransport { }); clearTimeout(timer); - resolve(); + // Resolve on the coordinator's peer list rather than on sending the + // introduction: MeshStore.init() then returns only after the post-join + // peer dials have started, so the first broadcasts are queued for the + // dialling peers instead of dropped (#23). A timeout keeps today's + // degraded behaviour for a coordinator that never answers. + this.resolveCoordinatorHandshake = resolve; + this.coordinatorHandshakeTimer = setTimeout(() => { + this.resolveCoordinatorHandshake = undefined; + resolve(); + }, CONNECT_TIMEOUT_MS); }); const timer = setTimeout(() => { @@ -441,6 +463,10 @@ export class TlsTransport { async connectToPeer(peer: PeerInfo, ownPeerId: string): Promise { if (this.peerConnections.has(peer.id)) return; + // Queue broadcasts until the connection registers: messages sent in the + // dial window previously had nowhere to go and were silently dropped. + this.pendingOutbound.set(peer.id, this.pendingOutbound.get(peer.id) ?? []); + await new Promise((resolve) => { const socket = tls.connect( { ...this.connectOptions, host: COORDINATOR_HOST, port: peer.port }, @@ -452,6 +478,8 @@ export class TlsTransport { const pong: MeshMessage = { method: "pong", peerId: ownPeerId }; socket.write(encode(pong)); + void this.flushPending(peer.id, socket); + // Wire up ongoing message handling socket.on("data", (data) => { const items = buffer.append(data.toString()); @@ -482,6 +510,7 @@ export class TlsTransport { socket.on("close", onDisconnect); socket.on("error", () => { + this.pendingOutbound.delete(peer.id); onDisconnect(); socket.destroy(); resolve(); @@ -567,15 +596,41 @@ export class TlsTransport { }), ); } + for (const queue of this.pendingOutbound.values()) { + queue.push(message); + if (queue.length > MAX_PENDING_PER_PEER) queue.shift(); + } await Promise.all(writes); } + /** Send messages queued while the peer's connection was being dialled. */ + private async flushPending( + peerId: string, + socket: tls.TLSSocket, + ): Promise { + const queue = this.pendingOutbound.get(peerId); + this.pendingOutbound.delete(peerId); + if (queue === undefined) return; + for (const message of queue) { + const sent = await writeAsync(socket, encode(message)).then( + () => true, + () => false, + ); + if (!sent) return; // connection is dying; close/error listeners clean up + } + } + // ----------------------------------------------------------------------- // MeshTransport — Shutdown / unref // ----------------------------------------------------------------------- shutdown(): Promise { this.shutDown = true; + if (this.coordinatorHandshakeTimer !== undefined) { + clearTimeout(this.coordinatorHandshakeTimer); + this.coordinatorHandshakeTimer = undefined; + } + this.resolveCoordinatorHandshake = undefined; // Destroy the coordinator client socket this.coordinatorSocket?.unref(); @@ -647,7 +702,10 @@ export class TlsTransport { if (this.shutDown) return; if (msg.method === "peer_list") { + // Fire onPeerList first: it starts the post-join dials (and their + // broadcast queues) before init() resolves. this.events.onPeerList(msg.peers); + this.completeCoordinatorHandshake(); } else if (msg.method === "peer_joined") { this.events.onPeerJoined(msg.peer); } else if (msg.method === "become_coordinator") { @@ -655,6 +713,16 @@ export class TlsTransport { } } + /** Complete connectToCoordinator's handshake after the peer list arrives. */ + private completeCoordinatorHandshake(): void { + if (this.coordinatorHandshakeTimer !== undefined) { + clearTimeout(this.coordinatorHandshakeTimer); + this.coordinatorHandshakeTimer = undefined; + } + this.resolveCoordinatorHandshake?.(); + this.resolveCoordinatorHandshake = undefined; + } + // ----------------------------------------------------------------------- // Internal — Data message dispatch // ----------------------------------------------------------------------- @@ -756,6 +824,7 @@ export class TlsTransport { if (!this.peerConnections.has(peerId)) { this.peerConnections.set(peerId, { socket, buffer }); } + void this.flushPending(peerId, socket); const handle: ConnectionHandle = { id: peerId }; const info: PeerInfo = { id: peerId, diff --git a/src/core/ws-transport.ts b/src/core/ws-transport.ts index 7bd35a7..c28bff9 100644 --- a/src/core/ws-transport.ts +++ b/src/core/ws-transport.ts @@ -32,6 +32,12 @@ const CONNECT_TIMEOUT_MS = 2000; // Async WS send helper (not exported) // --------------------------------------------------------------------------- +/** + * Safety valve so a dial that never completes cannot grow its outbound queue + * unbounded; oldest entries are dropped first (#23). + */ +const MAX_PENDING_PER_PEER = 100; + function sendAsync(ws: WebSocket, data: string): Promise { return new Promise((resolve, reject) => { if (ws.readyState !== WebSocket.OPEN) { @@ -61,9 +67,16 @@ export class WebSocketTransport implements MeshTransport { // -- Coordinator client socket (connection to the coordinator) -- private coordinatorWs: WebSocket | undefined; + // -- Coordinator introduction handshake (resolved on the peer list) -- + private resolveCoordinatorHandshake: (() => void) | undefined; + private coordinatorHandshakeTimer: ReturnType | undefined; + // -- Peer data connections (peer ID → WebSocket) -- private peerConnections = new Map(); + // -- Messages queued for peers whose dial is still in flight (#23) -- + private pendingOutbound = new Map(); + // -- All WS connections accepted by the data server (for shutdown cleanup) -- private dataServerSockets = new Set(); @@ -188,7 +201,16 @@ export class WebSocketTransport implements MeshTransport { }); clearTimeout(timer); - resolve(); + // Resolve on the coordinator's peer list rather than on sending the + // introduction: MeshStore.init() then returns only after the post-join + // peer dials have started, so the first broadcasts are queued for the + // dialling peers instead of dropped (#23). A timeout keeps today's + // degraded behaviour for a coordinator that never answers. + this.resolveCoordinatorHandshake = resolve; + this.coordinatorHandshakeTimer = setTimeout(() => { + this.resolveCoordinatorHandshake = undefined; + resolve(); + }, CONNECT_TIMEOUT_MS); }); ws.on("error", (err) => { @@ -304,6 +326,10 @@ export class WebSocketTransport implements MeshTransport { async connectToPeer(peer: PeerInfo, ownPeerId: string): Promise { if (this.peerConnections.has(peer.id)) return; + // Queue broadcasts until the connection registers: messages sent in the + // dial window previously had nowhere to go and were silently dropped. + this.pendingOutbound.set(peer.id, this.pendingOutbound.get(peer.id) ?? []); + await new Promise((resolve) => { const url = `ws://127.0.0.1:${String(peer.port)}`; const ws = new WebSocket(url); @@ -315,6 +341,8 @@ export class WebSocketTransport implements MeshTransport { const pong: MeshMessage = { method: "pong", peerId: ownPeerId }; ws.send(JSON.stringify(pong)); + void this.flushPending(peer.id, ws); + // Wire up ongoing message handling ws.on("message", (raw) => { const msg = parseMessage(raw); @@ -342,6 +370,7 @@ export class WebSocketTransport implements MeshTransport { ws.on("close", onDisconnect); ws.on("error", () => { + this.pendingOutbound.delete(peer.id); onDisconnect(); ws.terminate(); resolve(); @@ -429,9 +458,27 @@ export class WebSocketTransport implements MeshTransport { }), ); } + for (const queue of this.pendingOutbound.values()) { + queue.push(message); + if (queue.length > MAX_PENDING_PER_PEER) queue.shift(); + } await Promise.all(writes); } + /** Send messages queued while the peer's connection was being dialled. */ + private async flushPending(peerId: string, ws: WebSocket): Promise { + const queue = this.pendingOutbound.get(peerId); + this.pendingOutbound.delete(peerId); + if (queue === undefined) return; + for (const message of queue) { + const sent = await sendAsync(ws, JSON.stringify(message)).then( + () => true, + () => false, + ); + if (!sent) return; // connection is dying; close listeners clean up + } + } + // ----------------------------------------------------------------------- // MeshTransport — Listener management (not supported over WebSocket) // ----------------------------------------------------------------------- @@ -454,6 +501,11 @@ export class WebSocketTransport implements MeshTransport { shutdown(): Promise { this.shutDown = true; + if (this.coordinatorHandshakeTimer !== undefined) { + clearTimeout(this.coordinatorHandshakeTimer); + this.coordinatorHandshakeTimer = undefined; + } + this.resolveCoordinatorHandshake = undefined; // Destroy the coordinator client socket this.coordinatorWs?.terminate(); @@ -516,7 +568,10 @@ export class WebSocketTransport implements MeshTransport { if (this.shutDown) return; if (msg.method === "peer_list") { + // Fire onPeerList first: it starts the post-join dials (and their + // broadcast queues) before init() resolves. this.events.onPeerList(msg.peers); + this.completeCoordinatorHandshake(); } else if (msg.method === "peer_joined") { this.events.onPeerJoined(msg.peer); } else if (msg.method === "become_coordinator") { @@ -524,6 +579,16 @@ export class WebSocketTransport implements MeshTransport { } } + /** Complete connectToCoordinator's handshake after the peer list arrives. */ + private completeCoordinatorHandshake(): void { + if (this.coordinatorHandshakeTimer !== undefined) { + clearTimeout(this.coordinatorHandshakeTimer); + this.coordinatorHandshakeTimer = undefined; + } + this.resolveCoordinatorHandshake?.(); + this.resolveCoordinatorHandshake = undefined; + } + // ----------------------------------------------------------------------- // Internal — Data message dispatch // ----------------------------------------------------------------------- @@ -626,6 +691,7 @@ export class WebSocketTransport implements MeshTransport { if (!this.peerConnections.has(peerId)) { this.peerConnections.set(peerId, ws); } + void this.flushPending(peerId, ws); const handle: ConnectionHandle = { id: peerId }; const info: PeerInfo = { id: peerId, diff --git a/src/test/broadcast-window.integration.test.ts b/src/test/broadcast-window.integration.test.ts new file mode 100644 index 0000000..7c17eac --- /dev/null +++ b/src/test/broadcast-window.integration.test.ts @@ -0,0 +1,86 @@ +/** + * Integration test for issue #23: state patches broadcast before the TLS data connections are established must not be silently lost. + * + * Every production bridge calls registerAgent() immediately after store.init() returns, while the fire-and-forget peer dials are still in flight. Broadcasts landing in that window used to have nowhere to go, so the joining peer stayed invisible in established peers' list_agents until some later patch happened to arrive. The transports now queue broadcasts for dialling peers and flush them when the connection registers. + */ + +import * as assert from "node:assert/strict"; +import { MeshStore } from "../core/mesh-store.js"; +import { TlsTransport } from "../core/tls-transport.js"; +import { generateIdentity } from "../core/identity.js"; +import type { PeerIdentity } from "../core/identity.js"; + +const TEST_PORT = 19890; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** A peer wired like a real bridge: TLS transport, fingerprint peer ID. */ +function makePeer(identity: PeerIdentity): MeshStore { + const store = new MeshStore(TEST_PORT); + store.peerId = identity.fingerprint; + store.setTransport(new TlsTransport(store.events, identity)); + return store; +} + +/** Poll until the predicate holds, or fail with the message. */ +async function waitFor( + what: string, + check: () => Promise, +): Promise { + for (let i = 0; i < 20; i++) { + if (await check()) return; + await sleep(100); + } + assert.ok(false, `timed out waiting for ${what}`); +} + +async function main(): Promise { + // A is the coordinator and stays up throughout. + const a = makePeer(generateIdentity()); + await a.init(); + await a.registerAgent({ + name: "peer-a", + harness: "claude-code", + cwd: "/test/a", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + // B joins and registers IMMEDIATELY after init() — no settle delay. This is exactly the production bridge pattern that used to race the dials. + const b = makePeer(generateIdentity()); + await b.init(); + await b.registerAgent({ + name: "peer-b", + harness: "pi", + cwd: "/test/b", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const bId = b.peerId; + + await waitFor( + "the coordinator to see the immediately-registered agent", + async () => { + const agents = await a.listAgents(a.peerId); + const seen = agents.find((agent) => agent.id === bId); + return seen?.status === "active"; + }, + ); + + await waitFor("the joining peer to see the coordinator's agent", async () => { + const agents = await b.listAgents(b.peerId); + return agents.some((agent) => agent.id === a.peerId); + }); + + await b.shutdown(); + await a.shutdown(); + console.log("✓ immediate registration is visible without settle delays"); +} + +main().catch((err: unknown) => { + console.error("Test failed:", err); + process.exitCode = 1; + // The sequence above keeps mesh handles open when it fails partway; exit explicitly so a failure cannot hang the runner. + process.exit(1); +}); diff --git a/src/test/identity-restart.integration.test.ts b/src/test/identity-restart.integration.test.ts index badd107..e60209f 100644 --- a/src/test/identity-restart.integration.test.ts +++ b/src/test/identity-restart.integration.test.ts @@ -55,9 +55,6 @@ async function main(): Promise { // Peer B is a normal ephemeral bridge that stays up throughout. const b = makePeer(generateIdentity()); await b.store.init(); - // Registration must wait for the TLS data connections to establish: - // patches broadcast in between are silently lost (#23). - await sleep(300); await b.store.registerAgent({ name: "peer-b", harness: "claude-code", @@ -75,7 +72,6 @@ async function main(): Promise { const identityA = loadOrCreateIdentity(slot); const a1 = makePeer(identityA); await a1.store.init(); - await sleep(300); await a1.store.registerAgent({ name: "peer-a", harness: "pi", @@ -108,7 +104,6 @@ async function main(): Promise { a2.deliveries.push(ev); }; await a2.store.init(); - await sleep(300); await a2.store.registerAgent({ name: "peer-a", harness: "pi",