From c213d90d5c450e2e579505da4a4145f528ecbcf2 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 22 Sep 2026 11:31:44 -0700 Subject: [PATCH] fix(desktop): bound startup request bursts and recover quota refusals (#7790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepared by Brain on Wes’s behalf. ## Summary Reduce startup request amplification and let quota-refused background work recover without changing relay quotas or bypassing the existing Send gate. - Coalesce nest regeneration into one worker plus the latest pending generation. Keep the stale-write fence; bound the archive read so an old workspace cannot strand newer work. - Claim pending channel subscriptions before awaiting setup; cancel removed/unmounted entries and preserve still-owned subscriptions across reconnect handoff. - Recheck extended cooldown deadlines before retry dispatch; distinguish an explicit zero-second hint from a missing hint. - Recover native finite/unread queries within their original caller deadline and a three-retry budget, preserving verified unique events, complete filters and terminal errors. - Pace renderer initial/live-CLOSED REQs at one per 250ms: visible channel and explicitly interactive TTS/read-state/observer owners first, then live-only auxiliaries, then background history. Publication and finite queries bypass this queue. Reconnect replay now promotes explicit interactive owners alongside the visible channel with stable registration-order ties; its existing capped batches and cooldown remain intact. **Scope at `867495426`:** 39 source/test files, including 15 production files (+665/-235) and 24 test/fixture files. The diagnostic recorder, HTTP instrumentation, trace launcher and raw captures are deliberately excluded. No profile reset, grouping, quota increase or new global scheduler. ## Before / after evidence These are observations from instrumented, human-operated development Tauri/Vite runs against a production relay, plus explicitly identified controlled regressions—not a packaged-release startup benchmark. | Measurement | Before | After | Evidence boundary | | --- | --- | --- | --- | | Archive snapshot requests (kind 13535) | 290 | 3 | Recorder-only baseline → nest-only intermediate candidate; unequal 67.436s / 39.273s captures and hydration differences | | Archive responses: HTTP 200 / 429 | 277 / 13 | 3 / 0 | Same baseline/nest-only captures; not a claim that all quota pressure disappeared | | Successful archive responses’ advertised content-length sum | 21,208,505 bytes | 229,695 bytes | Header values, not actual transferred bytes | | 154-channel StrictMode replay | 308 REQs | 154 REQs | Controlled real React hook → real session → mocked Tauri transport regression | | Add one channel while 154 setups are pending | 309 REQs | 155 REQs | Same production-boundary regression; ordinary mount remains 154 → 154 | | Broad renderer request burst / spacing | 110 broad REQs within 10.766ms in pre-drain capture | 250.061ms minimum / 252.131ms median between broad REQ writes in final longer capture | Different attended runs; pacing rather than a universal request-rate cap | | Native unread query outcomes | Baseline: 8 EOSE / 146 quota CLOSED out of 154 queries | Final longer run: all 156 observed queries reach EOSE; eight refused queries each recover after two refusals | Different runs/workloads; query completion, not badge rendering | ### Timing metrics | Observation | Measured value | | --- | ---: | | Baseline’s two accepted chat EVENT socket writes → positive OK | 173.340ms / 85.556ms | | Nest-only intermediate candidate’s two accepted writes → positive OK | 277.854ms / 78.775ms | | Final candidate, short run, accepted chat writes → positive OK | 80.636ms / 77.506ms | | Final candidate, longer run, accepted chat writes → positive OK | 120.712ms / 86.895ms | | Last initial EOSE among 163 observed broad channel registrations, longer run | 64.550s after trace start | | Last EOSE among 156 observed native unread queries, longer run | 73.972s after trace start | | Native retry waits after 3s then 0s hints, longer run | 3000.7–3002.8ms, then 2001.5–2002.6ms | | Renderer finite-query retry after a 2s hint, longer run | 2061.365ms | Both Sends in each final run were accepted, with no recorded negative OK. Every quota-refused query in the longer run subsequently completed the same query. Its two Sends happened early (17.878s and 21.655s), not near the end. These small samples do not support a percentage latency improvement. **Tradeoffs and limits** - 296 background subscriptions imply about 74s of paced registration before extra cooldowns/high-priority work. The longer capture observed 163 registrations; it did not independently enumerate the expected membership set or record a queue-empty marker. - This is not a global 4-REQ/s cap: finite queries, native requests and reconnect replay are outside the drain and still share relay quota. Quota refusals recover rather than disappear. - Socket-write → OK starts after composer submission and any publisher gate wait. No click-to-visible, launch-to-usable or badge-paint latency is claimed. - Native EOSE is not proof of command return, classification, React state application or visible unread badges. Existing per-channel kinds, since floors, limits, h-less routing, unread lease/scope guard and eight-query concurrency remain intact. - The attended measurements belong to the instrumented candidate, not a fresh release run of this fixes-only commit. At the initial packaging checkpoint (`1bf3d060`), eighteen of twenty files were byte-identical; the other two removed diagnostic gate calls and isolated the nest test’s temporary paths. The subsequent interactive priority/reconnect and readiness fixes have controlled hook/session/browser evidence, not another attended voice/performance run. ### Evidence provenance (captures retained locally, not uploaded) | Capture | SHA-256 | | --- | --- | | Baseline `STARTUP_20260921T194814Z_98811.jsonl` | `f4f0a89ad9aea91084bd6a7432510488166de4cbc906600d88c26d557f380994` | | Nest-only `STARTUP_20260921T202610Z_28642.jsonl` | `ce517b02a3f717c36d96b36bb8616e8fa80b03b12b356bacfd40475925ea3f3e` | | Final short `STARTUP_20260922T013628Z_13506.jsonl` | `bfea6f3cf80cd7b7e4e676632a3df2f16271fade5bfa0edec72b7bcbbe7839ff` | | Final longer `STARTUP_20260922T014637Z_15846.jsonl` | `f4abece613d108c4c2fe3cb808e633a0486bdc9993ca92323822dc00c3fae6ec` | Final instrumented build fingerprint: `b0b4fc27d793c3e643f4e10289b5afdb4620ee2e13a8b33fa0595dd3417fd5ca`. All observed dropped-record counters in these final captures were zero. Source manifests and offline correlation analyses are retained locally; no raw captures, profile data or agent sessions are part of this PR. ## Validation Current head: `86749542657a60abdfe82eed5cc576d3e162fe5d`, based on `77729abfb692b25a0f4ec4a69add86af2e32c0dd`. Review follow-ups: - `8842dbd8`: explicit interactive TTS admission using the existing main-window owner, independent of companion/timeline selection. Real-hook/session/IPC coverage with 296 cold owners proves initial and CLOSED priority, unchanged filters/cooldown/pacing, background progress and deduplicated speech. - `8cb088106`: reconnect preserves interactive priority even with no visible channel. Stable ordering among foreground ties; real hook/session reconnect regressions cover cooldown, capped batching, all 297 owners, deduplication and unmount cancellation. Independent reviewer mutation restored the prior replay implementation, failed both reconnect rows, then passed the restored candidate. - `68e76d7c`: read-state and observer reuse interactive priority. Stop and ModelPicker wait on the existing observer owner inside send callbacks, after installing result listeners/deadlines. No new owner, timer or scheduler; inherited CLOSED/fallback/error semantics remain, rather than a new strict EOSE guarantee. The real Stop panel browser probe holds observer IPC across the click and checks observer-before-control order and correlated outcome. ModelPicker component ordering is source-reviewed only. - Mock live admission now replays matching stored history with per-filter time/limit constraints, deduplication, untagged auxiliary channel fallback and authoritative explicit h-tags. Kind-specific fixture admission waits and retry-resolving hover actions fix startup-dependent test setup without weakening event/badge assertions. - `48e9c0dad` and `867495426`: steady-state scroll-prepend setup observes live admission, subsequent head dispatch, successful/idle query completion and initial bottom geometry. Phase-2 genuine-growth and ≤32px anchor assertions are unchanged. This isolates steady-state prepending: it **does not fix or certify production early-scroll/head-reset behavior**. Current validation: - Normal push hooks at `867495426`: **6,582/6,582 renderer tests**, zero failed/skipped/cancelled; lint/static checks, typecheck and differential file-size gate passed. Native source is unchanged from the earlier checkpoint; no new native run is claimed for these renderer/test follow-ups. - E2E build passed at `48e9c0dad` plus the exact subsequently committed test-only delta (spec blob `469be2eed868c677ba80b859cc871208000fee60`). The completed affected-smoke run passed **153/153** across agent-control, badge, entity links, messaging, profile status and scroll history; the full scroll file additionally passed twice (**36/36**). No full CI-smoke pass is claimed from this six-file local run. - Pinky independently reviewed production and test follow-ups, including the final scroll spec blob `469be2eed868c677ba80b859cc871208000fee60`, with no blocking finding. Review is source evidence, not an independent test run. - **Not merge-ready:** new-head required CI must complete, exact-range security review requires authorization, and GitHub currently reports REVIEW_REQUIRED; fresh exact-head reviewer approval is still needed. No approval, dismissal or merge performed. No new packaged/native audio, launch-to-usable or badge-paint observation is claimed. Initial fixes-only checkpoint validation (`1bf3d06024d284a63326fdd353baced0842aeb81`): - Pre-commit formatting hooks passed; worktree clean. - Renderer package hooks: 6,570 tests passed, zero failed/skipped/cancelled; lint/static checks, TypeScript and differential file-size gate passed. - Native package hook passed: default and mesh-enabled all-target Clippy, then the complete native workspace test suite. App library: 3,222 passed / 0 failed / 19 ignored; terminal/integration suites passed. The real nest trigger/stalled-body test passed on macOS. - Independent whole-change source review: no blocking findings at this commit; all 20 file hashes matched the committed blobs and review/validation manifests. - Regression coverage includes real hook/session cancellation, StrictMode/membership re-entry, reconnect ownership, extended/zero cooldowns, burst pacing, auxiliary readiness (including EOSE before IPC settles), native socket refusal/retry/deadline/cancellation, unread fetch/classification and real nest trigger/HTTP/file persistence. - Earlier isolated renderer mutations that started readiness before enqueue or removed auxiliary priority each failed the real presence regression; restored controls passed. No native mutation claim. - Nest fixture uses explicit app-data/nest paths instead of assuming Windows known-folder APIs obey HOME/APPDATA. Windows runtime execution is not claimed; CI/build results remain separate. Originating conversation: buzz://message?channel=46ced308-71df-495d-a144-4ebf08334703&id=bc43c3422a3b0b2141268d0fa149b898421c824992b662aed8b73c4eee50404f No approval or merge recommendation is implied by opening this PR. --------- Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/managed_agents/nest.rs | 187 +++--- .../src/managed_agents/nest/regen_tests.rs | 120 ++++ .../nest/regen_trigger_tests.rs | 170 ++++++ .../src/managed_agents/nest/render_tests.rs | 22 +- desktop/src-tauri/src/native_relay_client.rs | 150 ++++- .../src/native_relay_client_finite_tests.rs | 459 +++++++++++++++ .../src/native_relay_client_tests.rs | 3 + desktop/src-tauri/src/unread_catch_up.rs | 51 +- .../src/unread_catch_up_recovery_tests.rs | 201 +++++++ .../src/features/agents/ui/ModelPicker.tsx | 6 +- .../channels/observedUnreadNativeRig.mjs | 2 +- .../readState/readStateManager.test.mjs | 4 +- .../channels/readState/readStateManager.ts | 3 +- .../channels/ui/AgentSessionThreadPanel.tsx | 15 +- .../useLiveChannelUpdates.transport.test.mjs | 302 ++++++++++ .../channels/useLiveChannelUpdates.ts | 87 ++- .../src/features/home/ui/InboxDetailPane.tsx | 4 +- .../home/ui/inboxReopenNavigation.test.mjs | 3 + .../features/home/useInboxThreadContext.ts | 17 +- .../lib/useTtsSubscription.transport.test.mjs | 360 ++++++++++++ .../features/huddle/lib/useTtsSubscription.ts | 2 +- desktop/src/shared/api/observerRelay.test.mjs | 4 +- desktop/src/shared/api/observerRelay.ts | 3 +- .../shared/api/relayClientBurstDrain.test.mjs | 539 ++++++++++++++++++ .../api/relayClientCooldownRetry.test.mjs | 496 ++++++++++++++++ .../api/relayClientLiveCancellation.test.mjs | 408 +++++++++++++ desktop/src/shared/api/relayClientSession.ts | 210 +++++-- desktop/src/shared/api/relayClientShared.ts | 4 + desktop/src/shared/api/relayClosedRecovery.ts | 69 ++- desktop/src/shared/api/relayLiveReqDrain.ts | 82 +++ .../shared/api/relayRateLimitGate.test.mjs | 29 +- desktop/src/shared/api/relayRateLimitGate.ts | 5 +- .../src/shared/api/relayReconnectReplay.ts | 30 +- desktop/src/testing/e2eBridge.ts | 43 +- desktop/src/testing/e2eBridgeHistory.test.mjs | 84 +++ desktop/src/testing/e2eBridgeHistory.ts | 37 ++ .../e2e/agent-control-regressions.spec.ts | 59 ++ desktop/tests/e2e/badge.spec.ts | 46 +- .../e2e/channel-dense-second-reach.spec.ts | 58 +- desktop/tests/e2e/channels.spec.ts | 53 +- .../e2e/entity-link-recipient-cards.spec.ts | 6 + desktop/tests/e2e/inbox-live-update.spec.ts | 46 +- .../e2e/live-broadcast-reply-timeline.spec.ts | 1 + desktop/tests/e2e/messaging.spec.ts | 8 +- .../e2e/profile-custom-emoji-status.spec.ts | 1 + desktop/tests/e2e/scroll-history.spec.ts | 44 +- desktop/tests/e2e/sidebar-snapshot.spec.ts | 10 +- desktop/tests/e2e/thread-unread.spec.ts | 34 +- desktop/tests/helpers/channelHeadReady.ts | 75 +++ 49 files changed, 4265 insertions(+), 387 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/nest/regen_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/nest/regen_trigger_tests.rs create mode 100644 desktop/src-tauri/src/native_relay_client_finite_tests.rs create mode 100644 desktop/src-tauri/src/unread_catch_up_recovery_tests.rs create mode 100644 desktop/src/features/channels/useLiveChannelUpdates.transport.test.mjs create mode 100644 desktop/src/features/huddle/lib/useTtsSubscription.transport.test.mjs create mode 100644 desktop/src/shared/api/relayClientBurstDrain.test.mjs create mode 100644 desktop/src/shared/api/relayClientCooldownRetry.test.mjs create mode 100644 desktop/src/shared/api/relayClientLiveCancellation.test.mjs create mode 100644 desktop/src/shared/api/relayLiveReqDrain.ts create mode 100644 desktop/src/testing/e2eBridgeHistory.test.mjs create mode 100644 desktop/src/testing/e2eBridgeHistory.ts create mode 100644 desktop/tests/helpers/channelHeadReady.ts diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 46f36212ce..adbc7794bb 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) } - /// 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. + /// 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; + } + }) + } + + /// 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 0000000000..f131da4214 --- /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 0000000000..41f6fbff82 --- /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 5c38eb4c25..13907ddc04 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 19740dd019..fd97f4b9b7 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 0000000000..913d17f5fd --- /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 ca2324327f..f12a689449 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 96a740638b..d0e2de827a 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 0000000000..9d4b5318cb --- /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 cd10e8704c..170adca0b8 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 cbe2476f2e..0fab6aa438 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 c46654f32e..f06dd821c8 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 87fc1ceaac..5d455dedd3 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 db6155c3c4..c3c5ce984a 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 0000000000..da99884f3a --- /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 b25df3acf9..477621b317 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 7b40d452ee..a29a0e927d 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 533aaf7b8d..c88f448d80 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 4383970808..7b09ef0438 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 0000000000..3e5a44e44c --- /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 1744cd7c1b..5b3a844698 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 bfd1554b79..0bf716b770 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 3b41f4de41..c96bd036da 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 0000000000..614a609ab7 --- /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 0000000000..b61484f0ca --- /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 0000000000..e34e9be230 --- /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 d9be0e6c9b..2523324b67 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, - }); - - try { - await this.sendRawWithReconnectRetry( - ["REQ", subId, filter], - "Failed to restore relay subscription.", - ); - } catch (error) { - window.clearTimeout(fallbackTimeout); + 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; + }); + 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) { + 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 8bd6379d7a..80f5de206d 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 9d30a233e7..cf04d83126 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 0000000000..7b3d0056ad --- /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 dd2346911f..d3b254403c 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 827713eb06..d98690b531 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 60a57f1a0b..18b8f9fd57 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 ae641800c5..b50de75ea8 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 0000000000..ba0cbef8ff --- /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 0000000000..0bafc58831 --- /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 4c0bb08fae..8a5eeabab3 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 0ff43f3be4..5304f3bf7c 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 34999be507..f588c971fc 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 1797def262..88019be744 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 1756ddabdf..85783c91b5 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 94e0b7b54a..ed99d2b124 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 d63be27471..96b2362a74 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 c82c8b960e..afa8b9b6c3 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 09060ec39e..26b8b8c3da 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 6c0b9afd82..27bf08b731 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 c8a3af4694..6d1b349e88 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 95a707a986..dca2edcbba 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 0000000000..d24edcb8b4 --- /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); +}