1use 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
28pub type OnCloseHook = Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
32
33pub type OnAbortHook = Arc<dyn Fn(Option<String>) -> BoxFuture<'static, ()> + Send + Sync>;
38
39pub struct OpenStreamWriterOptions {
41 pub progress_token: String,
43 pub publish_frame: PublishFrame,
45 pub content_type: Option<String>,
47 pub on_close: Option<OnCloseHook>,
49 pub on_abort: Option<OnAbortHook>,
51 pub idle_timeout: Option<Duration>,
58 pub probe_timeout: Duration,
61}
62
63impl OpenStreamWriterOptions {
64 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
72struct WriterKeepalive {
80 last_activity: Instant,
82 pending_probe_nonce: Option<String>,
84 probe_deadline: Option<Instant>,
86}
87
88struct WriterOpState {
90 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 op: Mutex<WriterOpState>,
102 progress: AtomicU64,
105 control_nonce: AtomicU64,
108 active: AtomicBool,
111 started: AtomicBool,
113 idle_timeout: Option<Duration>,
115 probe_timeout: Duration,
116 keepalive: StdMutex<WriterKeepalive>,
117}
118
119#[derive(Clone)]
121pub struct OpenStreamWriter {
122 inner: Arc<WriterInner>,
123}
124
125impl OpenStreamWriter {
126 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 pub fn progress_token(&self) -> &str {
153 &self.inner.progress_token
154 }
155
156 pub fn is_active(&self) -> bool {
160 self.inner.active.load(Ordering::SeqCst)
161 }
162
163 pub fn has_started(&self) -> bool {
169 self.inner.started.load(Ordering::SeqCst)
170 }
171
172 fn next_progress(&self) -> u64 {
174 self.inner.progress.fetch_add(1, Ordering::SeqCst) + 1
175 }
176
177 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 if self.inner.idle_timeout.is_some() {
197 self.inner.keepalive.lock().unwrap().last_activity = Instant::now();
198 }
199 Ok(())
200 }
201
202 pub async fn start(&self) -> crate::Result<()> {
204 let _op = self.inner.op.lock().await;
205 self.start_internal().await
206 }
207
208 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 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 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 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 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 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 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 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 pub fn dispose(&self) {
355 let _ = self.inner.active.swap(false, Ordering::SeqCst);
356 }
357
358 pub async fn close(&self) -> crate::Result<()> {
362 let op = self.inner.op.lock().await;
363 self.start_internal().await?;
364 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 pub async fn abort(&self, reason: Option<String>) -> crate::Result<()> {
391 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 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 ¬ification.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 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 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 reached.notified().await;
798
799 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 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 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 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 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 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 assert_eq!(writer.tick(t0), KeepaliveAction::None);
908 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 assert_eq!(
916 writer.tick(t0 + Duration::from_millis(15)),
917 KeepaliveAction::None
918 );
919 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 writer.ack_probe(&nonce);
938
939 let after_ack = Instant::now();
942 assert!(!matches!(
943 writer.tick(after_ack + Duration::from_millis(5)),
944 KeepaliveAction::Abort(_)
945 ));
946 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 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 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 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 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}