mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Adds bounded-backoff recovery reads to all four sidebar sync hooks (sections, sort, stars and mutes). A stale relay read at app open now converges without a user edit or a reconnect. ## What this does **Shared recovery hook (`useStaleReaderRecovery`).** All four hooks use one retry effect. It reads immediately on mount, then at 5, 10, 30 and 60 s, staying at 60 s after that. A change of `visibilityState` to `visible` triggers an immediate read. Reads are single-flight. The effect restarts whenever `pubkey` or `relayUrl` changes, so a hook that stays mounted while switching relays drops the old relay's in-flight reads. **Guards that stop a recovery read from rolling back a local edit:** 1. **Skip the read while an edit is pending.** If an edit is pending when a tick starts, the tick skips its read and just schedules the next one. Without this, the read could return the pre-edit blob after the edit published and pending cleared. At that point neither apply-time guard could reject it. 2. **Pending check inside the state updater.** `hasPending()` and the effect lifetime are re-read inside `setStore(prev => ...)`. That way the check runs when React executes the updater, not when the response arrives. 3. **Local-mutation revision.** Each hook counts its local edits. The count is snapshotted when a read starts and checked again at apply time. If an edit landed while the read was in flight, the response is discarded. 4. **Head advanced only after a successful cache write.** A failed `write*Store()` leaves the same head retryable on the next tick. **Ownership-aware pending clear.** Each sync manager's `doPublish(store)` now clears `pendingStore` only while it still holds that same `store`, on both the identical-payload early return and the success path. Before, an older publish that finished after a newer edit had replaced `pendingStore` wiped the newer edit's pending marker, so a recovery read before the newer debounce could apply the older relay head over it. `destroy()` still clears unconditionally. ## Scope Reader-side recovery plus the ownership-aware pending clear. No outboxes, arbitration or replay changes. It applies to `useChannelSections`, `useChannelSortPreference`, `useChannelStars` and `useChannelMutes`. Related: [#7806](https://github.com/block/buzz/pull/7806) --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
377 lines
11 KiB
JavaScript
377 lines
11 KiB
JavaScript
// A relay rejection addressed to one event must settle that event's pending
|
|
// publish *and* arm the rate-limit gate.
|
|
//
|
|
// History: the relay rejected an over-quota EVENT with a bare
|
|
// `["NOTICE", "rate-limited: ..."]`. A NOTICE carries no event id, and
|
|
// `pendingEvents` is keyed by event id, so nothing settled — the publish sat
|
|
// until PUBLISH_TIMEOUT_MS (25s) and surfaced as a message stuck on
|
|
// "Sending…". Startup quota exhaustion made that routine in the first seconds
|
|
// after launch. The relay now rejects on the OK channel instead, so the gate
|
|
// arming that used to live in the NOTICE branch has to happen here too.
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
const fakeNow = 0;
|
|
const pendingTimers = new Map();
|
|
let nextTimerId = 1;
|
|
const sendAttempts = [];
|
|
const deliveredFrames = [];
|
|
let invokeError = null;
|
|
let sendTransport = async (args) => {
|
|
deliveredFrames.push(args);
|
|
};
|
|
|
|
globalThis.window = {
|
|
setTimeout: (fn, ms) => {
|
|
const id = nextTimerId++;
|
|
pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
|
|
return id;
|
|
},
|
|
clearTimeout: (id) => pendingTimers.delete(id),
|
|
__TAURI_INTERNALS__: {
|
|
invoke: async (command, args) => {
|
|
if (command === "get_channels" && invokeError) throw invokeError;
|
|
if (command === "plugin:websocket|send") {
|
|
sendAttempts.push(args);
|
|
return sendTransport(args);
|
|
}
|
|
},
|
|
},
|
|
};
|
|
Date.now = () => fakeNow;
|
|
|
|
const { RelayClient } = await import("./relayClientSession.ts");
|
|
const { invokeTauri } = await import("./tauri.ts");
|
|
const { PublishCanceledError } = await import("./relayEventPublisher.ts");
|
|
const { activateRateLimit, isRateLimited, resetRateLimitGate } = await import(
|
|
"./relayRateLimitGate.ts"
|
|
);
|
|
|
|
function reset() {
|
|
resetRateLimitGate();
|
|
invokeError = null;
|
|
pendingTimers.clear();
|
|
nextTimerId = 1;
|
|
sendAttempts.length = 0;
|
|
deliveredFrames.length = 0;
|
|
sendTransport = async (args) => {
|
|
deliveredFrames.push(args);
|
|
};
|
|
}
|
|
|
|
function connectedClient() {
|
|
const client = new RelayClient();
|
|
client.wsId = 7;
|
|
return client;
|
|
}
|
|
|
|
function eventFrames() {
|
|
return deliveredFrames.filter(
|
|
({ message }) => JSON.parse(message.data)[0] === "EVENT",
|
|
);
|
|
}
|
|
|
|
async function flushUntil(predicate, attempts = 20) {
|
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
if (predicate()) return;
|
|
await Promise.resolve();
|
|
}
|
|
assert.fail("condition did not become true before the microtask limit");
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
/**
|
|
* Registers a pending publish the way `publishEvent` does, without needing a
|
|
* socket: the OK dispatch under test only reads `pendingEvents`.
|
|
*/
|
|
function armPendingPublish(client, eventId) {
|
|
const event = { id: eventId };
|
|
const settled = new Promise((resolve, reject) => {
|
|
client.pendingEvents.set(eventId, {
|
|
event,
|
|
resolve,
|
|
reject,
|
|
timeout: window.setTimeout(() => {}, 25_000),
|
|
});
|
|
});
|
|
// Keep the rejection from surfacing as an unhandled rejection.
|
|
return settled.then(
|
|
(value) => ({ status: "resolved", value }),
|
|
(error) => ({ status: "rejected", error }),
|
|
);
|
|
}
|
|
|
|
/** Feeds a raw relay frame through the real inbound dispatch path. */
|
|
function deliver(client, frame) {
|
|
return client.handleWsMessage(
|
|
{ type: "Text", data: JSON.stringify(frame) },
|
|
client.connectionGeneration,
|
|
);
|
|
}
|
|
|
|
test("a rate-limited OK rejection settles the pending publish", async () => {
|
|
resetRateLimitGate();
|
|
pendingTimers.clear();
|
|
const client = new RelayClient();
|
|
const eventId = "a".repeat(64);
|
|
const settled = armPendingPublish(client, eventId);
|
|
|
|
await deliver(client, [
|
|
"OK",
|
|
eventId,
|
|
false,
|
|
"rate-limited: quota exceeded; retry in 4s",
|
|
]);
|
|
|
|
const outcome = await settled;
|
|
assert.equal(
|
|
outcome.status,
|
|
"rejected",
|
|
"an over-quota publish must fail fast, not hang until the 25s publish timeout",
|
|
);
|
|
assert.match(outcome.error.message, /rate-limited/);
|
|
assert.equal(
|
|
client.pendingEvents.has(eventId),
|
|
false,
|
|
"the pending entry must be cleared",
|
|
);
|
|
});
|
|
|
|
test("a rate-limited OK rejection arms the rate-limit gate", async () => {
|
|
resetRateLimitGate();
|
|
pendingTimers.clear();
|
|
const client = new RelayClient();
|
|
const eventId = "b".repeat(64);
|
|
const settled = armPendingPublish(client, eventId);
|
|
|
|
assert.equal(isRateLimited(), false, "gate starts closed");
|
|
|
|
await deliver(client, [
|
|
"OK",
|
|
eventId,
|
|
false,
|
|
"rate-limited: quota exceeded; retry in 4s",
|
|
]);
|
|
await settled;
|
|
|
|
assert.equal(
|
|
isRateLimited(),
|
|
true,
|
|
"back-pressure now arrives on the OK channel — without arming here the " +
|
|
"client fails the send and immediately retries into the same quota",
|
|
);
|
|
});
|
|
|
|
test("an ordinary OK rejection does not arm the gate", async () => {
|
|
resetRateLimitGate();
|
|
pendingTimers.clear();
|
|
const client = new RelayClient();
|
|
const eventId = "c".repeat(64);
|
|
const settled = armPendingPublish(client, eventId);
|
|
|
|
await deliver(client, ["OK", eventId, false, "invalid: bad signature"]);
|
|
const outcome = await settled;
|
|
|
|
assert.equal(outcome.status, "rejected");
|
|
assert.equal(
|
|
isRateLimited(),
|
|
false,
|
|
"only `rate-limited:` rejections signal back-pressure",
|
|
);
|
|
});
|
|
|
|
test("an accepted OK still resolves the pending publish", async () => {
|
|
reset();
|
|
const client = new RelayClient();
|
|
const eventId = "d".repeat(64);
|
|
const settled = armPendingPublish(client, eventId);
|
|
|
|
await deliver(client, ["OK", eventId, true, ""]);
|
|
const outcome = await settled;
|
|
|
|
assert.equal(outcome.status, "resolved");
|
|
assert.equal(outcome.value.id, eventId);
|
|
});
|
|
|
|
test("a publish started during an ordinary outage reconnects once and settles", async () => {
|
|
reset();
|
|
const client = new RelayClient();
|
|
const event = { id: "0".repeat(64), kind: 1 };
|
|
let reconnects = 0;
|
|
client.ensureConnected = async () => {
|
|
reconnects++;
|
|
client.connectionGeneration++;
|
|
client.wsId = 8;
|
|
return client.connectionGeneration;
|
|
};
|
|
|
|
const published = client.publishEvent(event, "timed out", "send failed");
|
|
await flushUntil(() => eventFrames().length === 1);
|
|
|
|
assert.equal(reconnects, 1);
|
|
assert.equal(sendAttempts.length, 1);
|
|
assert.equal(client.pendingEvents.has(event.id), true);
|
|
|
|
await deliver(client, ["OK", event.id, true, ""]);
|
|
assert.equal(await published, event);
|
|
assert.equal(client.pendingEvents.size, 0);
|
|
});
|
|
|
|
test("a community switch while gated cannot publish through its replacement socket", async () => {
|
|
reset();
|
|
activateRateLimit(4);
|
|
const client = connectedClient();
|
|
const event = { id: "e".repeat(64), kind: 1 };
|
|
|
|
const published = client.publishEvent(event, "timed out", "send failed");
|
|
await Promise.resolve();
|
|
assert.equal(client.pendingEvents.size, 0);
|
|
|
|
client.disconnect();
|
|
resetRateLimitGate();
|
|
client.wsId = 8;
|
|
|
|
await assert.rejects(published, /community switch/);
|
|
assert.equal(client.pendingEvents.size, 0);
|
|
assert.equal(eventFrames().length, 0);
|
|
});
|
|
|
|
test("a community switch after send failure cannot retry through its replacement socket", async () => {
|
|
reset();
|
|
const client = connectedClient();
|
|
const event = { id: "f".repeat(64), kind: 1 };
|
|
const reconnect = deferred();
|
|
client.ensureConnected = async () => {
|
|
await reconnect.promise;
|
|
return client.connectionGeneration;
|
|
};
|
|
sendTransport = async () => {
|
|
throw new Error("old socket failed");
|
|
};
|
|
|
|
const published = client.publishEvent(event, "timed out", "send failed");
|
|
const outcome = published.then(
|
|
() => ({ status: "resolved" }),
|
|
(error) => ({ status: "rejected", error }),
|
|
);
|
|
await flushUntil(() => client.connectionGeneration === 1);
|
|
assert.equal(sendAttempts.length, 1);
|
|
assert.equal(eventFrames().length, 0);
|
|
assert.equal(
|
|
client.connectionGeneration,
|
|
1,
|
|
"the failed send reset its socket",
|
|
);
|
|
assert.equal(
|
|
client.pendingEvents.has(event.id),
|
|
true,
|
|
"the original publish remains owned while reconnect is pending",
|
|
);
|
|
|
|
client.disconnect();
|
|
client.wsId = 8;
|
|
const settledBeforeReconnect = await outcome;
|
|
assert.equal(
|
|
settledBeforeReconnect.status,
|
|
"rejected",
|
|
"community switch must settle the publish without waiting for reconnect",
|
|
);
|
|
assert.match(settledBeforeReconnect.error.message, /community switch/);
|
|
|
|
reconnect.resolve();
|
|
await published.catch(() => {});
|
|
await Promise.resolve();
|
|
assert.equal(client.pendingEvents.size, 0);
|
|
assert.equal(
|
|
sendAttempts.length,
|
|
1,
|
|
"the replacement socket must not be used",
|
|
);
|
|
assert.equal(eventFrames().length, 0);
|
|
});
|
|
|
|
// Drive the actual invoke rejection and publisher, not a classifier helper.
|
|
// Restoring HTTP -> WS gate propagation must fail before any timer advances.
|
|
for (const message of [
|
|
"relay rate-limited: retry in 50s",
|
|
"relay rate-limited: quota exceeded",
|
|
"relay rate-limited: retry in 1000000s",
|
|
]) {
|
|
test(`HTTP backoff does not withhold a WS publish: ${message}`, async () => {
|
|
reset();
|
|
invokeError = message;
|
|
await assert.rejects(invokeTauri("get_channels"), { message });
|
|
const client = connectedClient();
|
|
const event = { id: "9".repeat(64), kind: 9 };
|
|
const published = client.publishEvent(event, "timed out", "send failed");
|
|
try {
|
|
await flushUntil(() => eventFrames().length === 1);
|
|
assert.equal(isRateLimited(), false);
|
|
await deliver(client, ["OK", event.id, true, ""]);
|
|
assert.equal(await published, event);
|
|
assert.equal(client.pendingEvents.size, 0);
|
|
} finally {
|
|
// Also drain safely when the pre-fix gate coupling is restored.
|
|
resetRateLimitGate();
|
|
await flushUntil(() => eventFrames().length === 1);
|
|
await deliver(client, ["OK", event.id, true, ""]);
|
|
await published;
|
|
}
|
|
});
|
|
}
|
|
|
|
test("HTTP failure does not clear an existing WS backoff", async () => {
|
|
reset();
|
|
const client = connectedClient();
|
|
await deliver(client, [
|
|
"NOTICE",
|
|
"rate-limited: quota exceeded; retry in 4s",
|
|
]);
|
|
invokeError = "relay rate-limited: retry in 50s";
|
|
await assert.rejects(invokeTauri("get_channels"), { message: invokeError });
|
|
const event = { id: "8".repeat(64), kind: 9 };
|
|
const published = client.publishEvent(event, "timed out", "send failed");
|
|
await Promise.resolve();
|
|
assert.equal(eventFrames().length, 0);
|
|
assert.equal(isRateLimited(), true);
|
|
assert.ok([...pendingTimers.values()].some(({ fireAt }) => fireAt === 4_000));
|
|
assert.ok(
|
|
![...pendingTimers.values()].some(({ fireAt }) => fireAt === 50_000),
|
|
);
|
|
resetRateLimitGate();
|
|
await flushUntil(() => eventFrames().length === 1);
|
|
await deliver(client, ["OK", event.id, true, ""]);
|
|
assert.equal(await published, event);
|
|
});
|
|
|
|
test("isCurrent forwarded by publishEvent cancels a rate-limited send", async () => {
|
|
reset();
|
|
activateRateLimit(4);
|
|
const client = connectedClient();
|
|
const event = { id: "7".repeat(64), kind: 30078 };
|
|
let current = true;
|
|
const published = client.publishEvent(
|
|
event,
|
|
"timed out",
|
|
"send failed",
|
|
() => current,
|
|
);
|
|
await Promise.resolve();
|
|
assert.equal(sendAttempts.length, 0, "waits behind the gate");
|
|
current = false;
|
|
resetRateLimitGate();
|
|
await assert.rejects(published, PublishCanceledError);
|
|
assert.equal(sendAttempts.length, 0, "nothing reached the native send");
|
|
assert.equal(client.pendingEvents.size, 0);
|
|
assert.equal(pendingTimers.size, 0, "no publication timeout armed");
|
|
});
|