Skip to main content

contextvm_sdk/transport/open_stream/
registry.rs

1//! Per-peer registry of active CEP-41 reader sessions, keyed by `progressToken`.
2//!
3//! Ports `sdk/src/transport/open-stream/registry.ts`. Enforces admission
4//! (max-concurrent → `Policy`; duplicate token → `Sequence`; a session is
5//! created **only** on a `start` frame — any other first frame is a `Sequence`
6//! error), routes inbound frames to the matching [`OpenStreamSession`], and
7//! removes a session on its terminal close/abort (running optional per-session
8//! hooks, which are always cleaned up even if they error).
9
10use std::collections::HashMap;
11use std::time::Instant;
12
13use futures::future::BoxFuture;
14use serde_json::Value;
15
16use crate::core::types::JsonRpcNotification;
17use crate::transport::oversized_transfer::progress_token_string;
18
19use super::constants::{
20    DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM, DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM,
21    DEFAULT_MAX_CONCURRENT_OPEN_STREAMS, DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS,
22    DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS, DEFAULT_OPEN_STREAM_PROBE_TIMEOUT_MS,
23};
24use super::errors::OpenStreamError;
25use super::frame::OpenStreamFrame;
26use super::session::{
27    FrameOutcome, KeepaliveAction, OpenStreamSession, OpenStreamSessionOptions, PublishFrame,
28};
29
30const LOG_TARGET: &str = "contextvm_sdk::transport::open_stream";
31
32/// Hook fired after a session closes gracefully. Errors are logged and swallowed.
33pub type RegistryCloseHook = Box<dyn FnOnce() -> BoxFuture<'static, crate::Result<()>> + Send>;
34
35/// Hook fired after a session aborts (with the advisory reason). Errors are
36/// logged and swallowed.
37pub type RegistryAbortHook =
38    Box<dyn FnOnce(Option<String>) -> BoxFuture<'static, crate::Result<()>> + Send>;
39
40/// Reader admission / buffering / keepalive policy for a registry's sessions.
41///
42/// Projected from
43/// [`OpenStreamConfig`](crate::transport::open_stream::OpenStreamConfig) via
44/// `From<&OpenStreamConfig>`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct OpenStreamRegistryPolicy {
47    /// Maximum concurrently active streams.
48    pub max_concurrent_streams: usize,
49    /// Maximum buffered + queued chunks per stream.
50    pub max_buffered_chunks_per_stream: usize,
51    /// Maximum buffered + queued payload bytes per stream.
52    pub max_buffered_bytes_per_stream: usize,
53    /// Idle interval before a reader probes with a `ping` (ms).
54    pub idle_timeout_ms: u64,
55    /// Time a reader waits for a `pong` before aborting (ms).
56    pub probe_timeout_ms: u64,
57    /// Grace period after a `close` with unresolved gaps before aborting (ms).
58    pub close_grace_period_ms: u64,
59}
60
61impl Default for OpenStreamRegistryPolicy {
62    fn default() -> Self {
63        Self {
64            max_concurrent_streams: DEFAULT_MAX_CONCURRENT_OPEN_STREAMS,
65            max_buffered_chunks_per_stream: DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM,
66            max_buffered_bytes_per_stream: DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM,
67            idle_timeout_ms: DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS,
68            probe_timeout_ms: DEFAULT_OPEN_STREAM_PROBE_TIMEOUT_MS,
69            close_grace_period_ms: DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS,
70        }
71    }
72}
73
74/// Optional per-session wiring supplied at creation time.
75#[derive(Default)]
76pub struct OpenStreamSessionInit {
77    /// Outbound publisher for the reader's consumer `abort` frame.
78    pub publish_frame: Option<PublishFrame>,
79    /// Hook fired after a graceful close.
80    pub on_close: Option<RegistryCloseHook>,
81    /// Hook fired after an abort.
82    pub on_abort: Option<RegistryAbortHook>,
83}
84
85/// A registered session plus its terminal lifecycle hooks.
86struct RegistryEntry {
87    session: OpenStreamSession,
88    on_close: Option<RegistryCloseHook>,
89    on_abort: Option<RegistryAbortHook>,
90}
91
92/// Registry of active CEP-41 reader sessions keyed by `progressToken`.
93pub struct OpenStreamRegistry {
94    policy: OpenStreamRegistryPolicy,
95    sessions: HashMap<String, RegistryEntry>,
96}
97
98impl Default for OpenStreamRegistry {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl OpenStreamRegistry {
105    /// Create a registry with the default policy.
106    pub fn new() -> Self {
107        Self::with_policy(OpenStreamRegistryPolicy::default())
108    }
109
110    /// Create a registry with an explicit policy.
111    pub fn with_policy(policy: OpenStreamRegistryPolicy) -> Self {
112        Self {
113            policy,
114            sessions: HashMap::new(),
115        }
116    }
117
118    /// Number of active sessions.
119    pub fn size(&self) -> usize {
120        self.sessions.len()
121    }
122
123    /// Look up an active session by token.
124    pub fn get_session(&self, progress_token: &str) -> Option<OpenStreamSession> {
125        self.sessions
126            .get(progress_token)
127            .map(|entry| entry.session.clone())
128    }
129
130    /// Returns `true` when `notification` carries a CEP-41 frame in `params.cvm`.
131    ///
132    /// Mirrors the TS `OpenStreamRegistry.isOpenStreamProgress` narrowing.
133    pub fn is_open_stream_progress(notification: &JsonRpcNotification) -> bool {
134        notification
135            .params
136            .as_ref()
137            .and_then(|params| params.get("cvm"))
138            .map(OpenStreamFrame::is_frame_value)
139            .unwrap_or(false)
140    }
141
142    /// Create a session for `progress_token` with default wiring.
143    pub fn create_session(
144        &mut self,
145        progress_token: impl Into<String>,
146    ) -> Result<OpenStreamSession, OpenStreamError> {
147        self.create_session_with(progress_token, OpenStreamSessionInit::default())
148    }
149
150    /// Create a session for `progress_token` with explicit wiring, enforcing the
151    /// duplicate-token (`Sequence`) and max-concurrent (`Policy`) admission rules.
152    pub fn create_session_with(
153        &mut self,
154        progress_token: impl Into<String>,
155        init: OpenStreamSessionInit,
156    ) -> Result<OpenStreamSession, OpenStreamError> {
157        let progress_token = progress_token.into();
158        if self.sessions.contains_key(&progress_token) {
159            return Err(OpenStreamError::Sequence(format!(
160                "Stream session already exists for {progress_token}"
161            )));
162        }
163        if self.sessions.len() >= self.policy.max_concurrent_streams {
164            return Err(OpenStreamError::Policy(
165                "Maximum concurrent open streams exceeded".to_string(),
166            ));
167        }
168
169        let session = OpenStreamSession::new(OpenStreamSessionOptions {
170            progress_token: progress_token.clone(),
171            max_buffered_chunks: self.policy.max_buffered_chunks_per_stream,
172            max_buffered_bytes: self.policy.max_buffered_bytes_per_stream as u64,
173            idle_timeout_ms: self.policy.idle_timeout_ms,
174            probe_timeout_ms: self.policy.probe_timeout_ms,
175            close_grace_period_ms: self.policy.close_grace_period_ms,
176            publish_frame: init.publish_frame,
177        });
178        self.sessions.insert(
179            progress_token,
180            RegistryEntry {
181                session: session.clone(),
182                on_close: init.on_close,
183                on_abort: init.on_abort,
184            },
185        );
186        Ok(session)
187    }
188
189    /// Return the existing session for `progress_token`, creating one (with
190    /// default wiring) if absent.
191    pub fn get_or_create_session(
192        &mut self,
193        progress_token: impl Into<String>,
194    ) -> Result<OpenStreamSession, OpenStreamError> {
195        let progress_token = progress_token.into();
196        if let Some(session) = self.get_session(&progress_token) {
197            return Ok(session);
198        }
199        self.create_session(progress_token)
200    }
201
202    /// Route one inbound frame to its session, creating the session on a `start`.
203    ///
204    /// On a sequencing/policy violation the offending session is failed and
205    /// removed before the error is returned; on a terminal close/abort the
206    /// session is removed and its hook run (errors logged, never propagated).
207    pub async fn process_frame(
208        &mut self,
209        now: Instant,
210        notification: &JsonRpcNotification,
211    ) -> Result<FrameOutcome, OpenStreamError> {
212        let (progress_token, progress, frame) = parse_frame(notification)?;
213
214        if !self.sessions.contains_key(&progress_token) {
215            if frame.frame_type() != "start" {
216                return Err(OpenStreamError::Sequence(format!(
217                    "Received {} frame before start for {progress_token}",
218                    frame.frame_type()
219                )));
220            }
221            self.create_session_with(progress_token.clone(), OpenStreamSessionInit::default())?;
222        }
223
224        // Clone the Arc-backed handle out so the map is not borrowed across the
225        // hook `.await`s below.
226        let session = self
227            .sessions
228            .get(&progress_token)
229            .expect("session present after create")
230            .session
231            .clone();
232
233        match session.process_frame(now, progress, frame) {
234            Ok(FrameOutcome::Closed) => {
235                self.run_close(&progress_token).await;
236                Ok(FrameOutcome::Closed)
237            }
238            Ok(FrameOutcome::Aborted(reason)) => {
239                self.run_abort(&progress_token, reason.clone()).await;
240                Ok(FrameOutcome::Aborted(reason))
241            }
242            Ok(other) => Ok(other),
243            Err(error) => {
244                session.fail(error.clone());
245                // Forward the failure reason to the `on_abort` hook (TS passes
246                // `onAbort(error.message)`); the inbound-abort path already forwards
247                // the peer's reason — this covers the local processing-failure
248                // branch so deferral/metrics hooks see why.
249                self.run_abort(&progress_token, Some(error.to_string()))
250                    .await;
251                Err(error)
252            }
253        }
254    }
255
256    /// Drive the pure keepalive [`tick`](OpenStreamSession::tick) for every active
257    /// session, returning the `(progress_token, action)` pairs that need an
258    /// outbound send (`SendPing`) or signal a local abort (`Abort`). Sessions that
259    /// aborted on this tick are removed (their slot is freed); the transport sweep
260    /// performs the actual publish for each returned action.
261    pub fn tick_all(&mut self, now: Instant) -> Vec<(String, KeepaliveAction)> {
262        let mut actions = Vec::new();
263        let mut aborted = Vec::new();
264        for (token, entry) in self.sessions.iter() {
265            match entry.session.tick(now) {
266                KeepaliveAction::None => {}
267                action => {
268                    if matches!(action, KeepaliveAction::Abort(_)) {
269                        aborted.push(token.clone());
270                    }
271                    actions.push((token.clone(), action));
272                }
273            }
274        }
275        for token in aborted {
276            self.sessions.remove(&token);
277        }
278        actions
279    }
280
281    /// Dispose every session gracefully and drop them (runs no hooks).
282    pub fn clear(&mut self) {
283        for (_, entry) in self.sessions.drain() {
284            entry.session.dispose();
285        }
286    }
287
288    /// Consumer-cancel cleanup: finalize the session locally, run its `on_abort`
289    /// hook, and **remove the entry** so the concurrency slot is freed.
290    ///
291    /// The session's `process_frame`/`tick` paths only remove an entry on an
292    /// *inbound* terminal frame; a consumer that cancels its own read
293    /// ([`OpenStreamSession::abort`]) finalizes + publishes an `abort` frame but
294    /// leaves the registry entry counting against `max_concurrent_streams`. The
295    /// transport calls this to close that gap when wiring cancel. The outbound
296    /// `abort` *frame* is published by the caller via
297    /// [`OpenStreamSession::abort`]; here `fail` only guarantees the local stream
298    /// is terminal (idempotent if already finalized).
299    pub async fn consumer_abort(&mut self, progress_token: &str, reason: Option<String>) {
300        if let Some(entry) = self.sessions.remove(progress_token) {
301            entry
302                .session
303                .fail(OpenStreamError::abort(progress_token, reason.clone()));
304            if let Some(hook) = entry.on_abort {
305                if let Err(error) = hook(reason).await {
306                    tracing::debug!(
307                        target: LOG_TARGET,
308                        token = %progress_token,
309                        %error,
310                        "open-stream on_abort hook errored during consumer abort"
311                    );
312                }
313            }
314        }
315    }
316
317    /// Remove a closed session and run its `on_close` hook (errors swallowed).
318    async fn run_close(&mut self, progress_token: &str) {
319        if let Some(entry) = self.sessions.remove(progress_token) {
320            if let Some(hook) = entry.on_close {
321                if let Err(error) = hook().await {
322                    tracing::debug!(
323                        target: LOG_TARGET,
324                        token = %progress_token,
325                        %error,
326                        "open-stream on_close hook errored"
327                    );
328                }
329            }
330        }
331    }
332
333    /// Remove an aborted session and run its `on_abort` hook (errors swallowed).
334    async fn run_abort(&mut self, progress_token: &str, reason: Option<String>) {
335        if let Some(entry) = self.sessions.remove(progress_token) {
336            if let Some(hook) = entry.on_abort {
337                if let Err(error) = hook(reason).await {
338                    tracing::debug!(
339                        target: LOG_TARGET,
340                        token = %progress_token,
341                        %error,
342                        "open-stream on_abort hook errored"
343                    );
344                }
345            }
346        }
347    }
348}
349
350/// Extract `(progressToken, progress, frame)` from a `notifications/progress`
351/// payload, rejecting any non-CEP-41 or malformed notification with `Sequence`.
352fn parse_frame(
353    notification: &JsonRpcNotification,
354) -> Result<(String, i64, OpenStreamFrame), OpenStreamError> {
355    let params = notification.params.as_ref().ok_or_else(|| {
356        OpenStreamError::Sequence("Open stream frame is missing params".to_string())
357    })?;
358    let frame = params
359        .get("cvm")
360        .and_then(OpenStreamFrame::from_cvm_value)
361        .ok_or_else(|| {
362            OpenStreamError::Sequence("Notification is not an open-stream frame".to_string())
363        })?;
364
365    let progress_token = params
366        .get("progressToken")
367        .and_then(progress_token_string)
368        .filter(|token| !token.is_empty())
369        .ok_or_else(|| {
370            OpenStreamError::Sequence("Open stream frame is missing progressToken".to_string())
371        })?;
372
373    let progress = parse_progress(params.get("progress"), &progress_token)?;
374    Ok((progress_token, progress, frame))
375}
376
377/// Parse the outer `progress` as an integer (CEP-41 requires it on every frame).
378fn parse_progress(value: Option<&Value>, token: &str) -> Result<i64, OpenStreamError> {
379    let progress = match value {
380        Some(Value::Number(n)) => n.as_i64().or_else(|| {
381            n.as_f64()
382                .and_then(|f| if f.is_finite() { Some(f as i64) } else { None })
383        }),
384        _ => None,
385    };
386    progress.ok_or_else(|| {
387        OpenStreamError::Sequence(format!("Invalid progress value (token: {token})"))
388    })
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::Error;
395    use futures::StreamExt;
396    use serde_json::json;
397
398    fn now() -> Instant {
399        Instant::now()
400    }
401
402    fn notif(token: &str, progress: u64, frame: OpenStreamFrame) -> JsonRpcNotification {
403        frame
404            .into_progress_notification(token, progress, None)
405            .unwrap()
406    }
407
408    fn start() -> OpenStreamFrame {
409        OpenStreamFrame::Start { content_type: None }
410    }
411
412    fn chunk(index: u64, data: &str) -> OpenStreamFrame {
413        OpenStreamFrame::Chunk {
414            chunk_index: index,
415            data: data.to_string(),
416        }
417    }
418
419    fn small_policy(max_concurrent: usize) -> OpenStreamRegistryPolicy {
420        OpenStreamRegistryPolicy {
421            max_concurrent_streams: max_concurrent,
422            max_buffered_chunks_per_stream: 4,
423            max_buffered_bytes_per_stream: 128,
424            ..OpenStreamRegistryPolicy::default()
425        }
426    }
427
428    #[tokio::test]
429    async fn enforces_max_concurrent_and_reuses_slot_after_close() {
430        let mut registry = OpenStreamRegistry::with_policy(small_policy(1));
431        let _first = registry.create_session("token-1").unwrap();
432        assert!(matches!(
433            registry.create_session("token-2").unwrap_err(),
434            OpenStreamError::Policy(_)
435        ));
436
437        // Drive token-1 to a graceful close through the registry to free the slot.
438        registry
439            .process_frame(now(), &notif("token-1", 1, start()))
440            .await
441            .unwrap();
442        registry
443            .process_frame(
444                now(),
445                &notif(
446                    "token-1",
447                    2,
448                    OpenStreamFrame::Close {
449                        last_chunk_index: None,
450                    },
451                ),
452            )
453            .await
454            .unwrap();
455
456        registry.create_session("token-2").unwrap();
457        assert_eq!(registry.size(), 1);
458    }
459
460    #[test]
461    fn get_or_create_reuses_the_same_session() {
462        let mut registry = OpenStreamRegistry::with_policy(small_policy(2));
463        let first = registry.get_or_create_session("token-shared").unwrap();
464        let second = registry.get_or_create_session("token-shared").unwrap();
465        assert!(first.shares_state_with(&second));
466        assert_eq!(registry.size(), 1);
467    }
468
469    #[test]
470    fn rejects_creating_a_duplicate_token() {
471        let mut registry = OpenStreamRegistry::with_policy(small_policy(4));
472        registry.create_session("token-dup").unwrap();
473        // A second `create` for a live token is a sequence violation (not a new
474        // slot and not silent reuse) — distinct from the concurrency `Policy` cap.
475        assert!(matches!(
476            registry.create_session("token-dup").unwrap_err(),
477            OpenStreamError::Sequence(_)
478        ));
479        assert_eq!(registry.size(), 1);
480    }
481
482    #[tokio::test]
483    async fn routes_a_duplicate_start_frame_to_a_sequence_error() {
484        // Admission creates the session on the first `start`; a second `start`
485        // routes to the live session, which rejects it (the `Duplicate start`
486        // guard), and the registry then fails + removes the session.
487        let mut registry = OpenStreamRegistry::with_policy(small_policy(2));
488        registry
489            .process_frame(now(), &notif("token-dup-start", 1, start()))
490            .await
491            .unwrap();
492        let err = registry
493            .process_frame(now(), &notif("token-dup-start", 2, start()))
494            .await
495            .unwrap_err();
496        assert!(matches!(err, OpenStreamError::Sequence(_)));
497        assert!(registry.get_session("token-dup-start").is_none());
498    }
499
500    #[tokio::test]
501    async fn rejects_non_start_frames_for_unknown_tokens() {
502        let mut registry = OpenStreamRegistry::with_policy(small_policy(2));
503        let err = registry
504            .process_frame(now(), &notif("token-missing-start", 1, chunk(0, "orphan")))
505            .await
506            .unwrap_err();
507        assert!(matches!(err, OpenStreamError::Sequence(_)));
508        assert!(registry.get_session("token-missing-start").is_none());
509        assert_eq!(registry.size(), 0);
510    }
511
512    #[tokio::test]
513    async fn terminates_session_when_frame_processing_fails_after_creation() {
514        let mut registry = OpenStreamRegistry::with_policy(OpenStreamRegistryPolicy {
515            max_buffered_bytes_per_stream: 4,
516            ..small_policy(2)
517        });
518        registry
519            .process_frame(now(), &notif("token-fail", 1, start()))
520            .await
521            .unwrap();
522        let mut session = registry.get_session("token-fail").unwrap();
523
524        // 'hello' (5 bytes) at an out-of-order index exceeds the 4-byte budget.
525        let err = registry
526            .process_frame(now(), &notif("token-fail", 2, chunk(1, "hello")))
527            .await
528            .unwrap_err();
529        assert!(matches!(err, OpenStreamError::Sequence(_)));
530        assert!(registry.get_session("token-fail").is_none());
531
532        // The session was failed: its stream surfaces the same error class.
533        match session.next().await {
534            Some(Err(OpenStreamError::Sequence(_))) => {}
535            other => panic!("expected sequence error on the stream, got {other:?}"),
536        }
537    }
538
539    #[tokio::test]
540    async fn applies_default_buffering_limits() {
541        let mut registry = OpenStreamRegistry::new();
542        registry
543            .process_frame(now(), &notif("token-chunks", 1, start()))
544            .await
545            .unwrap();
546        // Buffer exactly DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM out-of-order chunks.
547        for i in 0..DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM as u64 {
548            registry
549                .process_frame(now(), &notif("token-chunks", i + 2, chunk(i + 1, "x")))
550                .await
551                .unwrap();
552        }
553        let err = registry
554            .process_frame(
555                now(),
556                &notif(
557                    "token-chunks",
558                    DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM as u64 + 2,
559                    chunk(DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM as u64 + 1, "x"),
560                ),
561            )
562            .await
563            .unwrap_err();
564        assert!(matches!(err, OpenStreamError::Sequence(_)));
565
566        // The default byte budget is enforced too.
567        let mut byte_registry = OpenStreamRegistry::new();
568        byte_registry
569            .process_frame(now(), &notif("token-bytes", 1, start()))
570            .await
571            .unwrap();
572        let oversized = "x".repeat(DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM + 1);
573        let err = byte_registry
574            .process_frame(now(), &notif("token-bytes", 2, chunk(1, &oversized)))
575            .await
576            .unwrap_err();
577        assert!(matches!(err, OpenStreamError::Sequence(_)));
578    }
579
580    #[tokio::test]
581    async fn ping_frame_routes_to_a_pong_outcome() {
582        let mut registry = OpenStreamRegistry::new();
583        registry
584            .process_frame(now(), &notif("token-timers", 1, start()))
585            .await
586            .unwrap();
587        let outcome = registry
588            .process_frame(
589                now(),
590                &notif(
591                    "token-timers",
592                    2,
593                    OpenStreamFrame::Ping {
594                        nonce: "peer-nonce".to_string(),
595                    },
596                ),
597            )
598            .await
599            .unwrap();
600        assert_eq!(outcome, FrameOutcome::SendPong("peer-nonce".to_string()));
601    }
602
603    #[tokio::test]
604    async fn clear_disposes_active_sessions() {
605        let mut registry = OpenStreamRegistry::with_policy(small_policy(2));
606        let session = registry.create_session("token-clear").unwrap();
607        registry
608            .process_frame(now(), &notif("token-clear", 1, start()))
609            .await
610            .unwrap();
611
612        registry.clear();
613        assert_eq!(registry.size(), 0);
614        // Dispose finalized the session gracefully.
615        session.closed().await;
616    }
617
618    #[tokio::test]
619    async fn accepts_start_frame_with_advisory_metadata_omitted() {
620        let mut registry = OpenStreamRegistry::with_policy(small_policy(2));
621        let outcome = registry
622            .process_frame(now(), &notif("token-advisory", 1, start()))
623            .await
624            .unwrap();
625        assert_eq!(outcome, FrameOutcome::None);
626        assert!(registry.get_session("token-advisory").is_some());
627
628        registry.clear();
629        assert_eq!(registry.size(), 0);
630    }
631
632    #[tokio::test]
633    async fn rejects_malformed_non_cep41_payloads() {
634        // The structural predicate rejects every non-CEP-41 shape.
635        let malformed = [
636            json!({ "progressToken": "missing-cvm", "progress": 1 }),
637            json!({ "progressToken": "wrong-type", "progress": 1, "cvm": { "type": "other", "frameType": "start" } }),
638            json!({ "progressToken": "missing-frame-type", "progress": 1, "cvm": { "type": "open-stream" } }),
639        ];
640        for params in malformed {
641            let notification = JsonRpcNotification {
642                jsonrpc: "2.0".to_string(),
643                method: "notifications/progress".to_string(),
644                params: Some(params),
645            };
646            assert!(!OpenStreamRegistry::is_open_stream_progress(&notification));
647        }
648        assert!(OpenStreamRegistry::is_open_stream_progress(&notif(
649            "ok",
650            1,
651            start()
652        )));
653
654        // Feeding a malformed payload to the router is a Sequence error.
655        let mut registry = OpenStreamRegistry::new();
656        let bad = JsonRpcNotification {
657            jsonrpc: "2.0".to_string(),
658            method: "notifications/progress".to_string(),
659            params: Some(json!({ "progressToken": "t", "progress": 1 })),
660        };
661        assert!(matches!(
662            registry.process_frame(now(), &bad).await.unwrap_err(),
663            OpenStreamError::Sequence(_)
664        ));
665    }
666
667    #[tokio::test]
668    async fn rejects_accept_as_the_first_frame() {
669        let mut registry = OpenStreamRegistry::with_policy(small_policy(2));
670        let err = registry
671            .process_frame(
672                now(),
673                &notif("token-orphan-accept", 1, OpenStreamFrame::Accept),
674            )
675            .await
676            .unwrap_err();
677        assert!(matches!(err, OpenStreamError::Sequence(_)));
678        assert!(registry.get_session("token-orphan-accept").is_none());
679    }
680
681    #[tokio::test]
682    async fn removes_session_even_when_on_close_hook_errors() {
683        let mut registry = OpenStreamRegistry::with_policy(small_policy(1));
684        let on_close: RegistryCloseHook = Box::new(|| {
685            Box::pin(async { Err::<(), Error>(Error::Other("close failed".to_string())) })
686        });
687        registry
688            .create_session_with(
689                "token-close-throws",
690                OpenStreamSessionInit {
691                    on_close: Some(on_close),
692                    ..Default::default()
693                },
694            )
695            .unwrap();
696
697        registry
698            .process_frame(now(), &notif("token-close-throws", 1, start()))
699            .await
700            .unwrap();
701        // Graceful close fires on_close (which errors) but the session is removed.
702        registry
703            .process_frame(
704                now(),
705                &notif(
706                    "token-close-throws",
707                    2,
708                    OpenStreamFrame::Close {
709                        last_chunk_index: None,
710                    },
711                ),
712            )
713            .await
714            .unwrap();
715
716        assert!(registry.get_session("token-close-throws").is_none());
717        assert_eq!(registry.size(), 0);
718    }
719
720    #[tokio::test]
721    async fn consumer_abort_frees_slot_and_runs_hook() {
722        // A consumer cancel must remove the registry entry (freeing the
723        // concurrency slot) and run the `on_abort` hook, even though no inbound
724        // terminal frame ever arrives.
725        let mut registry = OpenStreamRegistry::with_policy(small_policy(1));
726        let fired = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
727        let f = fired.clone();
728        let on_abort: RegistryAbortHook = Box::new(move |reason| {
729            let f = f.clone();
730            Box::pin(async move {
731                f.lock().unwrap().push(reason.unwrap_or_default());
732                Ok(())
733            })
734        });
735        let mut session = registry
736            .create_session_with(
737                "token-consumer-abort",
738                OpenStreamSessionInit {
739                    on_abort: Some(on_abort),
740                    ..Default::default()
741                },
742            )
743            .unwrap();
744        // The slot is occupied: a second admission is rejected by the cap.
745        assert!(matches!(
746            registry.create_session("token-other").unwrap_err(),
747            OpenStreamError::Policy(_)
748        ));
749
750        registry
751            .consumer_abort("token-consumer-abort", Some("user cancelled".to_string()))
752            .await;
753
754        assert_eq!(registry.size(), 0);
755        assert!(registry.get_session("token-consumer-abort").is_none());
756        assert_eq!(*fired.lock().unwrap(), vec!["user cancelled".to_string()]);
757        // The local stream surfaces the abort error on its next poll.
758        match session.next().await {
759            Some(Err(OpenStreamError::Abort { reason, .. })) => {
760                assert_eq!(reason.as_deref(), Some("user cancelled"));
761            }
762            other => panic!("expected abort error on the stream, got {other:?}"),
763        }
764        // Slot reclaimed: a fresh admission now succeeds.
765        registry.create_session("token-other").unwrap();
766    }
767
768    #[tokio::test]
769    async fn removes_session_even_when_on_abort_hook_errors() {
770        let mut registry = OpenStreamRegistry::with_policy(small_policy(1));
771        let on_abort: RegistryAbortHook = Box::new(|_reason| {
772            Box::pin(async { Err::<(), Error>(Error::Other("abort failed".to_string())) })
773        });
774        registry
775            .create_session_with(
776                "token-abort-throws",
777                OpenStreamSessionInit {
778                    on_abort: Some(on_abort),
779                    ..Default::default()
780                },
781            )
782            .unwrap();
783        let mut session = registry.get_session("token-abort-throws").unwrap();
784
785        registry
786            .process_frame(now(), &notif("token-abort-throws", 1, start()))
787            .await
788            .unwrap();
789        registry
790            .process_frame(
791                now(),
792                &notif(
793                    "token-abort-throws",
794                    2,
795                    OpenStreamFrame::Abort {
796                        reason: Some("boom".to_string()),
797                    },
798                ),
799            )
800            .await
801            .unwrap();
802
803        assert!(registry.get_session("token-abort-throws").is_none());
804        assert_eq!(registry.size(), 0);
805        // The session was finalized with the peer's abort reason.
806        match session.next().await {
807            Some(Err(OpenStreamError::Abort { reason, .. })) => {
808                assert_eq!(reason.as_deref(), Some("boom"));
809            }
810            other => panic!("expected abort error on the stream, got {other:?}"),
811        }
812    }
813
814    #[tokio::test]
815    async fn fires_only_on_close_on_graceful_close_and_only_on_abort_on_abort() {
816        use std::sync::{Arc as StdArc, Mutex as StdMutex};
817
818        // Build a (on_close, on_abort) pair that each record which hook fired,
819        // tagged by the session, into one shared log.
820        fn recording_hooks(
821            events: StdArc<StdMutex<Vec<String>>>,
822            token: &'static str,
823        ) -> (RegistryCloseHook, RegistryAbortHook) {
824            let close_events = events.clone();
825            let on_close: RegistryCloseHook = Box::new(move || {
826                Box::pin(async move {
827                    close_events.lock().unwrap().push(format!("close:{token}"));
828                    Ok(())
829                })
830            });
831            let on_abort: RegistryAbortHook = Box::new(move |_reason| {
832                Box::pin(async move {
833                    events.lock().unwrap().push(format!("abort:{token}"));
834                    Ok(())
835                })
836            });
837            (on_close, on_abort)
838        }
839
840        let events = StdArc::new(StdMutex::new(Vec::<String>::new()));
841        let mut registry = OpenStreamRegistry::with_policy(small_policy(4));
842
843        // (1) A graceful close fires only `on_close`.
844        let (on_close, on_abort) = recording_hooks(events.clone(), "graceful");
845        registry
846            .create_session_with(
847                "tok-graceful",
848                OpenStreamSessionInit {
849                    on_close: Some(on_close),
850                    on_abort: Some(on_abort),
851                    ..Default::default()
852                },
853            )
854            .unwrap();
855        registry
856            .process_frame(now(), &notif("tok-graceful", 1, start()))
857            .await
858            .unwrap();
859        registry
860            .process_frame(
861                now(),
862                &notif(
863                    "tok-graceful",
864                    2,
865                    OpenStreamFrame::Close {
866                        last_chunk_index: None,
867                    },
868                ),
869            )
870            .await
871            .unwrap();
872
873        // (2) An abort fires only `on_abort`.
874        let (on_close, on_abort) = recording_hooks(events.clone(), "aborted");
875        registry
876            .create_session_with(
877                "tok-aborted",
878                OpenStreamSessionInit {
879                    on_close: Some(on_close),
880                    on_abort: Some(on_abort),
881                    ..Default::default()
882                },
883            )
884            .unwrap();
885        registry
886            .process_frame(now(), &notif("tok-aborted", 1, start()))
887            .await
888            .unwrap();
889        registry
890            .process_frame(
891                now(),
892                &notif(
893                    "tok-aborted",
894                    2,
895                    OpenStreamFrame::Abort {
896                        reason: Some("bye".to_string()),
897                    },
898                ),
899            )
900            .await
901            .unwrap();
902
903        // Exactly one hook fired per session, the right one each time.
904        let fired = events.lock().unwrap().clone();
905        assert_eq!(
906            fired,
907            vec!["close:graceful".to_string(), "abort:aborted".to_string()]
908        );
909    }
910}