Skip to main content

contextvm_sdk/transport/open_stream/
session.rs

1//! Reader-side view of a CEP-41 open stream.
2//!
3//! Ports `sdk/src/transport/open-stream/session.ts`. An [`OpenStreamSession`] is
4//! fed inbound frames via the **synchronous** [`process_frame`](OpenStreamSession::process_frame)
5//! and consumed as an async [`Stream`] of payload chunks. It owns no live timers:
6//! the keepalive state machine is the pure [`tick`](OpenStreamSession::tick)
7//! method (idle → `ping`, probe → abort, close-grace → abort), which the owning
8//! transport drives from its periodic sweep.
9//!
10//! Design: the feed path is a plain sync call
11//! under a `std::sync::Mutex`; the drain path is a manual [`Stream`] impl over a
12//! `VecDeque` plus a stored [`Waker`]. The terminal error is delivered **once**
13//! on the next poll (`terminal.take()`), an intentional divergence from the TS
14//! reference where it is rejected to every parked reader; [`closed`](OpenStreamSession::closed)
15//! resolves to `()` on any finalize and never carries the error.
16
17use std::collections::{BTreeMap, VecDeque};
18use std::future::Future;
19use std::pin::Pin;
20use std::sync::{Arc, Mutex};
21use std::task::{Context, Poll, Waker};
22use std::time::{Duration, Instant};
23
24use futures::future::BoxFuture;
25use futures::Stream;
26use nostr_sdk::prelude::EventId;
27
28use crate::core::types::JsonRpcNotification;
29
30use super::constants::MAX_OPEN_STREAM_PING_NONCE_BYTES;
31use super::errors::OpenStreamError;
32use super::frame::OpenStreamFrame;
33
34/// Injected outbound publish closure (same seam as the writer).
35///
36/// Used by the reader's consumer [`abort`](OpenStreamSession::abort) to emit an
37/// `abort` frame to the peer. Cheaply clonable (`Arc`) so a session handle stays
38/// clonable across the registry and the consumer.
39pub type PublishFrame =
40    Arc<dyn Fn(JsonRpcNotification) -> BoxFuture<'static, crate::Result<EventId>> + Send + Sync>;
41
42/// The effect a single processed frame requires of the async caller.
43///
44/// Keeps the state machine I/O-free (like the oversized receiver): the inbound
45/// dispatcher performs any send the outcome demands.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum FrameOutcome {
48    /// No side effect; await more frames.
49    None,
50    /// A `ping` was received; the caller must publish a `pong` echoing the nonce.
51    SendPong(String),
52    /// The stream closed gracefully (terminal).
53    Closed,
54    /// The stream was aborted by the peer (terminal); advisory reason attached.
55    Aborted(Option<String>),
56}
57
58/// The keepalive action a [`tick`](OpenStreamSession::tick) requires of the sweep.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum KeepaliveAction {
61    /// Idle threshold not yet crossed (or a probe is already in flight).
62    None,
63    /// Idle threshold crossed: publish a `ping` carrying this nonce.
64    SendPing(String),
65    /// A deadline expired: the stream was aborted locally with this reason.
66    Abort(String),
67}
68
69/// Construction options for an [`OpenStreamSession`].
70pub struct OpenStreamSessionOptions {
71    /// The stream id (stringified `progressToken`).
72    pub progress_token: String,
73    /// Maximum buffered + queued chunks held before a `Sequence` error.
74    pub max_buffered_chunks: usize,
75    /// Maximum buffered + queued payload bytes before a `Sequence` error.
76    pub max_buffered_bytes: u64,
77    /// Idle interval before the reader probes the peer with a `ping` (ms).
78    pub idle_timeout_ms: u64,
79    /// Time the reader waits for a `pong` after probing before aborting (ms).
80    pub probe_timeout_ms: u64,
81    /// Grace period after a `close` with unresolved gaps before aborting (ms).
82    pub close_grace_period_ms: u64,
83    /// Outbound publisher for the consumer `abort` frame (optional).
84    pub publish_frame: Option<PublishFrame>,
85}
86
87/// Mutable per-stream state, guarded by a `std::sync::Mutex`.
88///
89/// All mutation happens under the lock in synchronous methods that never
90/// `.await`, so the lock is never held across a suspension point.
91struct SessionState {
92    progress_token: String,
93
94    // ── policy ──────────────────────────────────────────────────────
95    max_buffered_chunks: usize,
96    max_buffered_bytes: u64,
97    idle_timeout: Duration,
98    probe_timeout: Duration,
99    close_grace_period: Duration,
100
101    // ── data plane ──────────────────────────────────────────────────
102    /// Emittable chunks awaiting the consumer (FIFO).
103    queue: VecDeque<String>,
104    /// Out-of-order chunks buffered by `chunkIndex` until contiguous.
105    buffered: BTreeMap<u64, String>,
106    /// UTF-8 byte total of [`buffered`](Self::buffered).
107    buffered_bytes: u64,
108    /// UTF-8 byte total of [`queue`](Self::queue).
109    queued_bytes: u64,
110    /// The next contiguous `chunkIndex` to emit.
111    next_expected_chunk: u64,
112    /// Highest outer `progress` accepted so far (sentinel `-1`).
113    last_progress: i64,
114    started: bool,
115    active: bool,
116    closed_remotely: bool,
117    /// `close.lastChunkIndex` (only meaningful once `closed_remotely`).
118    expected_last_chunk_index: Option<u64>,
119    /// Terminal error, delivered once on the next stream poll.
120    terminal: Option<OpenStreamError>,
121    /// Parked stream consumer.
122    waker: Option<Waker>,
123    /// Parked `closed()` futures.
124    closed_wakers: Vec<Waker>,
125
126    // ── keepalive (pure `tick`, driven by the transport sweep) ──────
127    last_activity: Instant,
128    pending_probe_nonce: Option<String>,
129    control_nonce: u64,
130    probe_deadline: Option<Instant>,
131    close_grace_deadline: Option<Instant>,
132    /// Outbound `progress` counter for the consumer `abort` frame.
133    outbound_progress: u64,
134}
135
136impl SessionState {
137    fn assert_active(&self) -> Result<(), OpenStreamError> {
138        if !self.active {
139            return Err(OpenStreamError::Sequence(format!(
140                "Received frame for inactive stream {}",
141                self.progress_token
142            )));
143        }
144        Ok(())
145    }
146
147    fn assert_started(&self) -> Result<(), OpenStreamError> {
148        if !self.started {
149            return Err(OpenStreamError::Sequence(format!(
150                "Received non-start frame before start for {}",
151                self.progress_token
152            )));
153        }
154        Ok(())
155    }
156
157    /// The outer `progress` must be strictly increasing across all frames.
158    fn assert_progress(&mut self, progress: i64) -> Result<(), OpenStreamError> {
159        if progress <= self.last_progress {
160            return Err(OpenStreamError::Sequence(format!(
161                "Non-increasing progress for stream {}",
162                self.progress_token
163            )));
164        }
165        self.last_progress = progress;
166        Ok(())
167    }
168
169    fn buffer_chunk(&mut self, chunk_index: u64, data: String) -> Result<(), OpenStreamError> {
170        if chunk_index < self.next_expected_chunk {
171            return Err(OpenStreamError::Sequence(format!(
172                "Stale chunkIndex {chunk_index} for {}",
173                self.progress_token
174            )));
175        }
176        if self.buffered.contains_key(&chunk_index) {
177            return Err(OpenStreamError::Sequence(format!(
178                "Duplicate chunkIndex {chunk_index} for {}",
179                self.progress_token
180            )));
181        }
182
183        let chunk_bytes = data.len() as u64;
184        if self.buffered.len() + self.queue.len() >= self.max_buffered_chunks {
185            return Err(OpenStreamError::Sequence(format!(
186                "Buffered chunk limit exceeded for stream {}",
187                self.progress_token
188            )));
189        }
190        if self.buffered_bytes + self.queued_bytes + chunk_bytes > self.max_buffered_bytes {
191            return Err(OpenStreamError::Sequence(format!(
192                "Buffered byte limit exceeded for stream {}",
193                self.progress_token
194            )));
195        }
196
197        self.buffered.insert(chunk_index, data);
198        self.buffered_bytes += chunk_bytes;
199        Ok(())
200    }
201
202    /// Flush every contiguous buffered chunk into the emit queue, then — if the
203    /// peer has closed — attempt a graceful finish. Returns `Ok(true)` when the
204    /// stream finished gracefully on this call.
205    fn flush_contiguous_chunks(&mut self) -> Result<bool, OpenStreamError> {
206        while let Some(data) = self.buffered.remove(&self.next_expected_chunk) {
207            self.buffered_bytes = self.buffered_bytes.saturating_sub(data.len() as u64);
208            self.emit(data);
209            self.next_expected_chunk += 1;
210        }
211
212        if self.closed_remotely {
213            return self.maybe_finish_gracefully();
214        }
215        Ok(false)
216    }
217
218    /// Resolve a graceful close if all declared chunks have been emitted.
219    /// Returns `Ok(true)` on finish, `Ok(false)` while still waiting on a gap,
220    /// `Err(Sequence)` when the declared `lastChunkIndex` can never be met.
221    fn maybe_finish_gracefully(&mut self) -> Result<bool, OpenStreamError> {
222        if !self.closed_remotely || !self.buffered.is_empty() {
223            return Ok(false);
224        }
225
226        if let Some(expected) = self.expected_last_chunk_index {
227            if self.next_expected_chunk != expected + 1 {
228                return Err(OpenStreamError::Sequence(format!(
229                    "Incomplete stream for {}: expected chunks through {expected}",
230                    self.progress_token
231                )));
232            }
233        }
234
235        self.finalize(None);
236        Ok(true)
237    }
238
239    /// Push an emittable chunk and wake the parked consumer.
240    fn emit(&mut self, data: String) {
241        self.queued_bytes += data.len() as u64;
242        self.queue.push_back(data);
243        self.wake_stream();
244    }
245
246    fn handle_pong(&mut self, nonce: &str) {
247        if self.pending_probe_nonce.as_deref() == Some(nonce) {
248            self.pending_probe_nonce = None;
249            self.probe_deadline = None;
250        }
251    }
252
253    fn next_control_nonce(&mut self) -> String {
254        self.control_nonce += 1;
255        format!("{}:{}", self.progress_token, self.control_nonce)
256    }
257
258    /// Pure keepalive transition. The owning transport calls this from its sweep
259    /// with `Instant::now()` and performs any returned send.
260    fn tick(&mut self, now: Instant) -> KeepaliveAction {
261        if !self.active {
262            return KeepaliveAction::None;
263        }
264
265        // 1. A pending probe whose deadline passed → abort.
266        if let (Some(deadline), true) = (self.probe_deadline, self.pending_probe_nonce.is_some()) {
267            if now >= deadline {
268                let token = self.progress_token.clone();
269                self.finalize(Some(OpenStreamError::abort(
270                    token,
271                    Some("Probe timeout".to_string()),
272                )));
273                return KeepaliveAction::Abort("Probe timeout".to_string());
274            }
275        }
276
277        // 2. Close-grace deadline passed with chunks still missing → abort.
278        if let Some(deadline) = self.close_grace_deadline {
279            if self.closed_remotely && !self.buffered.is_empty() && now >= deadline {
280                let token = self.progress_token.clone();
281                self.finalize(Some(OpenStreamError::abort(
282                    token,
283                    Some("Close grace period expired".to_string()),
284                )));
285                return KeepaliveAction::Abort("Close grace period expired".to_string());
286            }
287        }
288
289        // 3. Idle past the threshold (and not closed / not already probing) → ping.
290        if !self.closed_remotely
291            && self.pending_probe_nonce.is_none()
292            && now.saturating_duration_since(self.last_activity) >= self.idle_timeout
293        {
294            let nonce = self.next_control_nonce();
295            self.pending_probe_nonce = Some(nonce.clone());
296            self.probe_deadline = Some(now + self.probe_timeout);
297            return KeepaliveAction::SendPing(nonce);
298        }
299
300        KeepaliveAction::None
301    }
302
303    /// Terminate the stream. Idempotent. `error` is delivered once on the next
304    /// stream poll; `None` ends the stream gracefully. Wakes the consumer and
305    /// any `closed()` futures.
306    fn finalize(&mut self, error: Option<OpenStreamError>) {
307        if !self.active {
308            return;
309        }
310        self.active = false;
311        self.terminal = error;
312        self.pending_probe_nonce = None;
313        self.probe_deadline = None;
314        self.close_grace_deadline = None;
315        self.wake_stream();
316        for waker in self.closed_wakers.drain(..) {
317            waker.wake();
318        }
319    }
320
321    fn wake_stream(&mut self) {
322        if let Some(waker) = self.waker.take() {
323            waker.wake();
324        }
325    }
326}
327
328/// Readable reader-side handle for a CEP-41 stream.
329///
330/// Cheaply clonable (`Arc`-backed): the registry holds one clone to feed frames
331/// while the consumer holds another to drain the [`Stream`].
332#[derive(Clone)]
333pub struct OpenStreamSession {
334    /// Immutable stream id, duplicated here so accessors/`Debug` need no lock.
335    progress_token: String,
336    state: Arc<Mutex<SessionState>>,
337    publish_frame: Option<PublishFrame>,
338}
339
340impl std::fmt::Debug for OpenStreamSession {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        f.debug_struct("OpenStreamSession")
343            .field("progress_token", &self.progress_token)
344            .finish_non_exhaustive()
345    }
346}
347
348impl OpenStreamSession {
349    /// Create a new reader session from explicit options.
350    pub fn new(options: OpenStreamSessionOptions) -> Self {
351        let publish_frame = options.publish_frame.clone();
352        let progress_token = options.progress_token.clone();
353        let state = SessionState {
354            progress_token: options.progress_token,
355            max_buffered_chunks: options.max_buffered_chunks,
356            max_buffered_bytes: options.max_buffered_bytes,
357            idle_timeout: Duration::from_millis(options.idle_timeout_ms),
358            probe_timeout: Duration::from_millis(options.probe_timeout_ms),
359            close_grace_period: Duration::from_millis(options.close_grace_period_ms),
360            queue: VecDeque::new(),
361            buffered: BTreeMap::new(),
362            buffered_bytes: 0,
363            queued_bytes: 0,
364            next_expected_chunk: 0,
365            last_progress: -1,
366            started: false,
367            active: true,
368            closed_remotely: false,
369            expected_last_chunk_index: None,
370            terminal: None,
371            waker: None,
372            closed_wakers: Vec::new(),
373            last_activity: Instant::now(),
374            pending_probe_nonce: None,
375            control_nonce: 0,
376            probe_deadline: None,
377            close_grace_deadline: None,
378            outbound_progress: 0,
379        };
380        Self {
381            progress_token,
382            state: Arc::new(Mutex::new(state)),
383            publish_frame,
384        }
385    }
386
387    /// The stream id (stringified `progressToken`).
388    pub fn progress_token(&self) -> &str {
389        &self.progress_token
390    }
391
392    /// Whether the stream is still live (not yet closed/aborted/failed).
393    pub fn is_active(&self) -> bool {
394        self.state.lock().unwrap().active
395    }
396
397    /// Whether the `start` frame has been observed.
398    pub fn has_started(&self) -> bool {
399        self.state.lock().unwrap().started
400    }
401
402    /// Process one inbound frame, stamping `last_activity = now`.
403    ///
404    /// Returns the [`FrameOutcome`] the async caller must act on (publish a
405    /// `pong`, observe a terminal close/abort). On a sequencing/policy violation
406    /// it returns `Err` **without** finalizing — the caller (registry) decides
407    /// whether to [`fail`](Self::fail) the session.
408    pub fn process_frame(
409        &self,
410        now: Instant,
411        progress: i64,
412        frame: OpenStreamFrame,
413    ) -> Result<FrameOutcome, OpenStreamError> {
414        let mut s = self.state.lock().unwrap();
415        s.assert_active()?;
416        s.assert_progress(progress)?;
417        // Every accepted frame counts as liveness.
418        s.last_activity = now;
419
420        match frame {
421            OpenStreamFrame::Start { .. } => {
422                if s.started {
423                    return Err(OpenStreamError::Sequence(format!(
424                        "Duplicate start frame for stream {}",
425                        s.progress_token
426                    )));
427                }
428                s.started = true;
429                Ok(FrameOutcome::None)
430            }
431            OpenStreamFrame::Accept => Ok(FrameOutcome::None),
432            OpenStreamFrame::Ping { nonce } => {
433                s.assert_started()?;
434                if nonce.len() > MAX_OPEN_STREAM_PING_NONCE_BYTES {
435                    return Err(OpenStreamError::Sequence(format!(
436                        "ping nonce exceeds {MAX_OPEN_STREAM_PING_NONCE_BYTES}-byte cap (got {} bytes)",
437                        nonce.len()
438                    )));
439                }
440                Ok(FrameOutcome::SendPong(nonce))
441            }
442            OpenStreamFrame::Pong { nonce } => {
443                s.assert_started()?;
444                s.handle_pong(&nonce);
445                Ok(FrameOutcome::None)
446            }
447            OpenStreamFrame::Chunk { chunk_index, data } => {
448                s.assert_started()?;
449                s.buffer_chunk(chunk_index, data)?;
450                let finished = s.flush_contiguous_chunks()?;
451                Ok(if finished {
452                    FrameOutcome::Closed
453                } else {
454                    FrameOutcome::None
455                })
456            }
457            OpenStreamFrame::Close { last_chunk_index } => {
458                s.assert_started()?;
459                s.closed_remotely = true;
460                s.expected_last_chunk_index = last_chunk_index;
461                let finished = s.flush_contiguous_chunks()?;
462                if finished {
463                    Ok(FrameOutcome::Closed)
464                } else {
465                    // A real out-of-order gap remains: arm the close grace timer
466                    // (only reached when chunks are still buffered).
467                    s.close_grace_deadline = Some(now + s.close_grace_period);
468                    Ok(FrameOutcome::None)
469                }
470            }
471            OpenStreamFrame::Abort { reason } => {
472                let token = s.progress_token.clone();
473                s.finalize(Some(OpenStreamError::abort(token, reason.clone())));
474                Ok(FrameOutcome::Aborted(reason))
475            }
476        }
477    }
478
479    /// Pure keepalive transition (idle → `ping`, probe/grace → abort). Driven by
480    /// the owning transport's periodic sweep with `Instant::now()`.
481    pub fn tick(&self, now: Instant) -> KeepaliveAction {
482        self.state.lock().unwrap().tick(now)
483    }
484
485    /// Terminate the stream with an explicit error (no abort frame published).
486    /// Mirrors the registry's `fail` path; the error surfaces on the next poll.
487    pub fn fail(&self, error: OpenStreamError) {
488        self.state.lock().unwrap().finalize(Some(error));
489    }
490
491    /// Dispose the stream gracefully (ends the [`Stream`] with `None`). Used by
492    /// the registry's `clear`; runs no hooks.
493    pub fn dispose(&self) {
494        self.state.lock().unwrap().finalize(None);
495    }
496
497    /// Consumer-initiated cancel: finalize locally (terminal abort error on the
498    /// next poll) and, if a publisher was injected, emit an `abort` frame to the
499    /// peer. Idempotent; the transport exposes this as the consumer's stream cancel.
500    pub async fn abort(&self, reason: Option<String>) {
501        let publish = {
502            let mut s = self.state.lock().unwrap();
503            if !s.active {
504                return;
505            }
506            let token = s.progress_token.clone();
507            s.finalize(Some(OpenStreamError::abort(token.clone(), reason.clone())));
508            self.publish_frame.as_ref().map(|publisher| {
509                s.outbound_progress += 1;
510                (publisher.clone(), token, s.outbound_progress)
511            })
512        };
513
514        if let Some((publisher, token, progress)) = publish {
515            if let Ok(notification) = (OpenStreamFrame::Abort {
516                reason: reason.clone(),
517            })
518            .into_progress_notification(&token, progress, None)
519            {
520                // Best effort — the local stream is already finalized.
521                let _ = publisher(notification).await;
522            }
523        }
524    }
525
526    /// A future that resolves to `()` when the stream finalizes (graceful close,
527    /// abort, fail, or dispose). It never carries the terminal error — that is
528    /// delivered once on the [`Stream`] (intentional Rust divergence from TS).
529    pub fn closed(&self) -> Closed {
530        Closed {
531            state: self.state.clone(),
532        }
533    }
534
535    /// Whether two handles refer to the same underlying session state (used by
536    /// the registry's get-or-create reuse tests).
537    #[cfg(test)]
538    pub(crate) fn shares_state_with(&self, other: &Self) -> bool {
539        Arc::ptr_eq(&self.state, &other.state)
540    }
541}
542
543impl Stream for OpenStreamSession {
544    type Item = Result<String, OpenStreamError>;
545
546    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
547        let mut s = self.state.lock().unwrap();
548        if let Some(item) = s.queue.pop_front() {
549            s.queued_bytes = s.queued_bytes.saturating_sub(item.len() as u64);
550            return Poll::Ready(Some(Ok(item)));
551        }
552        if !s.active {
553            return match s.terminal.take() {
554                Some(error) => Poll::Ready(Some(Err(error))),
555                None => Poll::Ready(None),
556            };
557        }
558        s.waker = Some(cx.waker().clone());
559        Poll::Pending
560    }
561}
562
563/// Future returned by [`OpenStreamSession::closed`]; resolves on finalize.
564pub struct Closed {
565    state: Arc<Mutex<SessionState>>,
566}
567
568impl Future for Closed {
569    type Output = ();
570
571    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
572        let mut s = self.state.lock().unwrap();
573        if !s.active {
574            Poll::Ready(())
575        } else {
576            s.closed_wakers.push(cx.waker().clone());
577            Poll::Pending
578        }
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use futures::{poll, StreamExt};
586
587    fn make_session(token: &str, max_chunks: usize, max_bytes: u64) -> OpenStreamSession {
588        OpenStreamSession::new(OpenStreamSessionOptions {
589            progress_token: token.to_string(),
590            max_buffered_chunks: max_chunks,
591            max_buffered_bytes: max_bytes,
592            idle_timeout_ms: 30_000,
593            probe_timeout_ms: 20_000,
594            close_grace_period_ms: 5_000,
595            publish_frame: None,
596        })
597    }
598
599    fn make_session_timers(token: &str, idle: u64, probe: u64, grace: u64) -> OpenStreamSession {
600        OpenStreamSession::new(OpenStreamSessionOptions {
601            progress_token: token.to_string(),
602            max_buffered_chunks: 8,
603            max_buffered_bytes: 1024,
604            idle_timeout_ms: idle,
605            probe_timeout_ms: probe,
606            close_grace_period_ms: grace,
607            publish_frame: None,
608        })
609    }
610
611    fn start() -> OpenStreamFrame {
612        OpenStreamFrame::Start { content_type: None }
613    }
614
615    fn chunk(index: u64, data: &str) -> OpenStreamFrame {
616        OpenStreamFrame::Chunk {
617            chunk_index: index,
618            data: data.to_string(),
619        }
620    }
621
622    fn close(last: Option<u64>) -> OpenStreamFrame {
623        OpenStreamFrame::Close {
624            last_chunk_index: last,
625        }
626    }
627
628    /// Drain a *finalized* stream's queued chunks (panics on a terminal error).
629    async fn drain_ok(session: &mut OpenStreamSession) -> Vec<String> {
630        let mut out = Vec::new();
631        while let Some(item) = session.next().await {
632            out.push(item.expect("graceful stream must not yield an error"));
633        }
634        out
635    }
636
637    // ── data-plane ──────────────────────────────────────────────────
638
639    #[tokio::test]
640    async fn yields_ordered_chunks_and_finishes_after_close() {
641        let now = Instant::now();
642        let mut s = make_session("token-1", 8, 1024);
643        s.process_frame(now, 1, start()).unwrap();
644        s.process_frame(now, 2, chunk(0, "hello")).unwrap();
645        s.process_frame(now, 3, chunk(1, " world")).unwrap();
646        assert_eq!(
647            s.process_frame(now, 4, close(None)).unwrap(),
648            FrameOutcome::Closed
649        );
650
651        assert_eq!(drain_ok(&mut s).await, vec!["hello", " world"]);
652        s.closed().await;
653    }
654
655    #[tokio::test]
656    async fn buffers_out_of_order_chunks_until_contiguous() {
657        let now = Instant::now();
658        let mut s = make_session("token-2", 8, 1024);
659        s.process_frame(now, 1, start()).unwrap();
660        s.process_frame(now, 2, chunk(1, "world")).unwrap();
661        s.process_frame(now, 3, chunk(0, "hello ")).unwrap();
662        s.process_frame(now, 4, close(None)).unwrap();
663
664        assert_eq!(drain_ok(&mut s).await, vec!["hello ", "world"]);
665    }
666
667    #[tokio::test]
668    async fn fails_when_progress_does_not_increase() {
669        let now = Instant::now();
670        let s = make_session("token-3", 8, 1024);
671        s.process_frame(now, 1, start()).unwrap();
672        let err = s.process_frame(now, 1, chunk(0, "repeat")).unwrap_err();
673        assert!(matches!(err, OpenStreamError::Sequence(_)));
674    }
675
676    #[tokio::test]
677    async fn rejects_a_duplicate_start_frame() {
678        let now = Instant::now();
679        let s = make_session("token-dup-start", 8, 1024);
680        s.process_frame(now, 1, start()).unwrap();
681        // A second `start` on an already-started stream is a sequencing violation.
682        let err = s.process_frame(now, 2, start()).unwrap_err();
683        assert!(matches!(err, OpenStreamError::Sequence(_)));
684    }
685
686    #[tokio::test]
687    async fn abort_frame_terminates_stream_with_error_on_next_poll() {
688        let now = Instant::now();
689        let mut s = make_session("token-4", 8, 1024);
690        s.process_frame(now, 1, start()).unwrap();
691        assert_eq!(
692            s.process_frame(
693                now,
694                2,
695                OpenStreamFrame::Abort {
696                    reason: Some("boom".to_string())
697                }
698            )
699            .unwrap(),
700            FrameOutcome::Aborted(Some("boom".to_string()))
701        );
702
703        match s.next().await {
704            Some(Err(OpenStreamError::Abort { reason, .. })) => {
705                assert_eq!(reason.as_deref(), Some("boom"));
706            }
707            other => panic!("expected abort error, got {other:?}"),
708        }
709        // The error is delivered once; the stream then ends.
710        assert!(s.next().await.is_none());
711        s.closed().await;
712    }
713
714    #[tokio::test]
715    async fn fails_when_buffered_chunk_count_exceeds_limit() {
716        let now = Instant::now();
717        let s = make_session("token-buffer-count", 1, 1024);
718        s.process_frame(now, 1, start()).unwrap();
719        s.process_frame(now, 2, chunk(1, "late")).unwrap();
720        let err = s.process_frame(now, 3, chunk(2, "later")).unwrap_err();
721        assert!(matches!(err, OpenStreamError::Sequence(_)));
722    }
723
724    #[tokio::test]
725    async fn fails_when_buffered_byte_count_exceeds_limit_utf8() {
726        let now = Instant::now();
727        let s = make_session("token-buffer-bytes", 4, 4);
728        s.process_frame(now, 1, start()).unwrap();
729        // "héllo" is 6 UTF-8 bytes (é = 2), exceeding the 4-byte cap; this also
730        // pins UTF-8 byte accounting (a `str::len` over chars would miscount).
731        let err = s.process_frame(now, 2, chunk(1, "héllo")).unwrap_err();
732        assert!(matches!(err, OpenStreamError::Sequence(_)));
733    }
734
735    #[tokio::test]
736    async fn fail_terminates_stream_and_closed_resolves() {
737        let now = Instant::now();
738        let mut s = make_session("token-explicit-fail", 4, 16);
739        s.process_frame(now, 1, start()).unwrap();
740        s.fail(OpenStreamError::Sequence("synthetic failure".to_string()));
741
742        match s.next().await {
743            Some(Err(OpenStreamError::Sequence(msg))) => assert!(msg.contains("synthetic")),
744            other => panic!("expected sequence error, got {other:?}"),
745        }
746        // `closed()` resolves to `()` (the error rode the stream, not `closed`).
747        s.closed().await;
748    }
749
750    #[tokio::test]
751    async fn counts_unread_queued_chunks_against_the_byte_limit() {
752        let now = Instant::now();
753        let s = make_session("token-queued-bytes", 4, 5);
754        s.process_frame(now, 1, start()).unwrap();
755        // chunk 0 flushes immediately → 3 queued bytes counted against the cap.
756        s.process_frame(now, 2, chunk(0, "abc")).unwrap();
757        let err = s.process_frame(now, 3, chunk(1, "def")).unwrap_err();
758        assert!(matches!(err, OpenStreamError::Sequence(_)));
759    }
760
761    #[tokio::test]
762    async fn releases_queued_byte_budget_after_consume() {
763        let now = Instant::now();
764        let mut s = make_session("token-queued-release", 4, 6);
765        s.process_frame(now, 1, start()).unwrap();
766        s.process_frame(now, 2, chunk(0, "abc")).unwrap();
767        s.process_frame(now, 3, chunk(1, "def")).unwrap();
768        // 3 + 3 + 1 = 7 > 6 → rejected (progress 4 is consumed by assert_progress).
769        assert!(matches!(
770            s.process_frame(now, 4, chunk(2, "g")).unwrap_err(),
771            OpenStreamError::Sequence(_)
772        ));
773
774        // Consuming "abc" frees 3 bytes, so the retry (progress 5) now fits.
775        assert_eq!(s.next().await.unwrap().unwrap(), "abc");
776        s.process_frame(now, 5, chunk(2, "g")).unwrap();
777    }
778
779    #[tokio::test]
780    async fn rejects_frames_after_close() {
781        let now = Instant::now();
782        let s = make_session("token-post-close", 8, 1024);
783        s.process_frame(now, 1, start()).unwrap();
784        assert_eq!(
785            s.process_frame(now, 2, close(None)).unwrap(),
786            FrameOutcome::Closed
787        );
788        let err = s.process_frame(now, 3, chunk(0, "late")).unwrap_err();
789        assert!(matches!(err, OpenStreamError::Sequence(_)));
790    }
791
792    #[tokio::test]
793    async fn rejects_frames_after_abort() {
794        let now = Instant::now();
795        let s = make_session("token-post-abort", 8, 1024);
796        s.process_frame(now, 1, start()).unwrap();
797        s.process_frame(
798            now,
799            2,
800            OpenStreamFrame::Abort {
801                reason: Some("boom".to_string()),
802            },
803        )
804        .unwrap();
805        assert!(matches!(
806            s.process_frame(now, 3, chunk(0, "late")).unwrap_err(),
807            OpenStreamError::Sequence(_)
808        ));
809        assert!(matches!(
810            s.process_frame(now, 4, close(None)).unwrap_err(),
811            OpenStreamError::Sequence(_)
812        ));
813    }
814
815    #[tokio::test]
816    async fn rejects_stale_and_duplicate_chunk_indexes() {
817        let now = Instant::now();
818        // Stale: an index below the contiguous frontier (already flushed).
819        let s = make_session("token-stale", 8, 1024);
820        s.process_frame(now, 1, start()).unwrap();
821        s.process_frame(now, 2, chunk(0, "hello")).unwrap();
822        assert!(matches!(
823            s.process_frame(now, 3, chunk(0, "late-duplicate"))
824                .unwrap_err(),
825            OpenStreamError::Sequence(_)
826        ));
827
828        // Duplicate: a still-buffered out-of-order index re-delivered.
829        let s2 = make_session("token-dup", 8, 1024);
830        s2.process_frame(now, 1, start()).unwrap();
831        s2.process_frame(now, 2, chunk(2, "a")).unwrap();
832        assert!(matches!(
833            s2.process_frame(now, 3, chunk(2, "b")).unwrap_err(),
834            OpenStreamError::Sequence(_)
835        ));
836    }
837
838    #[tokio::test]
839    async fn requires_all_chunks_through_close_last_chunk_index() {
840        let now = Instant::now();
841        let s = make_session("token-last-chunk-index", 8, 1024);
842        s.process_frame(now, 1, start()).unwrap();
843        s.process_frame(now, 2, chunk(0, "hello")).unwrap();
844        let err = s.process_frame(now, 3, close(Some(1))).unwrap_err();
845        assert!(matches!(err, OpenStreamError::Sequence(_)));
846    }
847
848    #[tokio::test]
849    async fn allows_graceful_close_when_last_chunk_index_matches() {
850        let now = Instant::now();
851        let mut s = make_session("token-last-chunk-complete", 8, 1024);
852        s.process_frame(now, 1, start()).unwrap();
853        s.process_frame(now, 2, chunk(0, "hello")).unwrap();
854        assert_eq!(
855            s.process_frame(now, 3, close(Some(0))).unwrap(),
856            FrameOutcome::Closed
857        );
858        assert_eq!(drain_ok(&mut s).await, vec!["hello"]);
859        s.closed().await;
860    }
861
862    #[tokio::test]
863    async fn rejects_malformed_close_last_chunk_index_and_stays_active() {
864        let now = Instant::now();
865        let mut s = make_session("token-malformed-last", 8, 1024);
866        s.process_frame(now, 1, start()).unwrap();
867        s.process_frame(now, 2, chunk(0, "hello")).unwrap();
868        // lastChunkIndex far larger than anything received → Sequence, but the
869        // stream is not finalized (a consumer abort can still clean it up).
870        assert!(matches!(
871            s.process_frame(now, 3, close(Some(5))).unwrap_err(),
872            OpenStreamError::Sequence(_)
873        ));
874        assert!(s.is_active());
875
876        s.abort(Some("cleanup".to_string())).await;
877        // The already-flushed "hello" drains before the terminal abort error.
878        assert_eq!(s.next().await.unwrap().unwrap(), "hello");
879        match s.next().await {
880            Some(Err(OpenStreamError::Abort { reason, .. })) => {
881                assert_eq!(reason.as_deref(), Some("cleanup"));
882            }
883            other => panic!("expected abort error, got {other:?}"),
884        }
885        s.closed().await;
886    }
887
888    #[tokio::test]
889    async fn rejects_negative_chunk_index_and_stays_active() {
890        use crate::transport::open_stream::OpenStreamRegistry;
891        use serde_json::json;
892
893        // A `chunk` whose `chunkIndex` is a negative JSON number is not a valid
894        // CEP-41 frame: `chunk_index` is a `u64`, so it fails to deserialize at
895        // the wire boundary and never becomes a typed `Chunk`. A non-integer
896        // index is rejected the same way.
897        let negative = json!({
898            "type": "open-stream",
899            "frameType": "chunk",
900            "chunkIndex": -1,
901            "data": "oops",
902        });
903        assert_eq!(OpenStreamFrame::from_cvm_value(&negative), None);
904        let fractional = json!({
905            "type": "open-stream",
906            "frameType": "chunk",
907            "chunkIndex": 1.5,
908            "data": "oops",
909        });
910        assert_eq!(OpenStreamFrame::from_cvm_value(&fractional), None);
911
912        // End to end: unlike a malformed-but-typed `lastChunkIndex` (see
913        // `rejects_malformed_close_last_chunk_index_and_stays_active`, which the
914        // session itself rejects), a negative index is rejected one layer out at
915        // the registry's `parse_frame` boundary with a `Sequence` error, and the
916        // already-admitted stream is left active (cleanable via a later frame).
917        let now = Instant::now();
918        let mut registry = OpenStreamRegistry::new();
919        registry
920            .process_frame(
921                now,
922                &start()
923                    .into_progress_notification("tok-neg", 1, None)
924                    .unwrap(),
925            )
926            .await
927            .unwrap();
928        assert_eq!(registry.size(), 1);
929
930        let bad = JsonRpcNotification {
931            jsonrpc: "2.0".to_string(),
932            method: "notifications/progress".to_string(),
933            params: Some(json!({
934                "progressToken": "tok-neg",
935                "progress": 2,
936                "cvm": negative,
937            })),
938        };
939        assert!(matches!(
940            registry.process_frame(now, &bad).await.unwrap_err(),
941            OpenStreamError::Sequence(_)
942        ));
943
944        // The malformed frame neither terminated nor removed the stream.
945        assert_eq!(registry.size(), 1);
946        assert!(registry.get_session("tok-neg").unwrap().is_active());
947    }
948
949    #[tokio::test]
950    async fn zero_chunk_close_succeeds() {
951        let now = Instant::now();
952        let mut s = make_session("token-zero-chunk", 8, 1024);
953        s.process_frame(now, 1, start()).unwrap();
954        assert_eq!(
955            s.process_frame(now, 2, close(None)).unwrap(),
956            FrameOutcome::Closed
957        );
958        assert!(s.next().await.is_none());
959        s.closed().await;
960    }
961
962    #[tokio::test]
963    async fn chunk_filling_last_gap_after_close_yields_closed() {
964        let now = Instant::now();
965        let mut s = make_session("token-fill-gap", 8, 1024);
966        s.process_frame(now, 1, start()).unwrap();
967        // Out-of-order chunk 1 buffered; close declares 1 is the last chunk.
968        s.process_frame(now, 2, chunk(1, "world")).unwrap();
969        assert_eq!(
970            s.process_frame(now, 3, close(Some(1))).unwrap(),
971            FrameOutcome::None
972        );
973        // Chunk 0 fills the gap → both flush and the stream closes.
974        assert_eq!(
975            s.process_frame(now, 4, chunk(0, "hello ")).unwrap(),
976            FrameOutcome::Closed
977        );
978        assert_eq!(drain_ok(&mut s).await, vec!["hello ", "world"]);
979    }
980
981    #[tokio::test]
982    async fn parked_reader_is_woken_by_an_arriving_chunk() {
983        let now = Instant::now();
984        let mut s = make_session("token-park", 8, 1024);
985        s.process_frame(now, 1, start()).unwrap();
986        // No data yet → the manual Stream impl parks (Pending), storing the waker.
987        assert!(poll!(s.next()).is_pending());
988        s.process_frame(now, 2, chunk(0, "x")).unwrap();
989        match poll!(s.next()) {
990            Poll::Ready(Some(Ok(value))) => assert_eq!(value, "x"),
991            other => panic!("expected the queued chunk, got {other:?}"),
992        }
993    }
994
995    #[tokio::test]
996    async fn consumer_abort_publishes_an_abort_frame_then_finalizes() {
997        use nostr_sdk::prelude::EventId;
998
999        let now = Instant::now();
1000        let published: Arc<Mutex<Vec<JsonRpcNotification>>> = Arc::new(Mutex::new(Vec::new()));
1001        let recorder = published.clone();
1002        let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
1003            let recorder = recorder.clone();
1004            Box::pin(async move {
1005                recorder.lock().unwrap().push(frame);
1006                Ok(EventId::all_zeros())
1007            })
1008        });
1009        let mut s = OpenStreamSession::new(OpenStreamSessionOptions {
1010            progress_token: "token-consumer-abort".to_string(),
1011            max_buffered_chunks: 8,
1012            max_buffered_bytes: 1024,
1013            idle_timeout_ms: 30_000,
1014            probe_timeout_ms: 20_000,
1015            close_grace_period_ms: 5_000,
1016            publish_frame: Some(publish),
1017        });
1018        s.process_frame(now, 1, start()).unwrap();
1019
1020        s.abort(Some("user cancelled".to_string())).await;
1021
1022        // The injected publisher emitted exactly one `abort` frame to the peer.
1023        {
1024            let frames = published.lock().unwrap();
1025            assert_eq!(frames.len(), 1);
1026            let cvm = &frames[0].params.as_ref().unwrap()["cvm"];
1027            assert_eq!(cvm["frameType"], "abort");
1028            assert_eq!(cvm["reason"], "user cancelled");
1029        }
1030
1031        // And the local stream is finalized with the abort error.
1032        match s.next().await {
1033            Some(Err(OpenStreamError::Abort { reason, .. })) => {
1034                assert_eq!(reason.as_deref(), Some("user cancelled"));
1035            }
1036            other => panic!("expected abort error, got {other:?}"),
1037        }
1038    }
1039
1040    // ── keepalive (pure `tick`, injected clock) ─────────────────────
1041
1042    #[tokio::test]
1043    async fn ping_frame_requests_a_pong() {
1044        let now = Instant::now();
1045        let s = make_session("token-ping", 8, 1024);
1046        s.process_frame(now, 1, start()).unwrap();
1047        assert_eq!(
1048            s.process_frame(
1049                now,
1050                2,
1051                OpenStreamFrame::Ping {
1052                    nonce: "nonce-1".to_string()
1053                }
1054            )
1055            .unwrap(),
1056            FrameOutcome::SendPong("nonce-1".to_string())
1057        );
1058    }
1059
1060    #[tokio::test]
1061    async fn rejects_ping_nonce_exceeding_64_bytes() {
1062        let now = Instant::now();
1063        let s = make_session("token-ping-cap", 8, 1024);
1064        s.process_frame(now, 1, start()).unwrap();
1065
1066        // A 64-byte nonce is at the cap and is echoed.
1067        let at_cap = "x".repeat(MAX_OPEN_STREAM_PING_NONCE_BYTES);
1068        assert_eq!(at_cap.len(), 64);
1069        assert_eq!(
1070            s.process_frame(
1071                now,
1072                2,
1073                OpenStreamFrame::Ping {
1074                    nonce: at_cap.clone()
1075                }
1076            )
1077            .unwrap(),
1078            FrameOutcome::SendPong(at_cap)
1079        );
1080
1081        // A 65-byte nonce exceeds the cap and is rejected (Sequence), without
1082        // finalizing the stream.
1083        let over_cap = "x".repeat(MAX_OPEN_STREAM_PING_NONCE_BYTES + 1);
1084        assert!(matches!(
1085            s.process_frame(now, 3, OpenStreamFrame::Ping { nonce: over_cap }),
1086            Err(OpenStreamError::Sequence(_))
1087        ));
1088        // The stream stays active: a normal ping still earns a pong.
1089        assert_eq!(
1090            s.process_frame(
1091                now,
1092                4,
1093                OpenStreamFrame::Ping {
1094                    nonce: "ok".to_string()
1095                }
1096            )
1097            .unwrap(),
1098            FrameOutcome::SendPong("ok".to_string())
1099        );
1100    }
1101
1102    #[tokio::test]
1103    async fn idle_sends_ping_then_probe_timeout_aborts() {
1104        let t0 = Instant::now();
1105        let mut s = make_session_timers("token-probe", 10, 10, 100);
1106        s.process_frame(t0, 1, start()).unwrap();
1107
1108        // Before the idle threshold: nothing.
1109        assert_eq!(s.tick(t0 + Duration::from_millis(5)), KeepaliveAction::None);
1110        // At the idle threshold: probe with a `{token}:{n}` nonce.
1111        assert_eq!(
1112            s.tick(t0 + Duration::from_millis(10)),
1113            KeepaliveAction::SendPing("token-probe:1".to_string())
1114        );
1115        // Probe in flight, deadline (t0+20) not yet reached: nothing.
1116        assert_eq!(
1117            s.tick(t0 + Duration::from_millis(15)),
1118            KeepaliveAction::None
1119        );
1120        // Probe deadline reached with no pong: abort.
1121        assert_eq!(
1122            s.tick(t0 + Duration::from_millis(20)),
1123            KeepaliveAction::Abort("Probe timeout".to_string())
1124        );
1125
1126        match s.next().await {
1127            Some(Err(OpenStreamError::Abort { reason, .. })) => {
1128                assert_eq!(reason.as_deref(), Some("Probe timeout"));
1129            }
1130            other => panic!("expected probe-timeout abort, got {other:?}"),
1131        }
1132    }
1133
1134    #[tokio::test]
1135    async fn matching_pong_clears_probe_and_stream_survives() {
1136        let t0 = Instant::now();
1137        let s = make_session_timers("token-probe-ok", 10, 20, 100);
1138        s.process_frame(t0, 1, start()).unwrap();
1139
1140        let nonce = match s.tick(t0 + Duration::from_millis(10)) {
1141            KeepaliveAction::SendPing(nonce) => nonce,
1142            other => panic!("expected a ping, got {other:?}"),
1143        };
1144        // A matching pong clears the probe and refreshes liveness.
1145        s.process_frame(
1146            t0 + Duration::from_millis(15),
1147            2,
1148            OpenStreamFrame::Pong { nonce },
1149        )
1150        .unwrap();
1151
1152        // At the original probe deadline the stream is NOT aborted.
1153        assert!(!matches!(
1154            s.tick(t0 + Duration::from_millis(30)),
1155            KeepaliveAction::Abort(_)
1156        ));
1157        assert!(s.is_active());
1158    }
1159
1160    #[tokio::test]
1161    async fn unexpected_pong_is_ignored_for_liveness() {
1162        let t0 = Instant::now();
1163        let mut s = make_session_timers("token-bad-pong", 10, 10, 100);
1164        s.process_frame(t0, 1, start()).unwrap();
1165        assert!(matches!(
1166            s.tick(t0 + Duration::from_millis(10)),
1167            KeepaliveAction::SendPing(_)
1168        ));
1169
1170        // A pong with a non-matching nonce must not clear the pending probe.
1171        s.process_frame(
1172            t0 + Duration::from_millis(12),
1173            2,
1174            OpenStreamFrame::Pong {
1175                nonce: "unexpected".to_string(),
1176            },
1177        )
1178        .unwrap();
1179
1180        assert_eq!(
1181            s.tick(t0 + Duration::from_millis(20)),
1182            KeepaliveAction::Abort("Probe timeout".to_string())
1183        );
1184        match s.next().await {
1185            Some(Err(OpenStreamError::Abort { .. })) => {}
1186            other => panic!("expected abort, got {other:?}"),
1187        }
1188    }
1189
1190    #[tokio::test]
1191    async fn close_grace_deadline_with_missing_chunks_aborts() {
1192        let t0 = Instant::now();
1193        let mut s = make_session_timers("token-grace", 100, 100, 10);
1194        s.process_frame(t0, 1, start()).unwrap();
1195        // Out-of-order chunk buffered, then a close with the gap unresolved.
1196        s.process_frame(t0, 2, chunk(1, "late")).unwrap();
1197        assert_eq!(
1198            s.process_frame(t0, 3, close(None)).unwrap(),
1199            FrameOutcome::None
1200        );
1201
1202        // Before the grace deadline: nothing. At it: abort.
1203        assert_eq!(s.tick(t0 + Duration::from_millis(5)), KeepaliveAction::None);
1204        assert_eq!(
1205            s.tick(t0 + Duration::from_millis(10)),
1206            KeepaliveAction::Abort("Close grace period expired".to_string())
1207        );
1208        match s.next().await {
1209            Some(Err(OpenStreamError::Abort { reason, .. })) => {
1210                assert_eq!(reason.as_deref(), Some("Close grace period expired"));
1211            }
1212            other => panic!("expected grace abort, got {other:?}"),
1213        }
1214    }
1215
1216    #[tokio::test]
1217    async fn close_with_missing_tail_and_nothing_buffered_fails_immediately_without_grace() {
1218        let t0 = Instant::now();
1219        let s = make_session_timers("token-no-grace", 100, 100, 10);
1220        s.process_frame(t0, 1, start()).unwrap();
1221        s.process_frame(t0, 2, chunk(0, "hello")).unwrap();
1222        // Tail declared (lastChunkIndex=1) but chunk 1 never arrived and nothing
1223        // is buffered → immediate Sequence error, no grace timer armed.
1224        assert!(matches!(
1225            s.process_frame(t0, 3, close(Some(1))).unwrap_err(),
1226            OpenStreamError::Sequence(_)
1227        ));
1228        // No close-grace deadline was armed: even far in the future, tick is inert.
1229        assert_eq!(s.tick(t0 + Duration::from_secs(60)), KeepaliveAction::None);
1230    }
1231}