feat(transport): nostr-tools relay pool that re-subscribes on reconnect (#42)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
First increment of the NDK -> nostr-tools transport swap (#42), the root fix for #41 (bunker goes silently deaf after a relay flap). NDK does not replay subscriptions on reconnect: a NDKRelaySubscription registers `relay.once("ready", execute)` and never re-arms, so after a flap the socket reconnects but the kind:24133 REQ is never re-sent. We chased that through #4/#7/#20/#21 without closing it because it is structural in NDK. `RelayPool` (src/daemon/lib/relay-pool.ts) owns the connect loop, modelled on lightning.pub's RelayConnection and signet's relay-pool (both nostr-tools, both bind resubscribe to reconnect). Every (re)connect re-subscribes the whole registry, so subscription liveness can't drift from socket liveness. It also exposes `healthy()` (connected AND registry subscribed on the wire) — the session-liveness signal the old connectedRelays()-only watchdog couldn't make, which is what let #20's reconnect mask the deaf state. We disable nostr-tools' own `enableReconnect`: its auto-resubscribe is version-fragile right now (regressed in 2.23.0 fb7de7f; the 455124e fix is unreleased as of 2026-06-26), so the resubscribe is OUR code, not a function of which nostr-tools version is installed. Regression test (tests/relay-pool.test.ts + tests/helpers/mock-relay.ts): an in-process mock relay flaps mid-session (down + back up on the same port) and we assert a subsequent inbound kind:24133 is still delivered — the exact #41 scenario, and the test that was missing every prior round. Green; existing lifecycle suite unchanged. Next increments on this branch: port Backend (NIP-46) + AdminInterface (RPC) onto the pool, wire run.ts, retire relay-reconnect.ts + the connection-only watchdog. Refs: #42, #41, #21, #20, #9
This commit is contained in:
parent
4a0a3e7cd4
commit
1409941d11
4 changed files with 636 additions and 1 deletions
104
tests/relay-pool.test.ts
Normal file
104
tests/relay-pool.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue