mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
The first message after a community switch (or a cold launch) spun for 25s, failed with "Timed out while sending the message", then worked on the second try. The relay's WS admission window (50 frames / 5s per pubkey) counts REQ and EVENT together, and the post-(re)connect REQ fan-out alone can exhaust it. An over-quota REQ already got a correlated `CLOSED <subId> rate-limited:`, but an over-quota EVENT got a bare `NOTICE rate-limited:` — nothing ever settled the publisher's pending OK, so it sat until PUBLISH_TIMEOUT_MS. Relay (`connection.rs`): replace the `Option<sub_id>` passed into `request_rejection_message` with a `RejectionTarget` so an EVENT refused by admission or the handler semaphore is answered with `["OK", <id>, false, "rate-limited: …"]`. REQ keeps CLOSED; COUNT keeps NOTICE. The `rate-limited:` prefix and `retry in Ns` hint are unchanged so every client's existing parser still applies. Desktop (`relayClientSession.ts`): on `OK false rate-limited:` arm the gate and re-send the EVENT once it clears instead of rejecting the publish — the relay dedups by id, so the resend cannot double-post. Exactly one retry per publish; a second refusal, or a gate longer than the publish timeout, fails fast with the relay's reason. The NOTICE branch gives in-flight publishes the same single retry so the fix also holds against relays that predate this change. buzz-acp (`relay.rs`): an `OK false rate-limited:` used to fall through to `acknowledge_observer_frame`, silently dropping the refused observer frame from the in-flight window. Arm the gate and put that one frame back at the head of the paced drain instead. Mobile (`relay_session.dart`): an `OK false rate-limited:` now arms the gate before failing the publish, so concurrent work backs off. Tests: relay frame-shape unit test; desktop fail-first unit tests for the retry, its bound, non-rate-limit rejection, legacy NOTICE, and a reset during the wait; acp requeue-only-the-refused-frame; mobile gate-armed-on-OK-false (verified failing before the lib change). Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
171 lines
5.2 KiB
JavaScript
171 lines
5.2 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
// ── Fake-timer setup ──────────────────────────────────────────────────────────
|
|
// The rate-limit gate and publish timeout both use window.setTimeout.
|
|
|
|
let fakeNow = 0;
|
|
const pendingTimers = new Map();
|
|
let nextTimerId = 1;
|
|
|
|
function fakeSetTimeout(fn, ms) {
|
|
const id = nextTimerId++;
|
|
pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
|
|
return id;
|
|
}
|
|
|
|
function fakeClearTimeout(id) {
|
|
pendingTimers.delete(id);
|
|
}
|
|
|
|
async function tickTo(ms) {
|
|
fakeNow = ms;
|
|
for (const [id, { fn, fireAt }] of Array.from(pendingTimers.entries())) {
|
|
if (fireAt <= fakeNow) {
|
|
pendingTimers.delete(id);
|
|
fn();
|
|
}
|
|
}
|
|
// Let promise chains hanging off fired timers (the gate) run.
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
Date.now = () => fakeNow;
|
|
globalThis.window = {
|
|
setTimeout: fakeSetTimeout,
|
|
clearTimeout: fakeClearTimeout,
|
|
};
|
|
|
|
// Import after the window shim is installed.
|
|
const { resetRateLimitGate, isRateLimited } = await import(
|
|
"./relayRateLimitGate.ts"
|
|
);
|
|
const { RelayClient } = await import("./relayClientSession.ts");
|
|
|
|
const RATE_LIMITED = "rate-limited: quota exceeded; retry in 3s";
|
|
|
|
function makeClient() {
|
|
pendingTimers.clear();
|
|
nextTimerId = 1;
|
|
fakeNow = 0;
|
|
resetRateLimitGate();
|
|
|
|
const client = new RelayClient();
|
|
const sent = [];
|
|
// Pretend the socket is open; capture frames instead of invoking Tauri.
|
|
client.wsId = 1;
|
|
client.sendRaw = async (payload) => {
|
|
sent.push(payload);
|
|
};
|
|
return { client, sent };
|
|
}
|
|
|
|
function deliver(client, frame) {
|
|
return client.handleWsMessage(JSON.stringify(frame), 0);
|
|
}
|
|
|
|
function watch(promise) {
|
|
const state = { settled: false, value: undefined, error: undefined };
|
|
promise.then(
|
|
(value) => Object.assign(state, { settled: true, value }),
|
|
(error) => Object.assign(state, { settled: true, error }),
|
|
);
|
|
return state;
|
|
}
|
|
|
|
const event = { id: "e1", kind: 9, content: "hi" };
|
|
|
|
test("OK=false rate-limited arms the gate and re-sends the EVENT once it clears", async () => {
|
|
const { client, sent } = makeClient();
|
|
const publish = watch(client.publishEvent(event, "timeout", "send failed"));
|
|
await tickTo(0);
|
|
assert.equal(sent.length, 1);
|
|
|
|
await deliver(client, ["OK", "e1", false, RATE_LIMITED]);
|
|
await tickTo(0);
|
|
assert.equal(publish.settled, false, "first refusal must not fail the send");
|
|
assert.equal(isRateLimited(), true, "gate armed from the OK reason");
|
|
assert.equal(sent.length, 1, "no resend while the gate is closed");
|
|
|
|
await tickTo(3_000);
|
|
assert.equal(sent.length, 2, "EVENT re-sent when the gate cleared");
|
|
assert.deepEqual(sent[1], ["EVENT", event]);
|
|
|
|
await deliver(client, ["OK", "e1", true, ""]);
|
|
await tickTo(3_000);
|
|
assert.equal(
|
|
publish.value,
|
|
event,
|
|
"retry's OK resolves the original publish",
|
|
);
|
|
});
|
|
|
|
test("a second rate-limited refusal fails fast with the relay's reason", async () => {
|
|
const { client, sent } = makeClient();
|
|
const publish = watch(client.publishEvent(event, "timeout", "send failed"));
|
|
await tickTo(0);
|
|
|
|
await deliver(client, ["OK", "e1", false, RATE_LIMITED]);
|
|
await tickTo(3_000);
|
|
assert.equal(sent.length, 2);
|
|
|
|
await deliver(client, ["OK", "e1", false, RATE_LIMITED]);
|
|
await tickTo(3_000);
|
|
assert.equal(publish.error?.message, RATE_LIMITED);
|
|
assert.equal(sent.length, 2, "retry is bounded to one");
|
|
});
|
|
|
|
test("a non-rate-limit OK=false still rejects immediately", async () => {
|
|
const { client } = makeClient();
|
|
const publish = watch(client.publishEvent(event, "timeout", "send failed"));
|
|
await tickTo(0);
|
|
|
|
await deliver(client, [
|
|
"OK",
|
|
"e1",
|
|
false,
|
|
"restricted: not a channel member",
|
|
]);
|
|
await tickTo(0);
|
|
assert.equal(publish.error?.message, "restricted: not a channel member");
|
|
});
|
|
|
|
test("legacy NOTICE rate-limited gives pending publishes the same single retry", async () => {
|
|
const { client, sent } = makeClient();
|
|
const publish = watch(client.publishEvent(event, "timeout", "send failed"));
|
|
await tickTo(0);
|
|
|
|
await deliver(client, ["NOTICE", RATE_LIMITED]);
|
|
await tickTo(0);
|
|
assert.equal(publish.settled, false);
|
|
assert.equal(sent.length, 1);
|
|
|
|
// A second uncorrelated NOTICE cannot be attributed to this publish; it must
|
|
// neither fail it nor grant a second retry.
|
|
await deliver(client, ["NOTICE", RATE_LIMITED]);
|
|
await tickTo(3_000);
|
|
assert.equal(publish.settled, false);
|
|
assert.equal(sent.length, 2, "exactly one resend");
|
|
|
|
await deliver(client, ["OK", "e1", true, ""]);
|
|
await tickTo(3_000);
|
|
assert.equal(publish.value, event);
|
|
});
|
|
|
|
test("a retry scheduled before a connection reset does not fire on the dead socket", async () => {
|
|
const { client, sent } = makeClient();
|
|
const publish = watch(client.publishEvent(event, "timeout", "send failed"));
|
|
await tickTo(0);
|
|
|
|
await deliver(client, ["OK", "e1", false, RATE_LIMITED]);
|
|
await tickTo(0);
|
|
client.resetConnection(new Error("Relay connection closed."), {
|
|
reconnect: false,
|
|
});
|
|
await tickTo(0);
|
|
assert.equal(publish.error?.message, "Relay connection closed.");
|
|
|
|
await tickTo(3_000);
|
|
assert.equal(sent.length, 1, "no resend after the publish was settled");
|
|
});
|