Files
bitvid/tests/nostr/nip07-channel-breaker.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 ff1afa1175 fix(nostr): count 'message channel closed' toward the circuit breaker
Real-env console (2026-06-15) showed the breaker never opening even as
decrypts failed for minutes. Cause: the breaker treated every non-timeout
error as "channel alive" and RESET the timeout streak — but
"message channel closed before a response was received" is the exact
dead-channel signal, and it rejects as a (fast) non-timeout error. So each
such rejection reset the counter and the circuit could never open.

Classify channel-death messages (message channel closed / could not
establish connection / receiving end does not exist / extension context
invalidated / connection lost) as channel failures that count TOWARD
opening the circuit, instead of as responsive failures that reset it.
Genuine extension responses (user rejection, permission denied, etc.)
still reset the streak.

Test: extends nip07-channel-breaker with a synchronous channel-closed op
asserting consecutive channel-death errors open the circuit and then
fast-fail (no waits).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-15 09:11:40 -04:00

162 lines
6.2 KiB
JavaScript

// Scenario (SCN-nip07-channel-breaker):
// Given the NIP-07 signer channel that lists decrypt through,
// When it stops responding (consecutive call timeouts, as happens when the
// extension's message port drops under the post-login burst — KNOWN_BUGS #0),
// Then after a threshold of consecutive timeouts the circuit OPENS and further
// calls fail FAST (code "nip07-channel-unresponsive") instead of each hanging
// to its full timeout — so the per-list retry loops stop pinning the CPU.
// And while open a single periodic PROBE is allowed through to detect recovery;
// one successful call CLOSES the circuit (no page refresh required).
// And a call that ERRORS (extension responded) does NOT count toward opening.
// And interactive permission prompts (bypassCircuitBreaker) are never fast-failed.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
__testExports,
} from "../../js/nostr/nip07Permissions.js";
const {
runNip07WithRetry,
resetNip07ChannelBreaker,
getNip07ChannelBreakerState,
CIRCUIT_TIMEOUT_THRESHOLD,
} = __testExports;
// A NIP-07 call that never responds -> withNip07Timeout fires the timeout.
const hangingOp = () => new Promise(() => {});
// A NIP-07 call the extension answers successfully (channel alive).
const successOp = async () => "ok";
// A NIP-07 call the extension answers with an error (channel alive, but refused).
const respondingErrorOp = async () => {
throw new Error("user rejected");
};
// A severed content-script port: rejects synchronously with a channel-death
// message. This is the real-env dead-channel signal (KNOWN_BUGS #0).
const channelClosedOp = async () => {
throw new Error(
"A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received",
);
};
// Small timeout so "hangs to full timeout" is fast in the test; no retry
// extension so each attempt is exactly one timeout window.
const FAST = { timeoutMs: 20, retryMultiplier: 1 };
const swallow = (p) => p.then(() => null).catch((e) => e);
test("circuit opens after consecutive timeouts and then fails fast", async () => {
resetNip07ChannelBreaker();
for (let i = 0; i < CIRCUIT_TIMEOUT_THRESHOLD; i++) {
await swallow(runNip07WithRetry(hangingOp, FAST));
}
assert.equal(
getNip07ChannelBreakerState().open,
true,
"circuit should be open after the timeout threshold",
);
// First call after opening is the recovery PROBE (still runs -> times out).
const probeErr = await swallow(runNip07WithRetry(hangingOp, FAST));
assert.equal(
probeErr.message,
NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
"the probe should actually run (and time out here), not fast-fail",
);
// The next call within the probe interval must FAIL FAST.
const start = Date.now();
const fastErr = await swallow(runNip07WithRetry(hangingOp, FAST));
const elapsed = Date.now() - start;
assert.equal(
fastErr.code,
"nip07-channel-unresponsive",
"calls while the circuit is open (outside the probe slot) must fast-fail",
);
assert.ok(
elapsed < FAST.timeoutMs,
`fast-fail must not wait for the timeout window (got ${elapsed}ms)`,
);
});
test("a successful probe closes the circuit (recovery without refresh)", async () => {
resetNip07ChannelBreaker();
for (let i = 0; i < CIRCUIT_TIMEOUT_THRESHOLD; i++) {
await swallow(runNip07WithRetry(hangingOp, FAST));
}
assert.equal(getNip07ChannelBreakerState().open, true, "precondition: open");
// First post-open call is the probe; let it succeed -> channel recovered.
const result = await runNip07WithRetry(successOp, FAST);
assert.equal(result, "ok");
const state = getNip07ChannelBreakerState();
assert.equal(state.open, false, "a successful probe must close the circuit");
assert.equal(
state.consecutiveTimeouts,
0,
"the timeout streak must reset on success",
);
});
test("'message channel closed' errors open the circuit (fast, no 15s waits)", async () => {
resetNip07ChannelBreaker();
// A severed port rejects FAST (no timeout wait). These must count toward
// opening the circuit — otherwise the real-env dead channel never trips it.
for (let i = 0; i < CIRCUIT_TIMEOUT_THRESHOLD; i++) {
await swallow(runNip07WithRetry(channelClosedOp, FAST));
}
assert.equal(
getNip07ChannelBreakerState().open,
true,
"consecutive 'message channel closed' errors must open the circuit",
);
// And once open, further calls fast-fail rather than re-hitting the dead port.
await swallow(runNip07WithRetry(channelClosedOp, FAST)); // consume probe slot
const fastErr = await swallow(runNip07WithRetry(channelClosedOp, FAST));
assert.equal(
fastErr.code,
"nip07-channel-unresponsive",
"calls after a channel-death streak must fast-fail",
);
});
test("extension errors (responsive failures) do not open the circuit", async () => {
resetNip07ChannelBreaker();
// Far more than the threshold, but the extension is RESPONDING (with errors),
// so the channel is alive and the breaker must stay closed.
for (let i = 0; i < CIRCUIT_TIMEOUT_THRESHOLD + 2; i++) {
await swallow(runNip07WithRetry(respondingErrorOp, FAST));
}
const state = getNip07ChannelBreakerState();
assert.equal(state.open, false, "responsive errors must not open the circuit");
assert.equal(state.consecutiveTimeouts, 0, "no timeout streak should accrue");
});
test("interactive (bypass) calls are never fast-failed while open", async () => {
resetNip07ChannelBreaker();
for (let i = 0; i < CIRCUIT_TIMEOUT_THRESHOLD; i++) {
await swallow(runNip07WithRetry(hangingOp, FAST));
}
// Consume the probe slot so a NON-bypass call would now fast-fail.
await swallow(runNip07WithRetry(hangingOp, FAST));
assert.equal(getNip07ChannelBreakerState().open, true, "precondition: open");
// A bypass call (interactive permission prompt) must still RUN — it can't be
// blocked by the breaker and can itself heal the channel.
const err = await swallow(
runNip07WithRetry(hangingOp, { ...FAST, bypassCircuitBreaker: true }),
);
assert.equal(
err.message,
NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE,
"bypass call should run to its timeout, not be fast-failed by the breaker",
);
});