From fde49ff83b7d1a5c6afbfe436fa05e1c6b881475 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:45:01 +0100 Subject: [PATCH 1/8] test: cover gossip-extension key collisions, regex anchoring, and close/handshake edge cases Assert sendGossipUpdate rejects extensions colliding with the addresses and snapshot-seconds mandatory fields specifically, not just device. Assert the extension-key pattern rejects trailing garbage after a valid prefix and a valid suffix reached from a non-domain-qualified start, proving the regex is anchored at both ends rather than merely searched. Assert close() while connected finalizes state to closed/"closed by you" and actually invokes the underlying connection's close(). Add FakeConnection.isClosed and .endStream() so a clean remote hang-up can be distinguished from a caller-initiated close in tests. Assert a frame already queued at the moment close() runs is dropped rather than applied, and that a second handshake frame arriving after the first has already settled negotiation cannot re-negotiate. Assert a rejected handshake's reason is exactly "no shared domains or version", and that a negotiated handshake stays negotiated once its own timeout later elapses instead of flipping to unanswered. --- ts/packages/core/test/mesh-session.test.ts | 192 +++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index 85f8297..344f77d 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -86,6 +86,17 @@ class FakeConnection { }; } + /** True once this connection's own close() has actually been invoked -- lets a test assert that a caller closed the link, distinct from the link merely ending its receive stream on its own (see endStream). */ + get isClosed(): boolean { + return this.ended; + } + + /** Ends the receive stream as if the remote hung up cleanly, without going through this side's own close() -- unlike close(), this leaves the session's own state untouched so a test can observe how the session itself reacts to a graceful remote end. */ + endStream(): void { + this.ended = true; + this.wake(); + } + push(frame: Frame): void { this.inbound.push(frame); this.wake(); @@ -314,6 +325,50 @@ describe("createMeshSession", () => { await session.close(); }); + it("sendGossipUpdate rejects an extension key that collides with the mandatory addresses field", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect(session.sendGossipUpdate({ addresses: [] })).rejects.toThrow( + /collides with a mandatory peer-advert field/, + ); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key that collides with the mandatory snapshot-seconds field", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ "snapshot-seconds": 0 }), + ).rejects.toThrow(/collides with a mandatory peer-advert field/); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key with a domain-qualified prefix but trailing garbage after it", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ "presence/status!": "idle" }), + ).rejects.toThrow(/must be domain-qualified/); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key that only matches a domain-qualified pattern partway through the string", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ "1presence/status": "idle" }), + ).rejects.toThrow(/must be domain-qualified/); + await session.close(); + }); + it("sendGossipUpdate with no extensions re-sends a plain self-advert", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); @@ -442,6 +497,143 @@ describe("createMeshSession", () => { const session = createMeshSession(transport, testIdentity, testClock); await expect(session.sendPing()).rejects.toThrow("not connected"); }); + + it("refuses a ping once the connection has failed and the session is closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect(session.sendPing()).rejects.toThrow("not connected"); + }); + + it("close() while connected finalizes state as closed by you, and actually closes the underlying connection", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_FAILURE); + await session.connect("ws://node", ["core/data"]); + await session.close(); + const event = (await eventsDone) as { + state: { status: string; reason?: string }; + }; + expect(event.state.status).toBe("closed"); + expect((event.state as { reason: string }).reason).toBe("closed by you"); + expect(connection.isClosed).toBe(true); + }); + + it("treats a clean end of the receive stream as a disconnect when still connected", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + const eventsDone = nthEvent(session, EVENTS_THROUGH_FAILURE); + connection.endStream(); + const event = (await eventsDone) as { + state: { status: string; reason: string }; + }; + expect(event.state.status).toBe("closed"); + expect(event.state.reason).toBe("node closed the connection"); + }); + + it("ignores a frame that was already queued when close() is called, instead of applying it", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const iterator = session.events[Symbol.asyncIterator](); + await session.connect("ws://node", ["core/data"]); + connection.push(gossipFor(deviceA)); + await session.close(); + for (let i = 0; i < EVENTS_THROUGH_FAILURE; i++) { + await iterator.next(); + } + const SHORT_WAIT_MS = 20; + const TIMEOUT_MARKER = "timeout" as const; + const result = await Promise.race([ + iterator.next(), + new Promise((resolve) => { + setTimeout(() => { + resolve(TIMEOUT_MARKER); + }, SHORT_WAIT_MS); + }), + ]); + expect(result).toBe(TIMEOUT_MARKER); + }); + + it("does not negotiate against a second handshake frame once the first has already settled the outcome", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/management", "core/data"]); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/data", "core/exec"], + } satisfies HandshakeFrame); + await eventsDone; + + const secondEventDone = nthEvent(session, 1); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/federation"], + } satisfies HandshakeFrame); + const event = (await secondEventDone) as { + state: { + status: string; + handshake: { status: string; sharedDomains: string[] }; + }; + }; + expect(event.state.handshake.status).toBe("negotiated"); + expect(event.state.handshake.sharedDomains).toEqual(["core/data"]); + await session.close(); + }); + + it("keeps the handshake negotiated after HANDSHAKE_TIMEOUT_MS elapses, instead of flipping to unanswered", async () => { + vi.useFakeTimers(); + try { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/data"]); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/data"], + } satisfies HandshakeFrame); + await eventsDone; + + await vi.advanceTimersByTimeAsync(HANDSHAKE_TIMEOUT_MS); + await session.sendPing(); + const lastFrame = connection.sent.at(-1) as { type: string }; + expect(lastFrame.type).toBe("ping"); + // sendPing itself would have thrown if state had reverted away from "connected", and negotiate() already proved the handshake status. A direct re-check below confirms the handshake status specifically stayed "negotiated". + const stillConnectedEvent = (await nthEvent(session, 1)) as { + state: { handshake?: { status: string } }; + }; + expect(stillConnectedEvent.state.handshake?.status).toBe("negotiated"); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("records a specific reason when the handshake is rejected for sharing no domains or version", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/federation"]); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/federation"], + }); + const event = (await eventsDone) as { + state: { handshake: { status: string; reason?: string } }; + }; + expect(event.state.handshake.status).toBe("rejected"); + expect((event.state.handshake as { reason: string }).reason).toBe( + "no shared domains or version", + ); + await session.close(); + }); }); describe("reconnect policy", () => { From 3de0c9e5df5cd23e57e728ee4647327a1fadb8ad Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:46:10 +0100 Subject: [PATCH 2/8] test: cover reconnect-timer cancellation and a failed retry dial Assert close() cancels an armed handshake timeout and a pending scheduled reconnect (via vi.getTimerCount()), rather than leaving either running after the session is torn down, and that a reconnect scheduled but not yet fired never dials again once closed. Assert close() called while a reconnect is pending still finalizes state to closed/"closed by you", the same as closing from any other state. Assert a reconnect attempt whose own dial rejects (as opposed to the resulting connection later failing) is treated as a genuine disconnect and reported through state.reason, rather than the rejection being silently dropped by the scheduler. --- ts/packages/core/test/mesh-session.test.ts | 111 +++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index 344f77d..951d955 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -719,6 +719,117 @@ describe("reconnect policy", () => { vi.useRealTimers(); } }); + + it("cancels the handshake timeout when close() is called before it fires", async () => { + vi.useFakeTimers(); + try { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + expect(vi.getTimerCount()).toBeGreaterThan(0); + await session.close(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels a pending scheduled reconnect when close() is called before it fires", async () => { + vi.useFakeTimers(); + try { + const { transport, connections } = multiConnectionTransport(); + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: 1, + delayMs: () => RECONNECT_DELAY_MS, + }); + await session.connect("ws://node", ["core/data"]); + connections[0]?.fail(new Error("dropped")); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBeGreaterThan(0); + await session.close(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); + expect(connections).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("finalizes as closed by you when close() is called while a reconnect is pending", async () => { + vi.useFakeTimers(); + try { + const { transport, connections } = multiConnectionTransport(); + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: 1, + delayMs: () => RECONNECT_DELAY_MS, + }); + await session.connect("ws://node", ["core/data"]); + connections[0]?.fail(new Error("dropped")); + const reconnecting = (await nthEvent( + session, + EVENTS_THROUGH_FIRST_RECONNECT, + )) as { state: { status: string } }; + expect(reconnecting.state.status).toBe("reconnecting"); + const eventsDone = nthEvent(session, 1); + await session.close(); + const event = (await eventsDone) as { + state: { status: string; reason?: string }; + }; + expect(event.state.status).toBe("closed"); + expect((event.state as { reason: string }).reason).toBe("closed by you"); + } finally { + vi.useRealTimers(); + } + }); + + it("treats a failed retry dial itself as a disconnect, not a silently swallowed error", async () => { + vi.useFakeTimers(); + try { + const connections: FakeConnection[] = []; + let calls = 0; + const transport: Transport = { + connect: async (address: string): Promise => { + calls += 1; + if (address !== "ws://node") { + return Promise.reject(new Error(`connect to ${address} failed`)); + } + if (calls > 1) { + return Promise.reject(new Error("dial failed on retry")); + } + const next = new FakeConnection(); + connections.push(next); + return Promise.resolve(next.connection); + }, + listen: async (): Promise => + Promise.reject(new Error("client-only transport")), + }; + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: 1, + delayMs: () => RECONNECT_DELAY_MS, + }); + await session.connect("ws://node", ["core/data"]); + connections[0]?.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FIRST_RECONNECT); + await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); + + const iterator = session.events[Symbol.asyncIterator](); + let event: { state: { status: string; reason?: string } } | null = null; + const MAX_EVENTS_TO_SCAN = 10; + for (let i = 0; i < MAX_EVENTS_TO_SCAN; i++) { + const result = (await iterator.next()) as { + value: { state: { status: string; reason?: string } }; + }; + event = result.value; + if (event.state.status === "closed") { + break; + } + } + expect(event?.state.status).toBe("closed"); + expect(event?.state.reason).toBe("dial failed on retry"); + } finally { + vi.useRealTimers(); + } + }); }); const TEST_TOKEN_SIGNATURE_BYTE = 3; From 919a0ca6c4ef28df34dc9d2dd555113f4f10e525 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:49:00 +0100 Subject: [PATCH 3/8] test: cover request-id sequencing, not-connected guards, and manage-request backlog delivery Assert sendManageRequest assigns strictly increasing request-ids across successive calls, and refuses to send both before the first connect and again after a connection has failed and closed (not just before the first connect ever happens). Assert a request with no timeoutMs given never resolves via the internal timeout race, by advancing time and then delivering a real response and checking it still wins. Assert respond() emits a session event reflecting the sent response frame, and that a manage-request received before anything was iterating incomingManageRequests is still delivered once iteration starts, drawn from its own backlog rather than being lost. Add a yielded() helper to narrow an IteratorResult to its value without an unsafe cast, since AsyncIterator's default TReturn=any otherwise infers `any` for .value even after checking .done. --- ts/packages/core/test/mesh-session.test.ts | 113 ++++++++++++++++++++- 1 file changed, 109 insertions(+), 4 deletions(-) diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index 951d955..25b234a 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -178,6 +178,14 @@ function multiConnectionTransport(): { return { transport, connections }; } +/** Narrows an IteratorResult to its yielded value, failing the test outright if the iterator has actually ended -- none of this file's own async iterators ever end, so a done:true result always indicates a broken assumption in the test itself, never legitimate data. */ +function yielded(result: IteratorResult): T { + if (result.done === true) { + throw new Error("expected the iterator to yield a value, got done: true"); + } + return result.value; +} + /** Resolves after the session has emitted at least `count` events, returning the latest. */ async function nthEvent( session: ReturnType, @@ -933,6 +941,66 @@ describe("capability tokens and manage-request plumbing", () => { await expect(usingSessionDefault).rejects.toThrow(); }); + it("assigns sequentially increasing request-ids to successive sendManageRequest calls", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const first = session.sendManageRequest(testCommand, testScope); + await Promise.resolve(); + const firstId = (connection.sent.at(-1) as ManageRequestFrame)[ + "request-id" + ]; + const second = session.sendManageRequest(testCommand, testScope); + await Promise.resolve(); + const secondId = (connection.sent.at(-1) as ManageRequestFrame)[ + "request-id" + ]; + expect(secondId).toBe(firstId + 1); + await session.close(); + await expect(first).rejects.toThrow(); + await expect(second).rejects.toThrow(); + }); + + it("refuses sendManageRequest while not connected", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await expect( + session.sendManageRequest(testCommand, testScope), + ).rejects.toThrow("not connected"); + }); + + it("refuses sendManageRequest once the connection has failed and closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect( + session.sendManageRequest(testCommand, testScope), + ).rejects.toThrow("not connected"); + }); + + it("never times out a request when no timeoutMs is given", async () => { + vi.useFakeTimers(); + try { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest(testCommand, testScope); + await vi.advanceTimersByTimeAsync(1); + const sentRequest = connection.sent.at(-1) as ManageRequestFrame; + connection.push({ + type: "manage-response", + "request-id": sentRequest["request-id"], + outcome: { result: "ok" }, + } satisfies ManageResponseFrame); + await expect(pending).resolves.toEqual({ result: "ok" }); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + it("resolves sendManageRequest only with the outcome of the matching manage-response", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); @@ -1036,12 +1104,15 @@ describe("capability tokens and manage-request plumbing", () => { it("surfaces an incoming manage-request on incomingManageRequests, and sends the response frame from respond()", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); await session.connect("ws://node", ["core/management"]); + await eventsDone; - const incomingDone = (async (): Promise => { + const incomingDone = (async (): Promise< + IteratorResult + > => { const iterator = session.incomingManageRequests[Symbol.asyncIterator](); - const result = await iterator.next(); - return result.value as IncomingManageRequest; + return iterator.next(); })(); connection.push({ @@ -1052,12 +1123,16 @@ describe("capability tokens and manage-request plumbing", () => { token: testToken, } satisfies ManageRequestFrame); - const incoming = await incomingDone; + const incomingResult = await incomingDone; + expect(incomingResult.done).toBe(false); + const incoming = yielded(incomingResult); expect(incoming.requestId).toBe(TEST_INCOMING_REQUEST_ID); expect(incoming.command).toEqual(testCommand); expect(incoming.scope).toEqual(testScope); expect(incoming.token).toEqual(testToken); + // Two events remain unconsumed: the received manage-request itself, then the sent response. + const responseEventDone = nthEvent(session, 2); await incoming.respond({ result: "ok" }); const sentResponse = connection.sent.at(-1) as ManageResponseFrame; expect(sentResponse).toEqual({ @@ -1065,6 +1140,36 @@ describe("capability tokens and manage-request plumbing", () => { "request-id": TEST_INCOMING_REQUEST_ID, outcome: { result: "ok" }, } satisfies ManageResponseFrame); + const responseEvent = (await responseEventDone) as { + frameLog: { direction: string; frame: { type: string } }[]; + }; + expect(responseEvent.frameLog.at(-1)).toEqual({ + direction: "sent", + frame: sentResponse, + }); + await session.close(); + }); + + it("delivers a manage-request queued before anyone was iterating incomingManageRequests, from the backlog", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/management"]); + await eventsDone; + + const nextEventDone = nthEvent(session, 1); + connection.push({ + type: "manage-request", + "request-id": TEST_INCOMING_REQUEST_ID, + command: testCommand, + scope: testScope, + } satisfies ManageRequestFrame); + await nextEventDone; + + const iterator = session.incomingManageRequests[Symbol.asyncIterator](); + const result = await iterator.next(); + expect(result.done).toBe(false); + expect(yielded(result).requestId).toBe(TEST_INCOMING_REQUEST_ID); await session.close(); }); }); From 88a04b487dbb9bf3164d5c38b79099b80de9c5b1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:50:26 +0100 Subject: [PATCH 4/8] test: cover events-iterator done flag and acceptMeshSession's own defaults Assert the events async iterator's next() result carries done: false both for a live event delivered after the wait began and for one already backlogged before anyone started iterating. Assert acceptMeshSession advertises no addresses when none are given (rather than falling through to some other default) and labels its connection state "accepted" when no label option is given. Strengthen the "refuses connect()" assertion to check the actual rejection message reachable through the public API, rather than any throw. --- ts/packages/core/test/mesh-session.test.ts | 52 +++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index 25b234a..d8e7466 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -642,6 +642,28 @@ describe("createMeshSession", () => { ); await session.close(); }); + + it("events iterator resolves a live event, delivered after the wait began, with done: false", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const iterator = session.events[Symbol.asyncIterator](); + // No event has been emitted yet, so this call registers a waiter rather than draining the backlog. + const pending = iterator.next(); + await session.connect("ws://node", ["core/data"]); + const result = await pending; + expect(result.done).toBe(false); + await session.close(); + }); + + it("events iterator resolves a backlogged event, queued before anyone was iterating, with done: false", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + const iterator = session.events[Symbol.asyncIterator](); + const result = await iterator.next(); + expect(result.done).toBe(false); + await session.close(); + }); }); describe("reconnect policy", () => { @@ -1483,6 +1505,32 @@ describe("acceptMeshSession", () => { await session.close(); }); + it("advertises no addresses in its self-advert when none are given, rather than a stray default", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession(fake.connection, testIdentity, [ + "core/data", + ]); + + const selfAdvert = fake.sent[1] as GossipFrame; + expect(selfAdvert.peers[0]?.addresses).toEqual([]); + await session.close(); + }); + + it("labels its connection state 'accepted' when no label is given", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession( + fake.connection, + testIdentity, + ["core/data"], + { clock: testClock }, + ); + const event = (await nthEvent(session, 1)) as { + state: { address: string }; + }; + expect(event.state.address).toBe("accepted"); + await session.close(); + }); + it("negotiates against the remote's own handshake exactly like the dial side", async () => { const fake = new FakeConnection(); const session = await acceptMeshSession(fake.connection, testIdentity, [ @@ -1535,7 +1583,9 @@ describe("acceptMeshSession", () => { const session = await acceptMeshSession(fake.connection, testIdentity, [ "core/data", ]); - await expect(session.connect("ws://node", ["core/data"])).rejects.toThrow(); + await expect(session.connect("ws://node", ["core/data"])).rejects.toThrow( + "connects once", + ); await session.close(); }); From 830244b4172b281090bb91938e0bea1fb12296fc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:51:31 +0100 Subject: [PATCH 5/8] test: cover sendRevocationAnnounce/sendGossipUpdate guards, emits, and revocation backlog Assert both methods emit a session event reflecting the sent frame, and refuse to run both before the first connect and again after a connection has failed and closed. Strengthen the revocation-announce receive test to check done: false on each yielded entry, and add delivery from the backlog for an entry queued before anyone was iterating revocationAnnouncements. --- ts/packages/core/test/mesh-session.test.ts | 102 ++++++++++++++++++++- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index d8e7466..d27e9c9 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -398,6 +398,38 @@ describe("createMeshSession", () => { await session.close(); }); + it("emits a session event after sending a gossip update", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/data"]); + await eventsDone; + + const sentEventDone = nthEvent(session, 1); + await session.sendGossipUpdate(); + const event = (await sentEventDone) as { + frameLog: { direction: string; frame: { type: string } }[]; + }; + expect(event.frameLog.at(-1)?.direction).toBe("sent"); + expect(event.frameLog.at(-1)?.frame.type).toBe("gossip"); + await session.close(); + }); + + it("refuses sendGossipUpdate while not connected", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await expect(session.sendGossipUpdate()).rejects.toThrow("not connected"); + }); + + it("refuses sendGossipUpdate once the connection has failed and closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect(session.sendGossipUpdate()).rejects.toThrow("not connected"); + }); + it("excludes the retired core/federation domain even when both sides offer it", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); @@ -1227,6 +1259,47 @@ describe("revocation-announce plumbing", () => { await session.close(); }); + it("emits a session event after sending a revocation-announce", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/management"]); + await eventsDone; + + const sentEventDone = nthEvent(session, 1); + await session.sendRevocationAnnounce([testEntryA]); + const event = (await sentEventDone) as { + frameLog: { direction: string; frame: { type: string } }[]; + }; + expect(event.frameLog.at(-1)).toEqual({ + direction: "sent", + frame: { + type: "revocation-announce", + entries: [testEntryA], + }, + }); + await session.close(); + }); + + it("refuses sendRevocationAnnounce while not connected", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await expect(session.sendRevocationAnnounce([testEntryA])).rejects.toThrow( + "not connected", + ); + }); + + it("refuses sendRevocationAnnounce once the connection has failed and closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect(session.sendRevocationAnnounce([testEntryA])).rejects.toThrow( + "not connected", + ); + }); + it("flattens an incoming revocation-announce frame's entries onto revocationAnnouncements, one item per entry", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); @@ -1235,8 +1308,12 @@ describe("revocation-announce plumbing", () => { const received: RevocationEntry[] = []; const receivedBoth = (async (): Promise => { const iterator = session.revocationAnnouncements[Symbol.asyncIterator](); - received.push((await iterator.next()).value as RevocationEntry); - received.push((await iterator.next()).value as RevocationEntry); + const first = await iterator.next(); + expect(first.done).toBe(false); + received.push(yielded(first)); + const second = await iterator.next(); + expect(second.done).toBe(false); + received.push(yielded(second)); })(); connection.push({ @@ -1248,6 +1325,27 @@ describe("revocation-announce plumbing", () => { expect(received).toEqual([testEntryA, testEntryB]); await session.close(); }); + + it("delivers a revocation entry queued before anyone was iterating revocationAnnouncements, from the backlog", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/management"]); + await eventsDone; + + const nextEventDone = nthEvent(session, 1); + connection.push({ + type: "revocation-announce", + entries: [testEntryA], + } satisfies RevocationAnnounceFrame); + await nextEventDone; + + const iterator = session.revocationAnnouncements[Symbol.asyncIterator](); + const result = await iterator.next(); + expect(result.done).toBe(false); + expect(yielded(result)).toEqual(testEntryA); + await session.close(); + }); }); describe("relay routing", () => { From 0bcb5afa0aa127b06775715a09a3aaa6e8d37556 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:51:59 +0100 Subject: [PATCH 6/8] test: cover a dial completing only after close() was already called Assert that when a dial resolves after the caller has already closed the session, the resulting connection is closed immediately rather than wired up (no handshake or self-advert ever sent), matching the intent that close() cancels any connection attempt still in flight. --- ts/packages/core/test/mesh-session.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index d27e9c9..172846d 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -561,6 +561,26 @@ describe("createMeshSession", () => { expect(connection.isClosed).toBe(true); }); + it("closes a dial that only completes after close() was already called, instead of wiring it up", async () => { + let resolveDial: ((connection: Connection) => void) | null = null; + const transport: Transport = { + connect: async (): Promise => + new Promise((resolve) => { + resolveDial = resolve; + }), + listen: async (): Promise => + Promise.reject(new Error("client-only transport")), + }; + const session = createMeshSession(transport, testIdentity, testClock); + const connectPromise = session.connect("ws://node", ["core/data"]); + await session.close(); + const lateConnection = new FakeConnection(); + resolveDial?.(lateConnection.connection); + await connectPromise; + expect(lateConnection.sent).toHaveLength(0); + expect(lateConnection.isClosed).toBe(true); + }); + it("treats a clean end of the receive stream as a disconnect when still connected", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); From 663216f8a45894daf3bac7dbc5275ef00d1c2c04 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 15:54:01 +0100 Subject: [PATCH 7/8] test: prove emit-after-send assertions with a real settle, not a close()-rescued one The respond()/sendRevocationAnnounce/sendGossipUpdate "emits a session event" tests were awaiting a second event via nthEvent, then always calling session.close() afterward. Since frameLog.push() happens unconditionally before each method's own emit() call, and close() unconditionally emits one final event of its own, a missing emit() in any of the three methods was silently masked: the awaited event just resolved later, from close()'s own trailing emit(), with a frameLog snapshot that still looked correct by coincidence. Add a withinShortWait() helper that races the next event against a short real timer, so the assertion only passes if the event was already available immediately after the send call, before close() is ever invoked. Apply it to all three "emits a session event" tests and reuse it in place of the ad hoc race already inlined in the close()-drops-a-queued-frame test. Also add a final-state assertion to the "closes a dial that only completes after close()" test, covering the state.status === "connecting" branch of close()'s own three-way state check -- the existing assertions only checked the late connection's own side effects, not that the session's state actually settles to closed. --- ts/packages/core/test/mesh-session.test.ts | 80 +++++++++++++++------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index 172846d..b562cb8 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -186,6 +186,23 @@ function yielded(result: IteratorResult): T { return result.value; } +const TIMEOUT_MARKER = "timeout" as const; +const SHORT_WAIT_MS = 20; + +/** Races a promise against a short real-time wait, resolving to TIMEOUT_MARKER if the promise hasn't settled yet -- used to prove a promise genuinely settled *now*, from the action just taken, rather than merely settling *eventually* by some unrelated later event (e.g. a trailing session.close() emitting one final event that would otherwise silently satisfy an unconsumed waiter and mask a missing emit() call). */ +async function withinShortWait( + promise: Readonly>, +): Promise { + return Promise.race([ + promise, + new Promise((resolve) => { + setTimeout(() => { + resolve(TIMEOUT_MARKER); + }, SHORT_WAIT_MS); + }), + ]); +} + /** Resolves after the session has emitted at least `count` events, returning the latest. */ async function nthEvent( session: ReturnType, @@ -405,11 +422,16 @@ describe("createMeshSession", () => { await session.connect("ws://node", ["core/data"]); await eventsDone; - const sentEventDone = nthEvent(session, 1); + const eventsIterator = session.events[Symbol.asyncIterator](); await session.sendGossipUpdate(); - const event = (await sentEventDone) as { - frameLog: { direction: string; frame: { type: string } }[]; - }; + // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call in sendGossipUpdate. + const result = await withinShortWait(eventsIterator.next()); + expect(result).not.toBe(TIMEOUT_MARKER); + const event = yielded( + result as IteratorResult<{ + frameLog: { direction: string; frame: { type: string } }[]; + }>, + ); expect(event.frameLog.at(-1)?.direction).toBe("sent"); expect(event.frameLog.at(-1)?.frame.type).toBe("gossip"); await session.close(); @@ -572,13 +594,22 @@ describe("createMeshSession", () => { Promise.reject(new Error("client-only transport")), }; const session = createMeshSession(transport, testIdentity, testClock); + const eventsIterator = session.events[Symbol.asyncIterator](); const connectPromise = session.connect("ws://node", ["core/data"]); + await eventsIterator.next(); // connecting await session.close(); const lateConnection = new FakeConnection(); resolveDial?.(lateConnection.connection); await connectPromise; expect(lateConnection.sent).toHaveLength(0); expect(lateConnection.isClosed).toBe(true); + const closedEvent = yielded(await eventsIterator.next()) as { + state: { status: string; reason?: string }; + }; + expect(closedEvent.state.status).toBe("closed"); + expect((closedEvent.state as { reason: string }).reason).toBe( + "closed by you", + ); }); it("treats a clean end of the receive stream as a disconnect when still connected", async () => { @@ -604,16 +635,7 @@ describe("createMeshSession", () => { for (let i = 0; i < EVENTS_THROUGH_FAILURE; i++) { await iterator.next(); } - const SHORT_WAIT_MS = 20; - const TIMEOUT_MARKER = "timeout" as const; - const result = await Promise.race([ - iterator.next(), - new Promise((resolve) => { - setTimeout(() => { - resolve(TIMEOUT_MARKER); - }, SHORT_WAIT_MS); - }), - ]); + const result = await withinShortWait(iterator.next()); expect(result).toBe(TIMEOUT_MARKER); }); @@ -1205,8 +1227,10 @@ describe("capability tokens and manage-request plumbing", () => { expect(incoming.scope).toEqual(testScope); expect(incoming.token).toEqual(testToken); - // Two events remain unconsumed: the received manage-request itself, then the sent response. - const responseEventDone = nthEvent(session, 2); + // Consume the "received manage-request" event that is already backlogged. + const eventsIterator = session.events[Symbol.asyncIterator](); + await eventsIterator.next(); + await incoming.respond({ result: "ok" }); const sentResponse = connection.sent.at(-1) as ManageResponseFrame; expect(sentResponse).toEqual({ @@ -1214,9 +1238,14 @@ describe("capability tokens and manage-request plumbing", () => { "request-id": TEST_INCOMING_REQUEST_ID, outcome: { result: "ok" }, } satisfies ManageResponseFrame); - const responseEvent = (await responseEventDone) as { - frameLog: { direction: string; frame: { type: string } }[]; - }; + // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call inside respond(). + const responseResult = await withinShortWait(eventsIterator.next()); + expect(responseResult).not.toBe(TIMEOUT_MARKER); + const responseEvent = yielded( + responseResult as IteratorResult<{ + frameLog: { direction: string; frame: { type: string } }[]; + }>, + ); expect(responseEvent.frameLog.at(-1)).toEqual({ direction: "sent", frame: sentResponse, @@ -1286,11 +1315,16 @@ describe("revocation-announce plumbing", () => { await session.connect("ws://node", ["core/management"]); await eventsDone; - const sentEventDone = nthEvent(session, 1); + const eventsIterator = session.events[Symbol.asyncIterator](); await session.sendRevocationAnnounce([testEntryA]); - const event = (await sentEventDone) as { - frameLog: { direction: string; frame: { type: string } }[]; - }; + // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call in sendRevocationAnnounce. + const result = await withinShortWait(eventsIterator.next()); + expect(result).not.toBe(TIMEOUT_MARKER); + const event = yielded( + result as IteratorResult<{ + frameLog: { direction: string; frame: { type: string } }[]; + }>, + ); expect(event.frameLog.at(-1)).toEqual({ direction: "sent", frame: { From 44d6962fa851d57aaa0484cc65ba0128094f4a62 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:37:53 +0100 Subject: [PATCH 8/8] fix(core): fix a closure-narrowing compile error in the late-dial test resolveDial, a plain let reassigned only inside a Promise executor nested in an object-property arrow function, lost its non-null narrowing across several intervening awaits, typing the eventual call site as never -- caught by tsconfig.node.json's own dedicated typecheck of test/, which the default tsconfig.json doesn't cover. Wrapping the resolver in an object property instead of a bare closed-over variable avoids the narrowing loss entirely, matching the resolvePeerDeviceId pattern used elsewhere in this same file. --- ts/packages/core/test/mesh-session.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index b562cb8..1c5dbb9 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -584,11 +584,14 @@ describe("createMeshSession", () => { }); it("closes a dial that only completes after close() was already called, instead of wiring it up", async () => { - let resolveDial: ((connection: Connection) => void) | null = null; + // A plain `let` reassigned only inside the Promise executor below loses its non-null narrowing by the time it's called several `await`s later -- an object property isn't narrowed the same way a bare closed-over variable is, so this sidesteps that entirely. + const dialResolver: { + resolve: ((connection: Connection) => void) | null; + } = { resolve: null }; const transport: Transport = { connect: async (): Promise => new Promise((resolve) => { - resolveDial = resolve; + dialResolver.resolve = resolve; }), listen: async (): Promise => Promise.reject(new Error("client-only transport")), @@ -599,7 +602,9 @@ describe("createMeshSession", () => { await eventsIterator.next(); // connecting await session.close(); const lateConnection = new FakeConnection(); - resolveDial?.(lateConnection.connection); + if (dialResolver.resolve === null) + throw new Error("expected resolveDial to be set"); + dialResolver.resolve(lateConnection.connection); await connectPromise; expect(lateConnection.sent).toHaveLength(0); expect(lateConnection.isClosed).toBe(true);