Skip to main content

contextvm_sdk/transport/open_stream/
writer.rs

1//! Producer-side writer for a CEP-41 open stream.
2//!
3//! Ports `sdk/src/transport/open-stream/writer.ts`. A tool emits an ordered
4//! sequence of `chunk` frames (plus keepalive `ping`/`pong` and a terminal
5//! `close`/`abort`) through an injected publish closure.
6//!
7//! Serialization design: `write`/`close`/`ping`/`pong`
8//! hold a `tokio::sync::Mutex` across their publish `.await`, so **call order ==
9//! wire order** natively (each op increments `progress`/`chunkIndex` under the
10//! lock). The liveness flag lives in a **separate `AtomicBool` outside that
11//! lock**, so [`abort`](OpenStreamWriter::abort) can claim the terminal
12//! transition and publish without queueing behind a stuck `write`.
13//!
14//! The handle is `Arc`-backed and `Clone` so it can be inserted into the rmcp
15//! request `extensions` typemap (`T: Clone + Send + Sync + 'static`) when the
16//! server transport wiring lands.
17
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
19use std::sync::{Arc, Mutex as StdMutex};
20use std::time::{Duration, Instant};
21
22use futures::future::BoxFuture;
23use tokio::sync::Mutex;
24
25use super::frame::OpenStreamFrame;
26use super::session::{KeepaliveAction, PublishFrame};
27
28/// Lifecycle hook fired after a terminal `close` frame is published.
29///
30/// Currently inert; the server transport will wire it to flush a deferred final response.
31pub type OnCloseHook = Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
32
33/// Lifecycle hook fired after a terminal `abort` frame is published (with the
34/// advisory reason).
35///
36/// Currently inert; the server transport will wire it to flush a deferred final response.
37pub type OnAbortHook = Arc<dyn Fn(Option<String>) -> BoxFuture<'static, ()> + Send + Sync>;
38
39/// Construction options for an [`OpenStreamWriter`].
40pub struct OpenStreamWriterOptions {
41    /// The stream id (stringified `progressToken`).
42    pub progress_token: String,
43    /// Outbound publisher (same seam as the session).
44    pub publish_frame: PublishFrame,
45    /// Optional advisory `start` content type (writer-settable, receiver-ignored).
46    pub content_type: Option<String>,
47    /// Fired after a terminal `close` frame is published.
48    pub on_close: Option<OnCloseHook>,
49    /// Fired after a terminal `abort` frame is published.
50    pub on_abort: Option<OnAbortHook>,
51    /// Sender-side keepalive idle window (CEP-41 §Timeout and Keepalive). `None`
52    /// disables keepalive (e.g. unit tests). When set, the writer arms the window
53    /// once it starts streaming and probes the peer with `ping` if no inbound
54    /// frame arrives within this duration; a missing `pong` aborts with
55    /// `"Probe timeout"`, closing a leak where a silently-disconnected client
56    /// left the writer (and its upstream producer) alive indefinitely.
57    pub idle_timeout: Option<Duration>,
58    /// Time the writer waits for a matching `pong` after probing before aborting.
59    /// Ignored when [`idle_timeout`](OpenStreamWriterOptions::idle_timeout) is `None`.
60    pub probe_timeout: Duration,
61}
62
63impl OpenStreamWriterOptions {
64    /// Enable sender-side keepalive with the given idle/probe windows.
65    pub fn with_keepalive(mut self, idle: Duration, probe: Duration) -> Self {
66        self.idle_timeout = Some(idle);
67        self.probe_timeout = probe;
68        self
69    }
70}
71
72/// Sender-side keepalive state (CEP-41), guarded by a `std::sync::Mutex` so the
73/// pure sync [`tick`](OpenStreamWriter::tick) can mutate it without awaiting.
74///
75/// Per CEP-41 only **inbound** frames reset
76/// [`last_activity`](Self::last_activity) — our own `chunk` publishes do not (a
77/// vanished client keeps accepting relay events, so a successful `write` is not
78/// liveness).
79struct WriterKeepalive {
80    /// Last time an inbound frame was observed. Armed at `start`.
81    last_activity: Instant,
82    /// Nonce of an in-flight probe awaiting a matching `pong`.
83    pending_probe_nonce: Option<String>,
84    /// When the in-flight probe times out → abort.
85    probe_deadline: Option<Instant>,
86}
87
88/// State mutated only under the op `Mutex` (serialized publishes).
89struct WriterOpState {
90    /// Next `chunkIndex` to assign (touched only by `write`).
91    chunk_index: u64,
92}
93
94struct WriterInner {
95    progress_token: String,
96    content_type: Option<String>,
97    publish_frame: PublishFrame,
98    on_close: Option<OnCloseHook>,
99    on_abort: Option<OnAbortHook>,
100    /// Serializes `write`/`close`/`ping`/`pong` so call order == wire order.
101    op: Mutex<WriterOpState>,
102    /// Monotonic outer `progress`, shared so `abort` can mint one without the op
103    /// lock. Frames use `fetch_add(1) + 1` → 1, 2, 3, …
104    progress: AtomicU64,
105    /// Control-frame nonce counter shared by manual `ping` and keepalive probes
106    /// so nonces stay unique within the stream (`{token}:{n}`).
107    control_nonce: AtomicU64,
108    /// Liveness flag, **outside** the op lock so `abort` never blocks on a stuck
109    /// write. `false` once closed or aborted.
110    active: AtomicBool,
111    /// Whether the lazy `start` frame has been published.
112    started: AtomicBool,
113    /// Sender-side keepalive config + state. Inert when `idle_timeout` is `None`.
114    idle_timeout: Option<Duration>,
115    probe_timeout: Duration,
116    keepalive: StdMutex<WriterKeepalive>,
117}
118
119/// Minimal CEP-41 producer/writer.
120#[derive(Clone)]
121pub struct OpenStreamWriter {
122    inner: Arc<WriterInner>,
123}
124
125impl OpenStreamWriter {
126    /// Create a new writer from explicit options.
127    pub fn new(options: OpenStreamWriterOptions) -> Self {
128        Self {
129            inner: Arc::new(WriterInner {
130                progress_token: options.progress_token,
131                content_type: options.content_type,
132                publish_frame: options.publish_frame,
133                on_close: options.on_close,
134                on_abort: options.on_abort,
135                op: Mutex::new(WriterOpState { chunk_index: 0 }),
136                progress: AtomicU64::new(0),
137                control_nonce: AtomicU64::new(0),
138                active: AtomicBool::new(true),
139                started: AtomicBool::new(false),
140                idle_timeout: options.idle_timeout,
141                probe_timeout: options.probe_timeout,
142                keepalive: StdMutex::new(WriterKeepalive {
143                    last_activity: Instant::now(),
144                    pending_probe_nonce: None,
145                    probe_deadline: None,
146                }),
147            }),
148        }
149    }
150
151    /// The stream id (stringified `progressToken`).
152    pub fn progress_token(&self) -> &str {
153        &self.inner.progress_token
154    }
155
156    /// Whether the writer is still live (not yet closed/aborted).
157    ///
158    /// `true` for any freshly-created writer; see [`has_started`](Self::has_started).
159    pub fn is_active(&self) -> bool {
160        self.inner.active.load(Ordering::SeqCst)
161    }
162
163    /// Whether the writer has begun streaming by publishing its `start` frame.
164    ///
165    /// Distinct from [`is_active`](Self::is_active): used to tell apart writers a
166    /// tool actually streams through from ones created only because the request
167    /// carried a progress token (the response-deferral guard).
168    pub fn has_started(&self) -> bool {
169        self.inner.started.load(Ordering::SeqCst)
170    }
171
172    /// Mint the next monotonic outer `progress` value (1, 2, 3, …).
173    fn next_progress(&self) -> u64 {
174        self.inner.progress.fetch_add(1, Ordering::SeqCst) + 1
175    }
176
177    /// Publish the lazy `start` frame on first use. Caller MUST hold the op lock.
178    /// On a publish failure `started` stays `false`, so a later op retries.
179    async fn start_internal(&self) -> crate::Result<()> {
180        if self.inner.started.load(Ordering::SeqCst) || !self.inner.active.load(Ordering::SeqCst) {
181            return Ok(());
182        }
183        let notification = OpenStreamFrame::Start {
184            content_type: self.inner.content_type.clone(),
185        }
186        .into_progress_notification(
187            &self.inner.progress_token,
188            self.next_progress(),
189            None,
190        )?;
191        (self.inner.publish_frame)(notification).await?;
192        self.inner.started.store(true, Ordering::SeqCst);
193        // Arm the sender-side keepalive idle window (CEP-41): the stream is active
194        // from `start`, so the probe clock starts here. Only inbound frames reset
195        // it afterwards — our own chunk publishes are not liveness.
196        if self.inner.idle_timeout.is_some() {
197            self.inner.keepalive.lock().unwrap().last_activity = Instant::now();
198        }
199        Ok(())
200    }
201
202    /// Explicitly publish the `start` frame (idempotent; lazy on first `write`).
203    pub async fn start(&self) -> crate::Result<()> {
204        let _op = self.inner.op.lock().await;
205        self.start_internal().await
206    }
207
208    /// Publish one ordered `chunk` frame, starting the stream lazily.
209    pub async fn write(&self, data: String) -> crate::Result<()> {
210        let mut op = self.inner.op.lock().await;
211        self.start_internal().await?;
212        if !self.inner.active.load(Ordering::SeqCst) {
213            return Ok(());
214        }
215        let progress = self.next_progress();
216        let chunk_index = op.chunk_index;
217        let notification = OpenStreamFrame::Chunk { chunk_index, data }
218            .into_progress_notification(&self.inner.progress_token, progress, None)?;
219        (self.inner.publish_frame)(notification).await?;
220        op.chunk_index += 1;
221        Ok(())
222    }
223
224    /// Publish a keepalive `ping` carrying a fresh `{token}:{n}` nonce.
225    pub async fn ping(&self) -> crate::Result<()> {
226        let _op = self.inner.op.lock().await;
227        if !self.inner.active.load(Ordering::SeqCst) {
228            return Ok(());
229        }
230        let n = self.inner.control_nonce.fetch_add(1, Ordering::SeqCst) + 1;
231        let nonce = format!("{}:{}", self.inner.progress_token, n);
232        let progress = self.next_progress();
233        let notification = OpenStreamFrame::Ping { nonce }.into_progress_notification(
234            &self.inner.progress_token,
235            progress,
236            None,
237        )?;
238        (self.inner.publish_frame)(notification).await?;
239        Ok(())
240    }
241
242    /// Publish a `pong` echoing the peer's `ping` nonce.
243    ///
244    /// An inbound `ping` is proof of peer liveness (CEP-41: receipt of any valid
245    /// frame resets the idle window), so this refreshes the keepalive clock
246    /// before responding.
247    pub async fn pong(&self, nonce: String) -> crate::Result<()> {
248        let _op = self.inner.op.lock().await;
249        if !self.inner.active.load(Ordering::SeqCst) {
250            return Ok(());
251        }
252        if self.inner.idle_timeout.is_some() {
253            self.inner.keepalive.lock().unwrap().last_activity = Instant::now();
254        }
255        let progress = self.next_progress();
256        let notification = OpenStreamFrame::Pong { nonce }.into_progress_notification(
257            &self.inner.progress_token,
258            progress,
259            None,
260        )?;
261        (self.inner.publish_frame)(notification).await?;
262        Ok(())
263    }
264
265    // ── sender-side keepalive (CEP-41), driven by the transport sweep ───────
266
267    /// Pure keepalive transition (idle → `ping`, probe deadline → abort), mirroring
268    /// the reader-side
269    /// [`OpenStreamSession::tick`](super::session::OpenStreamSession::tick).
270    /// Driven by the owning transport's periodic sweep with `Instant::now()`.
271    ///
272    /// Returns [`KeepaliveAction::None`] when keepalive is disabled, the writer is
273    /// inactive, or it has not yet started (the idle window arms at `start`).
274    pub fn tick(&self, now: Instant) -> KeepaliveAction {
275        let Some(idle_timeout) = self.inner.idle_timeout else {
276            return KeepaliveAction::None;
277        };
278        if !self.inner.active.load(Ordering::SeqCst) || !self.inner.started.load(Ordering::SeqCst) {
279            return KeepaliveAction::None;
280        }
281        let mut ka = self.inner.keepalive.lock().unwrap();
282
283        // 1. A pending probe whose deadline passed → abort.
284        if let Some(deadline) = ka.probe_deadline {
285            if ka.pending_probe_nonce.is_some() && now >= deadline {
286                return KeepaliveAction::Abort("Probe timeout".to_string());
287            }
288        }
289
290        // 2. Idle past the threshold (and not already probing) → ping.
291        if ka.pending_probe_nonce.is_none()
292            && now.saturating_duration_since(ka.last_activity) >= idle_timeout
293        {
294            let n = self.inner.control_nonce.fetch_add(1, Ordering::SeqCst) + 1;
295            let nonce = format!("{}:{}", self.inner.progress_token, n);
296            ka.pending_probe_nonce = Some(nonce.clone());
297            ka.probe_deadline = Some(now + self.inner.probe_timeout);
298            return KeepaliveAction::SendPing(nonce);
299        }
300
301        KeepaliveAction::None
302    }
303
304    /// Publish a keepalive `ping` carrying a `nonce` already minted by [`tick`](Self::tick).
305    ///
306    /// Used by the transport sweep; unlike [`ping`](Self::ping) it does not mint a
307    /// new nonce (the probe recorded by `tick` must match the frame on the wire so
308    /// a matching `pong` clears it). Does not take the op lock, so a stuck app
309    /// `write` cannot block keepalive (mirroring the TS writer's queue bypass).
310    ///
311    /// Ceiling: by skipping the op lock, this probe's `progress` (shared atomic)
312    /// can interleave with an in-flight `write` — a probe may carry a lower
313    /// `progress` than a chunk published just before it. Harmless: relays reorder
314    /// regardless and control frames aren't `chunkIndex`-gap-checked. Re-serialize
315    /// under the op lock only if strict monotonic publish-order is ever required
316    /// (which would re-block keepalive behind a stuck `write`).
317    pub async fn send_probe(&self, nonce: String) -> crate::Result<()> {
318        if !self.inner.active.load(Ordering::SeqCst) {
319            return Ok(());
320        }
321        let progress = self.next_progress();
322        let notification = OpenStreamFrame::Ping { nonce }.into_progress_notification(
323            &self.inner.progress_token,
324            progress,
325            None,
326        )?;
327        (self.inner.publish_frame)(notification).await.map(|_| ())
328    }
329
330    /// Acknowledge an inbound `pong` matching the pending keepalive probe (CEP-41).
331    ///
332    /// A matching `pong` clears the probe and refreshes the idle window. A `pong`
333    /// whose nonce does not match the pending probe is **not** evidence of
334    /// liveness (CEP-41: "A `pong` with an unknown … nonce MUST NOT be treated as
335    /// evidence of liveness") and is ignored.
336    pub fn ack_probe(&self, nonce: &str) {
337        let mut ka = self.inner.keepalive.lock().unwrap();
338        if self.inner.active.load(Ordering::SeqCst)
339            && ka.pending_probe_nonce.as_deref() == Some(nonce)
340        {
341            ka.pending_probe_nonce = None;
342            ka.probe_deadline = None;
343            ka.last_activity = Instant::now();
344        }
345    }
346
347    /// Release writer resources without publishing a terminal frame — used on
348    /// transport teardown so the writer no longer drives keepalive. Idempotent.
349    ///
350    /// Only flips `active` to `false`: [`tick`](Self::tick) and
351    /// [`ack_probe`](Self::ack_probe) early-return on that flag, so an armed probe
352    /// becomes inert without needing to be cleared (matching `close`/`abort`,
353    /// which also leave keepalive state untouched once terminal).
354    pub fn dispose(&self) {
355        let _ = self.inner.active.swap(false, Ordering::SeqCst);
356    }
357
358    /// Close the stream gracefully. Declares `lastChunkIndex` iff any chunks were
359    /// written. Runs [`on_close`](OpenStreamWriterOptions::on_close) **after** the
360    /// frame is published (even on publish failure); propagates the publish error.
361    pub async fn close(&self) -> crate::Result<()> {
362        let op = self.inner.op.lock().await;
363        self.start_internal().await?;
364        // Claim the terminal transition atomically; lose to a racing abort.
365        if !self.inner.active.swap(false, Ordering::SeqCst) {
366            return Ok(());
367        }
368        let last_chunk_index = if op.chunk_index > 0 {
369            Some(op.chunk_index - 1)
370        } else {
371            None
372        };
373        let notification = OpenStreamFrame::Close { last_chunk_index }.into_progress_notification(
374            &self.inner.progress_token,
375            self.next_progress(),
376            None,
377        )?;
378        let publish_result = (self.inner.publish_frame)(notification).await;
379        if let Some(hook) = &self.inner.on_close {
380            hook().await;
381        }
382        publish_result.map(|_| ())
383    }
384
385    /// Abort the stream (terminal). Claims the terminal transition without the op
386    /// lock, so it never waits on a stuck `write`. Runs
387    /// [`on_abort`](OpenStreamWriterOptions::on_abort) **after** the frame is
388    /// published (even on publish failure); propagates the publish error.
389    /// Idempotent: a second `abort` (or one after `close`) is a no-op.
390    pub async fn abort(&self, reason: Option<String>) -> crate::Result<()> {
391        // Claim the terminal transition; no op lock → never blocks on a write.
392        if !self.inner.active.swap(false, Ordering::SeqCst) {
393            return Ok(());
394        }
395        let notification = OpenStreamFrame::Abort {
396            reason: reason.clone(),
397        }
398        .into_progress_notification(
399            &self.inner.progress_token,
400            self.next_progress(),
401            None,
402        )?;
403        let publish_result = (self.inner.publish_frame)(notification).await;
404        if let Some(hook) = &self.inner.on_abort {
405            hook(reason).await;
406        }
407        publish_result.map(|_| ())
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::core::types::JsonRpcNotification;
415    use crate::Error;
416    use nostr_sdk::prelude::EventId;
417    use std::sync::Mutex as StdMutex;
418
419    type FrameLog = Arc<StdMutex<Vec<JsonRpcNotification>>>;
420
421    fn frame_log() -> FrameLog {
422        Arc::new(StdMutex::new(Vec::new()))
423    }
424
425    /// A publish closure that records every frame and returns a dummy id.
426    fn recording_publisher(log: FrameLog) -> PublishFrame {
427        Arc::new(move |frame: JsonRpcNotification| {
428            let log = log.clone();
429            Box::pin(async move {
430                log.lock().unwrap().push(frame);
431                Ok(EventId::all_zeros())
432            })
433        })
434    }
435
436    fn frame_type(notification: &JsonRpcNotification) -> String {
437        notification.params.as_ref().unwrap()["cvm"]["frameType"]
438            .as_str()
439            .unwrap()
440            .to_string()
441    }
442
443    fn frame_types(log: &FrameLog) -> Vec<String> {
444        log.lock().unwrap().iter().map(frame_type).collect()
445    }
446
447    fn progress_of(notification: &JsonRpcNotification) -> u64 {
448        notification.params.as_ref().unwrap()["progress"]
449            .as_u64()
450            .unwrap()
451    }
452
453    fn cvm(notification: &JsonRpcNotification) -> &serde_json::Value {
454        &notification.params.as_ref().unwrap()["cvm"]
455    }
456
457    fn writer_with(log: FrameLog) -> OpenStreamWriter {
458        OpenStreamWriter::new(OpenStreamWriterOptions {
459            progress_token: "tok".to_string(),
460            publish_frame: recording_publisher(log),
461            content_type: None,
462            on_close: None,
463            on_abort: None,
464            idle_timeout: None,
465            probe_timeout: Duration::from_millis(20_000),
466        })
467    }
468
469    #[tokio::test]
470    async fn has_started_reflects_start_or_chunk_frame() {
471        let log = frame_log();
472        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
473            progress_token: "token-started".to_string(),
474            publish_frame: recording_publisher(log),
475            content_type: None,
476            on_close: None,
477            on_abort: None,
478            idle_timeout: None,
479            probe_timeout: Duration::from_millis(20_000),
480        });
481
482        assert!(writer.is_active());
483        assert!(!writer.has_started());
484
485        // Control frames do not start the stream.
486        writer.ping().await.unwrap();
487        writer.pong("nonce".to_string()).await.unwrap();
488        assert!(!writer.has_started());
489
490        writer.write("hello".to_string()).await.unwrap();
491        assert!(writer.has_started());
492    }
493
494    #[tokio::test]
495    async fn has_started_after_explicit_start() {
496        let log = frame_log();
497        let writer = writer_with(log.clone());
498        assert!(!writer.has_started());
499
500        writer.start().await.unwrap();
501
502        assert!(writer.has_started());
503        assert_eq!(frame_types(&log), vec!["start"]);
504    }
505
506    #[tokio::test]
507    async fn emits_ping_and_pong_with_matching_nonces() {
508        let log = frame_log();
509        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
510            progress_token: "token-keepalive".to_string(),
511            publish_frame: recording_publisher(log.clone()),
512            content_type: None,
513            on_close: None,
514            on_abort: None,
515            idle_timeout: None,
516            probe_timeout: Duration::from_millis(20_000),
517        });
518
519        writer.start().await.unwrap();
520        writer.ping().await.unwrap();
521        writer.pong("keepalive-nonce".to_string()).await.unwrap();
522
523        let frames = log.lock().unwrap();
524        assert_eq!(frames.len(), 3);
525        assert_eq!(progress_of(&frames[1]), 2);
526        assert_eq!(cvm(&frames[1])["frameType"], "ping");
527        assert_eq!(cvm(&frames[1])["nonce"], "token-keepalive:1");
528        assert_eq!(progress_of(&frames[2]), 3);
529        assert_eq!(cvm(&frames[2])["frameType"], "pong");
530        assert_eq!(cvm(&frames[2])["nonce"], "keepalive-nonce");
531    }
532
533    #[tokio::test]
534    async fn close_omits_last_chunk_index_when_no_chunks() {
535        let log = frame_log();
536        let writer = writer_with(log.clone());
537        writer.close().await.unwrap();
538
539        let frames = log.lock().unwrap();
540        assert_eq!(frames.len(), 2);
541        assert_eq!(frame_type(&frames[1]), "close");
542        assert!(!cvm(&frames[1])
543            .as_object()
544            .unwrap()
545            .contains_key("lastChunkIndex"));
546    }
547
548    #[tokio::test]
549    async fn close_includes_last_chunk_index_after_chunks() {
550        let log = frame_log();
551        let writer = writer_with(log.clone());
552        writer.write("hello".to_string()).await.unwrap();
553        writer.write("world".to_string()).await.unwrap();
554        writer.close().await.unwrap();
555
556        let frames = log.lock().unwrap();
557        assert_eq!(frames.len(), 4);
558        assert_eq!(frame_type(&frames[3]), "close");
559        assert_eq!(cvm(&frames[3])["lastChunkIndex"], 1);
560    }
561
562    #[tokio::test]
563    async fn lifecycle_hooks_fire_after_terminal_frames() {
564        let lifecycle = Arc::new(StdMutex::new(Vec::<String>::new()));
565        let log = frame_log();
566
567        let lc = lifecycle.clone();
568        let on_close: OnCloseHook = Arc::new(move || {
569            let lc = lc.clone();
570            Box::pin(async move {
571                lc.lock().unwrap().push("close".to_string());
572            })
573        });
574        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
575            progress_token: "token-hooks".to_string(),
576            publish_frame: recording_publisher(log.clone()),
577            content_type: None,
578            on_close: Some(on_close),
579            on_abort: None,
580            idle_timeout: None,
581            probe_timeout: Duration::from_millis(20_000),
582        });
583        writer.close().await.unwrap();
584        assert_eq!(frame_type(log.lock().unwrap().last().unwrap()), "close");
585        assert_eq!(*lifecycle.lock().unwrap(), vec!["close"]);
586
587        let lc = lifecycle.clone();
588        let on_abort: OnAbortHook = Arc::new(move |reason: Option<String>| {
589            let lc = lc.clone();
590            Box::pin(async move {
591                lc.lock()
592                    .unwrap()
593                    .push(format!("abort:{}", reason.unwrap_or_default()));
594            })
595        });
596        let abort_writer = OpenStreamWriter::new(OpenStreamWriterOptions {
597            progress_token: "token-hooks-abort".to_string(),
598            publish_frame: recording_publisher(log.clone()),
599            content_type: None,
600            on_close: None,
601            on_abort: Some(on_abort),
602            idle_timeout: None,
603            probe_timeout: Duration::from_millis(20_000),
604        });
605        abort_writer.abort(Some("done".to_string())).await.unwrap();
606        let frames = log.lock().unwrap();
607        assert_eq!(frame_type(frames.last().unwrap()), "abort");
608        assert_eq!(cvm(frames.last().unwrap())["reason"], "done");
609        assert_eq!(*lifecycle.lock().unwrap(), vec!["close", "abort:done"]);
610    }
611
612    #[tokio::test]
613    async fn publishes_abort_before_running_abort_hook() {
614        let events = Arc::new(StdMutex::new(Vec::<String>::new()));
615
616        let ev = events.clone();
617        let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
618            let ev = ev.clone();
619            Box::pin(async move {
620                ev.lock()
621                    .unwrap()
622                    .push(format!("publish:{}", frame_type(&frame)));
623                Ok(EventId::all_zeros())
624            })
625        });
626        let ev = events.clone();
627        let on_abort: OnAbortHook = Arc::new(move |reason: Option<String>| {
628            let ev = ev.clone();
629            Box::pin(async move {
630                ev.lock()
631                    .unwrap()
632                    .push(format!("abort:{}", reason.unwrap_or_default()));
633            })
634        });
635        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
636            progress_token: "token-abort-order".to_string(),
637            publish_frame: publish,
638            content_type: None,
639            on_close: None,
640            on_abort: Some(on_abort),
641            idle_timeout: None,
642            probe_timeout: Duration::from_millis(20_000),
643        });
644
645        writer.abort(Some("ordered".to_string())).await.unwrap();
646        assert_eq!(
647            *events.lock().unwrap(),
648            vec!["publish:abort", "abort:ordered"]
649        );
650    }
651
652    #[tokio::test]
653    async fn retries_start_when_first_start_publish_fails() {
654        let log = frame_log();
655        let fail_start = Arc::new(AtomicBool::new(true));
656
657        let f = log.clone();
658        let fs = fail_start.clone();
659        let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
660            let f = f.clone();
661            let fs = fs.clone();
662            Box::pin(async move {
663                if frame_type(&frame) == "start" && fs.swap(false, Ordering::SeqCst) {
664                    return Err(Error::Transport("relay unavailable".to_string()));
665                }
666                f.lock().unwrap().push(frame);
667                Ok(EventId::all_zeros())
668            })
669        });
670        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
671            progress_token: "token-start-retry".to_string(),
672            publish_frame: publish,
673            content_type: None,
674            on_close: None,
675            on_abort: None,
676            idle_timeout: None,
677            probe_timeout: Duration::from_millis(20_000),
678        });
679
680        assert!(writer.start().await.is_err());
681        writer.write("hello".to_string()).await.unwrap();
682        assert_eq!(frame_types(&log), vec!["start", "chunk"]);
683    }
684
685    #[tokio::test]
686    async fn runs_close_cleanup_when_close_publish_fails() {
687        let lifecycle = Arc::new(StdMutex::new(Vec::<String>::new()));
688        let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
689            Box::pin(async move {
690                if frame_type(&frame) == "close" {
691                    return Err(Error::Transport("close publish failed".to_string()));
692                }
693                Ok(EventId::all_zeros())
694            })
695        });
696        let lc = lifecycle.clone();
697        let on_close: OnCloseHook = Arc::new(move || {
698            let lc = lc.clone();
699            Box::pin(async move {
700                lc.lock().unwrap().push("close".to_string());
701            })
702        });
703        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
704            progress_token: "token-close-fail".to_string(),
705            publish_frame: publish,
706            content_type: None,
707            on_close: Some(on_close),
708            on_abort: None,
709            idle_timeout: None,
710            probe_timeout: Duration::from_millis(20_000),
711        });
712
713        assert!(writer.close().await.is_err());
714        assert!(!writer.is_active());
715        assert_eq!(*lifecycle.lock().unwrap(), vec!["close"]);
716    }
717
718    #[tokio::test]
719    async fn runs_abort_cleanup_when_abort_publish_fails() {
720        let lifecycle = Arc::new(StdMutex::new(Vec::<String>::new()));
721        let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
722            Box::pin(async move {
723                if frame_type(&frame) == "abort" {
724                    return Err(Error::Transport("abort publish failed".to_string()));
725                }
726                Ok(EventId::all_zeros())
727            })
728        });
729        let lc = lifecycle.clone();
730        let on_abort: OnAbortHook = Arc::new(move |reason: Option<String>| {
731            let lc = lc.clone();
732            Box::pin(async move {
733                lc.lock()
734                    .unwrap()
735                    .push(format!("abort:{}", reason.unwrap_or_default()));
736            })
737        });
738        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
739            progress_token: "token-abort-fail".to_string(),
740            publish_frame: publish,
741            content_type: None,
742            on_close: None,
743            on_abort: Some(on_abort),
744            idle_timeout: None,
745            probe_timeout: Duration::from_millis(20_000),
746        });
747
748        assert!(writer.abort(Some("cleanup".to_string())).await.is_err());
749        assert!(!writer.is_active());
750        assert_eq!(*lifecycle.lock().unwrap(), vec!["abort:cleanup"]);
751    }
752
753    #[tokio::test]
754    async fn abort_deactivates_without_waiting_for_a_stuck_write() {
755        let lifecycle = Arc::new(StdMutex::new(Vec::<String>::new()));
756        let reached = Arc::new(tokio::sync::Notify::new());
757        let gate = Arc::new(tokio::sync::Notify::new());
758
759        let r = reached.clone();
760        let g = gate.clone();
761        let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
762            let r = r.clone();
763            let g = g.clone();
764            Box::pin(async move {
765                if frame_type(&frame) == "chunk" {
766                    // Signal that the write has reached the (stuck) chunk publish,
767                    // then block until released.
768                    r.notify_one();
769                    g.notified().await;
770                }
771                Ok(EventId::all_zeros())
772            })
773        });
774        let lc = lifecycle.clone();
775        let on_abort: OnAbortHook = Arc::new(move |reason: Option<String>| {
776            let lc = lc.clone();
777            Box::pin(async move {
778                lc.lock()
779                    .unwrap()
780                    .push(format!("abort:{}", reason.unwrap_or_default()));
781            })
782        });
783        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
784            progress_token: "token-stuck".to_string(),
785            publish_frame: publish,
786            content_type: None,
787            on_close: None,
788            on_abort: Some(on_abort),
789            idle_timeout: None,
790            probe_timeout: Duration::from_millis(20_000),
791        });
792
793        let writer2 = writer.clone();
794        let write_handle = tokio::spawn(async move { writer2.write("hello".to_string()).await });
795
796        // Wait until the write is parked inside the stuck chunk publish.
797        reached.notified().await;
798
799        // Abort must complete without acquiring the op lock the stuck write holds.
800        writer
801            .abort(Some("stuck publish".to_string()))
802            .await
803            .unwrap();
804        assert!(!writer.is_active());
805        assert_eq!(*lifecycle.lock().unwrap(), vec!["abort:stuck publish"]);
806
807        // Release the stuck write so the spawned task can finish cleanly.
808        gate.notify_one();
809        write_handle.await.unwrap().unwrap();
810    }
811
812    #[tokio::test]
813    async fn serializes_concurrent_writes_before_close() {
814        let log = frame_log();
815        let f = log.clone();
816        let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
817            let f = f.clone();
818            Box::pin(async move {
819                // A small delay on chunks would expose any interleaving if the op
820                // lock did not serialize build+publish.
821                if frame_type(&frame) == "chunk" {
822                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
823                }
824                f.lock().unwrap().push(frame);
825                Ok(EventId::all_zeros())
826            })
827        });
828        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
829            progress_token: "token-concurrent".to_string(),
830            publish_frame: publish,
831            content_type: None,
832            on_close: None,
833            on_abort: None,
834            idle_timeout: None,
835            probe_timeout: Duration::from_millis(20_000),
836        });
837
838        let (a, b, c) = tokio::join!(
839            writer.write("hello".to_string()),
840            writer.write("world".to_string()),
841            writer.close()
842        );
843        a.unwrap();
844        b.unwrap();
845        c.unwrap();
846
847        let frames = log.lock().unwrap();
848        let types: Vec<String> = frames.iter().map(frame_type).collect();
849        assert_eq!(types, vec!["start", "chunk", "chunk", "close"]);
850        assert_eq!(progress_of(&frames[1]), 2);
851        assert_eq!(cvm(&frames[1])["chunkIndex"], 0);
852        assert_eq!(cvm(&frames[1])["data"], "hello");
853        assert_eq!(progress_of(&frames[2]), 3);
854        assert_eq!(cvm(&frames[2])["chunkIndex"], 1);
855        assert_eq!(cvm(&frames[2])["data"], "world");
856        assert_eq!(progress_of(&frames[3]), 4);
857        assert_eq!(cvm(&frames[3])["lastChunkIndex"], 1);
858    }
859
860    // ── sender-side keepalive (CEP-41) ──────────────────────────────────────
861
862    /// A writer with sender-side keepalive armed (`idle_timeout = Some(..)`).
863    /// Returns the frame log so the probe publish can be inspected.
864    fn keepalive_writer(idle_ms: u64, probe_ms: u64) -> (OpenStreamWriter, FrameLog) {
865        let log = frame_log();
866        let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
867            progress_token: "tok-ka".to_string(),
868            publish_frame: recording_publisher(log.clone()),
869            content_type: None,
870            on_close: None,
871            on_abort: None,
872            idle_timeout: Some(Duration::from_millis(idle_ms)),
873            probe_timeout: Duration::from_millis(probe_ms),
874        });
875        (writer, log)
876    }
877
878    #[tokio::test]
879    async fn keepalive_disabled_writer_tick_is_always_none() {
880        // `writer_with` builds a keepalive-disabled writer (idle_timeout: None).
881        let log = frame_log();
882        let writer = writer_with(log);
883        writer.start().await.unwrap();
884
885        let t = Instant::now();
886        assert_eq!(writer.tick(t), KeepaliveAction::None);
887        assert_eq!(
888            writer.tick(t + Duration::from_secs(60)),
889            KeepaliveAction::None
890        );
891    }
892
893    #[tokio::test]
894    async fn tick_returns_none_before_start() {
895        let (writer, _) = keepalive_writer(10, 20);
896        // Keepalive arms at `start`; a never-started writer must not probe.
897        assert_eq!(writer.tick(Instant::now()), KeepaliveAction::None);
898    }
899
900    #[tokio::test]
901    async fn tick_idle_probes_then_probe_deadline_aborts() {
902        let (writer, _) = keepalive_writer(10, 20);
903        writer.start().await.unwrap();
904        let t0 = Instant::now();
905
906        // Before the idle threshold: nothing.
907        assert_eq!(writer.tick(t0), KeepaliveAction::None);
908        // At the idle threshold: probe with a `{token}:{n}` nonce.
909        let nonce = match writer.tick(t0 + Duration::from_millis(11)) {
910            KeepaliveAction::SendPing(n) => n,
911            other => panic!("expected SendPing, got {other:?}"),
912        };
913        assert!(nonce.starts_with("tok-ka:"));
914        // Probe in flight, deadline not yet reached: nothing.
915        assert_eq!(
916            writer.tick(t0 + Duration::from_millis(15)),
917            KeepaliveAction::None
918        );
919        // Probe deadline reached with no pong: abort.
920        assert_eq!(
921            writer.tick(t0 + Duration::from_millis(31)),
922            KeepaliveAction::Abort("Probe timeout".to_string())
923        );
924    }
925
926    #[tokio::test]
927    async fn matching_pong_clears_probe_and_rearms_idle() {
928        let (writer, _) = keepalive_writer(10, 20);
929        writer.start().await.unwrap();
930        let t0 = Instant::now();
931        let nonce = match writer.tick(t0 + Duration::from_millis(11)) {
932            KeepaliveAction::SendPing(n) => n,
933            other => panic!("expected SendPing, got {other:?}"),
934        };
935
936        // A matching pong clears the probe and refreshes liveness.
937        writer.ack_probe(&nonce);
938
939        // At the *old* probe deadline the stream is NOT aborted: the probe was
940        // cleared and the idle window restarted from ack time.
941        let after_ack = Instant::now();
942        assert!(!matches!(
943            writer.tick(after_ack + Duration::from_millis(5)),
944            KeepaliveAction::Abort(_)
945        ));
946        // A fresh probe fires only once the new idle window elapses.
947        assert!(matches!(
948            writer.tick(after_ack + Duration::from_millis(12)),
949            KeepaliveAction::SendPing(_)
950        ));
951    }
952
953    #[tokio::test]
954    async fn unmatched_pong_does_not_clear_probe() {
955        let (writer, _) = keepalive_writer(10, 20);
956        writer.start().await.unwrap();
957        let t0 = Instant::now();
958        let _ = writer.tick(t0 + Duration::from_millis(11));
959
960        // A pong with a non-matching nonce must not clear the pending probe
961        // (CEP-41: an unknown nonce is not evidence of liveness).
962        writer.ack_probe("tok-ka:999");
963
964        assert_eq!(
965            writer.tick(t0 + Duration::from_millis(31)),
966            KeepaliveAction::Abort("Probe timeout".to_string())
967        );
968    }
969
970    #[tokio::test]
971    async fn inbound_ping_refreshes_idle_window() {
972        let (writer, _) = keepalive_writer(10, 20);
973        writer.start().await.unwrap();
974
975        // Handling an inbound `ping` (pong) is liveness — refreshes the idle
976        // window so a tick shortly after does not probe.
977        writer.pong("peer-ping".to_string()).await.unwrap();
978        let after_pong = Instant::now();
979        assert_eq!(
980            writer.tick(after_pong + Duration::from_millis(5)),
981            KeepaliveAction::None
982        );
983        // Once the idle window elapses again, it probes.
984        assert!(matches!(
985            writer.tick(after_pong + Duration::from_millis(12)),
986            KeepaliveAction::SendPing(_)
987        ));
988    }
989
990    #[tokio::test]
991    async fn send_probe_publishes_a_ping_with_the_given_nonce() {
992        let (writer, log) = keepalive_writer(10, 20);
993        writer.start().await.unwrap();
994        writer.send_probe("custom-nonce".to_string()).await.unwrap();
995
996        // `frame_types` takes the `log` lock itself; call it before acquiring
997        // `frames` so the two lock acquisitions don't overlap (std::sync::Mutex is
998        // not re-entrant).
999        assert_eq!(frame_types(&log), vec!["start", "ping"]);
1000        let frames = log.lock().unwrap();
1001        assert_eq!(cvm(&frames[1])["nonce"], "custom-nonce");
1002    }
1003
1004    #[tokio::test]
1005    async fn dispose_makes_writer_inactive_and_tick_inert() {
1006        let (writer, _) = keepalive_writer(10, 20);
1007        writer.start().await.unwrap();
1008        assert!(writer.is_active());
1009
1010        writer.dispose();
1011
1012        assert!(!writer.is_active());
1013        assert_eq!(
1014            writer.tick(Instant::now() + Duration::from_secs(60)),
1015            KeepaliveAction::None
1016        );
1017    }
1018}