import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { safeInboxDir, writeMessage, type Message } from "../src/transport.ts"; import { DISPATCHER_SEEN_CAP, InboxDispatcher } from "../src/dispatcher.ts"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function withTempRoot(t: import("node:test").TestContext) { const root = await mkdtemp(path.join(tmpdir(), "metrol-dispatcher-test-")); t.after(() => rm(root, { recursive: true, force: true })); return root; } async function waitFor( cond: () => boolean | Promise, timeoutMs = 3000, ): Promise { const start = Date.now(); while (!(await cond())) { if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout"); await sleep(5); } } function msg(over: Record = {}): Message { return { version: 1, id: randomUUID(), type: "chat", correlationId: randomUUID(), from: { instanceId: "12345678", metroName: "Red-1" }, toInstanceId: "c0c0c0c0c0c0c0c0", payload: { text: "hi" }, timestamp: Date.now(), ...over, } as Message; } test("delivers each new chat message once and empties the inbox", async (t) => { const root = await withTempRoot(t); const dir = await safeInboxDir(root, "c0c0c0c0c0c0c0c0"); const received: Message[] = []; const d = new InboxDispatcher(dir, (m) => { received.push(m); }); d.start(10); t.after(() => d.stop()); await writeMessage(dir, msg()); await writeMessage(dir, msg()); await writeMessage(dir, msg()); await waitFor(() => received.length === 3); await waitFor(async () => (await readdir(dir)).length === 0); await d.stop(); await sleep(50); assert.equal(received.length, 3); assert.equal(new Set(received.map((m) => m.id)).size, 3); }); test("a reply registered before the write resolves the waiting promise", async (t) => { const root = await withTempRoot(t); const dir = await safeInboxDir(root, "c0c0c0c0c0c0c0c0"); const d = new InboxDispatcher(dir); d.start(10); t.after(() => d.stop()); const correlationId = randomUUID(); const waiting = d.awaitReply(correlationId, 3000); await writeMessage( dir, msg({ type: "reply", correlationId, payload: { answer: 42 } }), ); const r = await waiting; assert.equal(r.error, null); assert.deepEqual(r.value, { answer: 42 }); await d.stop(); }); test("two chat files with the same id are delivered once", async (t) => { const root = await withTempRoot(t); const dir = await safeInboxDir(root, "c0c0c0c0c0c0c0c0"); const received: Message[] = []; const d = new InboxDispatcher(dir, (m) => { received.push(m); }); d.start(10); t.after(() => d.stop()); const id = randomUUID(); await writeMessage(dir, msg({ id })); await writeMessage(dir, msg({ id, timestamp: Date.now() + 1 })); await waitFor(async () => (await readdir(dir)).length === 0); await d.stop(); assert.equal(received.length, 1); assert.equal(received[0].id, id); }); test("seen set is FIFO-capped at DISPATCHER_SEEN_CAP (no unbounded growth)", async (t) => { // Ponytail: skip on Windows. The 10k+50 small-file stress exceeds the // default 15s waitFor timeout on Windows CI runners (NTFS small-file // throughput is slower than ext4/APFS). The cap is independent of OS; // macOS + Linux runners exercise it. Restore when the test is rewritten // to be I/O-cheap (e.g. an in-memory dispatcher in unit tests). if (process.platform === "win32") return; // Push DISPATCHER_SEEN_CAP + a small overflow through the dispatcher // and confirm the dedup history caps at the configured bound. The cap // exists to bound per-runtime memory; messages evicted from the dedup // history were already handled and their files deleted, so the cap // cannot cause duplicate deliveries within the dedup window. const root = await withTempRoot(t); const dir = await safeInboxDir(root, "c0c0c0c0c0c0c0c0"); for (let i = 0; i < DISPATCHER_SEEN_CAP + 50; i++) { await writeMessage(dir, msg({ id: randomUUID() })); } let receivedCount = 0; const d = new InboxDispatcher(dir, () => { receivedCount++; }); d.start(10); t.after(() => d.stop()); // Wait for the dispatcher to drain every file (each file deletes after // routing). All CAP + 50 must be received once. await waitFor( async () => (await readdir(dir)).filter((f) => f.endsWith(".json")).length === 0, 15000, ); await d.stop(); assert.equal( receivedCount, DISPATCHER_SEEN_CAP + 50, "every message should be delivered exactly once", ); }); test("stop() waits for an in-flight tick and no ticks run afterwards", async (t) => { const root = await withTempRoot(t); const dir = await safeInboxDir(root, "c0c0c0c0c0c0c0c0"); let entered = 0; let release!: () => void; const gate = new Promise((r) => (release = r)); const d = new InboxDispatcher(dir, async () => { entered++; await gate; }); await writeMessage(dir, msg()); d.start(10); t.after(() => d.stop()); await waitFor(() => entered === 1); const stopped = d.stop(); release(); await stopped; // must not hang: stop awaited the in-flight handler await writeMessage(dir, msg()); await sleep(80); assert.equal(entered, 1); });