Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Folds three medium findings from the transport review into the RelayPool:
- RP-4: connectOnce used the static Relay.connect(), which silently DROPS a
{timeout} option in nostr-tools 2.20.0, so a black-holed TCP connect (SYN
accepted, never upgraded) stalled that relay's loop for the OS socket timeout
(minutes) with no retry. Now constructs the Relay and calls the instance
.connect({ timeout: 5000 }), which honours the timeout → prompt reject + backoff.
- RP-2: connectOnce didn't re-check `stopped` after the await. If stop() ran
while a connect was in flight, the resolved socket re-armed subscriptions on a
relay we meant to abandon, leaked the socket, and hung connectLoop (its promise
never resolved because onclose never fired). Now drops the socket cleanly and
resolves if stopped mid-connect.
- RP-1: no cross-relay event dedup — a kind:24133 request published to N relays
(the normal NIP-46 pattern), or re-delivered after a reconnect, drove the
daemon handler + recordSigning N times, making rate caps bind ~N× tighter
(fails closed, not open). Added a bounded (4000-id, ≈LRU) pool-wide seen-set;
onevent fires at most once per event id. Closes the CS-4 replay vector too.
Test: tests/relay-pool.test.ts asserts a duplicate event id is delivered once.
relay 3 / nip46 1 / admin 2 green; daemon bundles; tsc at baseline.
Refs: transport review RP-1/RP-2/RP-4, CS-4; #42
136 lines
5.2 KiB
TypeScript
136 lines
5.2 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
|
|
import { MockRelay } from "./helpers/mock-relay";
|
|
import { RelayPool } from "../src/daemon/lib/relay-pool";
|
|
|
|
// A throwaway client key. Events must be REAL (valid id + sig): nostr-tools
|
|
// verifies inbound events and silently drops invalid ones, so the test has to
|
|
// sign them exactly as a real bunker client would.
|
|
const CLIENT_SK = generateSecretKey();
|
|
// The spire pubkey the bunker subscribes for (`#p`), a fixture here.
|
|
const PUBKEY = "1508b42094e65dff982ac8ca5a264089f7de2d4bbda81bf32a91678f337ced3b";
|
|
|
|
function makeEvent(): { id: string; [k: string]: any } {
|
|
return finalizeEvent(
|
|
{
|
|
kind: 24133,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", PUBKEY]],
|
|
content: "encrypted-blob",
|
|
},
|
|
CLIENT_SK,
|
|
) as any;
|
|
}
|
|
|
|
void getPublicKey; // (kept available for future signed-response assertions)
|
|
|
|
async function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 25): Promise<void> {
|
|
const start = Date.now();
|
|
while (!pred()) {
|
|
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
|
await new Promise((r) => setTimeout(r, stepMs));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The regression that was missing every prior round (#4/#7/#20/#21): flap the
|
|
* relay mid-session and assert a subsequent inbound kind:24133 is still
|
|
* delivered. With the old NDK transport the socket reconnected but the REQ was
|
|
* never re-sent, so the bunker went silently deaf (#41). The RelayPool owns the
|
|
* connect loop and re-subscribes on every (re)connect, so this must pass.
|
|
*/
|
|
test("RelayPool re-subscribes after a relay flap — inbound events still delivered (#41/#42)", async () => {
|
|
const relay = new MockRelay();
|
|
await relay.start();
|
|
|
|
const received: string[] = [];
|
|
const pool = new RelayPool([relay.url], { log: () => {} });
|
|
pool.start();
|
|
|
|
// Subscribe for the bunker's kind:24133 channel; await the REQ landing.
|
|
await pool.subscribeAwaitingEose([{ kinds: [24133], "#p": [PUBKEY] }], (e) =>
|
|
received.push(e.id),
|
|
);
|
|
|
|
// Baseline: a matching event before any flap is delivered.
|
|
const before = makeEvent();
|
|
relay.inject(before);
|
|
await waitFor(() => received.includes(before.id));
|
|
|
|
// FLAP: relay down + back up on the same port (the relay-restart that took
|
|
// the demo bunker deaf). The pool must reconnect AND re-subscribe.
|
|
const reqsBefore = relay.reqCount;
|
|
await relay.flap();
|
|
|
|
// Wait until reconnected AND the subscription is re-established on the wire.
|
|
await waitFor(() => pool.healthy() && relay.reqCount > reqsBefore);
|
|
|
|
// THE assertion: a matching event AFTER the flap is still delivered.
|
|
const after = makeEvent();
|
|
relay.inject(after);
|
|
await waitFor(() => received.includes(after.id));
|
|
|
|
assert.ok(received.includes(before.id), "event before flap delivered");
|
|
assert.ok(received.includes(after.id), "event after flap delivered (resubscribed)");
|
|
assert.ok(pool.healthy(), "pool healthy (connected + subscribed) after flap");
|
|
|
|
pool.stop();
|
|
await relay.stop();
|
|
});
|
|
|
|
/**
|
|
* healthy() must distinguish a connected-but-deaf relay (the #41 state the old
|
|
* connectedRelays()-only watchdog could not see) from a genuinely serving one.
|
|
*/
|
|
test("RelayPool.healthy() is false until the registry is subscribed on the wire (#42)", async () => {
|
|
const relay = new MockRelay();
|
|
await relay.start();
|
|
|
|
const pool = new RelayPool([relay.url], { log: () => {} });
|
|
pool.start();
|
|
|
|
// No subscriptions registered yet — but once connected with an empty
|
|
// registry, healthy() is trivially true (nothing to be deaf about).
|
|
await waitFor(() => pool.connectedCount() === 1);
|
|
assert.equal(pool.healthy(), true, "connected, empty registry -> healthy");
|
|
|
|
// Register a sub; healthy stays true once it lands.
|
|
await pool.subscribeAwaitingEose([{ kinds: [24133], "#p": [PUBKEY] }], () => {});
|
|
assert.equal(pool.healthy(), true, "connected + subscribed -> healthy");
|
|
|
|
pool.stop();
|
|
await relay.stop();
|
|
});
|
|
|
|
/**
|
|
* RP-1: a NIP-46 request published to multiple relays (or re-delivered after a
|
|
* reconnect) must drive the subscription callback ONCE — otherwise the daemon
|
|
* signs N times and over-counts rate caps. The pool dedups by event id.
|
|
*/
|
|
test("RelayPool delivers each event id at most once (#RP-1)", async () => {
|
|
const relay = new MockRelay();
|
|
await relay.start();
|
|
|
|
const received: string[] = [];
|
|
const pool = new RelayPool([relay.url], { log: () => {} });
|
|
pool.start();
|
|
await pool.subscribeAwaitingEose([{ kinds: [24133], "#p": [PUBKEY] }], (e) =>
|
|
received.push(e.id),
|
|
);
|
|
|
|
const ev = makeEvent();
|
|
relay.inject(ev);
|
|
relay.inject(ev); // same id again (simulates a second relay / a replay)
|
|
await waitFor(() => received.includes(ev.id));
|
|
await new Promise((r) => setTimeout(r, 200)); // give a 2nd delivery a chance
|
|
|
|
assert.equal(
|
|
received.filter((id) => id === ev.id).length,
|
|
1,
|
|
"a duplicate event id is delivered to the callback only once",
|
|
);
|
|
|
|
pool.stop();
|
|
await relay.stop();
|
|
});
|