Files
buzz/desktop/src-tauri/src/native_relay_client_tests.rs
Wes c213d90d5c fix(desktop): bound startup request bursts and recover quota refusals (#7790)
Prepared by Brain on Wes’s behalf.

## Summary

Reduce startup request amplification and let quota-refused background
work recover without changing relay quotas or bypassing the existing
Send gate.

- Coalesce nest regeneration into one worker plus the latest pending
generation. Keep the stale-write fence; bound the archive read so an old
workspace cannot strand newer work.
- Claim pending channel subscriptions before awaiting setup; cancel
removed/unmounted entries and preserve still-owned subscriptions across
reconnect handoff.
- Recheck extended cooldown deadlines before retry dispatch; distinguish
an explicit zero-second hint from a missing hint.
- Recover native finite/unread queries within their original caller
deadline and a three-retry budget, preserving verified unique events,
complete filters and terminal errors.
- Pace renderer initial/live-CLOSED REQs at one per 250ms: visible
channel and explicitly interactive TTS/read-state/observer owners first,
then live-only auxiliaries, then background history. Publication and
finite queries bypass this queue. Reconnect replay now promotes explicit
interactive owners alongside the visible channel with stable
registration-order ties; its existing capped batches and cooldown remain
intact.

**Scope at `867495426`:** 39 source/test files, including 15 production
files (+665/-235) and 24 test/fixture files. The diagnostic recorder,
HTTP instrumentation, trace launcher and raw captures are deliberately
excluded. No profile reset, grouping, quota increase or new global
scheduler.

## Before / after evidence

These are observations from instrumented, human-operated development
Tauri/Vite runs against a production relay, plus explicitly identified
controlled regressions—not a packaged-release startup benchmark.

| Measurement | Before | After | Evidence boundary |
| --- | --- | --- | --- |
| Archive snapshot requests (kind 13535) | 290 | 3 | Recorder-only
baseline → nest-only intermediate candidate; unequal 67.436s / 39.273s
captures and hydration differences |
| Archive responses: HTTP 200 / 429 | 277 / 13 | 3 / 0 | Same
baseline/nest-only captures; not a claim that all quota pressure
disappeared |
| Successful archive responses’ advertised content-length sum |
21,208,505 bytes | 229,695 bytes | Header values, not actual transferred
bytes |
| 154-channel StrictMode replay | 308 REQs | 154 REQs | Controlled real
React hook → real session → mocked Tauri transport regression |
| Add one channel while 154 setups are pending | 309 REQs | 155 REQs |
Same production-boundary regression; ordinary mount remains 154 → 154 |
| Broad renderer request burst / spacing | 110 broad REQs within
10.766ms in pre-drain capture | 250.061ms minimum / 252.131ms median
between broad REQ writes in final longer capture | Different attended
runs; pacing rather than a universal request-rate cap |
| Native unread query outcomes | Baseline: 8 EOSE / 146 quota CLOSED out
of 154 queries | Final longer run: all 156 observed queries reach EOSE;
eight refused queries each recover after two refusals | Different
runs/workloads; query completion, not badge rendering |

### Timing metrics

| Observation | Measured value |
| --- | ---: |
| Baseline’s two accepted chat EVENT socket writes → positive OK |
173.340ms / 85.556ms |
| Nest-only intermediate candidate’s two accepted writes → positive OK |
277.854ms / 78.775ms |
| Final candidate, short run, accepted chat writes → positive OK |
80.636ms / 77.506ms |
| Final candidate, longer run, accepted chat writes → positive OK |
120.712ms / 86.895ms |
| Last initial EOSE among 163 observed broad channel registrations,
longer run | 64.550s after trace start |
| Last EOSE among 156 observed native unread queries, longer run |
73.972s after trace start |
| Native retry waits after 3s then 0s hints, longer run |
3000.7–3002.8ms, then 2001.5–2002.6ms |
| Renderer finite-query retry after a 2s hint, longer run | 2061.365ms |

Both Sends in each final run were accepted, with no recorded negative
OK. Every quota-refused query in the longer run subsequently completed
the same query. Its two Sends happened early (17.878s and 21.655s), not
near the end. These small samples do not support a percentage latency
improvement.

**Tradeoffs and limits**
- 296 background subscriptions imply about 74s of paced registration
before extra cooldowns/high-priority work. The longer capture observed
163 registrations; it did not independently enumerate the expected
membership set or record a queue-empty marker.
- This is not a global 4-REQ/s cap: finite queries, native requests and
reconnect replay are outside the drain and still share relay quota.
Quota refusals recover rather than disappear.
- Socket-write → OK starts after composer submission and any publisher
gate wait. No click-to-visible, launch-to-usable or badge-paint latency
is claimed.
- Native EOSE is not proof of command return, classification, React
state application or visible unread badges. Existing per-channel kinds,
since floors, limits, h-less routing, unread lease/scope guard and
eight-query concurrency remain intact.
- The attended measurements belong to the instrumented candidate, not a
fresh release run of this fixes-only commit. At the initial packaging
checkpoint (`1bf3d060`), eighteen of twenty files were byte-identical;
the other two removed diagnostic gate calls and isolated the nest test’s
temporary paths. The subsequent interactive priority/reconnect and
readiness fixes have controlled hook/session/browser evidence, not
another attended voice/performance run.

### Evidence provenance (captures retained locally, not uploaded)

| Capture | SHA-256 |
| --- | --- |
| Baseline `STARTUP_20260921T194814Z_98811.jsonl` |
`f4f0a89ad9aea91084bd6a7432510488166de4cbc906600d88c26d557f380994` |
| Nest-only `STARTUP_20260921T202610Z_28642.jsonl` |
`ce517b02a3f717c36d96b36bb8616e8fa80b03b12b356bacfd40475925ea3f3e` |
| Final short `STARTUP_20260922T013628Z_13506.jsonl` |
`bfea6f3cf80cd7b7e4e676632a3df2f16271fade5bfa0edec72b7bcbbe7839ff` |
| Final longer `STARTUP_20260922T014637Z_15846.jsonl` |
`f4abece613d108c4c2fe3cb808e633a0486bdc9993ca92323822dc00c3fae6ec` |

Final instrumented build fingerprint:
`b0b4fc27d793c3e643f4e10289b5afdb4620ee2e13a8b33fa0595dd3417fd5ca`. All
observed dropped-record counters in these final captures were zero.
Source manifests and offline correlation analyses are retained locally;
no raw captures, profile data or agent sessions are part of this PR.

## Validation

Current head: `86749542657a60abdfe82eed5cc576d3e162fe5d`, based on
`77729abfb692b25a0f4ec4a69add86af2e32c0dd`.

Review follow-ups:
- `8842dbd8`: explicit interactive TTS admission using the existing
main-window owner, independent of companion/timeline selection.
Real-hook/session/IPC coverage with 296 cold owners proves initial and
CLOSED priority, unchanged filters/cooldown/pacing, background progress
and deduplicated speech.
- `8cb088106`: reconnect preserves interactive priority even with no
visible channel. Stable ordering among foreground ties; real
hook/session reconnect regressions cover cooldown, capped batching, all
297 owners, deduplication and unmount cancellation. Independent reviewer
mutation restored the prior replay implementation, failed both reconnect
rows, then passed the restored candidate.
- `68e76d7c`: read-state and observer reuse interactive priority. Stop
and ModelPicker wait on the existing observer owner inside send
callbacks, after installing result listeners/deadlines. No new owner,
timer or scheduler; inherited CLOSED/fallback/error semantics remain,
rather than a new strict EOSE guarantee. The real Stop panel browser
probe holds observer IPC across the click and checks
observer-before-control order and correlated outcome. ModelPicker
component ordering is source-reviewed only.
- Mock live admission now replays matching stored history with
per-filter time/limit constraints, deduplication, untagged auxiliary
channel fallback and authoritative explicit h-tags. Kind-specific
fixture admission waits and retry-resolving hover actions fix
startup-dependent test setup without weakening event/badge assertions.
- `48e9c0dad` and `867495426`: steady-state scroll-prepend setup
observes live admission, subsequent head dispatch, successful/idle query
completion and initial bottom geometry. Phase-2 genuine-growth and ≤32px
anchor assertions are unchanged. This isolates steady-state prepending:
it **does not fix or certify production early-scroll/head-reset
behavior**.

Current validation:
- Normal push hooks at `867495426`: **6,582/6,582 renderer tests**, zero
failed/skipped/cancelled; lint/static checks, typecheck and differential
file-size gate passed. Native source is unchanged from the earlier
checkpoint; no new native run is claimed for these renderer/test
follow-ups.
- E2E build passed at `48e9c0dad` plus the exact subsequently committed
test-only delta (spec blob `469be2eed868c677ba80b859cc871208000fee60`).
The completed affected-smoke run passed **153/153** across
agent-control, badge, entity links, messaging, profile status and scroll
history; the full scroll file additionally passed twice (**36/36**). No
full CI-smoke pass is claimed from this six-file local run.
- Pinky independently reviewed production and test follow-ups, including
the final scroll spec blob `469be2eed868c677ba80b859cc871208000fee60`,
with no blocking finding. Review is source evidence, not an independent
test run.
- **Not merge-ready:** new-head required CI must complete, exact-range
security review requires authorization, and GitHub currently reports
REVIEW_REQUIRED; fresh exact-head reviewer approval is still needed. No
approval, dismissal or merge performed. No new packaged/native audio,
launch-to-usable or badge-paint observation is claimed.

Initial fixes-only checkpoint validation
(`1bf3d06024d284a63326fdd353baced0842aeb81`):

- Pre-commit formatting hooks passed; worktree clean.
- Renderer package hooks: 6,570 tests passed, zero
failed/skipped/cancelled; lint/static checks, TypeScript and
differential file-size gate passed.
- Native package hook passed: default and mesh-enabled all-target
Clippy, then the complete native workspace test suite. App library:
3,222 passed / 0 failed / 19 ignored; terminal/integration suites
passed. The real nest trigger/stalled-body test passed on macOS.
- Independent whole-change source review: no blocking findings at this
commit; all 20 file hashes matched the committed blobs and
review/validation manifests.
- Regression coverage includes real hook/session cancellation,
StrictMode/membership re-entry, reconnect ownership, extended/zero
cooldowns, burst pacing, auxiliary readiness (including EOSE before IPC
settles), native socket refusal/retry/deadline/cancellation, unread
fetch/classification and real nest trigger/HTTP/file persistence.
- Earlier isolated renderer mutations that started readiness before
enqueue or removed auxiliary priority each failed the real presence
regression; restored controls passed. No native mutation claim.
- Nest fixture uses explicit app-data/nest paths instead of assuming
Windows known-folder APIs obey HOME/APPDATA. Windows runtime execution
is not claimed; CI/build results remain separate.

Originating conversation:
buzz://message?channel=46ced308-71df-495d-a144-4ebf08334703&id=bc43c3422a3b0b2141268d0fa149b898421c824992b662aed8b73c4eee50404f

No approval or merge recommendation is implied by opening this PR.

---------

Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-09-22 18:31:44 +00:00

900 lines
34 KiB
Rust

//! Lifecycle tests for [`super`]'s CLOSED recovery and subscription bookkeeping.
//!
//! Split out of `native_relay_client.rs` to keep that file under the desktop
//! file-size ratchet. Same `#[path]` sibling-module convention as
//! `archive/sync.rs` and its `sync_tests.rs`.
use super::*;
use futures_util::{SinkExt, StreamExt};
use nostr::EventBuilder;
use tokio_tungstenite::tungstenite::protocol::Message;
/// The subscription id every test below drives.
const PROBE_ID: &str = "archive:probe";
/// Minimal relay that completes the NIP-42 handshake, records every REQ,
/// and sends a CLOSED only when the test asks it to.
///
/// A real socket rather than a fake `NostrWsConnection`, because the bug
/// this covers lives in the lifecycle between frames — the loop's only
/// reconcile triggers — and a fake that hands the loop a `Closed` value
/// cannot show that a REQ went back out over the wire afterwards. Same
/// `accept_async` stub shape as `native_websocket.rs`'s live-TCP tests.
///
/// CLOSED is test-driven rather than a scripted reply to the first REQ so
/// the test can wait for the session to go quiet first. `set_subscriptions`
/// queues a wake that may still be pending when an immediate CLOSED lands,
/// and that wake reopens the subscription on its own — which made the first
/// version of this test pass against the unfixed code.
///
/// `frames` reports REQ and CLOSE in wire order, not REQ alone: the
/// lifecycle tests below assert that a CLOSE was sent before the REQ that
/// follows it, which a REQ-only channel cannot express.
async fn stub_relay() -> (String, mpsc::Receiver<Frame>, mpsc::Sender<StubCommand>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind stub relay");
let address = listener.local_addr().expect("stub relay address");
let (req_tx, req_rx) = mpsc::channel(16);
let (closed_tx, mut closed_rx) = mpsc::channel::<StubCommand>(4);
tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept");
let mut socket = tokio_tungstenite::accept_async(stream)
.await
.expect("websocket handshake");
socket
.send(Message::Text(r#"["AUTH","stub-challenge"]"#.into()))
.await
.expect("send challenge");
loop {
tokio::select! {
incoming = socket.next() => {
let Some(Ok(Message::Text(text))) = incoming else { return };
let Ok(frame) = serde_json::from_str::<serde_json::Value>(&text) else {
continue;
};
match frame[0].as_str() {
Some("AUTH") => {
let id = frame[1]["id"].as_str().unwrap_or_default();
socket
.send(Message::Text(
serde_json::json!(["OK", id, true, ""]).to_string().into(),
))
.await
.expect("send auth ok");
}
Some("REQ") => {
let id = frame[1].as_str().unwrap_or_default().to_string();
if req_tx.send(Frame::Req(id)).await.is_err() {
return;
}
}
Some("CLOSE") => {
let id = frame[1].as_str().unwrap_or_default().to_string();
if req_tx.send(Frame::Close(id)).await.is_err() {
return;
}
}
_ => {}
}
}
Some(command) = closed_rx.recv() => {
let frame = match command {
StubCommand::Closed(id, message) => {
serde_json::json!(["CLOSED", id, message])
}
StubCommand::Eose(id) => serde_json::json!(["EOSE", id]),
StubCommand::Event(id, event) => {
serde_json::json!(["EVENT", id, event])
}
};
socket
.send(Message::Text(frame.to_string().into()))
.await
.expect("send stub frame");
}
}
}
});
(format!("ws://{address}"), req_rx, closed_tx)
}
/// A client→relay frame the stub observed, in wire order.
#[derive(Debug, PartialEq, Eq)]
enum Frame {
Req(String),
Close(String),
}
/// A relay→client frame the test asks the stub to emit.
enum StubCommand {
Closed(String, String),
Eose(String),
Event(String, serde_json::Value),
}
fn probe_subscription() -> Subscription {
Subscription {
id: PROBE_ID.to_string(),
filter: serde_json::json!({ "kinds": [1], "limit": 0 }),
}
}
async fn next_frame(frames: &mut mpsc::Receiver<Frame>, label: &str) -> Frame {
tokio::time::timeout(Duration::from_secs(10), frames.recv())
.await
.unwrap_or_else(|_| panic!("timed out waiting for {label}"))
.unwrap_or_else(|| panic!("stub relay closed before {label}"))
}
/// Waits for the next REQ, tolerating the CLOSE frames a reconcile sends
/// first. Asserting on `Frame::Req` directly would couple every test to
/// whether a particular reconcile also had cleanup to do.
async fn next_req(frames: &mut mpsc::Receiver<Frame>, label: &str) -> String {
loop {
if let Frame::Req(id) = next_frame(frames, label).await {
return id;
}
}
}
/// Waits out the wake `set_subscriptions` queued, so a CLOSED sent after
/// this cannot be reopened by anything but the CLOSED path itself.
///
/// A pending wake is harmless while the subscription is still open — that
/// reconcile is a no-op — so draining it before the CLOSED is what makes
/// the assertion below attributable.
async fn settle() {
tokio::time::sleep(Duration::from_millis(500)).await;
}
/// C's acceptance edge: a finite request shares the authenticated real socket
/// with a persistent subscription, completes on wire EOSE, and does not steal
/// later persistent delivery. A fake connection cannot establish any of those
/// transport/lifetime properties.
#[tokio::test]
async fn finite_fetch_multiplexes_with_persistent_delivery_on_a_real_websocket() {
let (relay_url, mut frames, commands) = stub_relay().await;
let (session, mut events) = start(relay_url, Keys::generate(), None).await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(next_req(&mut frames, "the persistent REQ").await, PROBE_ID);
let fetch = {
let session = Arc::clone(&session);
tokio::spawn(async move {
session
.fetch_events(
serde_json::json!({ "kinds": [buzz_core_pkg::kind::KIND_PERSONA], "limit": 500 }),
Duration::from_secs(10),
)
.await
})
};
let request_id = next_req(&mut frames, "the finite fetch REQ").await;
assert_ne!(request_id, PROBE_ID);
let relay_keys = Keys::generate();
let mut forged = EventBuilder::text_note("forged catalog page event")
.sign_with_keys(&relay_keys)
.unwrap();
forged.content = "tampered after signing".into();
commands
.send(StubCommand::Event(
request_id.clone(),
serde_json::to_value(forged).unwrap(),
))
.await
.unwrap();
let fetched = EventBuilder::text_note("catalog page event")
.sign_with_keys(&relay_keys)
.unwrap();
commands
.send(StubCommand::Event(
request_id.clone(),
serde_json::to_value(&fetched).unwrap(),
))
.await
.unwrap();
commands
.send(StubCommand::Eose(request_id.clone()))
.await
.unwrap();
assert_eq!(fetch.await.unwrap().unwrap(), vec![fetched]);
assert_eq!(
next_frame(&mut frames, "finite fetch CLOSE").await,
Frame::Close(request_id)
);
let persistent = EventBuilder::text_note("persistent event after fetch")
.sign_with_keys(&relay_keys)
.unwrap();
commands
.send(StubCommand::Event(
PROBE_ID.into(),
serde_json::to_value(&persistent).unwrap(),
))
.await
.unwrap();
let delivered = tokio::time::timeout(Duration::from_secs(10), events.recv())
.await
.unwrap()
.unwrap();
assert_eq!(delivered.subscription_id, PROBE_ID);
assert_eq!(*delivered.event, persistent);
session.shutdown();
}
async fn run_persistent_burst(drain_concurrently: bool) {
const BURST: usize = 1_200;
let (relay_url, mut frames, commands) = stub_relay().await;
let (session, mut events) = start(relay_url, Keys::generate(), None).await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(next_req(&mut frames, "the burst REQ").await, PROBE_ID);
let relay_keys = Keys::generate();
let event = EventBuilder::text_note("persistent burst event")
.sign_with_keys(&relay_keys)
.unwrap();
let send_burst = tokio::spawn({
let commands = commands.clone();
let event = serde_json::to_value(&event).unwrap();
async move {
for _ in 0..BURST {
commands
.send(StubCommand::Event(PROBE_ID.into(), event.clone()))
.await
.unwrap();
}
}
});
if !drain_concurrently {
// Let the bounded archive channel fill before draining. The socket loop
// must wait here rather than evicting live-only events.
tokio::time::sleep(Duration::from_millis(100)).await;
}
for _ in 0..BURST {
tokio::time::timeout(Duration::from_secs(60), events.recv())
.await
.expect("timed out draining persistent burst")
.expect("archive receiver closed during persistent burst");
}
send_burst.await.unwrap();
let after = EventBuilder::text_note("persistent event after burst")
.sign_with_keys(&relay_keys)
.unwrap();
commands
.send(StubCommand::Event(
PROBE_ID.into(),
serde_json::to_value(&after).unwrap(),
))
.await
.unwrap();
let delivered = tokio::time::timeout(Duration::from_secs(60), events.recv())
.await
.expect("timed out after persistent burst")
.expect("archive receiver closed after persistent burst");
assert_eq!(*delivered.event, after);
session.shutdown();
}
/// Persistent archive subscriptions use `limit: 0`, so an event lost during a
/// slow-consumer burst cannot be replayed. Both a fast control and a receiver
/// that starts late must therefore get the whole burst and remain live after it.
#[tokio::test]
async fn persistent_delivery_applies_backpressure_without_losing_a_burst() {
run_persistent_burst(true).await;
run_persistent_burst(false).await;
}
/// The blocker: a CLOSED with the desired set never changing again must
/// still reopen the subscription.
///
/// Before the fix the loop removed the id from `open` and waited on a wake
/// that only `set_subscriptions` can produce, so a stable desired set left
/// the subscription dead for the life of the socket — silent permanent
/// loss for ephemeral kind 24200.
#[tokio::test]
async fn a_closed_subscription_reopens_without_a_desired_set_change() {
let (relay_url, mut frames, closed) = stub_relay().await;
let (session, _events) = start(relay_url, Keys::generate(), None).await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
settle().await;
// Retryable class, sent once: the reopen is answered normally, so a
// failure here means "never retried" rather than "retried into another
// rejection".
closed
.send(StubCommand::Closed(
PROBE_ID.into(),
"error: temporary".into(),
))
.await
.expect("stub relay accepts the closed command");
// No `set_subscriptions` between the two REQs: the reopen must come
// from the CLOSED itself, which is exactly the edge that was missing.
assert_eq!(next_req(&mut frames, "the reopened REQ").await, PROBE_ID);
session.shutdown();
}
/// A relay that rejects on policy must not be re-asked in a tight loop.
#[tokio::test]
async fn a_terminal_closed_is_not_retried_on_the_same_socket() {
let (relay_url, mut frames, closed) = stub_relay().await;
let (session, _events) = start(relay_url, Keys::generate(), None).await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
settle().await;
closed
.send(StubCommand::Closed(
PROBE_ID.into(),
"restricted: not authorized".into(),
))
.await
.expect("stub relay accepts the closed command");
// Long enough that a retryable class (1s base) would have reopened
// several times, so this asserts suppression rather than just slowness.
let retried = tokio::time::timeout(Duration::from_secs(5), frames.recv()).await;
assert!(
retried.is_err(),
"a terminal CLOSED must not be retried on this socket, got {retried:?}"
);
session.shutdown();
}
/// M18: a subscription deleted and recreated must get a fresh REQ, even
/// though its terminal latch says never to retry.
///
/// The latch is scoped to the subscription that earned it. Recreating the
/// id is a new subscription that happens to share a name — `archive::sync`
/// derives the id from scope and kinds, so a delete/recreate of the same
/// saved subscription produces a byte-identical id and would otherwise
/// inherit a permanent suppression for the life of the socket.
#[tokio::test]
async fn a_recreated_subscription_does_not_inherit_a_terminal_latch() {
let (relay_url, mut frames, closed) = stub_relay().await;
let (session, _events) = start(relay_url, Keys::generate(), None).await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
settle().await;
closed
.send(StubCommand::Closed(
PROBE_ID.into(),
"restricted: not authorized".into(),
))
.await
.expect("stub relay accepts the closed command");
settle().await;
// Delete, then recreate — each observed as its own reconcile.
session.set_subscriptions(vec![]).await;
settle().await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(
next_req(&mut frames, "the REQ for the recreated subscription").await,
PROBE_ID,
);
session.shutdown();
}
/// M19: the same schedule, with both writes landing before the loop
/// consumes its single wake.
///
/// This is the mutant that discriminates the mechanism. The wake channel
/// has capacity 1 and `set_subscriptions` only ever queues "reconcile
/// pending", so the delete and the recreate collapse into ONE observed
/// reconcile whose desired set already contains the id again. A prune that
/// reads only the current desired set never sees the id absent and leaves
/// the latch in place — passing the test above while failing this one.
/// The departure is therefore recorded at write time, where it is visible.
///
/// No `settle()` between the two writes: that gap is the whole point, and
/// adding one would silently convert this into a duplicate of M18.
#[tokio::test]
async fn a_recreated_subscription_is_not_suppressed_when_the_writes_coalesce() {
let (relay_url, mut frames, closed) = stub_relay().await;
let (session, _events) = start(relay_url, Keys::generate(), None).await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
settle().await;
closed
.send(StubCommand::Closed(
PROBE_ID.into(),
"restricted: not authorized".into(),
))
.await
.expect("stub relay accepts the closed command");
settle().await;
session.set_subscriptions(vec![]).await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(
next_req(&mut frames, "the REQ for the recreated subscription").await,
PROBE_ID,
);
session.shutdown();
}
/// M20: pruning must be scoped to departures, not run every pass.
///
/// A reconcile triggered while the id is still desired must leave its
/// pending backoff alone. Clearing wholesale would collapse the CLOSED
/// backoff — every unrelated subscription change would re-ask a relay that
/// just rejected us, at the speed of the event loop.
#[tokio::test]
async fn a_reconcile_preserves_the_backoff_of_a_still_desired_subscription() {
let (relay_url, mut frames, closed) = stub_relay().await;
let (session, _events) = start(relay_url, Keys::generate(), None).await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
settle().await;
// Rate-limited: a long, unambiguously pending backoff, so a reopen
// inside the window is the prune and not the timer.
closed
.send(StubCommand::Closed(
PROBE_ID.into(),
"rate-limited: slow down; retry in 30s".into(),
))
.await
.expect("stub relay accepts the closed command");
settle().await;
// A change that adds an unrelated subscription. The probe never leaves
// the desired set, so its backoff must survive this reconcile.
session
.set_subscriptions(vec![
probe_subscription(),
Subscription {
id: "archive:other".to_string(),
filter: serde_json::json!({ "kinds": [7], "limit": 0 }),
},
])
.await;
assert_eq!(
next_req(&mut frames, "the REQ for the newly added subscription").await,
"archive:other",
);
let reopened = tokio::time::timeout(Duration::from_secs(3), frames.recv()).await;
assert!(
reopened.is_err(),
"a still-desired subscription must keep its pending backoff across a \
reconcile, got {reopened:?}"
);
session.shutdown();
}
/// M21: a CLOSED that arrives after we stopped running the subscription is
/// stale and must mint nothing.
///
/// Our CLOSE races the relay's in-flight frames — the EVENT arm already
/// guards this. Without the same guard on CLOSED, the frame recreates the
/// retry entry the drain just removed, and nothing can evict it: the id is
/// gone from the desired set, so no future departure records it again.
#[tokio::test]
async fn a_closed_arriving_after_removal_does_not_mint_retry_state() {
let (relay_url, mut frames, closed) = stub_relay().await;
let (session, _events) = start(relay_url, Keys::generate(), None).await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
settle().await;
// Delete first, and wait for our CLOSE to reach the wire: that ordering
// is what makes the CLOSED below arrive after the drain rather than
// before it, which is the schedule M18 and M19 do not cover.
session.set_subscriptions(vec![]).await;
assert_eq!(
next_frame(&mut frames, "the CLOSE for the deleted subscription").await,
Frame::Close(PROBE_ID.to_string()),
);
closed
.send(StubCommand::Closed(
PROBE_ID.into(),
"restricted: not authorized".into(),
))
.await
.expect("stub relay accepts the closed command");
settle().await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(
next_req(&mut frames, "the REQ for the recreated subscription").await,
PROBE_ID,
);
session.shutdown();
}
/// M22: a stale *terminal* CLOSED landing after the id was recreated must
/// not blackhole the live subscription.
///
/// This one survives every defense above. The CLOSED is legitimately
/// attributed — the id is open again, so the M21 guard passes it — and
/// terminal means no `due_at`, so the timer arm is disabled and no wake is
/// pending. `open` loses the id while the relay keeps delivering, and the
/// EVENT arm drops every frame in silence.
///
/// EOSE is the recovery edge because it is the only ordered fence
/// available: frames on one socket are totally ordered, so the previous
/// generation's CLOSED necessarily precedes the new generation's EOSE.
#[tokio::test]
async fn a_stale_terminal_closed_does_not_blackhole_a_recreated_subscription() {
let (relay_url, mut frames, closed) = stub_relay().await;
let (session, mut events) = start(relay_url, Keys::generate(), None).await;
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(next_req(&mut frames, "the initial REQ").await, PROBE_ID);
settle().await;
// Delete and recreate, so the id is open again under a new generation.
session.set_subscriptions(vec![]).await;
assert_eq!(
next_frame(&mut frames, "the CLOSE for the deleted subscription").await,
Frame::Close(PROBE_ID.to_string()),
);
session.set_subscriptions(vec![probe_subscription()]).await;
assert_eq!(
next_req(&mut frames, "the REQ for the recreated subscription").await,
PROBE_ID,
);
settle().await;
// The old generation's terminal CLOSED, delayed past the new REQ.
closed
.send(StubCommand::Closed(
PROBE_ID.into(),
"restricted: not authorized".into(),
))
.await
.expect("stub relay accepts the closed command");
// The new generation's EOSE, which the wire orders after it.
closed
.send(StubCommand::Eose(PROBE_ID.into()))
.await
.expect("stub relay accepts the eose command");
// The EOSE found the id closed, so it must drive a reconcile that
// reopens it. Nothing else can: terminal schedules no timer, and the
// desired set is stable.
assert_eq!(
next_req(&mut frames, "the REQ healing the open-map mismatch").await,
PROBE_ID,
);
// And the heal converges rather than storming: the replacement EOSE
// finds the id open, so it wakes nothing.
closed
.send(StubCommand::Eose(PROBE_ID.into()))
.await
.expect("stub relay accepts the second eose command");
let extra = tokio::time::timeout(Duration::from_secs(3), frames.recv()).await;
assert!(
extra.is_err(),
"an EOSE for an already-open subscription must not re-reconcile, got {extra:?}"
);
// The point of the heal: events flow again.
let event = EventBuilder::text_note("post-heal")
.sign_with_keys(&Keys::generate())
.expect("sign event");
let event_id = event.id.to_hex();
closed
.send(StubCommand::Event(
PROBE_ID.into(),
serde_json::to_value(&event).expect("serialize event"),
))
.await
.expect("stub relay accepts the event command");
let delivered = tokio::time::timeout(Duration::from_secs(10), events.recv())
.await
.expect("timed out waiting for an event after the heal")
.expect("session channel closed");
assert_eq!(
delivered.event.id.to_hex(),
event_id,
"events must flow again once the open map is healed"
);
session.shutdown();
}
/// M23: reusing an id for a changed filter must be *detected*.
///
/// This test pins detection and nothing else. Post-violation behavior —
/// whether the subscription reopens, what happens to its retry state, what
/// the relay is sent — is unspecified by design, because the wire carries
/// only the id and an in-flight CLOSED from the old filter is
/// indistinguishable from one caused by the new one. Asserting any of that
/// would turn an unsupported input into a supported one.
///
/// It exists because the `(id, filter)` departure diff is otherwise
/// unpinned: on every supported path it is byte-equivalent to an id-only
/// diff, so a refactor could revert it, pass every other test here, and
/// silently remove the one signal that tells C and D they broke the
/// contract.
#[test]
fn a_filter_change_under_a_reused_id_is_reported_as_a_contract_violation() {
let mut state = SessionState::default();
assert!(
state.replace_desired(vec![probe_subscription()]).is_empty(),
"a first desired set violates nothing"
);
assert!(
state.replace_desired(vec![probe_subscription()]).is_empty(),
"an unchanged subscription is not a filter change"
);
let violations = state.replace_desired(vec![Subscription {
id: PROBE_ID.to_string(),
filter: serde_json::json!({ "kinds": [7], "limit": 0 }),
}]);
assert_eq!(
violations,
vec![PROBE_ID.to_string()],
"a filter changed under a reused id must be reported"
);
}
#[test]
fn closed_messages_classify_like_the_renderer_policy() {
assert_eq!(
classify_closed("rate-limited: quota exceeded; retry in 4s"),
ClosedClass::RateLimited
);
assert_eq!(
classify_closed("restricted: not authorized"),
ClosedClass::Terminal
);
assert_eq!(
classify_closed("error: too many subscriptions"),
ClosedClass::Terminal
);
// Transient AUTH race, not a permanent rejection — the one prefix that
// looks terminal and deliberately is not.
assert_eq!(
classify_closed("auth-required: we can't serve unauthenticated"),
ClosedClass::Retryable
);
assert_eq!(classify_closed(""), ClosedClass::Retryable);
// Case and padding come from the relay, not from us.
assert_eq!(
classify_closed(" RESTRICTED: nope "),
ClosedClass::Terminal
);
}
#[test]
fn retry_delay_grows_and_stops_at_the_ceiling() {
let mut retry = ClosedRetry::default();
assert_eq!(retry.backoff(), CLOSED_RETRY_BASE_DELAY);
retry.schedule("error: temporary");
assert_eq!(retry.backoff(), CLOSED_RETRY_BASE_DELAY * 2);
for _ in 0..40 {
retry.schedule("error: temporary");
}
assert_eq!(
retry.backoff(),
CLOSED_RETRY_MAX_DELAY,
"backoff must saturate at the ceiling rather than wrapping"
);
}
#[test]
fn a_rate_limited_closed_waits_at_least_the_relay_hint() {
let mut retry = ClosedRetry::default();
retry.schedule("rate-limited: quota exceeded; retry in 12s");
let due = retry.due_at.expect("rate-limited must schedule a reopen");
// The hint dominates the 1s first backoff, so this asserts the hint was
// honored rather than that anything at all was scheduled.
assert!(
due >= Instant::now() + Duration::from_secs(11),
"a 12s hint must not be undercut by the base backoff"
);
}
#[test]
fn a_hintless_rate_limited_closed_uses_the_shared_default() {
let mut retry = ClosedRetry::default();
retry.schedule("rate-limited: quota exceeded");
let due = retry.due_at.expect("rate-limited must schedule a reopen");
assert!(
due >= Instant::now() + CLOSED_RATE_LIMIT_DEFAULT - Duration::from_secs(1),
"a hintless rate-limit must fall back to the shared default window"
);
}
#[test]
fn retry_hints_parse_the_relays_canonical_format() {
assert_eq!(
parse_retry_in_seconds("rate-limited: quota exceeded; retry in 4s"),
Some(4)
);
assert_eq!(parse_retry_in_seconds("rate-limited: quota exceeded"), None);
assert_eq!(parse_retry_in_seconds("retry in s"), None);
}
// ── Scope fencing at the client boundary ─────────────────────────────────────
//
// `ensure_session` is destructive on entry: a different scope's socket is shut
// down before the new one is installed. The archive lifecycle earns that right
// with `ArchiveOwnership`; the persona catalog and unread catch-up hold no such
// proof and reach the client through `session` instead.
//
// These tests drive `ensure_session` directly rather than `archive_session`,
// because `ArchiveOwnership` is un-constructible outside `archive::sync` — the
// compiler already enforces that half. `archive_session` delegates to
// `ensure_session` with no other effect on the slot, so this stages the exact
// state a live archive leaves behind.
//
// The relay URLs never accept a connection. Nothing here waits on a socket:
// the session task is spawned, its connect fails, and it backs off — while the
// slot bookkeeping and cancellation these tests assert on are synchronous.
/// A scope's relay URL. Distinct ports, on a closed loopback address, so the
/// two scopes are unequal and neither can connect.
fn scope_url(port: u16) -> String {
format!("ws://127.0.0.1:{port}")
}
async fn installed_session(client: &NativeRelayClient) -> Option<Arc<RelaySession>> {
client
.current
.lock()
.await
.as_ref()
.map(|managed| Arc::clone(&managed.session))
}
/// The required regression: a finite request that resumes after the scope
/// switched must not disturb the new scope's live session.
///
/// Staged in the order the bug needs — archive A installed, scope switches and
/// archive B installs, and only then does A's delayed fetch acquire. Against
/// the unfenced `session` (a straight `ensure_session` call) A's late arrival
/// shut B's socket down and installed its own, leaving B's archive attached to
/// a cancelled session: no events, no error, until the next lifecycle edge.
#[tokio::test]
async fn a_stale_finite_request_cannot_displace_the_new_scopes_session() {
let client = NativeRelayClient::default();
let scope_a = (scope_url(9), Keys::generate());
let scope_b = (scope_url(10), Keys::generate());
let archive_a = client
.ensure_session(scope_a.0.clone(), scope_a.1.clone())
.await;
let archive_b = client
.ensure_session(scope_b.0.clone(), scope_b.1.clone())
.await;
assert!(
archive_a.cancel.is_cancelled(),
"the archive lifecycle must still replace its own scope's session"
);
// Scope A's in-flight catalog/catch-up command, resuming late.
let stale = client.session(scope_a.0.clone(), scope_a.1.clone()).await;
assert!(
!archive_b.cancel.is_cancelled(),
"a stale finite request cancelled the live scope's session; its archive \
is now attached to a dead socket and will sit silent until the next \
lifecycle edge"
);
let installed = installed_session(&client)
.await
.expect("the slot must still hold a session");
assert!(
Arc::ptr_eq(&installed, &archive_b),
"a stale finite request replaced the installed session, so the next \
same-scope caller shares the wrong socket"
);
assert!(
!Arc::ptr_eq(&stale.session, &archive_b),
"the stale request must run on its own session, not the live scope's"
);
// Its own session is the lease's to end, and it must actually end: an
// un-cancelled private session leaks a reconnecting socket per request.
let private = stale.handle();
drop(stale);
assert!(
private.cancel.is_cancelled(),
"dropping a private lease must shut its session down"
);
}
/// The sharing half, and the mutant that matters: making every lease private
/// would satisfy the test above while quietly undoing the one-socket design and
/// letting a finite request's drop cancel the archive's session.
#[tokio::test]
async fn a_same_scope_lease_shares_the_installed_session_and_never_ends_it() {
let client = NativeRelayClient::default();
let (relay_url, keys) = (scope_url(11), Keys::generate());
let archive = client.ensure_session(relay_url.clone(), keys.clone()).await;
let lease = client.session(relay_url.clone(), keys.clone()).await;
assert!(
Arc::ptr_eq(&lease.session, &archive),
"a same-scope finite request must multiplex over the installed socket \
rather than opening a second one"
);
drop(lease);
assert!(
!archive.cancel.is_cancelled(),
"dropping a shared lease cancelled the archive's session"
);
assert!(
installed_session(&client)
.await
.is_some_and(|installed| Arc::ptr_eq(&installed, &archive)),
"the shared session must stay installed after a lease is dropped"
);
}
/// A lease taken before any archive start installs, so the archive start that
/// follows reuses that socket instead of opening a second one. This is the
/// common boot order: the catalog fetch runs before archive sync.
#[tokio::test]
async fn the_first_lease_installs_a_session_the_archive_then_reuses() {
let client = NativeRelayClient::default();
let (relay_url, keys) = (scope_url(12), Keys::generate());
let lease = client.session(relay_url.clone(), keys.clone()).await;
let leased = lease.handle();
drop(lease);
assert!(
!leased.cancel.is_cancelled(),
"the first lease owns the slot, so dropping it must not cancel the \
session the archive is about to reuse"
);
let archive = client.ensure_session(relay_url, keys).await;
assert!(
Arc::ptr_eq(&archive, &leased),
"the archive start must reuse the installed session rather than \
replacing an identically scoped one"
);
}
#[path = "native_relay_client_transport_tests.rs"]
mod transport_tests;
#[path = "native_relay_client_finite_tests.rs"]
mod finite_recovery_tests;