diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 46f36212cea..adbc7794bb7 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -653,82 +653,84 @@ pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io Ok(()) } -/// Serializes nest-context writes so a slow, stale regeneration cannot roll the -/// file back over a newer one. This is an ordered, latest-request-wins gate — -/// not a work coalescer: every superseded generation still performs its relay -/// reads, then drops its result at commit time. Adding a true dirty-loop owner -/// would be a larger change and is unwarranted at this user-driven trigger rate. +/// One regeneration worker with a latest-request-wins write fence. Startup +/// persona backfill can request hundreds of renders: intermediate requests must +/// supersede stale writes without each doing their own archive snapshot read. /// -/// Each regeneration request claims a monotonic generation *synchronously* at -/// request time (see [`NestRegenGate::claim`]), so the generation encodes -/// program order: boot's regen is claimed before `apply_workspace`'s, an edit's -/// regen before the next edit's. The claimed generation travels with the -/// spawned task and gates its write in [`NestRegenGate::commit`]: a task drops -/// its result once a *newer generation has been requested*, even if that newer -/// generation later fails before it writes. Gating on the highest *requested* -/// generation — not the highest *written* one — is what stops a slow, stale -/// pre-edit render from publishing after a newer post-edit render was claimed -/// and then failed during its relay work (which would otherwise leave the -/// obsolete roster authoritative until the next unrelated trigger). Declared -/// semantic: once a newer regeneration is requested, no older one publishes; -/// if that newer one fails, the file simply waits for the next trigger. -/// -/// `claim` and `commit` share one lock, so the "is this still the newest -/// request?" compare is atomic with the synchronous file write. A bare atomic -/// watermark checked separately from the write would let a new claim slip -/// between an older task's eligibility check and its write; holding the lock -/// across both closes that window (no `await` occurs while it is held). +/// Claiming, finishing and committing share one lock. A trigger during a read +/// leaves one latest-generation follow-up; a trigger at worker shutdown either +/// becomes that follow-up or starts a new worker. No debounce or cached archive +/// state is needed. Once a newer generation is requested, an older one cannot +/// publish, even if the newer render fails (the next trigger can try again). struct NestRegenGate { - /// Highest generation *requested* so far (`0` = none yet). Advanced by - /// [`claim`] and read by [`commit`]; guarding both under this single lock - /// keeps the eligibility compare atomic with the file write. - highest_requested: Mutex, + state: Mutex, +} + +struct NestRegenState { + highest_requested: u64, + running: bool, } impl NestRegenGate { const fn new() -> Self { Self { - highest_requested: Mutex::new(0), + state: Mutex::new(NestRegenState { + highest_requested: 0, + running: false, + }), } } - /// Claim the next generation. Call synchronously at request time so the - /// value reflects when the regeneration was requested, not when its task - /// happens to run. Advancing the shared watermark here is what lets a later - /// [`commit`] recognize — and drop — any older generation's stale render. - fn claim(&self) -> u64 { - let mut requested = self - .highest_requested - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *requested += 1; - *requested + /// Claim synchronously, before spawning. Only the idle-to-running caller + /// owns a worker; all other callers just advance the pending generation. + fn claim(&self) -> (u64, bool) { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + state.highest_requested += 1; + let start_worker = !state.running; + state.running = true; + (state.highest_requested, start_worker) + } + + /// Return work only to the caller that starts the worker. The callback is + /// the real regeneration path, supplied here so tests can hold its I/O. + fn request<'a, F, Fut>( + &'a self, + mut regenerate: F, + ) -> Option + 'a> + where + F: FnMut(u64) -> Fut + 'a, + Fut: std::future::Future> + 'a, + { + let (mut generation, start_worker) = self.claim(); + if !start_worker { + return None; + } + Some(async move { + loop { + if let Err(error) = regenerate(generation).await { + eprintln!("buzz-desktop: nest context regeneration failed: {error}"); + } + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + if state.highest_requested == generation { + state.running = false; + return; + } + generation = state.highest_requested; + } + }) } - /// Non-blocking [`claim`] against the *exact* lock `claim` takes. Returns - /// `Some(generation)` if it acquired the lock — i.e. a claim could proceed - /// with no contention — or `None` if the lock is already held, meaning a - /// concurrent claim would block on it. Because `claim` and `commit` share - /// `highest_requested`, calling this from inside `commit_hooked`'s - /// under-lock hook reports `None`: the eligibility compare and the write - /// are serialized against any new claim. A design that advanced the - /// watermark under a separate lock (or a lock-free atomic) would report - /// `Some` here — the regression this probe proves absent, with no reliance - /// on elapsed time or thread scheduling. + /// Probe the exact claim/commit lock, including inside the commit hook. #[cfg(test)] fn try_claim(&self) -> Option { - match self.highest_requested.try_lock() { - Ok(mut requested) => { - *requested += 1; - Some(*requested) - } - Err(std::sync::TryLockError::WouldBlock) => None, - Err(std::sync::TryLockError::Poisoned(poisoned)) => { - let mut requested = poisoned.into_inner(); - *requested += 1; - Some(*requested) - } - } + let mut state = match self.state.try_lock() { + Ok(state) => state, + Err(std::sync::TryLockError::WouldBlock) => return None, + Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + }; + state.highest_requested += 1; + state.running = true; + Some(state.highest_requested) } /// Commit `content` for `generation`, dropping the write once a newer @@ -755,10 +757,10 @@ impl NestRegenGate { under_lock: impl FnOnce(), ) -> io::Result { let requested = self - .highest_requested + .state .lock() .map_err(|_| io::Error::other("nest regen gate lock poisoned"))?; - if generation < *requested { + if generation < requested.highest_requested { return Ok(false); } under_lock(); @@ -767,7 +769,12 @@ impl NestRegenGate { } } -/// Process-wide ordered write gate for nest-context regeneration. +// A best-effort roster refresh must not strand every newer edit behind an old +// relay's unbounded NIP-11 body or admission wait. Bound the complete archive +// operation, not just request headers; timeout preserves the existing fail-open. +const NEST_ARCHIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Process-wide regeneration owner and ordered write gate. static NEST_REGEN: NestRegenGate = NestRegenGate::new(); pub async fn regenerate_nest_context( @@ -795,10 +802,20 @@ pub async fn regenerate_nest_context( // uses the same captured target as the rendered relay; a later generation's // task always wins the commit, so a fallback-relay boot render cannot bury a // later apply_workspace render. - let archived: HashSet = fetch_archived_pubkeys_at(&state, &target) - .await - .into_iter() - .collect(); + let archived: HashSet = match tokio::time::timeout( + NEST_ARCHIVE_TIMEOUT, + fetch_archived_pubkeys_at(&state, &target), + ) + .await + { + Ok(pubkeys) => pubkeys.into_iter().collect(), + Err(_) => { + eprintln!( + "buzz-desktop: nest archive read timed out; rendering without archive filter" + ); + HashSet::new() + } + }; let content = render_dynamic_section(&personas, &agents, &archived, &target.ws_url); NEST_REGEN .commit(&agents_md, &content, generation) @@ -807,27 +824,25 @@ pub async fn regenerate_nest_context( Ok(()) } -/// Convenience wrapper: claims a regeneration generation, then regenerates on a -/// spawned task, logging a warning on failure. -/// -/// All call sites treat regeneration as fire-and-forget — agents run fine with -/// a stale AGENTS.md, so we warn and continue rather than propagating the error. -/// The generation is claimed *here*, synchronously, so it encodes call order; -/// the spawned task carries it into [`NestRegenGate::commit`], which drops -/// a stale render rather than letting a slow task overwrite a newer file. -/// Archive/unarchive trigger this directly, but the regen races the relay's -/// `kind:13535` snapshot update, so a just-archived agent may still linger for -/// one cycle until the next regen (any agent/team edit or the next launch). +/// Fire-and-forget regeneration: one worker reads the latest state, with one +/// pending follow-up if another trigger arrives. Failures still warn and leave +/// the file for the next trigger; they never strand the worker as running. +/// Archive/unarchive can race the relay's snapshot update, so an archived agent +/// may still linger until the next trigger, as before. pub fn try_regenerate_nest(app: &AppHandle) { - let generation = NEST_REGEN.claim(); let app = app.clone(); - tauri::async_runtime::spawn(async move { - if let Err(error) = regenerate_nest_context(&app, generation).await { - eprintln!("buzz-desktop: nest context regeneration failed: {error}"); - } - }); + if let Some(work) = NEST_REGEN.request(move |generation| { + let app = app.clone(); + async move { regenerate_nest_context(&app, generation).await } + }) { + tauri::async_runtime::spawn(work); + } } +#[cfg(test)] +mod regen_tests; +#[cfg(test)] +mod regen_trigger_tests; #[cfg(test)] mod render_tests; #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/nest/regen_tests.rs b/desktop/src-tauri/src/managed_agents/nest/regen_tests.rs new file mode 100644 index 00000000000..f131da42148 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest/regen_tests.rs @@ -0,0 +1,120 @@ +//! Exercise the production request/worker/commit boundary with held I/O and a +//! real managed-section file. No sleeps, live relay, Tauri process or home writes. +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::sync::{mpsc, oneshot}; + +#[tokio::test] +async fn burst_coalesces_reads_and_commits_only_the_latest_workspace() { + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write(&file, "# User instructions\n").unwrap(); + let current = Mutex::new("wss://before.example"); + let reads = AtomicUsize::new(0); + let active = AtomicUsize::new(0); + let (started, mut starts) = mpsc::unbounded_channel(); + let worker = gate + .request(|generation| { + let relay = *current.lock().unwrap(); + let (release, released) = oneshot::channel(); + reads.fetch_add(1, Ordering::SeqCst); + assert_eq!(active.fetch_add(1, Ordering::SeqCst), 0); + started.send((generation, release)).unwrap(); + let gate = &gate; + let active = &active; + let file = &file; + async move { + released.await.unwrap(); + let content = render_dynamic_section(&[], &[], &HashSet::new(), relay); + gate.commit(file, &content, generation).unwrap(); + active.fetch_sub(1, Ordering::SeqCst); + Ok(()) + } + }) + .unwrap(); + tokio::pin!(worker); + let (first, release_first) = tokio::select! { + value = starts.recv() => value.unwrap(), + () = &mut worker => panic!("worker completed before its read"), + }; + assert_eq!(first, 1); + + // Model startup backfill and a workspace switch while the first snapshot + // read is held. New requests must not even start their expensive callback. + *current.lock().unwrap() = "wss://latest.example"; + for _ in 0..289 { + assert!(gate + .request(|_| async { panic!("duplicate worker") }) + .is_none()); + } + assert_eq!(reads.load(Ordering::SeqCst), 1); + release_first.send(()).unwrap(); + let (latest, release_latest) = tokio::select! { + value = starts.recv() => value.unwrap(), + () = &mut worker => panic!("latest request was lost"), + }; + assert_eq!(latest, 290); + assert_eq!(reads.load(Ordering::SeqCst), 2); + assert_eq!(fs::read_to_string(&file).unwrap(), "# User instructions\n"); + release_latest.send(()).unwrap(); + worker.await; + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.starts_with("# User instructions")); + assert!(content.contains("wss://latest.example")); + assert!(!content.contains("wss://before.example")); + assert_eq!(active.load(Ordering::SeqCst), 0); + assert!(!gate.state.lock().unwrap().running); +} + +#[tokio::test] +async fn trigger_during_follow_up_and_after_idle_is_not_lost() { + let gate = NestRegenGate::new(); + let passes = AtomicUsize::new(0); + gate.request(|generation| { + let pass = passes.fetch_add(1, Ordering::SeqCst); + if pass < 2 { + assert!(gate + .request(|_| async { panic!("second worker") }) + .is_none()); + } + async move { + assert_eq!(generation, pass as u64 + 1); + Ok(()) + } + }) + .unwrap() + .await; + assert_eq!(passes.load(Ordering::SeqCst), 3); + assert!(!gate.state.lock().unwrap().running); + + // A request after idle must acquire ownership, not remain stranded dirty. + gate.request(|generation| async move { + assert_eq!(generation, 4); + Ok(()) + }) + .unwrap() + .await; + assert!(!gate.state.lock().unwrap().running); +} + +#[tokio::test] +async fn failed_pass_runs_pending_latest_and_releases_owner_for_next_trigger() { + let gate = NestRegenGate::new(); + let passes = AtomicUsize::new(0); + gate.request(|_| { + if passes.fetch_add(1, Ordering::SeqCst) == 0 { + assert!(gate + .request(|_| async { panic!("second worker") }) + .is_none()); + } + async { Err("controlled regeneration failure".into()) } + }) + .unwrap() + .await; + assert_eq!(passes.load(Ordering::SeqCst), 2); + assert!(!gate.state.lock().unwrap().running); + gate.request(|_| async { Ok(()) }).unwrap().await; + assert!(!gate.state.lock().unwrap().running); +} diff --git a/desktop/src-tauri/src/managed_agents/nest/regen_trigger_tests.rs b/desktop/src-tauri/src/managed_agents/nest/regen_trigger_tests.rs new file mode 100644 index 00000000000..41f6fbff824 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest/regen_trigger_tests.rs @@ -0,0 +1,170 @@ +//! Real fire-and-forget trigger, real HTTP bodies, and real roster persistence. +//! Run in a child process so the home directory, Tauri runtime and global nest +//! owner cannot touch the user's profile or race unrelated package tests. +use super::*; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use std::time::Duration; + +#[test] +fn real_trigger_coalesces_and_survives_a_stalled_old_workspace() { + const CHILD: &str = "BUZZ_NEST_REGEN_TEST_HOME"; + if let Some(home) = std::env::var_os(CHILD) { + let home = PathBuf::from(home); + assert_eq!(std::env::var_os("HOME").unwrap(), home.as_os_str()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + tauri::async_runtime::set(tokio::runtime::Handle::current()); + exercise_real_trigger(&home).await; + }); + return; + } + let home = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "managed_agents::nest::regen_trigger_tests::real_trigger_coalesces_and_survives_a_stalled_old_workspace", "--nocapture"]) + .env(CHILD, home.path()).env("HOME", home.path()) + .env("XDG_DATA_HOME", home.path()).env("APPDATA", home.path()) + .env("LOCALAPPDATA", home.path()) + .env_remove("BUZZ_PRIVATE_KEY").env_remove("BUZZ_AUTH_TAG") + .env_remove("BUZZ_RELAY_URL").env_remove("BUZZ_NETWORK_TRACE") + .output().unwrap(); + assert!( + output.status.success(), + "child failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn exercise_real_trigger(home: &Path) { + use axum::{ + body::{Body, Bytes}, + response::Response, + routing::{get, post}, + Json, Router, + }; + let old_hits = Arc::new(AtomicUsize::new(0)); + let old_started = Arc::new(tokio::sync::Notify::new()); + let hits = old_hits.clone(); + let started = old_started.clone(); + let old = Router::new().route( + "/", + get(move || { + hits.fetch_add(1, Ordering::SeqCst); + started.notify_one(); + async { + // Headers succeed but the NIP-11 body never ends. Only the real + // nest-owned timeout can release this read; the test never does. + Response::new(Body::from_stream(futures_util::stream::pending::< + Result, + >())) + } + }), + ); + let keys = nostr::Keys::generate(); + let relay_self = keys.public_key().to_hex(); + let snapshot = nostr::EventBuilder::new(nostr::Kind::Custom(13535), "") + .sign_with_keys(&keys) + .unwrap(); + let query_hits = Arc::new(AtomicUsize::new(0)); + let hits = query_hits.clone(); + let latest = Router::new() + .route( + "/", + get(move || { + let value = relay_self.clone(); + async move { Json(serde_json::json!({"self": value})) } + }), + ) + .route( + "/query", + post(move || { + hits.fetch_add(1, Ordering::SeqCst); + let value = snapshot.clone(); + async move { Json(serde_json::json!([value])) } + }), + ); + async fn serve(router: Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + ( + url, + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }), + ) + } + let (old_url, old_server) = serve(old).await; + let (latest_url, latest_server) = serve(latest).await; + // Inject paths rather than relying on HOME/APPDATA: Windows known-folder + // APIs ignore those environment overrides. PathResolver joins the mock + // identifier onto data_dir; an absolute identifier replaces that base. + let app_data = home.join("app-data"); + let mut context = tauri::test::mock_context(tauri::test::noop_assets()); + context.config_mut().identifier = app_data.to_str().unwrap().to_owned(); + NEST_DIR.set(Some(home.join("nest"))).unwrap(); + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(old_url.clone()); + let app = tauri::test::mock_builder() + .manage(state) + .build(context) + .unwrap(); + assert_eq!(app.path().app_data_dir().unwrap(), app_data); + let nest = nest_dir().unwrap(); + assert_eq!(nest, home.join("nest")); + fs::create_dir_all(&nest).unwrap(); + let file = nest.join("AGENTS.md"); + fs::write(&file, "# User instructions\n").unwrap(); + + try_regenerate_nest(app.handle()); + tokio::time::timeout(Duration::from_secs(3), old_started.notified()) + .await + .unwrap(); + for _ in 0..288 { + try_regenerate_nest(app.handle()); + } + *app.state::().relay_url_override.lock().unwrap() = Some(latest_url.clone()); + try_regenerate_nest(app.handle()); + + // Wait through the actual operation deadline. Do not release the stalled + // response or manufacture a fresh worker; its pending latest trigger must + // progress on its own and finish with exactly one new-workspace query. + tokio::time::timeout(NEST_ARCHIVE_TIMEOUT + Duration::from_secs(5), async { + loop { + let content = fs::read_to_string(&file).unwrap(); + assert!(!content.contains(&old_url), "stale workspace was committed"); + if content.contains(&latest_url) && !NEST_REGEN.state.lock().unwrap().running { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(old_hits.load(Ordering::SeqCst), 1); + assert_eq!(query_hits.load(Ordering::SeqCst), 1); + assert!(fs::read_to_string(&file) + .unwrap() + .starts_with("# User instructions")); + + // Idle-to-running through the real trigger must still work. + try_regenerate_nest(app.handle()); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if !NEST_REGEN.state.lock().unwrap().running { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(query_hits.load(Ordering::SeqCst), 2); + old_server.abort(); + latest_server.abort(); +} diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs index 5c38eb4c250..13907ddc04c 100644 --- a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -528,8 +528,8 @@ fn commit_newer_generation_wins_over_a_stale_finisher() { let tmp = tempfile::tempdir().unwrap(); let file = agents_md_with_markers(tmp.path()); - let gen_a = gate.claim(); // pre-edit request - let gen_b = gate.claim(); // post-edit request + let gen_a = gate.claim().0; // pre-edit request + let gen_b = gate.claim().0; // post-edit request assert!(gen_a < gen_b); // B (newer) commits first. @@ -558,8 +558,8 @@ fn commit_boot_fallback_relay_cannot_bury_apply_workspace_relay() { let tmp = tempfile::tempdir().unwrap(); let file = agents_md_with_markers(tmp.path()); - let boot_gen = gate.claim(); // boot, fallback relay - let apply_gen = gate.claim(); // apply_workspace, workspace relay + let boot_gen = gate.claim().0; // boot, fallback relay + let apply_gen = gate.claim().0; // apply_workspace, workspace relay // apply_workspace's render lands first. assert!(gate @@ -599,8 +599,8 @@ fn commit_failed_newer_request_still_supersedes_older_snapshot() { let tmp = tempfile::tempdir().unwrap(); let file = agents_md_with_markers(tmp.path()); - let gen1 = gate.claim(); // pre-edit request - let gen2 = gate.claim(); // post-edit request + let gen1 = gate.claim().0; // pre-edit request + let gen2 = gate.claim().0; // post-edit request assert!(gen1 < gen2); // gen2 fails during relay work and never reaches commit — nothing written. @@ -640,7 +640,7 @@ fn commit_claim_at_the_older_tasks_cutover_supersedes_it() { let tmp = tempfile::tempdir().unwrap(); let file = agents_md_with_markers(tmp.path()); - let gen1 = gate.claim(); + let gen1 = gate.claim().0; let wrote_gen1 = gate .commit_hooked(&file, "gen1 roster", gen1, || { @@ -664,7 +664,7 @@ fn commit_claim_at_the_older_tasks_cutover_supersedes_it() { // The lock is free once commit returns, so a newer request now claims and // may publish over gen1. - let gen2 = gate.claim(); + let gen2 = gate.claim().0; assert!(gen1 < gen2); assert!(gate.commit(&file, "gen2 roster", gen2).unwrap()); @@ -681,7 +681,7 @@ fn commit_equal_generation_is_allowed() { let tmp = tempfile::tempdir().unwrap(); let file = agents_md_with_markers(tmp.path()); - let gen = gate.claim(); + let gen = gate.claim().0; assert!(gate.commit(&file, "first", gen).unwrap()); assert!( gate.commit(&file, "second", gen).unwrap(), @@ -702,11 +702,11 @@ fn commit_poisoned_lock_returns_error_instead_of_panicking() { let gate = std::sync::Arc::new(NestRegenGate::new()); let tmp = tempfile::tempdir().unwrap(); let file = agents_md_with_markers(tmp.path()); - let gen = gate.claim(); + let gen = gate.claim().0; let poisoner = gate.clone(); let _ = std::thread::spawn(move || { - let _guard = poisoner.highest_requested.lock().unwrap(); + let _guard = poisoner.state.lock().unwrap(); panic!("poison the gate lock"); }) .join(); diff --git a/desktop/src-tauri/src/native_relay_client.rs b/desktop/src-tauri/src/native_relay_client.rs index 19740dd0197..fd97f4b9b72 100644 --- a/desktop/src-tauri/src/native_relay_client.rs +++ b/desktop/src-tauri/src/native_relay_client.rs @@ -239,7 +239,30 @@ pub(crate) struct RelaySession { struct PendingRequest { events: Vec, + seen: HashSet, complete: oneshot::Sender, String>>, + deadline: Instant, + retry: ClosedRetry, +} + +impl PendingRequest { + fn is_active(&self) -> bool { + !self.complete.is_closed() && Instant::now() < self.deadline + } +} + +// Close before waking, including when fetch_events is dropped during registration. +// The existing session owner reclaims state; Drop does not spawn another owner. +struct RequestReceiver<'a> { + result: oneshot::Receiver, String>>, + wake: &'a mpsc::Sender<()>, +} + +impl Drop for RequestReceiver<'_> { + fn drop(&mut self) { + self.result.close(); + let _ = self.wake.try_send(()); + } } /// Desired set plus the write-time record of what has left it. @@ -304,12 +327,20 @@ impl RelaySession { timeout: Duration, ) -> Result, String> { let id = format!("native-fetch-{}", uuid::Uuid::new_v4()); + let deadline = Instant::now() + timeout; let (complete, result) = oneshot::channel(); + let mut receiver = RequestReceiver { + result, + wake: &self.wake, + }; self.requests.lock().await.insert( id.clone(), PendingRequest { events: Vec::new(), + seen: HashSet::new(), complete, + deadline, + retry: ClosedRetry::default(), }, ); { @@ -323,7 +354,7 @@ impl RelaySession { let outcome = tokio::select! { _ = self.cancel.cancelled() => Err("relay session cancelled".to_string()), - value = tokio::time::timeout(timeout, result) => match value { + value = tokio::time::timeout_at(deadline, &mut receiver.result) => match value { Ok(Ok(value)) => value, Ok(Err(_)) => Err("relay request ended before EOSE".to_string()), Err(_) => Err("relay request timed out".to_string()), @@ -334,14 +365,33 @@ impl RelaySession { } async fn finish_request(&self, id: &str) { - self.requests.lock().await.remove(id); + let mut requests = self.requests.lock().await; let mut state = self.state.lock().await; + // Do not leave an orphan transient if the caller drops during cleanup. + requests.remove(id); state.transient.retain(|subscription| subscription.id != id); state.removed.insert(id.to_string()); drop(state); let _ = self.wake.try_send(()); } + // Also called while connecting/backing off: caller cancellation must reclaim + // buffers even if authentication never succeeds and reconcile cannot run. + async fn prune_cancelled_requests(&self) { + let mut requests = self.requests.lock().await; + let mut removed = Vec::new(); + requests.retain(|id, request| { + if !request.complete.is_closed() { + return true; + } + removed.push(id.clone()); + false + }); + let mut state = self.state.lock().await; + state.transient.retain(|sub| !removed.contains(&sub.id)); + state.removed.extend(removed); + } + /// Replaces the desired subscription set and wakes the loop to reconcile. /// /// Reconciliation is declarative rather than incremental: callers state @@ -428,7 +478,17 @@ async fn run_session( return; } - match NostrWsConnection::connect_authenticated(&relay_url, &keys, auth_tag.as_ref()).await { + let connecting = + NostrWsConnection::connect_authenticated(&relay_url, &keys, auth_tag.as_ref()); + tokio::pin!(connecting); + let connected = loop { + tokio::select! { + _ = session.cancel.cancelled() => return, + Some(()) = wake_rx.recv() => session.prune_cancelled_requests().await, + result = &mut connecting => break result, + } + }; + match connected { Ok(conn) => { // A connection that authenticated is healthy regardless of how // long it then lived, so backoff resets here rather than on @@ -445,9 +505,14 @@ async fn run_session( if session.cancel.is_cancelled() { return; } - tokio::select! { - _ = session.cancel.cancelled() => return, - _ = tokio::time::sleep(delay) => {} + let backoff = tokio::time::sleep(delay); + tokio::pin!(backoff); + loop { + tokio::select! { + _ = session.cancel.cancelled() => return, + Some(()) = wake_rx.recv() => session.prune_cancelled_requests().await, + _ = &mut backoff => break, + } } delay = (delay * 2).min(RECONNECT_MAX_DELAY); } @@ -496,7 +561,19 @@ async fn run_connection( // Earliest pending reopen, or `None` when nothing is scheduled. The arm // below is disabled in that case rather than sleeping on a far-future // instant, so an idle connection never wakes on this branch. - let retry_at = retries.values().filter_map(|retry| retry.due_at).min(); + let finite_retry_at = session + .requests + .lock() + .await + .values() + .filter(|request| request.is_active()) + .filter_map(|request| request.retry.due_at) + .min(); + let retry_at = retries + .values() + .filter_map(|retry| retry.due_at) + .chain(finite_retry_at) + .min(); tokio::select! { _ = session.cancel.cancelled() => { @@ -515,6 +592,11 @@ async fn run_connection( _ = tokio::time::sleep_until(retry_at.unwrap_or_else(Instant::now)), if retry_at.is_some() => { + for request in session.requests.lock().await.values_mut() { + if request.retry.due_at.is_some_and(|due| due <= Instant::now()) { + request.retry.due_at = None; + } + } for retry in retries.values_mut() { if retry.due_at.is_some_and(|due| due <= Instant::now()) { retry.due_at = None; @@ -559,7 +641,9 @@ async fn run_connection( .await .get_mut(&subscription_id) { - request.events.push(*event); + if request.is_active() && request.seen.insert(event.id) { + request.events.push(*event); + } } continue; } @@ -598,7 +682,17 @@ async fn run_connection( if open.remove(&subscription_id).is_none() { continue; } - if let Some(request) = session.requests.lock().await.remove(&subscription_id) { + let mut requests = session.requests.lock().await; + if let Some(request) = requests.get_mut(&subscription_id) { + // Finite quota recovery belongs to the request, not the + // socket: reconnects and partial EVENTs cannot reset it. + if request.is_active() && classify_closed(&message) == ClosedClass::RateLimited + && request.retry.attempts < 3 { + request.retry.schedule(&message); + continue; + } + } + if let Some(request) = requests.remove(&subscription_id) { let _ = request.complete.send(Err(format!("relay closed request: {message}"))); let mut state = session.state.lock().await; state.transient.retain(|subscription| subscription.id != subscription_id); @@ -607,6 +701,7 @@ async fn run_connection( let _ = session.wake.try_send(()); continue; } + drop(requests); let retry = retries.entry(subscription_id.clone()).or_default(); retry.schedule(&message); eprintln!( @@ -620,8 +715,12 @@ async fn run_connection( // is what keeps an intermittent relay from ratcheting // its way to the 30s ceiling and staying there. let was_open = open.contains_key(&subscription_id); - if let Some(request) = session.requests.lock().await.remove(&subscription_id) { - let _ = request.complete.send(Ok(request.events)); + let mut requests = session.requests.lock().await; + if requests.contains_key(&subscription_id) && !was_open { continue; } + if let Some(request) = requests.remove(&subscription_id) { + let result = if request.is_active() { Ok(request.events) } + else { Err("relay request timed out or cancelled".to_string()) }; + let _ = request.complete.send(result); let mut state = session.state.lock().await; state.transient.retain(|subscription| subscription.id != subscription_id); state.removed.insert(subscription_id.clone()); @@ -629,6 +728,7 @@ async fn run_connection( let _ = session.wake.try_send(()); continue; } + drop(requests); retries.remove(&subscription_id); // The relay is running a subscription this socket does // not think is open, so the two disagree. EOSE is the @@ -676,6 +776,7 @@ async fn reconcile( // `set_subscriptions` land in the gap, spending its removal against a // desired set captured before it — reopening a subscription the caller had // just dropped, with no record left to catch it on the next pass. + session.prune_cancelled_requests().await; let (desired, removed) = { let mut state = session.state.lock().await; let removed = std::mem::take(&mut state.removed); @@ -683,8 +784,9 @@ async fn reconcile( state .desired .iter() - .chain(&state.transient) .cloned() + .map(|sub| (sub, false)) + .chain(state.transient.iter().cloned().map(|sub| (sub, true))) .collect::>(), removed, ) @@ -699,7 +801,7 @@ async fn reconcile( } for id in open.keys().cloned().collect::>() { - if desired.iter().any(|s| s.id == id) { + if desired.iter().any(|(s, _)| s.id == id) { continue; } if conn @@ -712,7 +814,7 @@ async fn reconcile( open.remove(&id); } - for sub in desired { + for (sub, finite) in desired { // A filter change under the same id must reopen, not be skipped: the // relay replaces a subscription by id, so re-sending REQ is the update. if open.get(&sub.id) == Some(&sub.filter) { @@ -725,6 +827,22 @@ async fn reconcile( if retries.get(&sub.id).is_some_and(ClosedRetry::is_blocked) { continue; } + if session.cancel.is_cancelled() { + return false; + } + if finite { + let requests = session.requests.lock().await; + if !requests + .get(&sub.id) + .is_some_and(|request| request.is_active() && !request.retry.is_blocked()) + { + continue; + } + } + // The request-lock acquisition above can suspend through shutdown. + if session.cancel.is_cancelled() { + return false; + } if conn .send_raw(&serde_json::json!(["REQ", sub.id, sub.filter])) .await @@ -786,7 +904,9 @@ impl ClosedRetry { .unwrap_or(CLOSED_RATE_LIMIT_DEFAULT); // The longer of the two: a short hint must not undercut a // backoff already grown by repeated rejections. - self.due_at = Some(Instant::now() + self.backoff().max(hinted)); + self.due_at = Instant::now().checked_add(self.backoff().max(hinted)); + // An unrepresentable hint is a hold, never an immediate retry. + self.terminal = self.due_at.is_none(); self.attempts = self.attempts.saturating_add(1); } ClosedClass::Retryable => { diff --git a/desktop/src-tauri/src/native_relay_client_finite_tests.rs b/desktop/src-tauri/src/native_relay_client_finite_tests.rs new file mode 100644 index 00000000000..913d17f5fdd --- /dev/null +++ b/desktop/src-tauri/src/native_relay_client_finite_tests.rs @@ -0,0 +1,459 @@ +//! Finite CLOSED recovery through the real native session and WebSocket loop. +use super::*; + +fn fetch( + session: &Arc, + timeout: Duration, +) -> tokio::task::JoinHandle, String>> { + let session = Arc::clone(session); + tokio::spawn(async move { + session + .fetch_events( + serde_json::json!({"kinds": [9], "#h": ["unread"], "since": 42, "limit": 1000}), + timeout, + ) + .await + }) +} + +#[tokio::test] +async fn quota_retry_preserves_events_without_duplicates_and_keeps_archive_live() { + let (url, mut frames, commands) = stub_relay().await; + let (session, mut archive) = start(url, Keys::generate(), None).await; + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "archive").await, PROBE_ID); + let pending = fetch(&session, Duration::from_secs(10)); + let id = next_req(&mut frames, "finite").await; + let event = EventBuilder::text_note("retained") + .sign_with_keys(&Keys::generate()) + .unwrap(); + let encoded = serde_json::to_value(&event).unwrap(); + commands + .send(StubCommand::Event(id.clone(), encoded.clone())) + .await + .unwrap(); + let refused = Instant::now(); + commands + .send(StubCommand::Closed( + id.clone(), + "rate-limited: quota exceeded; retry in 1s".into(), + )) + .await + .unwrap(); + // Non-finite delivery is a receive-loop barrier, not a sleep or test-side retry setup. + commands + .send(StubCommand::Event(PROBE_ID.into(), encoded.clone())) + .await + .unwrap(); + assert_eq!( + *tokio::time::timeout(Duration::from_secs(3), archive.recv()) + .await + .unwrap() + .unwrap() + .event, + event + ); + assert!( + !pending.is_finished(), + "quota refusal must retain the finite operation" + ); + assert_eq!(next_req(&mut frames, "quota retry").await, id); + assert!(refused.elapsed() >= Duration::from_secs(1)); + commands + .send(StubCommand::Event(id.clone(), encoded)) + .await + .unwrap(); + commands.send(StubCommand::Eose(id.clone())).await.unwrap(); + assert_eq!(pending.await.unwrap().unwrap(), vec![event]); + assert_eq!( + next_frame(&mut frames, "finite CLOSE").await, + Frame::Close(id) + ); + session.shutdown(); +} + +#[tokio::test] +async fn zero_hint_exhausts_three_retries_as_an_error() { + let (url, mut frames, commands) = stub_relay().await; + let (session, _archive) = start(url, Keys::generate(), None).await; + let pending = fetch(&session, Duration::from_secs(12)); + let id = next_req(&mut frames, "finite").await; + for attempt in 0..4 { + commands + .send(StubCommand::Closed( + id.clone(), + "rate-limited: quota exceeded; retry in 0s".into(), + )) + .await + .unwrap(); + if attempt < 3 { + assert_eq!(next_req(&mut frames, "bounded retry").await, id); + } + } + let error = pending.await.unwrap().unwrap_err(); + assert!(error.contains("rate-limited:"), "{error}"); + assert!(session.requests.lock().await.is_empty()); + session.shutdown(); +} + +#[tokio::test] +async fn terminal_refusal_and_deadline_do_not_become_empty_success() { + for message in [ + "restricted: denied", + "error: transient", + "rate-limited: quota exceeded; retry in 10s", + "rate-limited: quota exceeded", + "rate-limited: quota exceeded; retry in 18446744073709551615s", + ] { + let (url, mut frames, commands) = stub_relay().await; + let (session, _archive) = start(url, Keys::generate(), None).await; + let pending = fetch(&session, Duration::from_millis(300)); + let id = next_req(&mut frames, "finite").await; + commands + .send(StubCommand::Closed(id, message.into())) + .await + .unwrap(); + let error = pending.await.unwrap().unwrap_err(); + if message.starts_with("rate-limited:") { + assert!(error.contains("timed out"), "{error}"); + } else { + assert!(error.contains(message), "{error}"); + } + assert!(session.requests.lock().await.is_empty()); + assert!(session.state.lock().await.transient.is_empty()); + assert!( + frames.try_recv().is_err(), + "must not dispatch through denial/deadline" + ); + session.shutdown(); + } +} + +#[tokio::test] +async fn dropping_finite_caller_closes_request_without_stopping_archive() { + let (url, mut frames, commands) = stub_relay().await; + let (session, mut archive) = start(url, Keys::generate(), None).await; + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "archive").await, PROBE_ID); + let pending = fetch(&session, Duration::from_secs(10)); + let id = next_req(&mut frames, "finite").await; + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + assert_eq!( + next_frame(&mut frames, "cancelled finite CLOSE").await, + Frame::Close(id) + ); + assert!(session.requests.lock().await.is_empty()); + assert!(session.state.lock().await.transient.is_empty()); + let event = EventBuilder::text_note("archive remains live") + .sign_with_keys(&Keys::generate()) + .unwrap(); + commands + .send(StubCommand::Event( + PROBE_ID.into(), + serde_json::to_value(&event).unwrap(), + )) + .await + .unwrap(); + assert_eq!( + *tokio::time::timeout(Duration::from_secs(3), archive.recv()) + .await + .unwrap() + .unwrap() + .event, + event + ); + session.shutdown(); +} + +#[tokio::test] +async fn reconnect_preserves_quota_hold_and_partial_events_do_not_reset_budget() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let (seen_tx, mut seen_rx) = mpsc::channel(8); + let event = EventBuilder::text_note("partial replay") + .sign_with_keys(&Keys::generate()) + .unwrap(); + let server = tokio::spawn(async move { + let mut original: Option = None; + let mut refused = std::time::Instant::now(); + for connection in 0..2 { + let (tcp, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap(); + ws.send(Message::Text( + serde_json::json!(["AUTH", "reconnect-test"]) + .to_string() + .into(), + )) + .await + .unwrap(); + let mut requests = 0; + while let Some(Ok(Message::Text(text))) = ws.next().await { + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + match frame[0].as_str() { + Some("AUTH") => ws + .send(Message::Text( + serde_json::json!(["OK", frame[1]["id"], true, ""]) + .to_string() + .into(), + )) + .await + .unwrap(), + Some("REQ") => { + if let Some(expected) = &original { + assert_eq!(&frame, expected); + assert!( + refused.elapsed() + >= Duration::from_secs(if connection == 1 && requests == 0 { + 2 + } else { + 1 + }), + "reconnect must not erase due time" + ); + } else { + original = Some(frame.clone()); + } + seen_tx.send(frame.clone()).await.unwrap(); + ws.send(Message::Text( + serde_json::json!(["EVENT", frame[1], event]) + .to_string() + .into(), + )) + .await + .unwrap(); + let hint = if connection == 0 { 2 } else { 0 }; + refused = std::time::Instant::now(); + ws.send(Message::Text( + serde_json::json!([ + "CLOSED", + frame[1], + format!("rate-limited: quota exceeded; retry in {hint}s") + ]) + .to_string() + .into(), + )) + .await + .unwrap(); + requests += 1; + if connection == 0 { + ws.close(None).await.unwrap(); + break; + } + if requests == 3 { + return; + } + } + _ => {} + } + } + } + }); + let (session, _archive) = start(url, Keys::generate(), None).await; + let pending = fetch(&session, Duration::from_secs(15)); + let error = tokio::time::timeout(Duration::from_secs(12), pending) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(error.contains("rate-limited:"), "{error}"); + let mut count = 0; + while seen_rx.try_recv().is_ok() { + count += 1; + } + assert_eq!( + count, 4, + "initial request plus three retries across both sockets" + ); + assert!(session.requests.lock().await.is_empty()); + server.await.unwrap(); + session.shutdown(); +} + +#[tokio::test] +async fn caller_drop_during_authentication_is_reclaimed_without_restarting_connect() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let (connected_tx, connected) = oneshot::channel(); + let (release, release_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap(); + connected_tx.send(()).unwrap(); + release_rx.await.unwrap(); + ws.send(Message::Text( + serde_json::json!(["AUTH", "held-auth"]).to_string().into(), + )) + .await + .unwrap(); + let Some(Ok(Message::Text(text))) = ws.next().await else { + panic!("same authentication attempt must survive wake") + }; + let auth: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(auth[0], "AUTH"); + ws.send(Message::Text( + serde_json::json!(["OK", auth[1]["id"], true, ""]) + .to_string() + .into(), + )) + .await + .unwrap(); + let next = tokio::time::timeout(Duration::from_millis(200), ws.next()).await; + assert!(next.is_err(), "cancelled request must not be replayed"); + }); + let (session, _archive) = start(url, Keys::generate(), None).await; + connected.await.unwrap(); + let pending = fetch(&session, Duration::from_secs(10)); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if !session.state.lock().await.transient.is_empty() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if session.requests.lock().await.is_empty() + && session.state.lock().await.transient.is_empty() + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("cleanup must not require successful auth"); + release.send(()).unwrap(); + server.await.unwrap(); + session.shutdown(); +} + +#[tokio::test] +async fn cancellation_and_stale_eose_during_hold_cannot_complete_or_retry() { + for shutdown in [false, true] { + let (url, mut frames, commands) = stub_relay().await; + let (session, mut archive) = start(url, Keys::generate(), None).await; + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "archive").await, PROBE_ID); + let pending = fetch(&session, Duration::from_secs(10)); + let id = next_req(&mut frames, "finite").await; + commands + .send(StubCommand::Closed( + id.clone(), + "rate-limited: quota exceeded; retry in 1s".into(), + )) + .await + .unwrap(); + commands.send(StubCommand::Eose(id)).await.unwrap(); + let event = EventBuilder::text_note("barrier") + .sign_with_keys(&Keys::generate()) + .unwrap(); + commands + .send(StubCommand::Event( + PROBE_ID.into(), + serde_json::to_value(event).unwrap(), + )) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), archive.recv()) + .await + .unwrap() + .unwrap(); + assert!( + !pending.is_finished(), + "stale EOSE must not finish refused history" + ); + if shutdown { + session.shutdown(); + assert!(pending.await.unwrap().unwrap_err().contains("cancelled")); + } else { + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + } + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if session.requests.lock().await.is_empty() + && session.state.lock().await.transient.is_empty() + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(1100), frames.recv()) + .await + .ok() + .flatten() + .is_none(), + "cancelled retry must never reopen" + ); + session.shutdown(); + } +} + +#[tokio::test] +async fn timeout_cleanup_remains_owned_when_caller_drops_under_state_contention() { + let (url, mut frames, _commands) = stub_relay().await; + let (session, _archive) = start(url, Keys::generate(), None).await; + let pending = fetch(&session, Duration::from_millis(150)); + let id = next_req(&mut frames, "finite").await; + let state = session.state.lock().await; + // Hold the real cleanup lock beyond the caller's absolute deadline. Abort + // only after cleanup has been runnable, then let the existing loop reclaim. + tokio::time::sleep(Duration::from_millis(250)).await; + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + drop(state); + assert_eq!( + next_frame(&mut frames, "cancelled cleanup CLOSE").await, + Frame::Close(id) + ); + assert!(session.requests.lock().await.is_empty()); + assert!(session.state.lock().await.transient.is_empty()); + session.shutdown(); +} + +#[tokio::test] +async fn cancellation_during_registration_does_not_leave_transient_state() { + let (url, mut frames, _commands) = stub_relay().await; + let (session, _archive) = start(url, Keys::generate(), None).await; + session.set_subscriptions(vec![probe_subscription()]).await; + assert_eq!(next_req(&mut frames, "archive").await, PROBE_ID); + let state = session.state.lock().await; + let pending = fetch(&session, Duration::from_secs(10)); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if !session.requests.lock().await.is_empty() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + drop(state); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if session.requests.lock().await.is_empty() + && session.state.lock().await.transient.is_empty() + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(frames.try_recv().is_err()); + session.shutdown(); +} diff --git a/desktop/src-tauri/src/native_relay_client_tests.rs b/desktop/src-tauri/src/native_relay_client_tests.rs index ca2324327f3..f12a689449c 100644 --- a/desktop/src-tauri/src/native_relay_client_tests.rs +++ b/desktop/src-tauri/src/native_relay_client_tests.rs @@ -894,3 +894,6 @@ async fn the_first_lease_installs_a_session_the_archive_then_reuses() { #[path = "native_relay_client_transport_tests.rs"] mod transport_tests; + +#[path = "native_relay_client_finite_tests.rs"] +mod finite_recovery_tests; diff --git a/desktop/src-tauri/src/unread_catch_up.rs b/desktop/src-tauri/src/unread_catch_up.rs index 96a740638b9..d0e2de827ac 100644 --- a/desktop/src-tauri/src/unread_catch_up.rs +++ b/desktop/src-tauri/src/unread_catch_up.rs @@ -153,6 +153,33 @@ pub(crate) async fn unread_catch_up( // particular do not move the lease into a task or narrow its scope. let session = relay_client.session(relay_url.clone(), keys).await; + let (fetched, failures) = fetch_channels(session.handle(), &request).await?; + + let current_keys = state.signing_keys()?; + if current_keys.public_key().to_hex() != owner + || crate::relay::relay_ws_url_with_override(&state) != relay_url + { + return Err("unread catch-up scope changed while fetching".to_string()); + } + + let membership = crate::observed_unread::load_membership( + &app, + &crate::observed_unread::ObservedUnreadScope { + pubkey: owner, + relay_url, + }, + )?; + let mut channels = classify_batch(&request, fetched, &membership); + channels.extend(failures); + Ok(UnreadCatchUpResponse { channels }) +} + +// Shared production boundary: original per-channel filters, finite requests, +// and error partitioning. Scope/membership checks remain in the command above. +async fn fetch_channels( + session: std::sync::Arc, + request: &UnreadCatchUpRequest, +) -> Result<(Vec, Vec), String> { let concurrency = std::sync::Arc::new(Semaphore::new(8)); let mut pending = JoinSet::new(); // One command replaces N renderer invokes while the shared session still @@ -163,7 +190,7 @@ pub(crate) async fn unread_catch_up( .acquire_owned() .await .map_err(|error| error.to_string())?; - let session = session.handle(); + let session = std::sync::Arc::clone(&session); pending.spawn(async move { let _permit = permit; let kinds: &[u32] = if channel.channel_type == "dm" { @@ -217,23 +244,7 @@ pub(crate) async fn unread_catch_up( fetched.sort_by_key(|item| item.order); - let current_keys = state.signing_keys()?; - if current_keys.public_key().to_hex() != owner - || crate::relay::relay_ws_url_with_override(&state) != relay_url - { - return Err("unread catch-up scope changed while fetching".to_string()); - } - - let membership = crate::observed_unread::load_membership( - &app, - &crate::observed_unread::ObservedUnreadScope { - pubkey: owner, - relay_url, - }, - )?; - let mut channels = classify_batch(&request, fetched, &membership); - channels.extend(failures); - Ok(UnreadCatchUpResponse { channels }) + Ok((fetched, failures)) } fn classify_batch( @@ -667,3 +678,7 @@ mod tests { assert_eq!(actual, expected); } } + +#[cfg(test)] +#[path = "unread_catch_up_recovery_tests.rs"] +mod recovery_tests; diff --git a/desktop/src-tauri/src/unread_catch_up_recovery_tests.rs b/desktop/src-tauri/src/unread_catch_up_recovery_tests.rs new file mode 100644 index 00000000000..9d4b5318cbb --- /dev/null +++ b/desktop/src-tauri/src/unread_catch_up_recovery_tests.rs @@ -0,0 +1,201 @@ +//! Socket refusal -> production unread fetch/filter -> batch classifier. +use super::*; +use futures_util::{SinkExt, StreamExt}; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test] +async fn refused_unread_history_recovers_unique_events_with_original_filters() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let author = Keys::generate(); + let root = "a".repeat(64); + let make = |content: &str, signer: &Keys, at, tags: Vec| { + EventBuilder::new(Kind::Custom(9), content) + .custom_created_at(Timestamp::from(at)) + .tags(tags) + .sign_with_keys(signer) + .unwrap() + }; + let participation = make( + "participation", + &keys, + 42, + vec![ + Tag::parse(["h", "stream"]).unwrap(), + Tag::parse(["e", &root, "", "reply"]).unwrap(), + ], + ); + let external = make( + "reply", + &author, + 43, + vec![ + Tag::parse(["h", "stream"]).unwrap(), + Tag::parse(["e", &root, "", "reply"]).unwrap(), + ], + ); + let dm = make("dm", &author, 101, vec![Tag::parse(["h", "dm"]).unwrap()]); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let expected = [participation.clone(), external.clone(), dm.clone()]; + let server = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap(); + ws.send(Message::Text( + serde_json::json!(["AUTH", "unread-test"]) + .to_string() + .into(), + )) + .await + .unwrap(); + let mut originals = std::collections::HashMap::new(); + let mut attempts = std::collections::HashMap::::new(); + let mut completed = 0; + while let Some(Ok(Message::Text(text))) = ws.next().await { + let frame: serde_json::Value = serde_json::from_str(&text).unwrap(); + let id = frame[1].as_str().unwrap_or_default(); + match frame[0].as_str() { + Some("AUTH") => { + ws.send(Message::Text( + serde_json::json!(["OK", frame[1]["id"], true, ""]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + Some("REQ") => { + let channel = frame[2]["#h"][0].as_str().unwrap(); + let count = attempts.entry(channel.to_owned()).or_default(); + *count += 1; + if channel == "denied" { + ws.send(Message::Text( + serde_json::json!(["CLOSED", id, "restricted: denied"]) + .to_string() + .into(), + )) + .await + .unwrap(); + continue; + } + assert_eq!(frame[2]["limit"], 1000); + assert_eq!(frame[2]["since"], if channel == "dm" { 101 } else { 42 }); + assert_eq!( + frame[2]["kinds"], + if channel == "dm" { + serde_json::json!([9, 40002, 45001, 45003, KIND_HUDDLE_STARTED]) + } else { + serde_json::json!([9, 40002, 45001, 45003]) + } + ); + if *count == 1 { + originals.insert( + channel.to_owned(), + (frame.clone(), std::time::Instant::now()), + ); + if channel == "stream" { + ws.send(Message::Text( + serde_json::json!(["EVENT", id, participation]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + ws.send(Message::Text( + serde_json::json!([ + "CLOSED", + id, + "rate-limited: quota exceeded; retry in 1s" + ]) + .to_string() + .into(), + )) + .await + .unwrap(); + } else { + assert_eq!(*count, 2); + let (original, refused) = &originals[channel]; + assert_eq!(&frame, original, "retry must keep exact filter and id"); + assert!(refused.elapsed() >= Duration::from_secs(1)); + let events = if channel == "dm" { + vec![&dm] + } else { + vec![&participation, &external, &external] + }; + for event in events { + ws.send(Message::Text( + serde_json::json!(["EVENT", id, event]).to_string().into(), + )) + .await + .unwrap(); + } + ws.send(Message::Text( + serde_json::json!(["EOSE", id]).to_string().into(), + )) + .await + .unwrap(); + completed += 1; + } + } + Some("CLOSE") if completed == 2 => break, + _ => {} + } + } + assert_eq!(completed, 2); + assert_eq!(attempts["denied"], 1); + }); + let (session, _archive) = crate::native_relay_client::start(url, keys, None).await; + let request = UnreadCatchUpRequest { + self_pubkey: owner, + muted_channel_ids: HashSet::new(), + channels: vec![ + ("stream", "stream", 41), + ("dm", "dm", 100), + ("denied", "stream", 41), + ] + .into_iter() + .map(|(id, channel_type, read_at)| CatchUpChannel { + id: id.into(), + channel_type: channel_type.into(), + name: id.into(), + read_at: Some(read_at), + }) + .collect(), + }; + let (fetched, failures) = fetch_channels(std::sync::Arc::clone(&session), &request) + .await + .unwrap(); + assert_eq!(failures.len(), 1); + assert!( + matches!(&failures[0], ChannelResult::Error {channel_id, error} if channel_id == "denied" && error.contains("restricted:")) + ); + assert_eq!(fetched.iter().map(|f| f.events.len()).sum::(), 3); + let result = classify_batch(&request, fetched, &std::collections::HashMap::new()); + let ChannelResult::Success { + observed_events, + max_trigger, + discovered, + .. + } = &result[0] + else { + panic!("stream success") + }; + assert_eq!(observed_events.len(), 1); + assert_eq!(observed_events[0].id, expected[1].id.to_hex()); + assert!(observed_events[0].high_priority); + assert_eq!(*max_trigger, 43); + assert_eq!(discovered.participated, [root]); + let ChannelResult::Success { + observed_events, .. + } = &result[1] + else { + panic!("dm success") + }; + assert_eq!(observed_events.len(), 1); + assert_eq!(observed_events[0].id, expected[2].id.to_hex()); + assert!(observed_events[0].counts_toward_app_badge); + server.await.unwrap(); + session.shutdown(); +} diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index cd10e8704c7..170adca0b86 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -9,7 +9,10 @@ import type { AgentModelsResponse, ManagedAgent } from "@/shared/api/types"; import { getAgentModels, updateManagedAgent } from "@/shared/api/tauri"; import { switchManagedAgentModel } from "@/shared/api/agentControl"; import { awaitLiveSwitchOutcome } from "@/features/agents/lib/liveSwitchOutcome"; -import { subscribeControlResults } from "@/features/agents/observerRelayStore"; +import { + ensureRelayObserverSubscription, + subscribeControlResults, +} from "@/features/agents/observerRelayStore"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { useAgentConfigSurface, @@ -128,6 +131,7 @@ export function ModelPicker({ subscribe: (listener) => subscribeControlResults(agent.pubkey, listener), sendSwitches: async () => { + await ensureRelayObserverSubscription(); await Promise.all( channelIds.map((channelId) => switchManagedAgentModel( diff --git a/desktop/src/features/channels/observedUnreadNativeRig.mjs b/desktop/src/features/channels/observedUnreadNativeRig.mjs index cbe2476f2ef..0fab6aa4387 100644 --- a/desktop/src/features/channels/observedUnreadNativeRig.mjs +++ b/desktop/src/features/channels/observedUnreadNativeRig.mjs @@ -374,7 +374,7 @@ export function makeStubRelayClient() { return { fetchEvents: async () => [], fetchFirstEvent: async () => null, - subscribeLive: async () => async () => {}, + subscribeInteractive: async () => async () => {}, subscribeToReconnects: () => () => {}, publishEvent: async (event) => event, }; diff --git a/desktop/src/features/channels/readState/readStateManager.test.mjs b/desktop/src/features/channels/readState/readStateManager.test.mjs index c46654f32e6..f06dd821c8e 100644 --- a/desktop/src/features/channels/readState/readStateManager.test.mjs +++ b/desktop/src/features/channels/readState/readStateManager.test.mjs @@ -81,7 +81,7 @@ function makeFakeRelay() { return { fetchEvents: async () => [], publishEvent: async () => {}, - subscribeLive: () => () => {}, + subscribeInteractive: () => () => {}, }; } @@ -792,7 +792,7 @@ test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => { const fakeRelay = { fetchEvents: async () => [], publishEvent: async () => {}, - subscribeLive: () => () => {}, + subscribeInteractive: () => () => {}, }; const pubkey = "b".repeat(64); diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 87fc1ceaac8..5d455dedd3f 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -530,7 +530,8 @@ export class ReadStateManager { private async startLiveSubscription(): Promise { try { - const unsub = await this.relayClient.subscribeLive( + // Unread UI readiness depends on this subscription, not the cold backlog. + const unsub = await this.relayClient.subscribeInteractive( { kinds: [KIND_READ_STATE], authors: [this.pubkey], diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index db6155c3c47..c3c5ce984aa 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -25,7 +25,10 @@ import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll"; import { useStableArrayShallow } from "@/shared/hooks/useStableReference"; import { cancelManagedAgentTurn } from "@/shared/api/agentControl"; import { awaitCancelTurnOutcome } from "@/features/agents/lib/cancelTurnOutcome"; -import { subscribeControlResults } from "@/features/agents/observerRelayStore"; +import { + ensureRelayObserverSubscription, + subscribeControlResults, +} from "@/features/agents/observerRelayStore"; import type { Channel } from "@/shared/api/types"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; @@ -254,8 +257,14 @@ export function AgentSessionThreadPanel({ channelId: sessionChannelId, subscribe: (listener) => subscribeControlResults(agent.pubkey, listener), - sendCancel: () => - cancelManagedAgentTurn(agent.pubkey, sessionChannelId, requestId), + sendCancel: async () => { + await ensureRelayObserverSubscription(); + await cancelManagedAgentTurn( + agent.pubkey, + sessionChannelId, + requestId, + ); + }, scheduleTimeout: (onTimeout) => { const timeout = window.setTimeout(onTimeout, 8_000); return () => window.clearTimeout(timeout); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.transport.test.mjs b/desktop/src/features/channels/useLiveChannelUpdates.transport.test.mjs new file mode 100644 index 00000000000..da99884f3a4 --- /dev/null +++ b/desktop/src/features/channels/useLiveChannelUpdates.transport.test.mjs @@ -0,0 +1,302 @@ +// Real hook -> RelayClient -> mocked Tauri IPC. No native app or relay traffic. +import assert from "node:assert/strict"; +import { after, afterEach, beforeEach, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, +}); +const writes = []; +let sendHook = async () => {}; +window.__TAURI_INTERNALS__ = { + async invoke(command, args) { + if (command === "plugin:websocket|send") { + writes.push(JSON.parse(args.message.data)); + return sendHook(JSON.parse(args.message.data)); + } + if (command === "plugin:websocket|disconnect") return; + assert.fail(`unexpected IPC: ${command}`); + }, +}; +// Advance the production drain and readiness timers on the same deterministic clock. +const originalNow = Date.now; +let now = 1_000_000; +Date.now = () => now; +const timers = new Map(); +let timerId = 1; +window.setTimeout = (fn, ms, ...args) => { + const id = timerId++; + timers.set(id, { fn, args, at: now + ms }); + return id; +}; +window.clearTimeout = (id) => timers.delete(id); +const React = await import("react"); +const { act, cleanup, renderHook } = await import("@testing-library/react"); +const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" +); +const { relayClient } = await import("@/shared/api/relayClient"); +const { useLiveChannelUpdates } = await import("./useLiveChannelUpdates.ts"); +const clients = []; +const channels = (count) => + Array.from({ length: count }, (_, i) => ({ + id: `channel-${i}`, + name: `channel-${i}`, + channelType: "stream", + })); +const frames = (op) => writes.filter((f) => f[0] === op); +const flush = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); +}; +const deliver = (frame) => + relayClient.handleWsMessage( + { type: "Text", data: JSON.stringify(frame) }, + relayClient.connectionGeneration, + ); +beforeEach(() => { + now = 1_000_000; + writes.length = 0; + sendHook = async () => {}; + relayClient.wsId = 7; +}); +afterEach(async () => { + await act(async () => { + cleanup(); + await flush(); + }); + relayClient.disconnect(); + for (const client of clients.splice(0)) client.clear(); + // EOSE can flush a preceding EVENT before its already-scheduled batch tick. + await advance(50); + assert.equal(timers.size, 0, "no retired readiness or drain timers"); +}); +after(() => { + Date.now = originalNow; + dom.window.close(); +}); +async function advance(ms) { + await act(async () => { + const target = now + ms; + let fired = 0; + for (;;) { + const next = [...timers] + .filter(([, t]) => t.at <= target) + .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]; + if (!next) break; + assert.ok(++fired < 1000, "timer loop must make progress"); + now = next[1].at; + timers.delete(next[0]); + next[1].fn(...next[1].args); + await flush(); + } + now = target; + await flush(); + }); +} +async function mount(count, strict = false, onChannelMessage = () => {}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + clients.push(queryClient); + const wrapper = ({ children }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + let hook; + await act(async () => { + hook = renderHook( + ({ members }) => + useLiveChannelUpdates(members, null, { + currentPubkey: "a".repeat(64), + onChannelMessage, + }), + { + wrapper, + reactStrictMode: strict, + initialProps: { members: channels(count) }, + }, + ); + await flush(); + }); + return { + async members(members) { + await act(async () => { + hook.rerender({ members }); + await flush(); + }); + }, + async unmount() { + await act(async () => { + hook.unmount(); + await flush(); + }); + }, + async eose() { + await act(async () => { + for (const f of frames("REQ")) await deliver(["EOSE", f[1]]); + await flush(); + }); + }, + }; +} +for (const strict of [false, true]) { + test(`154 channels produce 154 actual REQs (StrictMode=${strict})`, async () => { + const h = await mount(154, strict); + try { + assert.equal(frames("REQ").length, 1, "cold setup must not burst"); + await advance(153 * 250); + assert.equal(frames("REQ").length, 154); + assert.equal(new Set(frames("REQ").map((f) => f[2]["#h"][0])).size, 154); + } finally { + await h.eose(); + } + }); +} +test("adding one pending channel retains 154 entries/filters and emits only one REQ", async () => { + const observed = []; + const h = await mount(154, false, (...args) => observed.push(args)); + const owned = [...relayClient.subscriptions]; + const initial = owned.map(([id, sub]) => [ + "REQ", + id, + structuredClone(sub.filter), + ]); + try { + await h.members(channels(155)); + for (const [id, sub] of owned) + assert.equal( + relayClient.subscriptions.get(id), + sub, + "retain queued and pending owners", + ); + assert.equal( + frames("REQ").length, + 1, + "membership addition cannot flush the queue", + ); + await advance(154 * 250); + assert.equal(frames("REQ").length, 155); + assert.deepEqual( + frames("REQ").slice(0, 154), + initial, + "retain original IDs and coverage floors", + ); + assert.equal(frames("CLOSE").length, 0); + const [first] = initial; + await act(async () => { + // H-less delivery still belongs to this subscription's channel. + await deliver([ + "EVENT", + first[1], + { + id: "event", + kind: 9, + pubkey: "b".repeat(64), + created_at: 1, + content: "hi", + tags: [], + sig: "", + }, + ]); + await deliver(["EOSE", first[1]]); + await flush(); + }); + assert.equal(observed.length, 1, "callback survives replacement effect"); + assert.equal(observed[0][0], first[2]["#h"][0]); + await h.eose(); + assert.equal( + frames("CLOSE").length, + 0, + "late readiness cannot retire retained entries", + ); + } finally { + await h.eose(); + } +}); +test("remove then re-add during readiness retires only the old entry immediately", async () => { + const h = await mount(2); + const [old] = frames("REQ"); + try { + await h.members(channels(2).slice(1)); + assert.ok( + frames("CLOSE").some((f) => f[1] === old[1]), + "CLOSE before readiness", + ); + await h.members(channels(2)); + await advance(500); + assert.equal(frames("REQ").length, 3); + await h.eose(); + assert.equal( + relayClient.subscriptions.size, + 2, + "late old setup does not remove replacement", + ); + await h.members(channels(2)); + assert.equal(frames("REQ").length, 3); + } finally { + await h.eose(); + } +}); +for (const stop of ["remove", "unmount", "workspace disconnect"]) { + test(`${stop} while connection is pending cannot dispatch retired REQs`, async () => { + const connection = Promise.withResolvers(); + relayClient.connectPromise = connection.promise; + const h = await mount(2); + assert.equal(frames("REQ").length, 0); + try { + if (stop === "remove") await h.members([]); + if (stop === "unmount") await h.unmount(); + if (stop === "workspace disconnect") { + relayClient.disconnect(); + relayClient.wsId = 8; + } + await act(async () => { + connection.resolve(relayClient.connectionGeneration); + await flush(); + }); + assert.equal(frames("REQ").length, 0); + } finally { + relayClient.connectPromise = null; + await h.eose(); + } + }); +} + +test("late initial send failure after real replay retains the hook entry and its original floor", async () => { + const first = Promise.withResolvers(); + let requests = 0; + sendHook = async (f) => { + if (f[0] === "REQ" && ++requests === 1) await first.promise; + }; + const h = await mount(1); + try { + const original = structuredClone(frames("REQ")[0]); + const entry = relayClient.subscriptions.get(original[1]); + await act(async () => { + relayClient.resetConnection(new Error("socket closed")); + window.clearTimeout(relayClient.reconnectTimeout); + relayClient.reconnectTimeout = null; + relayClient.wsId = 8; + await relayClient.replayLiveSubscriptions(); + first.reject(new Error("late IPC failure")); + await flush(); + }); + assert.equal(relayClient.subscriptions.get(original[1]), entry); + assert.equal(frames("CLOSE").length, 0); + assert.deepEqual(frames("REQ"), [original, original]); + await h.members(channels(2)); + assert.equal( + frames("REQ").length, + 3, + "unchanged channel remains owned after handoff", + ); + } finally { + first.resolve(); + await h.eose(); + } +}); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index b25df3acf92..477621b317a 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -115,6 +115,17 @@ function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { ); } +type LiveChannelEntry = { + controller: AbortController; + pending: Promise; + dispose?: () => Promise; +}; + +function retireLiveChannel(entry: LiveChannelEntry) { + entry.controller.abort(); + void entry.dispose?.().catch(() => {}); +} + const SEEN_NOTIFICATION_EVENT_LIMIT = 5_000; export function trackSeenEvent( @@ -361,7 +372,7 @@ export function useLiveChannelUpdates( }); }, [queryClient]); - const liveSubsRef = React.useRef(new Map Promise>()); + const liveSubsRef = React.useRef(new Map()); React.useEffect(() => { let isCancelled = false; @@ -372,10 +383,10 @@ export function useLiveChannelUpdates( const activeSubs = liveSubsRef.current; const targetIds = new Set(channelIdsKey ? channelIdsKey.split(",") : []); - for (const [channelId, dispose] of activeSubs) { + for (const [channelId, entry] of activeSubs) { if (!targetIds.has(channelId)) { activeSubs.delete(channelId); - void dispose().catch(() => {}); + retireLiveChannel(entry); } } @@ -385,37 +396,55 @@ export function useLiveChannelUpdates( dmSubscriptionStartedAtRef.current = Math.floor(Date.now() / 1000); } - let anyFailed = false; - const additions = Array.from(targetIds) - .filter((channelId) => !activeSubs.has(channelId)) - .map(async (channelId) => { - try { - const dispose = await relayClient.subscribeLive( - { - kinds: [...CHANNEL_EVENT_KINDS], - "#h": [channelId], - limit: 1000, - since: Math.floor(Date.now() / 1_000), - }, - (event) => - handleIncomingMessage(withChannelTagFallback(event, channelId)), - ); - if (isCancelled) { + const pending = Array.from(targetIds).map((channelId) => { + const existing = activeSubs.get(channelId); + if (existing) return existing.pending; + const entry: LiveChannelEntry = { + controller: new AbortController(), + pending: Promise.resolve(true), + }; + // Claim before the first await so a membership rerun retains this work. + activeSubs.set(channelId, entry); + entry.pending = relayClient + .subscribeLive( + { + kinds: [...CHANNEL_EVENT_KINDS], + "#h": [channelId], + limit: 1000, + since: Math.floor(Date.now() / 1_000), + }, + (event) => { + if (activeSubs.get(channelId) === entry) { + handleIncomingMessage(withChannelTagFallback(event, channelId)); + } + }, + undefined, + undefined, + entry.controller.signal, + ) + .then((dispose) => { + if (activeSubs.get(channelId) !== entry) { void dispose().catch(() => {}); - return; + } else { + entry.dispose = dispose; } - activeSubs.set(channelId, dispose); - } catch (err) { - anyFailed = true; + return true; + }) + .catch((err) => { + if (activeSubs.get(channelId) !== entry) return true; + activeSubs.delete(channelId); + if (err instanceof DOMException && err.name === "AbortError") + return true; console.error( "Failed to subscribe to live channel updates", channelId, err, ); - } - }); - await Promise.allSettled(additions); - return !anyFailed; + return false; + }); + return entry.pending; + }); + return (await Promise.all(pending)).every(Boolean); }; const runSync = async () => { @@ -450,8 +479,8 @@ export function useLiveChannelUpdates( return () => { channelsInvalidateRef.current?.cancel(); - for (const dispose of liveSubsRef.current.values()) { - void dispose().catch(() => {}); + for (const entry of liveSubsRef.current.values()) { + retireLiveChannel(entry); } liveSubsRef.current.clear(); }; diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 7b40d452ee5..a29a0e927dc 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -312,7 +312,7 @@ function InboxMessageDetailPane({ videoReviewMessages, ], ); - const { onScroll } = useAnchoredScroll({ + const { onScroll, settleAtBottomAfterLayout } = useAnchoredScroll({ channelId: conversationId, contentRef, isLoading: isThreadContextLoading, @@ -430,6 +430,8 @@ function InboxMessageDetailPane({ scrollContainerRef, composerWrapperRef, conversationId, + "padding", + settleAtBottomAfterLayout, ); if (!item) { diff --git a/desktop/src/features/home/ui/inboxReopenNavigation.test.mjs b/desktop/src/features/home/ui/inboxReopenNavigation.test.mjs index 533aaf7b8db..c88f448d805 100644 --- a/desktop/src/features/home/ui/inboxReopenNavigation.test.mjs +++ b/desktop/src/features/home/ui/inboxReopenNavigation.test.mjs @@ -136,6 +136,9 @@ Object.defineProperty(globalThis, "navigator", { writable: true, }); globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); +// JSDOM omits CSS.escape; this fixture uses only selector-safe hex event IDs. +// Composer layout settlement now exercises the real anchored-scroll lookup. +globalThis.CSS = { escape: (value) => value }; dom.window.matchMedia = () => ({ matches: false, addEventListener() {}, diff --git a/desktop/src/features/home/useInboxThreadContext.ts b/desktop/src/features/home/useInboxThreadContext.ts index 43839708082..7b09ef04381 100644 --- a/desktop/src/features/home/useInboxThreadContext.ts +++ b/desktop/src/features/home/useInboxThreadContext.ts @@ -57,7 +57,12 @@ export function useInboxThreadContext( ): InboxThreadContextResult { const [fetchedEvents, setFetchedEvents] = React.useState([]); const [hasLoadError, setHasLoadError] = React.useState(false); - const [isLoading, setIsLoading] = React.useState(false); + // Readiness belongs to the selection whose context actually settled. A + // boolean set in the effect reports false for the first render of a new + // selection, allowing the scroll owner to center before its rows arrive. + const [settledEvent, setSettledEvent] = React.useState( + null, + ); const selectedEvent = React.useMemo( () => (item ? relayEventFromFeedItem(item) : null), @@ -79,7 +84,7 @@ export function useInboxThreadContext( if (fullChannel || !selectedEvent || !selectedThreadRootId) { setFetchedEvents([]); setHasLoadError(false); - setIsLoading(false); + setSettledEvent(null); return () => { isCancelled = true; }; @@ -92,7 +97,7 @@ export function useInboxThreadContext( return; } - setIsLoading(true); + setSettledEvent(null); setHasLoadError(false); try { @@ -190,7 +195,7 @@ export function useInboxThreadContext( } } finally { if (!isCancelled) { - setIsLoading(false); + setSettledEvent(targetEvent); } } } @@ -371,7 +376,9 @@ export function useInboxThreadContext( hasLoadError: fullChannel ? options.hasChannelLoadError === true : hasLoadError, - isLoading: fullChannel ? options.isChannelLoading === true : isLoading, + isLoading: fullChannel + ? options.isChannelLoading === true + : selectedEvent !== null && settledEvent !== selectedEvent, structuralEvents, refreshStructuralEvents, reactionEvents, diff --git a/desktop/src/features/huddle/lib/useTtsSubscription.transport.test.mjs b/desktop/src/features/huddle/lib/useTtsSubscription.transport.test.mjs new file mode 100644 index 00000000000..3e5a44e44ce --- /dev/null +++ b/desktop/src/features/huddle/lib/useTtsSubscription.transport.test.mjs @@ -0,0 +1,360 @@ +// Real main-window TTS hook -> RelayClient -> fake Tauri IPC and clock. +// The companion's huddle channel is deliberately NOT the main visible channel. +import assert from "node:assert/strict"; +import { after, afterEach, beforeEach, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", + pretendToBeVisual: true, +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, +}); +const originalNow = Date.now; +const START = 1_000_000; +let now = START; +Date.now = () => now; +const timers = new Map(); +let timerId = 1; +window.setTimeout = (fn, ms, ...args) => { + const id = timerId++; + timers.set(id, { fn, args, at: now + ms }); + return id; +}; +window.setInterval = (fn, ms) => { + const id = timerId++; + timers.set(id, { fn, args: [], at: now + ms, interval: ms }); + return id; +}; +window.clearTimeout = window.clearInterval = (id) => timers.delete(id); +const writes = []; +const spoken = []; +const callbacks = new Map(); +let callbackId = 1; +window.__TAURI_INTERNALS__ = { + transformCallback(callback) { + const id = callbackId++; + callbacks.set(id, callback); + return id; + }, + unregisterCallback(id) { + callbacks.delete(id); + }, + async invoke(command, args) { + if (command === "plugin:websocket|send") { + writes.push({ frame: JSON.parse(args.message.data), at: now }); + return; + } + if (command === "plugin:websocket|disconnect") return; + if (command === "plugin:event|listen") return args.handler; + if (command === "plugin:event|unlisten") { + callbacks.delete(args.eventId); + return; + } + if (command === "get_huddle_agent_pubkeys") return ["agent"]; + if (command === "get_huddle_state") return { tts_enabled: true }; + if (command === "speak_agent_message") { + spoken.push(args); + return; + } + assert.fail(`unexpected IPC: ${command}`); + }, +}; +window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { + unregisterListener: (_event, id) => callbacks.delete(id), +}; +const { act, cleanup, renderHook } = await import("@testing-library/react"); +const { relayClient } = await import("@/shared/api/relayClient"); +const { resetRateLimitGate } = await import("@/shared/api/relayRateLimitGate"); +const { buildHuddleTtsLiveFilter } = await import( + "@/shared/api/relayChannelFilters" +); +const { useTtsSubscription } = await import("./useTtsSubscription.ts"); +const flush = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); +}; +const deliver = (frame) => + relayClient.handleWsMessage( + { type: "Text", data: JSON.stringify(frame) }, + relayClient.connectionGeneration, + ); +const requests = () => writes.filter(({ frame }) => frame[0] === "REQ"); +const ttsRequests = () => + requests().filter(({ frame }) => frame[2]["#h"]?.[0] === "huddle"); +async function advance(ms) { + await act(async () => { + const target = now + ms; + let fired = 0; + for (;;) { + const next = [...timers] + .filter(([, t]) => t.at <= target) + .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]; + if (!next) break; + assert.ok(++fired < 1000, "timer loop must make progress"); + const [id, t] = next; + now = t.at; + if (t.interval) t.at += t.interval; + else timers.delete(id); + t.fn(...t.args); + await flush(); + } + now = target; + await flush(); + }); +} +beforeEach(async () => { + now = START; + writes.length = 0; + spoken.length = 0; + resetRateLimitGate(); + relayClient.wsId = 7; + relayClient.setVisibleChannelId("main-timeline"); + for (let i = 0; i < 296; i++) { + void relayClient + .subscribeLive( + { kinds: [9, 5, 7], "#h": [`cold-${i}`], since: 123, limit: 1000 }, + () => {}, + ) + .catch(() => {}); + } + await flush(); + await advance(1000); +}); +afterEach(async () => { + await act(async () => { + cleanup(); + await flush(); + }); + relayClient.disconnect(); + resetRateLimitGate(); + await advance(50); + assert.equal( + timers.size, + 0, + "release readiness, drain and membership timers", + ); + assert.equal(callbacks.size, 0, "release native listeners"); +}); +after(() => { + Date.now = originalNow; + dom.window.close(); +}); +async function mountTts() { + const self = { current: "human" }; + let hook; + await act(async () => { + hook = renderHook(() => useTtsSubscription("huddle", self)); + await flush(); + }); + return hook; +} + +for (const cooldown of [false, true]) { + test(`companion TTS outranks 296 cold subscriptions without changing main visibility (cooldown=${cooldown})`, async () => { + if (cooldown) + await deliver(["NOTICE", "rate-limited: quota exceeded; retry in 4s"]); + const count = requests().length; + await mountTts(); + const expectedFilter = buildHuddleTtsLiveFilter("huddle", 996); + await advance(cooldown ? 3999 : 249); + assert.equal( + requests().length, + count, + "priority must not bypass pacing or cooldown", + ); + await advance(1); + assert.equal( + ttsRequests().length, + 1, + "active speech must take the next permitted slot", + ); + const { frame, at } = ttsRequests()[0]; + assert.equal(at, START + (cooldown ? 5000 : 1250)); + assert.deepEqual( + frame[2], + expectedFilter, + "retain bounded replay, not limit:0", + ); + assert.equal( + relayClient.visibleChannelId, + "main-timeline", + "do not borrow timeline visibility", + ); + assert.ok(requests().length < 296, "cold backlog still exists"); + const event = { + id: "reply", + kind: 9, + pubkey: "agent", + created_at: 1001, + tags: [["h", "huddle"]], + content: "Hello from the huddle", + sig: "", + }; + await act(async () => { + await deliver(["EVENT", frame[1], event]); + await deliver(["EOSE", frame[1]]); + await flush(); + }); + assert.deepEqual( + spoken.map(({ text }) => text), + [event.content], + ); + await advance(250); + assert.ok( + requests().at(-1).frame[2]["#h"][0].startsWith("cold-"), + "background drain resumes", + ); + // A quota-refused active subscription keeps the same priority and filter. + await deliver([ + "CLOSED", + frame[1], + "rate-limited: quota exceeded; retry in 4s", + ]); + const beforeRetry = requests().length; + await advance(3999); + assert.equal(requests().length, beforeRetry); + await advance(1); + assert.deepEqual( + ttsRequests()[1]?.frame, + frame, + "active retry takes next permitted slot", + ); + await act(async () => { + await deliver(["EVENT", frame[1], event]); + await deliver(["EOSE", frame[1]]); + await flush(); + }); + assert.equal( + spoken.length, + 1, + "stored/live or retry overlap is not spoken twice", + ); + for (let i = 1; i < requests().length; i++) + assert.ok(requests()[i].at - requests()[i - 1].at >= 250); + }); +} + +// Exercise the session's reconnect entry point; only replacement socket +// establishment is fake. Hook registration, reset, replay and delivery are real. +for (const visibleChannel of [null, "cold-295"]) { + test(`companion TTS retains priority across reconnect (visible=${visibleChannel})`, async () => { + await mountTts(); + await advance(250); + const original = ttsRequests()[0].frame; + const event = { + id: "before-reconnect", + kind: 9, + pubkey: "agent", + created_at: 1001, + tags: [["h", "huddle"]], + content: "Already spoken", + sig: "", + }; + await act(async () => { + await deliver(["EVENT", original[1], event]); + await deliver(["EOSE", original[1]]); + await flush(); + }); + assert.equal(spoken.length, 1); + relayClient.setVisibleChannelId(visibleChannel); + relayClient.resetConnection(new Error("socket lost")); + window.clearTimeout(relayClient.reconnectTimeout); + relayClient.reconnectTimeout = null; + relayClient.wsId = 8; + writes.length = 0; + await deliver(["NOTICE", "rate-limited: quota exceeded; retry in 4s"]); + const replay = relayClient.replayLiveSubscriptions(); + await advance(3999); + assert.equal(requests().length, 0, "priority cannot bypass cooldown"); + await advance(1); + assert.equal(requests().length, 8, "retain the reconnect batch cap"); + const firstChannels = requests().map(({ frame }) => frame[2]["#h"][0]); + assert.deepEqual( + firstChannels.slice(0, visibleChannel ? 2 : 1), + visibleChannel ? [visibleChannel, "huddle"] : ["huddle"], + "visible and interactive tie in registration order ahead of cold work", + ); + assert.deepEqual( + ttsRequests()[0].frame, + original, + "retain replay filter and owner", + ); + assert.equal(relayClient.visibleChannelId, visibleChannel); + const replayStart = now; + await act(async () => { + await deliver(["EVENT", original[1], event]); + await deliver([ + "EVENT", + original[1], + { ...event, id: "after-reconnect", content: "New speech" }, + ]); + await deliver(["EOSE", original[1]]); + await flush(); + }); + assert.deepEqual( + spoken.map(({ text }) => text), + ["Already spoken", "New speech"], + ); + await advance(49); + assert.equal(requests().length, 8, "retain inter-batch delay"); + await advance(1); + assert.equal(requests().length, 16); + await deliver(["NOTICE", "rate-limited: quota exceeded; retry in 4s"]); + await advance(3999); + assert.equal(requests().length, 16, "recheck cooldown between batches"); + await advance(1); + assert.equal(requests().length, 24); + await advance(2000); + await replay; + relayClient.reconnectWaiters.settle(); + await flush(); + assert.equal( + requests().length, + 297, + "all background owners recover exactly once", + ); + assert.equal(new Set(requests().map(({ frame }) => frame[1])).size, 297); + for (let i = 8; i < requests().length; i += 8) + assert.ok(requests()[i].at - requests()[i - 1].at >= 50); + assert.equal(requests()[0].at, replayStart); + await advance(1000); + assert.equal( + requests().length, + 297, + "retired startup drain cannot duplicate replay", + ); + }); +} + +test("unmounting the companion TTS hook during reconnect cooldown cancels its owner", async () => { + const hook = await mountTts(); + await advance(250); + const id = ttsRequests()[0].frame[1]; + relayClient.resetConnection(new Error("socket lost")); + window.clearTimeout(relayClient.reconnectTimeout); + relayClient.reconnectTimeout = null; + relayClient.wsId = 8; + writes.length = 0; + await deliver(["NOTICE", "rate-limited: quota exceeded; retry in 4s"]); + const replay = relayClient.replayLiveSubscriptions(); + await act(async () => { + hook.unmount(); + await flush(); + }); + assert.equal(relayClient.subscriptions.has(id), false); + await advance(6000); + await replay; + relayClient.reconnectWaiters.settle(); + await flush(); + assert.equal( + ttsRequests().length, + 0, + "reconnect must not resurrect a departed huddle", + ); + assert.equal(requests().length, 296); +}); diff --git a/desktop/src/features/huddle/lib/useTtsSubscription.ts b/desktop/src/features/huddle/lib/useTtsSubscription.ts index 1744cd7c1bc..5b3a844698f 100644 --- a/desktop/src/features/huddle/lib/useTtsSubscription.ts +++ b/desktop/src/features/huddle/lib/useTtsSubscription.ts @@ -285,7 +285,7 @@ export function useTtsSubscription( const seenOrder: string[] = []; const MAX_SEEN_EVENTS = 5000; relayClient - .subscribeLive( + .subscribeInteractive( buildHuddleTtsLiveFilter(ephemeralChannelId, replaySince), (event) => { if (disposed) return; diff --git a/desktop/src/shared/api/observerRelay.test.mjs b/desktop/src/shared/api/observerRelay.test.mjs index bfd1554b797..0bf716b7709 100644 --- a/desktop/src/shared/api/observerRelay.test.mjs +++ b/desktop/src/shared/api/observerRelay.test.mjs @@ -10,7 +10,7 @@ import { subscribeToAgentObserverFrames } from "./observerRelay.ts"; // network drop never re-delivers and the active-agents badge never appears. test("subscribeToAgentObserverFrames requests a replay-capable limit with a since window", () => { const calls = []; - mock.method(relayClient, "subscribeLive", (filter) => { + mock.method(relayClient, "subscribeInteractive", (filter) => { calls.push(filter); return () => {}; }); @@ -44,7 +44,7 @@ test("subscribeToAgentObserverFrames since is at least 300s before now", () => { mock.method(Date, "now", () => FIXED_NOW_MS); const calls = []; - mock.method(relayClient, "subscribeLive", (filter) => { + mock.method(relayClient, "subscribeInteractive", (filter) => { calls.push(filter); return () => {}; }); diff --git a/desktop/src/shared/api/observerRelay.ts b/desktop/src/shared/api/observerRelay.ts index 3b41f4de411..c96bd036da4 100644 --- a/desktop/src/shared/api/observerRelay.ts +++ b/desktop/src/shared/api/observerRelay.ts @@ -15,7 +15,8 @@ export function subscribeToAgentObserverFrames( ownerPubkey: string, onEvent: (event: RelayEvent) => void, ) { - return relayClient.subscribeLive( + // Ephemeral control results must not wait behind cold channel history. + return relayClient.subscribeInteractive( { kinds: [KIND_AGENT_OBSERVER_FRAME], "#p": [ownerPubkey], diff --git a/desktop/src/shared/api/relayClientBurstDrain.test.mjs b/desktop/src/shared/api/relayClientBurstDrain.test.mjs new file mode 100644 index 00000000000..614a609ab7b --- /dev/null +++ b/desktop/src/shared/api/relayClientBurstDrain.test.mjs @@ -0,0 +1,539 @@ +// Exercise the production session from live registration/refusal through actual IPC dispatch. +// The IPC transport and clock are fake; no native app or external relay is used. +import assert from "node:assert/strict"; +import { after, beforeEach, afterEach, test } from "node:test"; + +const originalNow = Date.now; +const originalWindow = globalThis.window; +let now = 0; +let nextTimerId = 1; +const timers = new Map(); +const writes = []; +const clients = []; +let onSend; +globalThis.window = { + setTimeout(fn, ms) { + const id = nextTimerId++; + timers.set(id, { fn, at: now + ms }); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + __TAURI_INTERNALS__: { + async invoke(command, args) { + if (command === "plugin:websocket|send") { + writes.push({ + socket: args.id, + frame: JSON.parse(args.message.data), + at: now, + }); + await onSend?.(writes.at(-1)); + return; + } + if (command === "plugin:websocket|disconnect") return; + assert.fail(`unexpected IPC: ${command}`); + }, + }, +}; +Date.now = () => now; +const { RelayClient } = await import("./relayClientSession.ts"); +const { resetRateLimitGate } = await import("./relayRateLimitGate.ts"); + +const { openPresenceSubscription } = await import( + "./presenceRelaySubscription.ts" +); + +beforeEach(() => { + resetRateLimitGate(); + now = 0; + timers.clear(); + writes.length = 0; + onSend = undefined; +}); +afterEach(() => { + for (const client of clients.splice(0)) client.disconnect(); + resetRateLimitGate(); + assert.equal( + timers.size, + 0, + "subscriptions and gate must release their timers", + ); +}); +after(() => { + Date.now = originalNow; + globalThis.window = originalWindow; +}); + +async function flush() { + for (let i = 0; i < 20; i++) await Promise.resolve(); +} +async function tickTo(target) { + assert.ok(target >= now); + let fired = 0; + for (;;) { + const next = [...timers] + .filter(([, timer]) => timer.at <= target) + .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]; + if (!next) break; + assert.ok(++fired < 1000, "timer loop must make progress"); + now = next[1].at; + timers.delete(next[0]); + next[1].fn(); + await flush(); + } + now = target; + await flush(); +} +function deliver(client, frame) { + return client.handleWsMessage( + { type: "Text", data: JSON.stringify(frame) }, + client.connectionGeneration, + ); +} +function frames(op) { + return writes.filter((write) => write.frame[0] === op); +} + +function session() { + const client = new RelayClient(); + client.wsId = 7; + clients.push(client); + return client; +} +function start(client, count) { + return Array.from({ length: count }, (_, i) => { + const filter = { + kinds: [9, 5, 7], + "#h": [`channel-${i}`], + since: 123, + limit: 1000, + }; + const controller = new AbortController(); + const ready = []; + const promise = client.subscribeLive( + filter, + () => {}, + (r) => ready.push(r), + 250, + controller.signal, + ); + void promise.catch(() => {}); + return { filter, controller, ready, promise }; + }); +} + +test("cold setup drains at most one live REQ per 250ms, prioritizes visible channel and preserves every filter", async () => { + const client = session(); + const entries = start(client, 296); + await flush(); + assert.equal(frames("REQ").length, 1, "startup must not dump 296 REQs"); + assert.deepEqual( + entries[295].ready, + [], + "queued setup is not yet a readiness timeout", + ); + client.setVisibleChannelId("channel-295"); + await tickTo(250); + assert.deepEqual(frames("REQ")[1].frame[2], entries[295].filter); + await tickTo(73750); + assert.equal(frames("REQ").length, 296); + const requests = frames("REQ"); + for (let i = 1; i < requests.length; i++) + assert.ok(requests[i].at - requests[i - 1].at >= 250); + assert.deepEqual( + new Set(requests.map((r) => JSON.stringify(r.frame[2]))), + new Set(entries.map((e) => JSON.stringify(e.filter))), + ); + for (const { frame } of requests) await deliver(client, ["EOSE", frame[1]]); + await Promise.all(entries.map((e) => e.promise)); +}); + +test("observer control-result subscription takes the next paced slot ahead of cold channels", async () => { + const { relayClient: client } = await import("./relayClient.ts"); + const { subscribeToAgentObserverFrames } = await import("./observerRelay.ts"); + client.wsId = 7; + clients.push(client); + start(client, 296); + await flush(); + const received = []; + const pending = subscribeToAgentObserverFrames("owner", (event) => + received.push(event), + ); + void pending.catch(() => {}); + await tickTo(249); + assert.equal(frames("REQ").length, 1, "observer priority must retain pacing"); + await tickTo(250); + const request = frames("REQ")[1].frame; + assert.deepEqual(request[2], { + kinds: [24200], + "#p": ["owner"], + limit: 1000, + since: -300, + }); + const result = { id: "result", kind: 24200, tags: [["p", "owner"]] }; + await deliver(client, ["EVENT", request[1], result]); + await deliver(client, ["EOSE", request[1]]); + const dispose = await pending; + assert.deepEqual( + received, + [result], + "ephemeral control results reach the admitted consumer", + ); + await dispose(); + await tickTo(266); // Flush the session's existing event batch timer. +}); + +test("read-state initialization takes the next paced slot ahead of cold channels", async () => { + const { ReadStateManager } = await import( + "../../features/channels/readState/readStateManager.ts" + ); + const { KIND_READ_STATE } = await import("../constants/kinds.ts"); + const previousDocument = globalThis.document; + const previousStorage = globalThis.localStorage; + const store = new Map(); + const events = new EventTarget(); + Object.assign(window, { + localStorage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + }, + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + }); + globalThis.localStorage = window.localStorage; + globalThis.document = new EventTarget(); + const client = session(); + const background = start(client, 296); + await flush(); + onSend = async ({ frame }) => { + if (frame[0] === "REQ") await deliver(client, ["EOSE", frame[1]]); + }; + const pubkey = "a".repeat(64); + const manager = new ReadStateManager(pubkey, client); + let ready = false; + const initialized = manager.initialize().then(() => { + ready = true; + }); + try { + await tickTo(249); + assert.equal( + ready, + false, + "initialization must retain its live readiness gate", + ); + assert.equal( + frames("REQ").filter(({ frame }) => frame[1].startsWith("live-")).length, + 1, + ); + await tickTo(250); + assert.equal( + ready, + true, + "unread UI must not wait behind 295 cold channels", + ); + await initialized; + const live = frames("REQ").filter(({ frame }) => + frame[1].startsWith("live-"), + ); + assert.equal(live.length, 2); + assert.equal( + live[1].at, + 250, + "read-state must retain ordinary request pacing", + ); + assert.deepEqual(live[1].frame[2], { + kinds: [KIND_READ_STATE], + authors: [pubkey], + "#t": ["read-state"], + limit: 500, + }); + } finally { + manager.destroy(); + for (const entry of background) entry.controller.abort(); + client.disconnect(); + await initialized; + globalThis.document = previousDocument; + globalThis.localStorage = previousStorage; + delete window.localStorage; + delete window.addEventListener; + delete window.removeEventListener; + } +}); + +test("125 refused live subscriptions cannot stampede beside a publish when cooldown releases", async () => { + const client = session(); + const entries = start(client, 125); + await flush(); + await tickTo(31250); + const initial = frames("REQ"); + assert.equal(initial.length, 125); + for (const { frame } of initial) await deliver(client, ["EOSE", frame[1]]); + await Promise.all(entries.map((e) => e.promise)); + for (const { frame } of initial) + await deliver(client, [ + "CLOSED", + frame[1], + "rate-limited: quota exceeded; retry in 4s", + ]); + const event = { id: "a".repeat(64), kind: 9 }; + const published = client.publishEvent(event, "timeout", "send failed"); + void published.catch(() => {}); + await tickTo(35250); + assert.ok( + frames("REQ").length <= 126, + "only one retry may join Send at gate release", + ); + assert.equal( + frames("EVENT").length, + 1, + "Send must not await the live backlog", + ); + await deliver(client, ["OK", event.id, true, ""]); + assert.equal(await published, event); + await tickTo(66250); + assert.equal(frames("REQ").length, 250); + const retries = frames("REQ").slice(125); + assert.deepEqual( + retries.map((r) => r.frame.slice(1)), + initial.map((r) => r.frame.slice(1)), + ); + for (let i = 1; i < retries.length; i++) + assert.ok(retries[i].at - retries[i - 1].at >= 250); +}); + +test("presence waits beyond its 5s budget behind the gate, then EOSE before IPC settles succeeds", async () => { + const client = session(); + const background = start(client, 32); + await flush(); + await deliver(client, [ + "NOTICE", + "rate-limited: quota exceeded; retry in 6s", + ]); + let outcome = "pending"; + const presence = openPresenceSubscription( + ["a".repeat(64)], + () => {}, + client.subscribeLive.bind(client), + ); + presence.then( + () => { + outcome = "ready"; + }, + () => { + outcome = "failed"; + }, + ); + await flush(); + await tickTo(5999); + assert.equal(outcome, "pending"); + const presenceEntry = [...client.subscriptions].find( + ([, sub]) => sub.filter.limit === 0, + ); + assert.ok(presenceEntry, "presence remains owned while queued"); + assert.equal(frames("REQ").length, 1); + const sent = Promise.withResolvers(); + onSend = async ({ frame }) => { + if (frame[0] !== "REQ" || frame[1] !== presenceEntry[0]) return; + await deliver(client, ["EOSE", frame[1]]); + await sent.promise; + }; + try { + await tickTo(6000); + assert.equal( + frames("REQ")[1].frame[1], + presenceEntry[0], + "limit:0 outranks the background backlog", + ); + await tickTo(12000); + assert.equal(outcome, "pending", "setup still awaits transport completion"); + sent.resolve(); + await flush(); + const dispose = await presence; + assert.equal( + outcome, + "ready", + "queued time must not consume presence readiness", + ); + assert.ok(client.subscriptions.has(presenceEntry[0])); + await dispose(); + } finally { + sent.resolve(); + for (const entry of background) entry.controller.abort(); + } +}); + +test("a later gate extension pauses an active drain without changing filters or counting another retry", async () => { + const client = session(); + const entries = start(client, 4); + await flush(); + await tickTo(750); + const initial = frames("REQ"); + for (const { frame } of initial) await deliver(client, ["EOSE", frame[1]]); + await Promise.all(entries.map((e) => e.promise)); + for (const { frame } of initial) + await deliver(client, ["CLOSED", frame[1], "error: temporary failure"]); + await tickTo(1750); + assert.equal(frames("REQ").length, 5); + await tickTo(1800); + await deliver(client, [ + "NOTICE", + "rate-limited: quota exceeded; retry in 4s", + ]); + await tickTo(3000); + await deliver(client, [ + "NOTICE", + "rate-limited: quota exceeded; retry in 4s", + ]); + await tickTo(6999); + assert.equal(frames("REQ").length, 5); + for (const { frame } of initial) + assert.equal(client.subscriptions.get(frame[1]).closedRetryAttempt, 1); + await tickTo(7500); + assert.deepEqual( + frames("REQ") + .slice(4) + .map((r) => r.at), + [1750, 7000, 7250, 7500], + ); + assert.deepEqual( + frames("REQ") + .slice(4) + .map((r) => r.frame), + initial.map((r) => r.frame), + ); +}); + +for (const stop of ["abort", "terminal CLOSED", "workspace switch"]) { + test(`${stop} retires queued cold setup without a later REQ`, async () => { + const client = session(); + const entries = start(client, 2); + await flush(); + const [id] = [...client.subscriptions].find( + ([, sub]) => sub.filter["#h"][0] === "channel-1", + ); + if (stop === "abort") entries[1].controller.abort(); + if (stop === "terminal CLOSED") + await deliver(client, ["CLOSED", id, "restricted: denied"]); + if (stop === "workspace switch") { + client.disconnect(); + client.wsId = 8; + } + const result = await Promise.allSettled([entries[1].promise]); + assert.equal( + result[0].status, + stop === "terminal CLOSED" ? "fulfilled" : "rejected", + ); + assert.ok(!client.subscriptions.has(id)); + await tickTo(60000); + assert.equal(frames("REQ").length, 1); + assert.ok(!writes.some((w) => w.socket === 8)); + }); +} + +for (const stop of [ + "dispose", + "EOSE", + "EVENT", + "terminal CLOSED", + "workspace switch", +]) { + test(`${stop} cancels a retry already queued in the drain`, async () => { + const client = session(); + const entries = start(client, 2); + await flush(); + await tickTo(250); + const initial = frames("REQ"); + for (const { frame } of initial) await deliver(client, ["EOSE", frame[1]]); + const disposers = await Promise.all(entries.map((e) => e.promise)); + for (const { frame } of initial) + await deliver(client, ["CLOSED", frame[1], "error: temporary failure"]); + await tickTo(1250); + assert.equal(frames("REQ").length, 3); + const id = initial[1].frame[1]; + if (stop === "dispose") await disposers[1](); + if (stop === "EOSE") await deliver(client, ["EOSE", id]); + if (stop === "EVENT") + await deliver(client, [ + "EVENT", + id, + { id: "recovered", kind: 9, created_at: 123 }, + ]); + if (stop === "terminal CLOSED") + await deliver(client, ["CLOSED", id, "restricted: denied"]); + if (stop === "workspace switch") { + client.disconnect(); + client.wsId = 8; + } + await tickTo(60000); + assert.equal(frames("REQ").length, 3); + assert.ok(!writes.some((w) => w.socket === 8)); + }); +} + +for (const stage of ["setup", "CLOSED retry"]) { + test(`reconnect takes over queued ${stage} without a second drain dispatch`, async () => { + const client = session(); + const entries = start(client, 3); + await flush(); + if (stage === "CLOSED retry") { + await tickTo(500); + for (const { frame } of frames("REQ")) + await deliver(client, ["EOSE", frame[1]]); + await Promise.all(entries.map((e) => e.promise)); + for (const { frame } of frames("REQ")) + await deliver(client, ["CLOSED", frame[1], "error: temporary failure"]); + await tickTo(1500); + assert.equal(frames("REQ").length, 4); + } + const originalIds = [...client.subscriptions.keys()]; + client.resetConnection(new Error("socket lost")); + // Only connection establishment is fake; ownership/reset and replay are production. + window.clearTimeout(client.reconnectTimeout); + client.reconnectTimeout = null; + entries[2].controller.abort(); + client.wsId = 8; + await client.replayLiveSubscriptions(); + client.reconnectWaiters.settle(); + await flush(); + await tickTo(now + 60000); + const replay = frames("REQ").filter((w) => w.socket === 8); + assert.deepEqual( + replay.map((w) => w.frame[1]), + originalIds.slice(0, 2), + ); + assert.deepEqual( + replay.map((w) => w.frame[2]), + entries.slice(0, 2).map((e) => e.filter), + ); + await Promise.allSettled(entries.map((e) => e.promise)); + }); +} + +test("finite history and its CLOSED retry bypass a live backlog without consuming queued response budget", async () => { + const client = session(); + start(client, 296); + await flush(); + const filter = { kinds: [7], "#e": ["message"], limit: 1000 }; + const history = client.fetchEvents(filter); + void history.catch(() => {}); + await flush(); + const initial = frames("REQ").find((w) => w.frame[1].startsWith("history-")); + assert.ok(initial); + assert.equal(initial.at, 0); + await deliver(client, [ + "CLOSED", + initial.frame[1], + "rate-limited: quota exceeded; retry in 4s", + ]); + await tickTo(4000); + const retry = frames("REQ").filter((w) => + w.frame[1].startsWith("history-"), + )[1]; + assert.ok(retry, "history retry is not queued behind 295 live entries"); + assert.equal(retry.at, 4000); + assert.deepEqual(retry.frame[2], filter); + await deliver(client, ["EOSE", retry.frame[1]]); + assert.deepEqual(await history, []); +}); diff --git a/desktop/src/shared/api/relayClientCooldownRetry.test.mjs b/desktop/src/shared/api/relayClientCooldownRetry.test.mjs new file mode 100644 index 00000000000..b61484f0ca3 --- /dev/null +++ b/desktop/src/shared/api/relayClientCooldownRetry.test.mjs @@ -0,0 +1,496 @@ +// Exercise real subscribeLive -> inbound CLOSED -> retry timer -> Tauri send. +// The IPC transport and clock are fake; no native app or external relay is used. +import assert from "node:assert/strict"; +import { after, beforeEach, afterEach, test } from "node:test"; + +const originalNow = Date.now; +const originalWindow = globalThis.window; +let now = 0; +let nextTimerId = 1; +const timers = new Map(); +const writes = []; +const clients = []; +let onSend; +globalThis.window = { + setTimeout(fn, ms) { + const id = nextTimerId++; + timers.set(id, { fn, at: now + ms }); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + __TAURI_INTERNALS__: { + async invoke(command, args) { + if (command === "plugin:websocket|send") { + writes.push({ + socket: args.id, + frame: JSON.parse(args.message.data), + at: now, + }); + await onSend?.(writes.at(-1)); + return; + } + if (command === "plugin:websocket|disconnect") return; + assert.fail(`unexpected IPC: ${command}`); + }, + }, +}; +Date.now = () => now; +const { RelayClient } = await import("./relayClientSession.ts"); +const { resetRateLimitGate, isRateLimited } = await import( + "./relayRateLimitGate.ts" +); + +beforeEach(() => { + resetRateLimitGate(); + now = 0; + timers.clear(); + writes.length = 0; + onSend = undefined; +}); +afterEach(() => { + for (const client of clients.splice(0)) client.disconnect(); + resetRateLimitGate(); + assert.equal( + timers.size, + 0, + "subscriptions and gate must release their timers", + ); +}); +after(() => { + Date.now = originalNow; + globalThis.window = originalWindow; +}); + +async function flush() { + for (let i = 0; i < 20; i++) await Promise.resolve(); +} +async function tickTo(target) { + assert.ok(target >= now); + let fired = 0; + for (;;) { + const next = [...timers] + .filter(([, timer]) => timer.at <= target) + .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]; + if (!next) break; + assert.ok(++fired < 1000, "timer loop must make progress"); + now = next[1].at; + timers.delete(next[0]); + next[1].fn(); + await flush(); + } + now = target; + await flush(); +} +function deliver(client, frame) { + return client.handleWsMessage( + { type: "Text", data: JSON.stringify(frame) }, + client.connectionGeneration, + ); +} +function frames(op) { + return writes.filter((write) => write.frame[0] === op); +} +async function liveChannels(count = 1) { + const client = new RelayClient(); + client.wsId = 7; // Already authenticated; subscribe and dispatch remain real. + clients.push(client); + const filters = Array.from({ length: count }, (_, i) => ({ + kinds: [9, 5, 7], + "#h": [`channel-${i}`], + since: 123, + limit: 1000, + })); + const ready = filters.map((filter) => client.subscribeLive(filter, () => {})); + for (const pending of ready) void pending.catch(() => {}); + await flush(); + await tickTo(now + (count - 1) * 250); + const initial = frames("REQ").slice(-count); + assert.equal(initial.length, count); + for (const { frame } of initial) await deliver(client, ["EOSE", frame[1]]); + const disposers = await Promise.all(ready); + return { + client, + filters, + ids: initial.map(({ frame }) => frame[1]), + disposers, + }; +} +async function refuse(client, id) { + await deliver(client, [ + "CLOSED", + id, + "rate-limited: quota exceeded; retry in 4s", + ]); +} +async function extend(client, seconds) { + await deliver(client, [ + "NOTICE", + `rate-limited: quota exceeded; retry in ${seconds}s`, + ]); +} + +test("154 real live retries and a publish respect a repeatedly extended cooldown", async () => { + const { client, ids, filters } = await liveChannels(154); + const base = now; + for (const id of ids) await refuse(client, id); + const event = { id: "a".repeat(64), kind: 9 }; + const published = client.publishEvent(event, "timeout", "send failed"); + // Cleanup disconnects on an assertion failure; don't leak its rejection. + void published.catch(() => {}); + await tickTo(base + 1000); + await extend(client, 10); // Original retries wake at 4s; gate now ends at 11s. + await tickTo(base + 4000); + assert.equal(frames("REQ").length, 154, "no retry through an extended gate"); + assert.equal(frames("EVENT").length, 0); + assert.equal( + client.pendingEvents.size, + 0, + "publish budget starts after gate", + ); + for (const id of ids) { + assert.equal(client.subscriptions.get(id).closedRetryAttempt, 1); + } + await tickTo(base + 5000); + await extend(client, 10); // Extend again while the replacement timers sleep. + await tickTo(base + 11000); + assert.equal(frames("REQ").length, 154); + assert.equal(frames("EVENT").length, 0); + await tickTo(base + 14999); + assert.equal(frames("REQ").length, 154); + await tickTo(base + 15000); + assert.equal(isRateLimited(), false); + assert.equal( + frames("REQ").length, + 155, + "only one live subscription retries alongside Send", + ); + assert.equal(frames("EVENT").length, 1); + await deliver(client, ["OK", event.id, true, ""]); + assert.equal(await published, event); + await tickTo(base + 15000 + 153 * 250); + assert.equal( + frames("REQ").length, + 308, + "each live subscription retries once", + ); + assert.deepEqual( + frames("REQ") + .slice(154) + .map(({ frame }) => frame.slice(1)), + ids.map((id, i) => [id, filters[i]]), + "retry preserves coverage/filter and ID", + ); + assert.ok( + frames("REQ") + .slice(154) + .every(({ at }, i) => at === base + 15000 + i * 250), + ); + for (const id of ids) await deliver(client, ["EOSE", id]); + await tickTo(base + 60000); + assert.equal(frames("REQ").length, 308, "EOSE leaves no extra retry"); +}); + +for (const stop of ["dispose", "EOSE", "terminal CLOSED", "workspace switch"]) { + test(`${stop} cancels a live retry re-armed behind the cooldown`, async () => { + const { + client, + ids: [id], + disposers: [dispose], + } = await liveChannels(); + await refuse(client, id); + await tickTo(1000); + await extend(client, 10); + await tickTo(4000); + assert.equal(frames("REQ").length, 1, "retry must still be pending"); + if (stop === "dispose") await dispose(); + if (stop === "EOSE") await deliver(client, ["EOSE", id]); + if (stop === "terminal CLOSED") + await deliver(client, ["CLOSED", id, "restricted: denied"]); + if (stop === "workspace switch") { + client.disconnect(); + resetRateLimitGate(); + client.wsId = 8; + } + await tickTo(60000); + assert.equal(frames("REQ").length, 1, "retired retry must not send"); + assert.ok(!writes.some(({ socket }) => socket === 8)); + }); +} + +test("a non-quota live retry also respects a later shared cooldown", async () => { + const { + client, + ids: [id], + } = await liveChannels(); + await deliver(client, ["CLOSED", id, "error: temporary failure"]); + await tickTo(500); + await extend(client, 4); + await tickTo(1000); + assert.equal(frames("REQ").length, 1); + await tickTo(4500); + assert.equal(frames("REQ").length, 2); + await deliver(client, ["EOSE", id]); +}); + +async function historyRequest() { + const client = new RelayClient(); + client.wsId = 7; + clients.push(client); + const filter = { kinds: [7], "#e": ["target"], since: 123, limit: 10000 }; + const history = client.fetchEvents(filter); + void history.catch(() => {}); + await flush(); + const id = frames("REQ").at(-1).frame[1]; + return { client, history, filter, id }; +} +function historyId(client) { + return [...client.subscriptions].find(([, sub]) => sub.mode === "history")[0]; +} + +test("history retry and Send respect extended deadlines without consuming response budget", async () => { + const { client, history, filter, id } = await historyRequest(); + const partial = { id: "partial", kind: 7, created_at: 10, tags: [] }; + await deliver(client, ["EVENT", id, partial]); + await refuse(client, id); + const retryId = historyId(client); + const subscription = client.subscriptions.get(retryId); + const event = { id: "b".repeat(64), kind: 9 }; + const published = client.publishEvent(event, "timeout", "send failed"); + void published.catch(() => {}); + await tickTo(1000); + await extend(client, 30); // Wait exceeds the normal 25s response timeout. + await tickTo(4000); + assert.equal( + frames("REQ").length, + 1, + "history cannot retry through extended gate", + ); + await tickTo(5000); + await extend(client, 30); // deadline now 35s, not 31s. + await tickTo(31000); + assert.equal(frames("REQ").length, 1); + assert.equal(client.subscriptions.get(retryId), subscription); + assert.equal(subscription.closedRetryAttempt, 1, "waiting is not an attempt"); + assert.equal(frames("EVENT").length, 0); + assert.equal(client.pendingEvents.size, 0); + await tickTo(34999); + assert.equal(frames("REQ").length, 1); + await tickTo(35000); + assert.deepEqual(frames("REQ").at(-1).frame, ["REQ", retryId, filter]); + assert.equal(frames("REQ").at(-1).at, 35000); + assert.equal(frames("EVENT").length, 1); + await tickTo(40000); // Plenty of response budget left after the long wait. + await deliver(client, ["EOSE", retryId]); + assert.deepEqual(await history, [partial]); + await deliver(client, ["OK", event.id, true, ""]); + assert.equal(await published, event); +}); + +for (const stop of ["workspace switch", "terminal CLOSED", "EOSE"]) { + test(`${stop} cancels history retry re-armed behind the gate`, async () => { + const { client, history, id } = await historyRequest(); + await refuse(client, id); + await tickTo(1000); + await extend(client, 10); + await tickTo(4000); + assert.equal(frames("REQ").length, 1); + const retryId = historyId(client); + if (stop === "workspace switch") { + client.disconnect(); + resetRateLimitGate(); + client.wsId = 8; + await assert.rejects(history, /community switch/); + } else if (stop === "terminal CLOSED") { + await deliver(client, ["CLOSED", retryId, "restricted: denied"]); + await assert.rejects(history, /restricted/); + } else { + await deliver(client, ["EOSE", retryId]); + assert.deepEqual(await history, []); + } + await tickTo(60000); + assert.equal(frames("REQ").length, 1); + assert.ok(!writes.some(({ socket }) => socket === 8)); + }); +} + +test("zero-second history refusal does not hold Send for the missing-hint default", async () => { + const { client, history, id } = await historyRequest(); + await deliver(client, [ + "CLOSED", + id, + "rate-limited: quota exceeded; retry in 0s", + ]); + const event = { id: "c".repeat(64), kind: 9 }; + const published = client.publishEvent(event, "timeout", "send failed"); + void published.catch(() => {}); + await tickTo(0); + assert.equal(isRateLimited(), false); + assert.equal(frames("REQ").length, 2); + assert.equal(frames("EVENT").length, 1); + await deliver(client, ["EOSE", historyId(client)]); + assert.deepEqual(await history, []); + await deliver(client, ["OK", event.id, true, ""]); + assert.equal(await published, event); +}); + +test("repeated zero-second history refusals exhaust the existing three-retry budget", async () => { + const { client, history, id } = await historyRequest(); + onSend = ({ frame }) => + frame[0] === "REQ" + ? deliver(client, [ + "CLOSED", + frame[1], + "rate-limited: quota exceeded; retry in 0s", + ]) + : undefined; + await deliver(client, [ + "CLOSED", + id, + "rate-limited: quota exceeded; retry in 0s", + ]); + await tickTo(0); + assert.equal( + frames("REQ").length, + 4, + "initial request plus three retries, not an infinite loop", + ); + await assert.rejects(history, /quota exceeded/); + assert.equal(client.subscriptions.size, 0); + assert.equal(timers.size, 0); +}); + +test("immediate retry EOSE settles history without leaving an op-timeout", async () => { + const { client, history, id } = await historyRequest(); + onSend = ({ frame }) => + frame[0] === "REQ" ? deliver(client, ["EOSE", frame[1]]) : undefined; + await refuse(client, id); + await tickTo(4000); + assert.deepEqual(await history, []); + assert.equal( + timers.size, + 0, + "response can arrive before the send promise settles", + ); +}); + +test("history response timeout starts at admitted retry dispatch and sends CLOSE", async () => { + const { client, history, id } = await historyRequest(); + await refuse(client, id); + await tickTo(1000); + await extend(client, 30); + await tickTo(31000); + const retryId = historyId(client); + assert.equal(frames("REQ").at(-1).at, 31000); + await tickTo(55999); + assert.ok(client.subscriptions.has(retryId)); + await tickTo(56000); + await assert.rejects(history, /closed the history/); + assert.ok(frames("CLOSE").some(({ frame }) => frame[1] === retryId)); + assert.equal(client.subscriptions.size, 0); +}); + +test("late failed history send cannot cancel the next rotated retry", async () => { + const { client, history, id } = await historyRequest(); + let rejectOldSend; + onSend = ({ frame }) => + frame[0] === "REQ" + ? new Promise((_, reject) => { + rejectOldSend = reject; + }) + : undefined; + await refuse(client, id); + await tickTo(4000); + const oldId = historyId(client); + await refuse(client, oldId); + const nextId = historyId(client); + const nextSub = client.subscriptions.get(nextId); + const nextTimer = nextSub.timeout; + onSend = undefined; + rejectOldSend(new Error("late old IPC failure")); + await flush(); + assert.equal(client.subscriptions.get(nextId), nextSub); + assert.ok( + timers.has(nextTimer), + "late failure must not clear the next retry timer", + ); + assert.equal( + client.connectionGeneration, + 0, + "retired send must not reset socket", + ); + await tickTo(8000); + assert.equal(frames("REQ").length, 3); + assert.equal(frames("REQ").at(-1).frame[1], nextId); + await deliver(client, ["EOSE", nextId]); + assert.deepEqual(await history, []); +}); + +test("zero-second live refusals retain existing exponential retry backoff", async () => { + const { + client, + ids: [id], + } = await liveChannels(); + onSend = ({ frame }) => + frame[0] === "REQ" + ? deliver(client, [ + "CLOSED", + frame[1], + "rate-limited: quota exceeded; retry in 0s", + ]) + : undefined; + await deliver(client, [ + "CLOSED", + id, + "rate-limited: quota exceeded; retry in 0s", + ]); + await tickTo(999); + assert.equal(frames("REQ").length, 1); + await tickTo(1000); + assert.equal(frames("REQ").length, 2); + await tickTo(3000); + assert.equal(frames("REQ").length, 3); + await tickTo(7000); + assert.equal(frames("REQ").length, 4); + assert.equal(isRateLimited(), false); + await deliver(client, ["EOSE", id]); +}); + +test("zero-second negative OK remains a publish error, not successful acceptance", async () => { + const { client } = await liveChannels(); + const event = { id: "d".repeat(64), kind: 9 }; + const published = client.publishEvent(event, "timeout", "send failed"); + void published.catch(() => {}); + await flush(); + await deliver(client, [ + "OK", + event.id, + false, + "rate-limited: quota exceeded; retry in 0s", + ]); + await assert.rejects(published, /quota exceeded/); + assert.equal( + frames("EVENT").length, + 1, + "no automatic uncertain publish replay", + ); + assert.equal(isRateLimited(), false); +}); + +test("zero hint on a history retry preserves another operation's active deadline", async () => { + const { client, history, id } = await historyRequest(); + await extend(client, 4); + await tickTo(3000); + await deliver(client, [ + "CLOSED", + id, + "rate-limited: quota exceeded; retry in 0s", + ]); + await tickTo(3999); + assert.equal(frames("REQ").length, 1); + await tickTo(4000); + assert.equal(frames("REQ").length, 2); + await deliver(client, ["EOSE", historyId(client)]); + assert.deepEqual(await history, []); +}); diff --git a/desktop/src/shared/api/relayClientLiveCancellation.test.mjs b/desktop/src/shared/api/relayClientLiveCancellation.test.mjs new file mode 100644 index 00000000000..e34e9be2308 --- /dev/null +++ b/desktop/src/shared/api/relayClientLiveCancellation.test.mjs @@ -0,0 +1,408 @@ +// Exercise actual session cancellation at the IPC boundary, including in-flight sends. +// The IPC transport and clock are fake; no native app or external relay is used. +import assert from "node:assert/strict"; +import { getEventListeners } from "node:events"; +import { after, beforeEach, afterEach, test } from "node:test"; + +const originalNow = Date.now; +const originalWindow = globalThis.window; +let now = 0; +let nextTimerId = 1; +const timers = new Map(); +const writes = []; +const clients = []; +let sendHook = async () => {}; +globalThis.window = { + setTimeout(fn, ms) { + const id = nextTimerId++; + timers.set(id, { fn, at: now + ms }); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + __TAURI_INTERNALS__: { + async invoke(command, args) { + if (command === "plugin:websocket|send") { + writes.push({ + socket: args.id, + frame: JSON.parse(args.message.data), + at: now, + }); + return sendHook(args, JSON.parse(args.message.data)); + } + if (command === "plugin:websocket|disconnect") return; + assert.fail(`unexpected IPC: ${command}`); + }, + }, +}; +Date.now = () => now; +const { RelayClient } = await import("./relayClientSession.ts"); +const { resetRateLimitGate, isRateLimited } = await import( + "./relayRateLimitGate.ts" +); + +beforeEach(() => { + resetRateLimitGate(); + now = 0; + timers.clear(); + writes.length = 0; + sendHook = async () => {}; +}); +afterEach(() => { + for (const client of clients.splice(0)) client.disconnect(); + resetRateLimitGate(); + assert.equal( + timers.size, + 0, + "subscriptions and gate must release their timers", + ); +}); +after(() => { + Date.now = originalNow; + globalThis.window = originalWindow; +}); + +async function flush() { + for (let i = 0; i < 20; i++) await Promise.resolve(); +} +async function tickTo(target) { + assert.ok(target >= now); + let fired = 0; + for (;;) { + const next = [...timers] + .filter(([, timer]) => timer.at <= target) + .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]; + if (!next) break; + assert.ok(++fired < 1000, "timer loop must make progress"); + now = next[1].at; + timers.delete(next[0]); + next[1].fn(); + await flush(); + } + now = target; + await flush(); +} +function deliver(client, frame) { + return client.handleWsMessage( + { type: "Text", data: JSON.stringify(frame) }, + client.connectionGeneration, + ); +} +function frames(op) { + return writes.filter((write) => write.frame[0] === op); +} +const filter = { kinds: [9], "#h": ["channel"], since: 123, limit: 1000 }; +function setup() { + const client = new RelayClient(); + client.wsId = 7; + clients.push(client); + const controller = new AbortController(); + let outcome = "pending"; + const readiness = []; + const events = []; + const start = () => { + const pending = client.subscribeLive( + filter, + (e) => events.push(e), + (r) => readiness.push(r), + 250, + controller.signal, + ); + pending.then( + () => { + outcome = "ready"; + }, + (e) => { + outcome = e.name; + }, + ); + return pending; + }; + return { + client, + controller, + start, + readiness, + events, + outcome: () => outcome, + }; +} +for (const stop of ["abort", "disconnect"]) { + test(`${stop} settles setup while connection is pending, without late registration`, async () => { + const h = setup(); + const connection = Promise.withResolvers(); + h.client.connectPromise = connection.promise; + h.start(); + if (stop === "abort") h.controller.abort(); + else h.client.disconnect(); + await flush(); + try { + assert.notEqual(h.outcome(), "pending"); + h.client.wsId = 8; + connection.resolve(h.client.connectionGeneration); + await flush(); + assert.equal(frames("REQ").length, 0); + assert.equal(h.client.subscriptions.size, 0); + } finally { + connection.resolve(h.client.connectionGeneration); + h.client.connectPromise = null; + await flush(); + } + }); +} +test("abort during an in-flight REQ suppresses delivery and closes again after send settles", async () => { + const h = setup(); + const send = Promise.withResolvers(); + sendHook = async (_args, f) => { + if (f[0] === "REQ") await send.promise; + }; + h.start(); + await flush(); + const id = frames("REQ")[0].frame[1]; + h.controller.abort(); + await flush(); + try { + assert.equal(h.client.subscriptions.size, 0); + assert.equal(h.outcome(), "AbortError"); + const before = frames("CLOSE").length; + assert.ok(before > 0); + await deliver(h.client, [ + "EVENT", + id, + { id: "event", kind: 9, created_at: 1 }, + ]); + await deliver(h.client, ["EOSE", id]); + assert.equal(h.events.length, 0); + send.resolve(); + await flush(); + assert.ok( + frames("CLOSE").length > before, + "post-flight close preserves wire order", + ); + assert.equal(h.client.wsId, 7); + assert.deepEqual(h.readiness, []); + } finally { + send.resolve(); + await flush(); + } +}); +for (const stage of ["first send", "reconnect wait", "retry send"]) { + test(`cancellation at ${stage} cannot reset or resurrect the replacement socket`, async () => { + const h = setup(); + const first = Promise.withResolvers(); + const retry = Promise.withResolvers(); + let attempts = 0; + sendHook = async (_args, f) => { + if (f[0] !== "REQ") return; + await (++attempts === 1 ? first.promise : retry.promise); + }; + h.start(); + await flush(); + try { + if (stage === "first send") { + h.controller.abort(); + first.reject(new Error("late IPC error")); + await flush(); + assert.equal(h.client.wsId, 7); + assert.equal(h.client.reconnectTimeout, null); + } else { + first.reject(new Error("socket lost")); + await flush(); + assert.notEqual(h.client.reconnectTimeout, null); + if (stage === "reconnect wait") h.controller.abort(); + // Resolve the existing reconnect waiter: only transport establishment is fake. + window.clearTimeout(h.client.reconnectTimeout); + h.client.reconnectTimeout = null; + h.client.wsId = 8; + h.client.reconnectWaiters.settle(); + await flush(); + if (stage === "retry send") { + assert.equal(attempts, 2); + h.controller.abort(); + retry.reject(new Error("late retry IPC error")); + await flush(); + } else assert.equal(attempts, 1); + assert.equal(h.client.wsId, 8); + } + assert.equal(h.client.subscriptions.size, 0); + assert.equal(h.outcome(), "AbortError"); + } finally { + first.resolve(); + retry.resolve(); + await flush(); + } + }); +} +for (const finish of ["EOSE", "timeout", "terminal CLOSED", "disconnect"]) { + test(`${finish} settles readiness once and cleans up cancellation`, async () => { + const h = setup(); + h.start(); + await flush(); + const id = frames("REQ")[0].frame[1]; + if (finish === "EOSE") { + await deliver(h.client, ["EOSE", id]); + await deliver(h.client, ["EOSE", id]); + } + if (finish === "timeout") await tickTo(250); + if (finish === "terminal CLOSED") + await deliver(h.client, ["CLOSED", id, "restricted: denied"]); + if (finish === "disconnect") h.client.disconnect(); + await flush(); + assert.notEqual(h.outcome(), "pending"); + assert.ok(h.readiness.length <= 1); + assert.equal(timers.size, 0); + h.controller.abort(); + await flush(); + assert.equal(h.client.subscriptions.size, 0); + }); +} + +for (const stop of ["abort", "disconnect"]) { + test(`${stop} cancels an actual CLOSED retry while its send is in flight`, async () => { + const h = setup(); + const started = h.start(); + await flush(); + const id = frames("REQ")[0].frame[1]; + await deliver(h.client, ["EOSE", id]); + await started; + await deliver(h.client, ["CLOSED", id, "error: temporary failure"]); + const retry = Promise.withResolvers(); + sendHook = async (_args, f) => { + if (f[0] === "REQ") await retry.promise; + }; + await tickTo(1000); + assert.equal(frames("REQ").length, 2); + if (stop === "abort") h.controller.abort(); + else { + h.client.disconnect(); + h.client.wsId = 8; + } + retry.reject(new Error("late CLOSED retry failure")); + await flush(); + await tickTo(60000); + assert.equal(frames("REQ").length, 2); + assert.equal(h.client.wsId, stop === "abort" ? 7 : 8); + assert.equal(h.client.subscriptions.size, 0); + assert.equal(timers.size, 0); + }); +} +for (const stop of ["abort", "dispose", "terminal CLOSED", "disconnect"]) { + test(`${stop} releases caller and session abort listeners after EOSE`, async () => { + const h = setup(); + const sessionSignal = h.client.liveSessionAbort.signal; + const started = h.start(); + await flush(); + const id = frames("REQ")[0].frame[1]; + await deliver(h.client, ["EOSE", id]); + const dispose = await started; + assert.equal( + getEventListeners(h.controller.signal, "abort").length, + 1, + "live cancellation remains armed after readiness", + ); + if (stop === "abort") h.controller.abort(); + if (stop === "dispose") await dispose(); + if (stop === "terminal CLOSED") + await deliver(h.client, ["CLOSED", id, "restricted: denied"]); + if (stop === "disconnect") h.client.disconnect(); + await flush(); + assert.equal(getEventListeners(h.controller.signal, "abort").length, 0); + assert.equal(getEventListeners(sessionSignal, "abort").length, 0); + assert.equal(timers.size, 0); + }); +} +test("workspace switch during in-flight setup cannot close or reset the new socket", async () => { + const h = setup(); + const send = Promise.withResolvers(); + sendHook = async (_args, f) => { + if (f[0] === "REQ") await send.promise; + }; + h.start(); + await flush(); + h.client.disconnect(); + h.client.wsId = 8; + send.reject(new Error("old workspace send failed")); + await flush(); + assert.equal(h.outcome(), "AbortError"); + assert.equal(h.client.wsId, 8); + assert.ok(writes.every((w) => w.socket === 7)); + assert.equal(h.client.subscriptions.size, 0); + assert.equal(timers.size, 0); +}); + +test("late old-socket failure preserves a still-owned live entry restored by replay", async () => { + const h = setup(); + const first = Promise.withResolvers(); + let requests = 0; + sendHook = async (_args, f) => { + if (f[0] === "REQ" && ++requests === 1) await first.promise; + }; + h.start(); + await flush(); + const id = frames("REQ")[0].frame[1]; + const entry = h.client.subscriptions.get(id); + h.client.resetConnection(new Error("socket closed during setup")); + window.clearTimeout(h.client.reconnectTimeout); + h.client.reconnectTimeout = null; + h.client.wsId = 8; + await h.client.replayLiveSubscriptions(); + assert.equal(frames("REQ").length, 2); + assert.equal(frames("REQ")[1].frame[1], id); + first.reject(new Error("late old-socket IPC failure")); + await flush(); + assert.equal(h.outcome(), "ready"); + assert.equal(h.client.subscriptions.get(id), entry); + assert.equal(frames("CLOSE").length, 0); + assert.equal(h.client.wsId, 8); + assert.equal(frames("REQ").length, 2, "replay, not fresh setup"); +}); + +test("late retry-send failure preserves an entry handed to another reconnect", async () => { + const h = setup(); + const retry = Promise.withResolvers(); + let requests = 0; + sendHook = async (_args, f) => { + if (f[0] !== "REQ") return; + if (++requests === 1) throw new Error("first socket failed"); + if (requests === 2) await retry.promise; + }; + h.start(); + await flush(); + window.clearTimeout(h.client.reconnectTimeout); + h.client.reconnectTimeout = null; + h.client.wsId = 8; + h.client.reconnectWaiters.settle(); + await flush(); + assert.equal(requests, 2); + const id = frames("REQ")[0].frame[1]; + const entry = h.client.subscriptions.get(id); + h.client.resetConnection(new Error("retry socket closed")); + window.clearTimeout(h.client.reconnectTimeout); + h.client.reconnectTimeout = null; + h.client.wsId = 9; + await h.client.replayLiveSubscriptions(); + retry.reject(new Error("late retry-socket failure")); + await flush(); + assert.equal(h.outcome(), "ready"); + assert.equal(h.client.subscriptions.get(id), entry); + assert.equal(frames("CLOSE").length, 0); + assert.equal(h.client.wsId, 9); +}); + +test("a failed reconnect attempt still rejects setup rather than claiming a successful handoff", async () => { + const h = setup(); + sendHook = async (_args, f) => { + if (f[0] === "REQ") throw new Error("socket failed"); + }; + h.start(); + await flush(); + window.clearTimeout(h.client.reconnectTimeout); + h.client.reconnectTimeout = null; + h.client.reconnectWaiters.settle(new Error("authentication failed")); + await flush(); + assert.equal(h.outcome(), "Error"); + assert.equal(h.client.subscriptions.size, 0); + assert.equal(frames("REQ").length, 1); + assert.equal(timers.size, 0); +}); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index d9be0e6c9bc..2523324b679 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -37,6 +37,7 @@ import { } from "@/shared/api/relayClosedRecovery"; import { getChannelReconnectRepairEvents } from "@/shared/api/channelReconnectRepair"; import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay"; +import { RelayLiveReqDrain } from "./relayLiveReqDrain"; import { publishSessionEvent } from "@/shared/api/relayEventPublisher"; import { activateRateLimitIfSignalled } from "@/shared/api/relayRateLimitGate"; import { @@ -84,6 +85,7 @@ export class RelayClient { private keepAliveRequested = false; private authRequest: RelayAuthRequest | null = null; private subscriptions = new Map(); + private liveReqDrain = new RelayLiveReqDrain(); private pendingEvents = new Map(); private eventBuffer: SubscriptionEventBufferItem[] = []; private flushTimeout: number | null = null; @@ -93,6 +95,7 @@ export class RelayClient { private onMessageChannel: Channel | null = null; private connectionGeneration = 0; private sessionEpoch = 0; + private liveSessionAbort = new AbortController(); private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; private authOkTracker = new AuthOkTracker(); @@ -123,7 +126,10 @@ export class RelayClient { } this.stallWatchdog.stop(); this.sessionEpoch++; + this.liveSessionAbort.abort(); + this.liveSessionAbort = new AbortController(); this.connectionGeneration++; + this.liveReqDrain.reset(); this.keepAliveRequested = false; this.relayUrl = null; this.hasConnectedOnce = false; @@ -153,6 +159,7 @@ export class RelayClient { sub.reject(error); } else { clearClosedRetry(sub); + sub.onRemoved?.(); } this.subscriptions.delete(subId); } @@ -409,9 +416,25 @@ export class RelayClient { onEvent: (event: RelayEvent) => void, onReady?: (readiness: LiveSubscriptionReadiness) => void, readinessTimeoutMs?: number, + signal?: AbortSignal, ) { - return this.subscribe(filter, onEvent, onReady, readinessTimeoutMs); + return this.subscribe(filter, onEvent, onReady, readinessTimeoutMs, signal); } + /** Prioritize an interactive live consumer without changing its replay filter or pacing. */ + async subscribeInteractive( + filter: RelaySubscriptionFilter, + onEvent: (event: RelayEvent) => void, + ) { + return this.subscribe( + filter, + onEvent, + undefined, + undefined, + undefined, + "interactive", + ); + } + async preconnect() { // Explicit re-engagement (reconnect card / community switch): clears the // terminal latch and AUTH rejection streak, and bypasses backoff once. @@ -589,52 +612,88 @@ export class RelayClient { onEvent: (event: RelayEvent) => void, onReady?: (readiness: LiveSubscriptionReadiness) => void, readinessTimeoutMs = 250, + signal?: AbortSignal, + priority?: "interactive", ) { - await this.ensureConnected(); - + const epoch = this.sessionEpoch; + const sessionSignal = this.liveSessionAbort.signal; + signal?.throwIfAborted(); const subId = `live-${crypto.randomUUID()}`; - let resolveReady = (_readiness: LiveSubscriptionReadiness) => {}; - const ready = new Promise((resolve) => { - resolveReady = (readiness) => { - window.clearTimeout(fallbackTimeout); - onReady?.(readiness); - resolve(); - }; - }); - const fallbackTimeout = window.setTimeout( - () => resolveReady("timeout"), - readinessTimeoutMs, - ); - - this.subscriptions.set(subId, { + let fallbackTimeout: number | undefined; + let settleReady = () => {}; + const onRemoved = () => { + signal?.removeEventListener("abort", abort); + sessionSignal.removeEventListener("abort", abort); + window.clearTimeout(fallbackTimeout); + settleReady(); + }; + const subscription: Extract = { mode: "live", filter, + priority, onEvent, - resolveReady, + onRemoved, + }; + const dispose = async () => { + if (this.subscriptions.get(subId) !== subscription) return; + this.subscriptions.delete(subId); + this.liveReqDrain.cancel(subId); + clearClosedRetry(subscription); + onRemoved(); + // Workspace teardown closes its socket; never send on its replacement. + if (epoch === this.sessionEpoch) await this.closeSubscription(subId); + }; + let rejectCancelled = (_error: Error) => {}; + const cancelled = new Promise((_resolve, reject) => { + rejectCancelled = reject; }); - - try { - await this.sendRawWithReconnectRetry( - ["REQ", subId, filter], - "Failed to restore relay subscription.", + const abort = () => { + rejectCancelled( + new DOMException("Live subscription cancelled.", "AbortError"), ); + void dispose().catch(() => {}); + onRemoved(); + }; + signal?.addEventListener("abort", abort, { once: true }); + sessionSignal.addEventListener("abort", abort, { once: true }); + try { + await Promise.race([this.ensureConnected(), cancelled]); + signal?.throwIfAborted(); + sessionSignal.throwIfAborted(); + const ready = new Promise((resolve) => { + settleReady = resolve; + }); + let readySettled = false; + subscription.resolveReady = (readiness) => { + if (readySettled) return; + readySettled = true; + window.clearTimeout(fallbackTimeout); + onReady?.(readiness); + settleReady(); + }; + this.subscriptions.set(subId, subscription); + await Promise.race([ + this.sendRawWithReconnectRetry( + ["REQ", subId, filter], + "Failed to restore relay subscription.", + () => { + if (readySettled) return; + window.clearTimeout(fallbackTimeout); + fallbackTimeout = window.setTimeout( + () => subscription.resolveReady?.("timeout"), + readinessTimeoutMs, + ); + }, + ), + cancelled, + ]); + await Promise.race([ready, cancelled]); + return dispose; } catch (error) { - window.clearTimeout(fallbackTimeout); - this.subscriptions.delete(subId); + await dispose().catch(() => {}); + onRemoved(); throw error; } - await ready; - - return async () => { - const active = this.subscriptions.get(subId); - if (active?.mode !== "live") { - return; - } - - this.subscriptions.delete(subId); - clearClosedRetry(active); - await this.closeSubscription(subId); - }; } private async sendRaw(payload: unknown[]) { @@ -678,18 +737,83 @@ export class RelayClient { private async sendRawWithReconnectRetry( payload: unknown[], fallbackMessage: string, + onDispatch?: () => void, ) { + const epoch = this.sessionEpoch; + const subId = payload[1] as string; + const subscription = this.subscriptions.get(subId); + const isOwned = () => + subscription !== undefined && + epoch === this.sessionEpoch && + this.subscriptions.get(subId) === subscription; + const checkOwned = () => { + if (!isOwned()) + throw new DOMException("Relay subscription retired.", "AbortError"); + }; + let generation = this.connectionGeneration; + const send = async () => { + checkOwned(); + generation = this.connectionGeneration; + try { + onDispatch?.(); + await this.sendRawForGeneration(payload, generation); + } finally { + // A dispatched REQ cannot be unsent. Converge to CLOSE on that socket, + // without touching a replacement workspace or connection. + if ( + !isOwned() && + epoch === this.sessionEpoch && + generation === this.connectionGeneration + ) { + await this.closeSubscription(subId).catch(() => {}); + } + } + }; + const dispatch = () => { + const queuedGeneration = this.connectionGeneration; + return subscription?.mode === "live" + ? this.liveReqDrain.run( + subId, + () => isOwned() && queuedGeneration === this.connectionGeneration, + () => + subscription.priority === "interactive" || + (this.visibleChannelId !== null && + subscription.filter["#h"]?.includes(this.visibleChannelId) === + true) + ? 0 + : subscription.filter.limit === 0 + ? 1 + : 2, + send, + ) + : send(); + }; try { - await this.sendRaw(payload); + await dispatch(); } catch (error) { + checkOwned(); // Cancellation is not a socket failure and must not reconnect. + if (generation !== this.connectionGeneration) { + // Live entries survive reset and belong to the new connection's replay. + // A late old-socket failure must not tear down that retained ownership. + if (subscription?.mode === "live") return; + throw error; + } const normalizedError = this.recoverFromSocketFailure( error, fallbackMessage, ); + let retryGeneration: number | null = null; try { await this.ensureConnected(); - await this.sendRaw(payload); + checkOwned(); + retryGeneration = this.connectionGeneration; + await dispatch(); } catch (retryError) { + checkOwned(); + if (generation !== this.connectionGeneration) { + if (retryGeneration !== null && subscription?.mode === "live") return; + throw retryError; + } throw this.recoverFromSocketFailure( retryError, normalizedError.message, @@ -800,6 +924,7 @@ export class RelayClient { ), closeSubscription: (subId) => this.closeSubscription(subId), }); + if (!this.subscriptions.has(rest[0])) this.liveReqDrain.cancel(rest[0]); return; } @@ -839,6 +964,7 @@ export class RelayClient { } if (!prepareSubscriptionEvent(subscription, event)) return; + this.liveReqDrain.cancel(subId); this.eventBuffer.push({ subId, event, generation }); this.flushTimeout ??= window.setTimeout( () => this.flushEventBuffer(), @@ -855,6 +981,7 @@ export class RelayClient { } private handleEose(subId: string, generation: number) { + this.liveReqDrain.cancel(subId); this.flushEventBuffer(); // Deliver preceding EVENT frames before EOSE. handleSubscriptionEose({ subscriptions: this.subscriptions, @@ -991,6 +1118,7 @@ export class RelayClient { this.onMessageChannel = null; this.stallWatchdog.stop(); this.connectionGeneration++; + this.liveReqDrain.reset(); if (this.stabilityTimer !== null) { window.clearTimeout(this.stabilityTimer); this.stabilityTimer = null; diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 8bd6379d7a9..80f5de206d1 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -73,8 +73,12 @@ export type LiveSubscriptionReadiness = "eose" | "closed" | "timeout"; type LiveSubscription = { mode: "live"; filter: RelaySubscriptionFilter; + /** Client-side admission only; interactive consumers still obey cooldown/pacing. */ + priority?: "interactive"; onEvent: (event: RelayEvent) => void; resolveReady?: (readiness: LiveSubscriptionReadiness) => void; + /** Release readiness/cancellation listeners when this entry is retired. */ + onRemoved?: () => void; lastSeenCreatedAt?: number; /** * Lower bound of a reconnect backfill window that has not yet completed. diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts index 9d30a233e7b..cf04d83126e 100644 --- a/desktop/src/shared/api/relayClosedRecovery.ts +++ b/desktop/src/shared/api/relayClosedRecovery.ts @@ -56,39 +56,45 @@ export function handleRelayClosed({ const attempt = subscription.closedRetryAttempt ?? 0; if (attempt < 3) { subscription.closedRetryAttempt = attempt + 1; - // Clear the existing op-timeout so it doesn't fire while waiting for - // the rate-limit window. The setTimeout delay covers this interval. + // The same cancellable timeout owns admission waiting and, only once + // dispatched, the response budget. A later hint can extend the gate. window.clearTimeout(subscription.timeout); - const hintMs = (hintSeconds ?? 10) * 1_000; - const delayMs = Math.max(rateLimitRemainingMs() || hintMs, hintMs); - // Re-register under a new id so the old subId can be evicted cleanly. - // Accumulated events are preserved on the subscription object. const newSubId = `history-${crypto.randomUUID()}`; subscriptions.delete(subId); subscriptions.set(newSubId, subscription); - // Use the rate-limit delay as the new timeout budget so the - // subscription is not left open indefinitely while waiting. - subscription.timeout = window.setTimeout(() => { - if (!subscriptions.has(newSubId)) return; // cancelled while waiting - void sendReq(newSubId, subscription.filter).catch(() => { - subscriptions.delete(newSubId); - subscription.reject( - new Error(message || "Relay closed the history subscription."), + const isOwned = () => subscriptions.get(newSubId) === subscription; + const retryWhenAdmitted = () => { + if (!isOwned()) return; + const remainingMs = rateLimitRemainingMs(); + if (remainingMs > 0) { + subscription.timeout = window.setTimeout( + retryWhenAdmitted, + remainingMs, ); - }); - // Set a new op-timeout for the retry REQ so the subscription - // cannot hang indefinitely if the relay stops responding. + return; + } + // Arm before transport: EOSE/CLOSED may arrive before send settles. subscription.timeout = window.setTimeout(() => { + if (!isOwned()) return; subscriptions.delete(newSubId); - // History promise is already being rejected below; swallow any - // IPC/socket error from the CLOSE send so it does not surface as - // an unhandled rejection on a path that has no live error handler. closeSubscription?.(newSubId)?.catch(() => {}); subscription.reject( new Error("Relay closed the history subscription."), ); }, subscription.timeoutMs); - }, delayMs); + void sendReq(newSubId, subscription.filter).catch(() => { + if (!isOwned()) return; + window.clearTimeout(subscription.timeout); + subscriptions.delete(newSubId); + subscription.reject( + new Error(message || "Relay closed the history subscription."), + ); + }); + }; + subscription.timeout = window.setTimeout( + retryWhenAdmitted, + rateLimitRemainingMs(), + ); return; } } @@ -132,6 +138,8 @@ function recoverLiveSubscriptionFromClosed({ // Auth/access/filter failure — permanently remove the subscription so it // doesn't silently loop. subscriptions.delete(subId); + clearClosedRetry(subscription); + subscription.onRemoved?.(); return; } @@ -158,9 +166,20 @@ function recoverLiveSubscriptionFromClosed({ } subscription.closedRetryAttempt = attempt + 1; - subscription.closedRetryTimeout = window.setTimeout(() => { + const retryWhenAdmitted = () => { subscription.closedRetryTimeout = undefined; if (subscriptions.get(subId) !== subscription) return; + // A later quota hint can extend the shared gate after this retry was + // scheduled. Keep the existing cancellable timer owner while waiting; + // postponement is not another failed attempt and must not grow backoff. + const remainingMs = rateLimitRemainingMs(); + if (remainingMs > 0) { + subscription.closedRetryTimeout = window.setTimeout( + retryWhenAdmitted, + remainingMs, + ); + return; + } void sendReq(subId, subscription.filter).catch((error) => { if (subscriptions.get(subId) !== subscription) return; console.error("Failed to restore closed relay subscription", error); @@ -172,7 +191,11 @@ function recoverLiveSubscriptionFromClosed({ sendReq, }); }); - }, delayMs); + }; + subscription.closedRetryTimeout = window.setTimeout( + retryWhenAdmitted, + delayMs, + ); } export function prepareSubscriptionEvent( diff --git a/desktop/src/shared/api/relayLiveReqDrain.ts b/desktop/src/shared/api/relayLiveReqDrain.ts new file mode 100644 index 00000000000..7b3d0056ad3 --- /dev/null +++ b/desktop/src/shared/api/relayLiveReqDrain.ts @@ -0,0 +1,82 @@ +import { rateLimitRemainingMs } from "./relayRateLimitGate"; + +// Deliberately below the relay's default total WS allowance: other request +// families and publications share that budget. This is pacing, not a quota guarantee. +const LIVE_REQ_INTERVAL_MS = 250; +type PendingReq = { + current: () => boolean; + priority: () => number; + send: () => Promise; + resolve: () => void; + reject: (error: unknown) => void; + promise: Promise; +}; + +/** Session-owned cold/live-retry drain; entries are bounded by owned subscriptions. */ +export class RelayLiveReqDrain { + private pending = new Map(); + private timer: number | undefined; + private nextAt = 0; + + run( + id: string, + current: () => boolean, + priority: () => number, + send: () => Promise, + ): Promise { + const existing = this.pending.get(id); + if (existing) return existing.promise; + let resolve = () => {}; + let reject = (_error: unknown) => {}; + const promise = new Promise((ok, fail) => { + resolve = ok; + reject = fail; + }); + this.pending.set(id, { current, priority, send, promise, resolve, reject }); + this.drain(); + return promise; + } + + cancel(id: string) { + const entry = this.pending.get(id); + this.pending.delete(id); + entry?.resolve(); + if (this.pending.size === 0) { + window.clearTimeout(this.timer); + this.timer = undefined; + } + } + + reset() { + for (const id of this.pending.keys()) this.cancel(id); + this.nextAt = 0; + } + + private drain = () => { + window.clearTimeout(this.timer); + this.timer = undefined; + for (const [id, entry] of this.pending) { + if (!entry.current()) this.cancel(id); + } + if (this.pending.size === 0) return; + const delay = Math.max(this.nextAt - Date.now(), rateLimitRemainingMs()); + if (delay > 0) { + this.timer = window.setTimeout(this.drain, delay); + return; + } + const entries = [...this.pending]; + const selected = entries.sort( + (a, b) => a[1].priority() - b[1].priority(), + )[0]; + if (!selected) return; + const [id, entry] = selected; + this.pending.delete(id); + this.nextAt = Date.now() + LIVE_REQ_INTERVAL_MS; + // send checks ownership again immediately before IPC. Do not await its + // outcome here: publications and independent subscriptions must keep moving. + void entry.send().then(entry.resolve, entry.reject); + if (this.pending.size > 0) { + this.timer = window.setTimeout(this.drain, LIVE_REQ_INTERVAL_MS); + } + }; +} diff --git a/desktop/src/shared/api/relayRateLimitGate.test.mjs b/desktop/src/shared/api/relayRateLimitGate.test.mjs index dd2346911fe..d3b254403ce 100644 --- a/desktop/src/shared/api/relayRateLimitGate.test.mjs +++ b/desktop/src/shared/api/relayRateLimitGate.test.mjs @@ -132,13 +132,34 @@ test("null hint uses 10s default", () => { assert.equal(isRateLimited(), false); }); -test("zero hint uses 10s default (0s gate would be swallowed)", () => { +test("explicit zero hint adds no cooldown or timer", async () => { reset(0); + assert.equal( + parseRateLimitHint("rate-limited: quota exceeded; retry in 0s"), + 0, + ); activateRateLimit(0); - tickTo(9_999); - assert.equal(isRateLimited(), true); - tickTo(10_001); assert.equal(isRateLimited(), false); + assert.equal(pendingTimers.size, 0); + await waitForRateLimit(); +}); + +test("explicit zero neither shortens nor extends another active deadline", async () => { + reset(0); + activateRateLimit(4); + let settled = false; + const waiting = waitForRateLimit().then(() => { + settled = true; + }); + setFakeNow(3000); + activateRateLimit(0); + assert.equal(rateLimitRemainingMs(), 1000); + assert.equal(pendingTimers.size, 1); + await Promise.resolve(); + assert.equal(settled, false); + tickTo(4000); + await waiting; + assert.equal(settled, true); }); test("negative hint uses 10s default", () => { diff --git a/desktop/src/shared/api/relayRateLimitGate.ts b/desktop/src/shared/api/relayRateLimitGate.ts index 827713eb069..d98690b531e 100644 --- a/desktop/src/shared/api/relayRateLimitGate.ts +++ b/desktop/src/shared/api/relayRateLimitGate.ts @@ -44,13 +44,14 @@ export function parseRateLimitHint(msg: string): number | null { * * If the gate is already active, the expiry is pushed forward to the maximum of * the existing expiry and the new hint — overlapping hints never shrink the - * window. Non-positive or absent hints use the 10-second default; a 0s gate - * would resolve immediately and swallow the signal. + * window. An explicit zero adds no hold; it is not a missing hint and does not + * clear an existing deadline. Negative or absent hints use the 10-second default. * * Note: buzz-acp uses a 5s no-hint default; desktop deliberately uses 10s here * for a wider back-off window on degraded connections. */ export function activateRateLimit(retryInSeconds: number | null): void { + if (retryInSeconds === 0) return; const durationMs = (retryInSeconds != null && retryInSeconds > 0 ? Math.min(retryInSeconds, MAX_HINT_SECONDS) diff --git a/desktop/src/shared/api/relayReconnectReplay.ts b/desktop/src/shared/api/relayReconnectReplay.ts index 60a57f1a0b2..18b8f9fd573 100644 --- a/desktop/src/shared/api/relayReconnectReplay.ts +++ b/desktop/src/shared/api/relayReconnectReplay.ts @@ -278,22 +278,20 @@ export async function replayLiveSubscriptions({ }; }); - // Sort the visible channel's subscriptions first so the user sees their - // active channel recover before others on degraded networks. - if (visibleChannelId !== null) { - replayRequests.sort((a, b) => { - const aVis = - (a.subscription.filter["#h"] as string[] | undefined)?.includes( - visibleChannelId, - ) ?? false; - const bVis = - (b.subscription.filter["#h"] as string[] | undefined)?.includes( - visibleChannelId, - ) ?? false; - if (aVis === bVis) return 0; - return aVis ? -1 : 1; - }); - } + // Visible timeline and interactive consumers (including off-screen huddle + // speech) recover before cold work. Stable sort preserves registration order + // within each tier, including ties between visible and interactive owners. + const isForeground = ( + subscription: Extract, + ) => + subscription.priority === "interactive" || + (visibleChannelId !== null && + (subscription.filter["#h"]?.includes(visibleChannelId) ?? false)); + replayRequests.sort( + (a, b) => + Number(isForeground(b.subscription)) - + Number(isForeground(a.subscription)), + ); // Send live REQs in capped batches with inter-batch delays to avoid // triggering per-pubkey admission control on degraded/recovering connections. diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ae641800c51..b50de75ea82 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -31,6 +31,7 @@ import { } from "@/features/agents/observerRelayStore"; import { switchManagedAgentModel } from "@/shared/api/agentControl"; import { mockSearchHitMatches } from "./e2eBridgeSearch.ts"; +import { selectMockHistory } from "./e2eBridgeHistory.ts"; export { mockSearchHitMatches }; import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { @@ -4924,36 +4925,10 @@ function emitMockHistory( channelIds: string[], filter: MockFilter, ) { - const events = channelIds - .flatMap((channelId) => getMockMessageStore(channelId)) - .filter((event) => { - if (filter.kinds && !filter.kinds.includes(event.kind)) { - return false; - } - if (filter.since !== undefined && event.created_at < filter.since) { - return false; - } - if (filter.until !== undefined && event.created_at > filter.until) { - return false; - } - return true; - }) - // Relay order is `created_at DESC, id ASC` — match it (both the WS history - // page and the `get_channel_messages_before` keyset are backed by that one - // order in production, so the mock must be self-consistent too, else a - // same-second slice returned here won't line up with the keyset's tiebreak - // and the dense-second escape hatch can't prove completeness). Bare `until` - // still can't advance past a second denser than one page; the composite - // keyset is the escape hatch. - .sort( - (left, right) => - right.created_at - left.created_at || left.id.localeCompare(right.id), - ) - .slice(0, filter.limit ?? 50) - .sort( - (left, right) => - left.created_at - right.created_at || left.id.localeCompare(right.id), - ); + const events = selectMockHistory( + new Map(channelIds.map((id) => [id, getMockMessageStore(id)])), + [filter], + ); const emit = () => { for (const event of events) { @@ -10920,6 +10895,14 @@ function sendToMockSocket(args: { kinds: kinds.size > 0 ? [...kinds] : null, ownerPubkeys: [...ownerPubkeys], }); + // Live requests still replay stored matches; pacing can admit them after + // a publish. Ephemeral/global fixtures are not channel history. + const history = new Map( + [...channelIds].map((id) => [id, getMockMessageStore(id)]), + ); + for (const event of selectMockHistory(history, filters)) { + sendWsText(socket.handler, ["EVENT", subId, event]); + } sendWsText(socket.handler, ["EOSE", subId]); return; } diff --git a/desktop/src/testing/e2eBridgeHistory.test.mjs b/desktop/src/testing/e2eBridgeHistory.test.mjs new file mode 100644 index 00000000000..ba0cbef8ffc --- /dev/null +++ b/desktop/src/testing/e2eBridgeHistory.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { selectMockHistory } from "./e2eBridgeHistory.ts"; + +const event = (id, created_at, overrides = {}) => ({ + id, + created_at, + kind: 9, + pubkey: "author", + content: id, + sig: "", + tags: [ + ["h", "channel"], + ["e", "root"], + ], + ...overrides, +}); +const rows = [event("a", 10), event("b", 20), event("c", 20), event("d", 30)]; +const ids = (filters, events = rows) => + selectMockHistory(new Map([["channel", events]]), filters).map((e) => e.id); + +test("replay includes admission-gap events with inclusive time bounds and relay ordering", () => { + assert.deepEqual( + ids([{ "#h": ["channel"], since: 10, until: 20, limit: 2 }]), + ["b", "c"], + ); + assert.deepEqual(ids([{ since: 20, limit: 2 }]), ["b", "d"]); + assert.deepEqual(ids([{ until: 0 }]), []); +}); +test("live-only requests do not replay and old synthetic live events stay outside since", () => { + assert.deepEqual(ids([{ limit: 0 }]), []); + assert.deepEqual(ids([{ since: 31 }]), []); +}); +test("filters retain author, kind, id and tag constraints", () => { + for (const filter of [ + { authors: ["other"] }, + { kinds: [7] }, + { ids: ["absent"] }, + { "#h": ["other"] }, + { "#e": ["other"] }, + ]) { + assert.deepEqual(ids([filter]), []); + } + assert.deepEqual( + ids([{ authors: ["author"], kinds: [9], ids: ["b"], "#e": ["root"] }]), + ["b"], + ); +}); +test("multiple filters apply independent limits and deduplicate their union", () => { + assert.deepEqual( + ids([ + { until: 20, limit: 1 }, + { since: 20, limit: 2 }, + ]), + ["b", "d"], + ); + assert.deepEqual(ids([{ limit: 10 }], [...rows, rows[0]]), [ + "a", + "b", + "c", + "d", + ]); +}); + +test("channel ownership scopes untagged auxiliary events without rewriting wire tags", () => { + const reaction = event("reaction", 20, { kind: 7, tags: [["e", "root"]] }); + const foreign = event("foreign", 20, { tags: [["e", "root"]] }); + const conflicting = event("conflicting", 20, { + kind: 7, + tags: [ + ["e", "root"], + ["h", "other"], + ], + }); + const channels = new Map([ + ["channel", [reaction, conflicting]], + ["other", [foreign]], + ]); + assert.deepEqual( + selectMockHistory(channels, [{ "#h": ["channel"], "#e": ["root"] }]), + [reaction], + ); + assert.deepEqual(reaction.tags, [["e", "root"]]); +}); diff --git a/desktop/src/testing/e2eBridgeHistory.ts b/desktop/src/testing/e2eBridgeHistory.ts new file mode 100644 index 00000000000..0bafc588314 --- /dev/null +++ b/desktop/src/testing/e2eBridgeHistory.ts @@ -0,0 +1,37 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { matchFilter, type Filter } from "nostr-tools/filter"; + +/** Stored-event replay for mock REQs: per-filter limits, union, then one EOSE. */ +export function selectMockHistory( + channels: ReadonlyMap, + filters: Filter[], +): RelayEvent[] { + const selected = new Map(); + for (const filter of filters) { + // The relay scopes reactions/deletions through their stored channel, even + // when their wire tags only name an event. Keep emitted tags unchanged. + const { "#h": channelIds, ...eventFilter } = filter; + const candidates = [...channels] + .filter(([channelId]) => !channelIds || channelIds.includes(channelId)) + .flatMap(([, events]) => events); + const ordered = [ + ...new Map(candidates.map((event) => [event.id, event])).values(), + ].sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id)); + for (const event of ordered + .filter( + (event) => + matchFilter( + event.tags.some(([tag]) => tag === "h") ? filter : eventFilter, + event, + ) && + (filter.since === undefined || event.created_at >= filter.since) && + (filter.until === undefined || event.created_at <= filter.until), + ) + .slice(0, filter.limit ?? 50)) { + selected.set(event.id, event); + } + } + return [...selected.values()].sort( + (a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id), + ); +} diff --git a/desktop/tests/e2e/agent-control-regressions.spec.ts b/desktop/tests/e2e/agent-control-regressions.spec.ts index 4c0bb08faef..8a5eeabab38 100644 --- a/desktop/tests/e2e/agent-control-regressions.spec.ts +++ b/desktop/tests/e2e/agent-control-regressions.spec.ts @@ -171,6 +171,49 @@ test.describe("agent control browser regressions", () => { test("Stop publishes from a channelId-only panel with no Channel object", async ({ page, }) => { + // Hold the actual observer IPC send across the click to prove ordering. + await page.addInitScript(() => { + let internals: Record; + const probe = window as Window & { + __observerOrder?: string[]; + __releaseObserver?: () => void; + }; + probe.__observerOrder = []; + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + get: () => internals, + set(value) { + internals = value; + let invoke: (command: string, args: unknown) => Promise; + Object.defineProperty(value, "invoke", { + configurable: true, + get: () => invoke, + set(original) { + invoke = async (command, args) => { + if (command === "plugin:websocket|send") { + const message = (args as { message: { data: string } }) + .message; + const frame = JSON.parse(message.data); + if ( + frame[0] === "REQ" && + frame[1].startsWith("live-") && + frame[2].kinds?.includes(24200) + ) { + await new Promise((resolve) => { + probe.__releaseObserver = resolve; + }); + probe.__observerOrder?.push("observer"); + } + if (frame[0] === "EVENT" && frame[1].kind === 24200) + probe.__observerOrder?.push("control"); + } + return original(command, args); + }; + }, + }); + }, + }); + }); await installMockBridge(page, { observerControlResults: [{ type: "cancel_turn", status: "sent" }], }); @@ -196,7 +239,17 @@ test.describe("agent control browser regressions", () => { await page.getByTestId("agent-session-settings-menu-trigger").click(); const stop = page.getByTestId("agent-session-stop-turn"); await expect(stop).toBeEnabled(); + await page.waitForFunction( + () => + typeof (window as Window & { __releaseObserver?: unknown }) + .__releaseObserver === "function", + ); await stop.click(); + await page.evaluate(() => + ( + window as Window & { __releaseObserver?: () => void } + ).__releaseObserver?.(), + ); await expect .poll(() => readControlRequests(page)) .toEqual( @@ -212,6 +265,12 @@ test.describe("agent control browser regressions", () => { ]), ); await expect(page.getByText(/Stop signal sent to charlie/)).toBeVisible(); + expect( + await page.evaluate( + () => + (window as Window & { __observerOrder?: string[] }).__observerOrder, + ), + ).toEqual(["observer", "control"]); }); test("Stop reports ambiguous_target without claiming success", async ({ diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index 0ff43f3be47..5304f3bf7c9 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -9,29 +9,33 @@ const SHOTS = "test-results/channel-row-decoration-pr"; async function waitForMockLiveSubscription( page: import("@playwright/test").Page, channelName: string, - kind?: number, + kind = 40002, ) { await expect - .poll(async () => { - return page.evaluate( - ({ currentChannelName, kind: k }) => { - return ( - ( - window as Window & { - __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { - channelName: string; - kind?: number; - }) => boolean; - } - ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ - channelName: currentChannelName, - kind: k, - }) ?? false - ); - }, - { currentChannelName: channelName, kind }, - ); - }) + // Background admission is paced: the 15-channel fixture takes >5s to drain. + .poll( + async () => { + return page.evaluate( + ({ currentChannelName, kind: k }) => { + return ( + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind?: number; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: currentChannelName, + kind: k, + }) ?? false + ); + }, + { currentChannelName: channelName, kind }, + ); + }, + { timeout: 15_000 }, + ) .toBe(true); } diff --git a/desktop/tests/e2e/channel-dense-second-reach.spec.ts b/desktop/tests/e2e/channel-dense-second-reach.spec.ts index 34999be5076..f588c971fca 100644 --- a/desktop/tests/e2e/channel-dense-second-reach.spec.ts +++ b/desktop/tests/e2e/channel-dense-second-reach.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { waitForMockChannelHeadReady } from "../helpers/channelHeadReady"; // Lane 1c regression — the dense-second reachability wall. // @@ -19,7 +20,7 @@ import { installMockBridge } from "../helpers/bridge"; // (a) a *continuation* window request fired (cursor != null) — the head load // always issues `get_channel_window`, so only a cursor-bearing request // proves keyset paging engaged, and -// (b) every dense-second message becomes reachable (union of rendered rows +// (b) every dense-second message becomes reachable (union of viewport-visible rows // equals the full seed) — impossible behind a bare-`until` wall. const DENSE_SECOND = 1_700_000_000; const DENSE_COUNT = 450; // many multiples of CHANNEL_WINDOW_PAGE_SIZE (50) @@ -64,6 +65,11 @@ test("dense single second beyond one window page is fully reachable via composit await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockChannelHeadReady( + page, + "general", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + ); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); await page.waitForFunction(() => { @@ -73,15 +79,29 @@ test("dense single second beyond one window page is fully reachable via composit return element ? element.scrollHeight > element.clientHeight + 500 : false; }); - // Collect the union of dense-second indices ever rendered. Virtualization - // only mounts a window of rows, so we accumulate across scroll passes rather - // than snapshot once. - const renderedDenseIndices = async () => + // Collect only visible rows intersecting the timeline viewport, not mounted + // overscan rows. Accumulate across overlapping scroll steps so every seeded + // identity must actually become user-reachable. + const visibleDenseIndices = async () => timeline.evaluate((element) => { const found: number[] = []; + const viewport = element.getBoundingClientRect(); for (const row of ( element as HTMLDivElement ).querySelectorAll("[data-message-id]")) { + const bounds = row.getBoundingClientRect(); + if ( + !row.checkVisibility({ + checkOpacity: true, + checkVisibilityCSS: true, + }) || + bounds.bottom <= viewport.top || + bounds.top >= viewport.bottom || + bounds.right <= viewport.left || + bounds.left >= viewport.right + ) { + continue; + } const match = row.textContent?.match(/dense (\d+)/); if (match) found.push(Number(match[1])); } @@ -92,20 +112,26 @@ test("dense single second beyond one window page is fully reachable via composit // a genuine leave→enter transition (IntersectionObserver), so a raw // `scrollTop = 0` write on the virtualized container can fail to re-fire. // A wheel event is what a real user issues and what the observer honors. + const wheelStep = await timeline.evaluate( + (element) => element.clientHeight / 2, + ); const wheelToTop = async () => { for (let step = 0; step < 12; step += 1) { const atTop = await timeline.evaluate( (element) => (element as HTMLDivElement).scrollTop <= 1, ); if (atTop) break; - await page.mouse.wheel(0, -6000); + // Overlapping viewport steps let the virtualizer actually mount each + // row. A 6,000px jump can skip rows even when every page is reachable. + await page.mouse.wheel(0, -wheelStep); await page.waitForTimeout(40); + await collectVisible(); } }; const seen = new Set(); - const collectRendered = async () => { - for (const index of await renderedDenseIndices()) { + const collectVisible = async () => { + for (const index of await visibleDenseIndices()) { seen.add(index); } }; @@ -127,7 +153,7 @@ test("dense single second beyond one window page is fully reachable via composit await expect .poll( async () => { - await collectRendered(); + await collectVisible(); return seen.size; }, { timeout: 4_000 }, @@ -136,7 +162,7 @@ test("dense single second beyond one window page is fully reachable via composit } catch { // No growth this pass — count it toward a genuine stall. } - await collectRendered(); + await collectVisible(); if (seen.size > before) { stallStreak = 0; } else { @@ -158,11 +184,9 @@ test("dense single second beyond one window page is fully reachable via composit ); expect(continuationRequests).toBeGreaterThan(0); - // (b) Reachability parity: the union of paged dense rows crosses far past - // one window page — impossible behind a bare-`until` wall, where paging - // stalls on the newest slice of the dense second. We assert the vast - // majority became reachable; virtualization can drop a few transient rows - // between scroll settles, so we allow a small slack rather than demanding - // an exact 450. - expect(seen.size).toBeGreaterThan(DENSE_COUNT * 0.9); + // (b) Every seeded identity must be reachable: cardinality or a percentage + // threshold can silently accept an interior gap in the dense second. + expect([...seen].sort((a, b) => a - b)).toEqual( + Array.from({ length: DENSE_COUNT }, (_, index) => index), + ); }); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 1797def2622..88019be7440 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -223,26 +223,29 @@ async function waitForMockLiveSubscription( kind?: number, ) { await expect - .poll(async () => { - return page.evaluate( - ({ currentChannelName, kind }) => { - return ( - ( - window as Window & { - __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { - channelName: string; - kind?: number; - }) => boolean; - } - ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ - channelName: currentChannelName, - kind, - }) ?? false - ); - }, - { currentChannelName: channelName, kind }, - ); - }) + .poll( + async () => { + return page.evaluate( + ({ currentChannelName, kind }) => { + return ( + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind?: number; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: currentChannelName, + kind, + }) ?? false + ); + }, + { currentChannelName: channelName, kind }, + ); + }, + { timeout: 15_000 }, + ) .toBe(true); } @@ -1647,7 +1650,6 @@ test("create ephemeral stream shows sidebar and header affordances", async ({ throw new Error("Created ephemeral channel is missing its channel id."); } - await waitForMockLiveSubscription(page, channelName); await page.getByTestId("channel-general").click(); await page.evaluate( ({ agentPubkey, channelId: activeChannelId }) => { @@ -1692,6 +1694,9 @@ test("create ephemeral stream shows sidebar and header affordances", async ({ .getByRole("button", { name: "Toggle Sidebar", exact: true }) .click(); + // The active channel's window subscription is not the background unread + // listener. Wait for message admission after switching away. + await waitForMockLiveSubscription(page, channelName, 40002); await page.evaluate( ({ channelName: targetChannelName, mentionPubkey, senderPubkey }) => { (window as MockFeedWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ @@ -2521,7 +2526,7 @@ test("sidebar shows unread indicator for newly active channels", async ({ await page.goto("/"); await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); - await waitForMockLiveSubscription(page, "random"); + await waitForMockLiveSubscription(page, "random", 40002); // The unread tracker ignores the current user's own messages, so emit as // alice — simulating a real "another user posted while I was elsewhere". @@ -2555,7 +2560,7 @@ test("sidebar shows unread indicator for new forum posts", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0); - await waitForMockLiveSubscription(page, "watercooler"); + await waitForMockLiveSubscription(page, "watercooler", 45001); // Emit as alice — the unread tracker ignores self-authored messages. await page.evaluate( @@ -2585,7 +2590,7 @@ test("sidebar clears unread indicator after opening a DM", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0); - await waitForMockLiveSubscription(page, "alice-tyler"); + await waitForMockLiveSubscription(page, "alice-tyler", 9); await page.evaluate((pubkey) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index 1756ddabdfa..85783c91b5b 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -375,6 +375,12 @@ test("entity tooltip uses project context while relay metadata is delayed", asyn await installMockBridge(page); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByTestId("channel-general").click(); + await page.waitForFunction(() => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 39005, + }), + ); await page.evaluate(() => window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__?.(300), ); diff --git a/desktop/tests/e2e/inbox-live-update.spec.ts b/desktop/tests/e2e/inbox-live-update.spec.ts index 94e0b7b54a1..ed99d2b124a 100644 --- a/desktop/tests/e2e/inbox-live-update.spec.ts +++ b/desktop/tests/e2e/inbox-live-update.spec.ts @@ -32,6 +32,7 @@ type MockWindow = Window & { mentionPubkeys?: string[]; id?: string; kind?: number; + createdAt?: number; extraTags?: string[][]; }) => RelayEvent; __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: { @@ -296,6 +297,14 @@ test.describe("inbox stable-conversation regressions", () => { await expect(detail).toContainText("Nested anchor"); expect(await getItemParam(page)).toBe(anchor.id); + // This tests steady-state delivery, not paced startup admission. + await page.waitForFunction(() => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 9, + }), + ); + // Add filler replies to make the detail pane scrollable. await page.evaluate( ({ senderPubkey, rootId }) => { @@ -1143,11 +1152,13 @@ test.describe("inbox stable-conversation regressions", () => { const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__; if (!emit || !push) throw new Error("Bridge helpers not ready"); + const start = Math.floor(Date.now() / 1000) - 20; const fetchRoot = emit({ channelName: "general", content: "Reaction-drift test root.", pubkey: senderPubkey, id: "e0".repeat(32), + createdAt: start, }); // 10 older replies — in mockMessages, prepended above fetchNewest @@ -1160,6 +1171,7 @@ test.describe("inbox stable-conversation regressions", () => { parentEventId: fetchRoot.id, pubkey: senderPubkey, id: `e${i.toString(16).padStart(1, "0")}`.repeat(32), + createdAt: start + i, }); olderReplyIds.push(reply.id); } @@ -1172,6 +1184,7 @@ test.describe("inbox stable-conversation regressions", () => { pubkey: senderPubkey, mentionPubkeys: [currentPubkey], id: "ef".repeat(32), + createdAt: start + 11, }); // Later replies ensure the selected row has enough content below it to @@ -1183,6 +1196,7 @@ test.describe("inbox stable-conversation regressions", () => { parentEventId: fetchRoot.id, pubkey: senderPubkey, id: `f${i.toString(16).padStart(1, "0")}`.repeat(32), + createdAt: start + 11 + i, }); } @@ -1191,7 +1205,7 @@ test.describe("inbox stable-conversation regressions", () => { kind: fetchNewest.kind, pubkey: fetchNewest.pubkey, content: fetchNewest.content, - created_at: fetchNewest.created_at + 11, + created_at: fetchNewest.created_at, channel_id: channelId, channel_name: "general", tags: fetchNewest.tags, @@ -1247,6 +1261,9 @@ test.describe("inbox stable-conversation regressions", () => { ); }); expect(msgCenterOffsetBeforeReactions).not.toBeNull(); + expect(Math.abs(msgCenterOffsetBeforeReactions ?? Infinity)).toBeLessThan( + 30, + ); expect(await getScrollIntoViewCount(page)).toBe(1); // ── Emit late reactions targeting messages ABOVE fetchNewest ────── @@ -1353,11 +1370,13 @@ test.describe("inbox stable-conversation regressions", () => { const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__; if (!emit || !push) throw new Error("Bridge helpers not ready"); + const start = Math.floor(Date.now() / 1000) - 20; const fetchRoot = emit({ channelName: "general", content: "Reaction-drift test root.", pubkey: senderPubkey, id: "e0".repeat(32), + createdAt: start, }); // 10 older replies — in mockMessages, prepended above fetchNewest @@ -1370,6 +1389,7 @@ test.describe("inbox stable-conversation regressions", () => { parentEventId: fetchRoot.id, pubkey: senderPubkey, id: `e${i.toString(16).padStart(1, "0")}`.repeat(32), + createdAt: start + i, }); olderReplyIds.push(reply.id); } @@ -1382,6 +1402,7 @@ test.describe("inbox stable-conversation regressions", () => { pubkey: senderPubkey, mentionPubkeys: [currentPubkey], id: "ef".repeat(32), + createdAt: start + 11, }); // Later replies ensure the selected row has enough content below it to @@ -1394,6 +1415,7 @@ test.describe("inbox stable-conversation regressions", () => { parentEventId: fetchRoot.id, pubkey: senderPubkey, id: `f${i.toString(16).padStart(1, "0")}`.repeat(32), + createdAt: start + 11 + i, }); } @@ -1402,7 +1424,7 @@ test.describe("inbox stable-conversation regressions", () => { kind: fetchNewest.kind, pubkey: fetchNewest.pubkey, content: fetchNewest.content, - created_at: fetchNewest.created_at + 11, + created_at: fetchNewest.created_at, channel_id: channelId, channel_name: "general", tags: fetchNewest.tags, @@ -1440,6 +1462,26 @@ test.describe("inbox stable-conversation regressions", () => { // Older replies are now rendered (fetch landed). await expect(detail).toContainText("Reaction-drift older reply 1"); expect(await getScrollIntoViewCount(page)).toBe(1); + // Prove the intended centered state and real downward scroll room before + // testing hold release; do not manufacture either with a setup scroll. + const geometry = await page.evaluate(() => { + const selected = document.querySelector( + '[data-testid="home-inbox-selected-message"]', + ); + const pane = document.querySelector( + '[data-testid="home-inbox-detail"] [aria-busy]', + ); + if (!selected || !pane) return null; + const row = selected.getBoundingClientRect(); + const bounds = pane.getBoundingClientRect(); + return { + offset: row.top + row.height / 2 - (bounds.top + bounds.height / 2), + room: pane.scrollHeight - pane.clientHeight - pane.scrollTop, + }; + }); + expect(geometry).not.toBeNull(); + expect(Math.abs(geometry?.offset ?? Infinity)).toBeLessThan(30); + expect(geometry?.room ?? 0).toBeGreaterThan(0); // Simulate a scrollbar drag: change the actual scroll container directly // and dispatch only `scroll`, without wheel/touch/key input. This must diff --git a/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts b/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts index d63be27471b..96b2362a741 100644 --- a/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts +++ b/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts @@ -45,6 +45,7 @@ async function waitForMockLiveSubscription( (ch) => window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch, + kind: 39005, }) ?? false, channelName, ), diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index c82c8b960e3..afa8b9b6c3e 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -3946,10 +3946,10 @@ test("closing a thread while editing a reply preserves the typed edit", async ({ const timelineRoot = page .getByTestId("message-timeline") .getByTestId("message-row") - .last(); + .filter({ hasText: root }); await expect(timelineRoot).toContainText(root); await waitForAnimations(page); - await timelineRoot.scrollIntoViewIfNeeded(); + // Hover auto-scrolls and re-resolves the row if rendering replaces it. await timelineRoot.hover(); await timelineRoot.getByRole("button", { name: "Reply" }).click(); @@ -4036,10 +4036,10 @@ test("main ArrowUp refuses to replace a dirty thread edit", async ({ const timelineRoot = page .getByTestId("message-timeline") .getByTestId("message-row") - .last(); + .filter({ hasText: root }); await expect(timelineRoot).toContainText(root); await waitForAnimations(page); - await timelineRoot.scrollIntoViewIfNeeded(); + // Hover auto-scrolls and re-resolves the row if rendering replaces it. await timelineRoot.hover(); await timelineRoot.getByRole("button", { name: "Reply" }).click(); diff --git a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts index 09060ec39ee..26b8b8c3da1 100644 --- a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts +++ b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts @@ -197,6 +197,7 @@ test("keeps an open status draft when the saved status expires", async ({ page, }) => { await page.goto("/"); + await waitForMockGlobalKindSubscription(page, 30315); const nowSeconds = Math.floor(Date.now() / 1_000); await seedMockStatus(page, { text: "Original draft", diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 6c0b9afd820..27bf08b731f 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { waitForMockChannelHeadReady } from "../helpers/channelHeadReady"; // First-pass settle budget for a full channel-history prepend. CI Linux font // rasterization can leave the restored anchor a subpixel off the local value @@ -182,17 +183,18 @@ test("preserves user scroll while older channel history loads", async ({ () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); - // Use the `deep-history` channel: its store is seeded with 600 messages, - // more than CHANNEL_HISTORY_LIMIT (300, hooks.ts), so the cold load windows - // to the newest 300 and leaves ~300 genuinely older messages behind the - // `until` cursor. A shallow seed (store < 300) is fully drained by the cold - // load, so the wheel `fetchOlder` returns only already-cached duplicates that - // dedup to zero net growth -- the anchor never has a real prepend to hold and - // the assertion would measure virtualizer re-measure, not scroll preservation. + // The 600-row seed exceeds the 50-row head window, leaving genuinely older + // pages behind its composite cursor. A fully drained seed would only measure + // virtualizer re-measurement, not scroll preservation across real growth. await page.getByTestId("channel-deep-history").click(); await expect(page.getByTestId("chat-title")).toHaveText("deep-history"); + + await waitForMockChannelHeadReady( + page, + "deep-history", + "feedf00d-0000-4000-8000-000000000007", + ); const timeline = page.getByTestId("message-timeline"); - await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); await page.waitForFunction(() => { const element = document.querySelector( '[data-testid="message-timeline"]', @@ -1316,6 +1318,12 @@ test("fast middle-page scroll settles with continuous mounted coverage", async ( return element && element.scrollHeight > element.clientHeight * 3; }); + await page.waitForFunction(() => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 39005, + }), + ); // Land a genuine prepend first. This is what turns `shift` on; subsequent // ordinary list updates and measurements must happen with it cleared. const scrollHeightBeforePrepend = (await getTimelineMetrics(page)) @@ -1648,6 +1656,11 @@ test("channel intro stays hidden while paginating past the timeline cap", async await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockChannelHeadReady( + page, + "general", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + ); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); @@ -1785,6 +1798,11 @@ test("older-history fetches never overlap (no concurrent in-flight requests)", a await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockChannelHeadReady( + page, + "general", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + ); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); @@ -1848,6 +1866,11 @@ test("older-history spinner stays visible in viewport while fetching mid-scroll" await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockChannelHeadReady( + page, + "general", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + ); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); @@ -2024,6 +2047,11 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockChannelHeadReady( + page, + "general", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + ); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); await page.waitForFunction(() => { diff --git a/desktop/tests/e2e/sidebar-snapshot.spec.ts b/desktop/tests/e2e/sidebar-snapshot.spec.ts index c8a3af46946..6d1b349e884 100644 --- a/desktop/tests/e2e/sidebar-snapshot.spec.ts +++ b/desktop/tests/e2e/sidebar-snapshot.spec.ts @@ -532,16 +532,18 @@ test("hash mismatch replaces the snapshot with the full live list", async ({ page, }) => { await seedSnapshot(page, { hash: "stale-hash" }); + await trackSnapshotRows(page); await installMockBridge(page, { channelsReadDelayMs: READ_DELAY_MS, honorChannelsKnownHash: true, }); await page.goto("/"); - await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount( - FULL_SNAPSHOT.length, - { timeout: 500 }, - ); + // Observe the boot frame even if live revalidation finishes before the + // test runner gets its next turn; cold-boot coverage separately times paint. + await expect + .poll(() => getTrackedSnapshotRows(page)) + .toEqual(FULL_SNAPSHOT.map((channel) => channel.id)); await expect .poll(() => getChannelsPayloads(page)) .toEqual([{ knownHash: "stale-hash" }]); diff --git a/desktop/tests/e2e/thread-unread.spec.ts b/desktop/tests/e2e/thread-unread.spec.ts index 95a707a9863..dca2edcbba8 100644 --- a/desktop/tests/e2e/thread-unread.spec.ts +++ b/desktop/tests/e2e/thread-unread.spec.ts @@ -13,20 +13,26 @@ async function waitForMockLiveSubscription( channelName: string, ) { await expect - .poll(async () => { - return page.evaluate( - ({ ch }) => - ( - window as Window & { - __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { - channelName: string; - }) => boolean; - } - ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ?? - false, - { ch: channelName }, - ); - }) + .poll( + async () => { + return page.evaluate( + ({ ch }) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind: number; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: ch, + kind: 9, + }) ?? false, + { ch: channelName }, + ); + }, + { timeout: 15_000 }, + ) .toBe(true); } diff --git a/desktop/tests/helpers/channelHeadReady.ts b/desktop/tests/helpers/channelHeadReady.ts new file mode 100644 index 00000000000..d24edcb8b49 --- /dev/null +++ b/desktop/tests/helpers/channelHeadReady.ts @@ -0,0 +1,75 @@ +import { expect, type Page } from "@playwright/test"; + +// These default mock fixtures have immediate EOSE and no injected reconnect. +// Steady-state pagination begins after live admission and its head refresh, +// which can replace paged tails. This does not test early startup scrolling. +export async function waitForMockChannelHeadReady( + page: Page, + channelName: string, + channelId: string, +) { + await page.waitForFunction( + ({ channelId, channelName }) => { + if ( + !window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName, + kind: 39005, + }) + ) { + return false; + } + const commands = window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []; + const liveIndex = commands.findIndex(({ command, payload }) => { + if (command !== "plugin:websocket|send") return false; + const message = ( + payload as { message?: { type: string; data: string } } + )?.message; + if (message?.type !== "Text") return false; + const [type, id, ...filters] = JSON.parse(message.data); + return ( + type === "REQ" && + id.startsWith("live-") && + filters.some( + (filter: { kinds?: number[]; "#h"?: string[] }) => + filter.kinds?.includes(39005) && + filter["#h"]?.includes(channelId), + ) + ); + }); + const refreshed = commands + .slice(liveIndex + 1) + .some(({ command, payload }) => { + const args = payload as { + channelId?: string; + cursor?: unknown; + } | null; + return ( + command === "get_channel_window" && + args?.channelId === channelId && + args.cursor === null + ); + }); + const state = window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState([ + "channel-messages", + channelId, + ]); + return ( + liveIndex >= 0 && + refreshed && + state?.status === "success" && + state.fetchStatus === "idle" + ); + }, + { channelId, channelName }, + ); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + await expect + .poll(async () => { + return timeline.evaluate( + (element) => + element.scrollHeight - element.scrollTop - element.clientHeight, + ); + }) + .toBeLessThanOrEqual(2); +}