A reply routinely fanned out to every session of the sender's member, which is the "peers spread messages to the wrong peers" noise. Three links fed each other: - channel meta put the MEMBER key in from_pubkey/from_id, and the skill told the model to reply by from_name (repeatable across sessions). from_pubkey/from_id are now the sender's session key; the member key moves to from_member_pubkey; the skill says reply to from_pubkey. - a DM to a member key was claimed by that member's daemon socket and shown to all its sessions. New daemon/resolve-target.ts is the single resolver for /v1/send: a member key maps to its only live session, is refused (409 member_target_ambiguous + candidates) when it has several, and needs --fanout to reach all of them on purpose. A duplicated display name or prefix is ambiguous instead of first-match; control- plane rows are never addressable. - a session MCP whose token didn't resolve fell into the unfiltered SSE stream and rendered every sibling's DMs. The MCP now subscribes with scope=session and gets no message events without a resolved session. Also: --self lists only the current mesh and requires a registered session (it could message its own new row); a DM to a control-plane row says so instead of the wrong "session pubkeys are ephemeral" hint, and `peer list --all` tags those rows (bug9). Spec: .artifacts/specs/2026-09-26-session-identity-and-routing.md §2-3, §6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
5.4 KiB
TypeScript
150 lines
5.4 KiB
TypeScript
/**
|
|
* MCP SSE resubscribe (1.37.1) — regression for the 2026-09-08 finding
|
|
* that a daemon dying hard (SIGKILL / process.exit) left every MCP
|
|
* plugin's `/v1/events` subscription dead: the response emitted
|
|
* `aborted`/`close` (never `end`), and the pre-1.37.1 client only
|
|
* resubscribed on `end`. Also covers the idle watchdog (no bytes at all,
|
|
* e.g. a half-open socket after sleep).
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { createServer, type Server, type ServerResponse } from "node:http";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { subscribeEvents } from "~/mcp/server.js";
|
|
|
|
let dir: string;
|
|
let server: Server | null = null;
|
|
let sockPath: string;
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), "cmh-sse-"));
|
|
sockPath = join(dir, "d.sock");
|
|
});
|
|
afterEach(async () => {
|
|
if (server) await new Promise<void>((r) => server!.close(() => r()));
|
|
server = null;
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
interface Fake {
|
|
connections: number;
|
|
responses: ServerResponse[];
|
|
}
|
|
|
|
function startFakeDaemon(opts: { keepalive?: boolean } = {}): Promise<Fake> {
|
|
const fake: Fake = { connections: 0, responses: [] };
|
|
server = createServer((req, res) => {
|
|
if ((req.url ?? "").split("?")[0] !== "/v1/events") { res.statusCode = 404; res.end(); return; }
|
|
fake.connections++;
|
|
fake.responses.push(res);
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.write(": connected\n\n");
|
|
if (opts.keepalive !== false) {
|
|
const t = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { /* gone */ } }, 100);
|
|
res.on("close", () => clearInterval(t));
|
|
}
|
|
});
|
|
return new Promise((resolve) => server!.listen(sockPath, () => resolve(fake)));
|
|
}
|
|
|
|
const waitFor = async (pred: () => boolean, ms = 3_000): Promise<void> => {
|
|
const start = Date.now();
|
|
while (!pred()) {
|
|
if (Date.now() - start > ms) throw new Error("timeout waiting for condition");
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
}
|
|
};
|
|
|
|
describe("subscribeEvents resubscribe", () => {
|
|
it("reconnects when the daemon destroys the socket without ending the response (kill -9)", async () => {
|
|
const fake = await startFakeDaemon();
|
|
const events: string[] = [];
|
|
const logs: string[] = [];
|
|
const sub = subscribeEvents((e) => events.push(e.kind), {
|
|
socketPath: sockPath,
|
|
backoffMs: [50, 50],
|
|
idleTimeoutMs: 0,
|
|
log: (m) => logs.push(m),
|
|
});
|
|
try {
|
|
await waitFor(() => fake.connections === 1);
|
|
// Simulate a hard daemon death: tear the TCP/UDS socket down with no
|
|
// `end` frame. The client sees close/aborted/error — never `end`.
|
|
fake.responses[0]!.socket?.destroy();
|
|
await waitFor(() => fake.connections >= 2, 3_000);
|
|
expect(fake.connections).toBeGreaterThanOrEqual(2);
|
|
expect(logs).toContain("sse_reconnect");
|
|
expect(logs.filter((l) => l === "sse_subscribed").length).toBeGreaterThanOrEqual(2);
|
|
// The new stream is live: an event pushed on it reaches onEvent.
|
|
const res = fake.responses[fake.responses.length - 1]!;
|
|
res.write(`event: message\ndata: ${JSON.stringify({ ts: "t", body: "hi" })}\n\n`);
|
|
await waitFor(() => events.includes("message"));
|
|
} finally {
|
|
sub.close();
|
|
}
|
|
});
|
|
|
|
it("reconnects when the daemon is down at first and comes up later (connect error path)", async () => {
|
|
const events: string[] = [];
|
|
const sub = subscribeEvents((e) => events.push(e.kind), {
|
|
socketPath: sockPath, backoffMs: [50, 50], idleTimeoutMs: 0,
|
|
});
|
|
try {
|
|
await new Promise((r) => setTimeout(r, 120)); // a couple of ECONNREFUSED/ENOENT attempts
|
|
const fake = await startFakeDaemon();
|
|
await waitFor(() => fake.connections >= 1, 3_000);
|
|
} finally {
|
|
sub.close();
|
|
}
|
|
});
|
|
|
|
it("idle watchdog: destroys a silent stream and resubscribes", async () => {
|
|
const fake = await startFakeDaemon({ keepalive: false });
|
|
const logs: string[] = [];
|
|
const sub = subscribeEvents(() => { /* ignore */ }, {
|
|
socketPath: sockPath,
|
|
backoffMs: [30],
|
|
idleTimeoutMs: 300,
|
|
log: (m) => logs.push(m),
|
|
});
|
|
try {
|
|
await waitFor(() => fake.connections === 1);
|
|
await waitFor(() => fake.connections >= 2, 3_000);
|
|
expect(logs).toContain("sse_idle_timeout");
|
|
} finally {
|
|
sub.close();
|
|
}
|
|
});
|
|
|
|
it("a live keepalive stream is NOT torn down by the watchdog", async () => {
|
|
const fake = await startFakeDaemon({ keepalive: true }); // keepalive every 100 ms
|
|
const logs: string[] = [];
|
|
const sub = subscribeEvents(() => { /* ignore */ }, {
|
|
socketPath: sockPath, backoffMs: [30], idleTimeoutMs: 400, log: (m) => logs.push(m),
|
|
});
|
|
try {
|
|
await waitFor(() => fake.connections === 1);
|
|
await new Promise((r) => setTimeout(r, 900));
|
|
expect(fake.connections).toBe(1);
|
|
expect(logs).not.toContain("sse_idle_timeout");
|
|
} finally {
|
|
sub.close();
|
|
}
|
|
});
|
|
|
|
it("close() stops reconnecting", async () => {
|
|
const fake = await startFakeDaemon();
|
|
const sub = subscribeEvents(() => { /* ignore */ }, {
|
|
socketPath: sockPath, backoffMs: [30], idleTimeoutMs: 0,
|
|
});
|
|
await waitFor(() => fake.connections === 1);
|
|
sub.close();
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
expect(fake.connections).toBe(1);
|
|
});
|
|
});
|