1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
19use std::sync::Arc;
20
21use futures::future::BoxFuture;
22use tokio::sync::Mutex;
23
24use super::frame::OpenStreamFrame;
25use super::session::PublishFrame;
26
27pub type OnCloseHook = Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
31
32pub type OnAbortHook = Arc<dyn Fn(Option<String>) -> BoxFuture<'static, ()> + Send + Sync>;
37
38pub struct OpenStreamWriterOptions {
40 pub progress_token: String,
42 pub publish_frame: PublishFrame,
44 pub content_type: Option<String>,
46 pub on_close: Option<OnCloseHook>,
48 pub on_abort: Option<OnAbortHook>,
50}
51
52struct WriterOpState {
54 chunk_index: u64,
56 control_nonce: u64,
58}
59
60struct WriterInner {
61 progress_token: String,
62 content_type: Option<String>,
63 publish_frame: PublishFrame,
64 on_close: Option<OnCloseHook>,
65 on_abort: Option<OnAbortHook>,
66 op: Mutex<WriterOpState>,
68 progress: AtomicU64,
71 active: AtomicBool,
74 started: AtomicBool,
76}
77
78#[derive(Clone)]
80pub struct OpenStreamWriter {
81 inner: Arc<WriterInner>,
82}
83
84impl OpenStreamWriter {
85 pub fn new(options: OpenStreamWriterOptions) -> Self {
87 Self {
88 inner: Arc::new(WriterInner {
89 progress_token: options.progress_token,
90 content_type: options.content_type,
91 publish_frame: options.publish_frame,
92 on_close: options.on_close,
93 on_abort: options.on_abort,
94 op: Mutex::new(WriterOpState {
95 chunk_index: 0,
96 control_nonce: 0,
97 }),
98 progress: AtomicU64::new(0),
99 active: AtomicBool::new(true),
100 started: AtomicBool::new(false),
101 }),
102 }
103 }
104
105 pub fn progress_token(&self) -> &str {
107 &self.inner.progress_token
108 }
109
110 pub fn is_active(&self) -> bool {
114 self.inner.active.load(Ordering::SeqCst)
115 }
116
117 pub fn has_started(&self) -> bool {
123 self.inner.started.load(Ordering::SeqCst)
124 }
125
126 fn next_progress(&self) -> u64 {
128 self.inner.progress.fetch_add(1, Ordering::SeqCst) + 1
129 }
130
131 async fn start_internal(&self) -> crate::Result<()> {
134 if self.inner.started.load(Ordering::SeqCst) || !self.inner.active.load(Ordering::SeqCst) {
135 return Ok(());
136 }
137 let notification = OpenStreamFrame::Start {
138 content_type: self.inner.content_type.clone(),
139 }
140 .into_progress_notification(
141 &self.inner.progress_token,
142 self.next_progress(),
143 None,
144 )?;
145 (self.inner.publish_frame)(notification).await?;
146 self.inner.started.store(true, Ordering::SeqCst);
147 Ok(())
148 }
149
150 pub async fn start(&self) -> crate::Result<()> {
152 let _op = self.inner.op.lock().await;
153 self.start_internal().await
154 }
155
156 pub async fn write(&self, data: String) -> crate::Result<()> {
158 let mut op = self.inner.op.lock().await;
159 self.start_internal().await?;
160 if !self.inner.active.load(Ordering::SeqCst) {
161 return Ok(());
162 }
163 let progress = self.next_progress();
164 let chunk_index = op.chunk_index;
165 let notification = OpenStreamFrame::Chunk { chunk_index, data }
166 .into_progress_notification(&self.inner.progress_token, progress, None)?;
167 (self.inner.publish_frame)(notification).await?;
168 op.chunk_index += 1;
169 Ok(())
170 }
171
172 pub async fn ping(&self) -> crate::Result<()> {
174 let mut op = self.inner.op.lock().await;
175 if !self.inner.active.load(Ordering::SeqCst) {
176 return Ok(());
177 }
178 op.control_nonce += 1;
179 let nonce = format!("{}:{}", self.inner.progress_token, op.control_nonce);
180 let progress = self.next_progress();
181 let notification = OpenStreamFrame::Ping { nonce }.into_progress_notification(
182 &self.inner.progress_token,
183 progress,
184 None,
185 )?;
186 (self.inner.publish_frame)(notification).await?;
187 Ok(())
188 }
189
190 pub async fn pong(&self, nonce: String) -> crate::Result<()> {
192 let _op = self.inner.op.lock().await;
193 if !self.inner.active.load(Ordering::SeqCst) {
194 return Ok(());
195 }
196 let progress = self.next_progress();
197 let notification = OpenStreamFrame::Pong { nonce }.into_progress_notification(
198 &self.inner.progress_token,
199 progress,
200 None,
201 )?;
202 (self.inner.publish_frame)(notification).await?;
203 Ok(())
204 }
205
206 pub async fn close(&self) -> crate::Result<()> {
210 let op = self.inner.op.lock().await;
211 self.start_internal().await?;
212 if !self.inner.active.swap(false, Ordering::SeqCst) {
214 return Ok(());
215 }
216 let last_chunk_index = if op.chunk_index > 0 {
217 Some(op.chunk_index - 1)
218 } else {
219 None
220 };
221 let notification = OpenStreamFrame::Close { last_chunk_index }.into_progress_notification(
222 &self.inner.progress_token,
223 self.next_progress(),
224 None,
225 )?;
226 let publish_result = (self.inner.publish_frame)(notification).await;
227 if let Some(hook) = &self.inner.on_close {
228 hook().await;
229 }
230 publish_result.map(|_| ())
231 }
232
233 pub async fn abort(&self, reason: Option<String>) -> crate::Result<()> {
239 if !self.inner.active.swap(false, Ordering::SeqCst) {
241 return Ok(());
242 }
243 let notification = OpenStreamFrame::Abort {
244 reason: reason.clone(),
245 }
246 .into_progress_notification(
247 &self.inner.progress_token,
248 self.next_progress(),
249 None,
250 )?;
251 let publish_result = (self.inner.publish_frame)(notification).await;
252 if let Some(hook) = &self.inner.on_abort {
253 hook(reason).await;
254 }
255 publish_result.map(|_| ())
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use crate::core::types::JsonRpcNotification;
263 use crate::Error;
264 use nostr_sdk::prelude::EventId;
265 use std::sync::Mutex as StdMutex;
266
267 type FrameLog = Arc<StdMutex<Vec<JsonRpcNotification>>>;
268
269 fn frame_log() -> FrameLog {
270 Arc::new(StdMutex::new(Vec::new()))
271 }
272
273 fn recording_publisher(log: FrameLog) -> PublishFrame {
275 Arc::new(move |frame: JsonRpcNotification| {
276 let log = log.clone();
277 Box::pin(async move {
278 log.lock().unwrap().push(frame);
279 Ok(EventId::all_zeros())
280 })
281 })
282 }
283
284 fn frame_type(notification: &JsonRpcNotification) -> String {
285 notification.params.as_ref().unwrap()["cvm"]["frameType"]
286 .as_str()
287 .unwrap()
288 .to_string()
289 }
290
291 fn frame_types(log: &FrameLog) -> Vec<String> {
292 log.lock().unwrap().iter().map(frame_type).collect()
293 }
294
295 fn progress_of(notification: &JsonRpcNotification) -> u64 {
296 notification.params.as_ref().unwrap()["progress"]
297 .as_u64()
298 .unwrap()
299 }
300
301 fn cvm(notification: &JsonRpcNotification) -> &serde_json::Value {
302 ¬ification.params.as_ref().unwrap()["cvm"]
303 }
304
305 fn writer_with(log: FrameLog) -> OpenStreamWriter {
306 OpenStreamWriter::new(OpenStreamWriterOptions {
307 progress_token: "tok".to_string(),
308 publish_frame: recording_publisher(log),
309 content_type: None,
310 on_close: None,
311 on_abort: None,
312 })
313 }
314
315 #[tokio::test]
316 async fn has_started_reflects_start_or_chunk_frame() {
317 let log = frame_log();
318 let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
319 progress_token: "token-started".to_string(),
320 publish_frame: recording_publisher(log),
321 content_type: None,
322 on_close: None,
323 on_abort: None,
324 });
325
326 assert!(writer.is_active());
327 assert!(!writer.has_started());
328
329 writer.ping().await.unwrap();
331 writer.pong("nonce".to_string()).await.unwrap();
332 assert!(!writer.has_started());
333
334 writer.write("hello".to_string()).await.unwrap();
335 assert!(writer.has_started());
336 }
337
338 #[tokio::test]
339 async fn has_started_after_explicit_start() {
340 let log = frame_log();
341 let writer = writer_with(log.clone());
342 assert!(!writer.has_started());
343
344 writer.start().await.unwrap();
345
346 assert!(writer.has_started());
347 assert_eq!(frame_types(&log), vec!["start"]);
348 }
349
350 #[tokio::test]
351 async fn emits_ping_and_pong_with_matching_nonces() {
352 let log = frame_log();
353 let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
354 progress_token: "token-keepalive".to_string(),
355 publish_frame: recording_publisher(log.clone()),
356 content_type: None,
357 on_close: None,
358 on_abort: None,
359 });
360
361 writer.start().await.unwrap();
362 writer.ping().await.unwrap();
363 writer.pong("keepalive-nonce".to_string()).await.unwrap();
364
365 let frames = log.lock().unwrap();
366 assert_eq!(frames.len(), 3);
367 assert_eq!(progress_of(&frames[1]), 2);
368 assert_eq!(cvm(&frames[1])["frameType"], "ping");
369 assert_eq!(cvm(&frames[1])["nonce"], "token-keepalive:1");
370 assert_eq!(progress_of(&frames[2]), 3);
371 assert_eq!(cvm(&frames[2])["frameType"], "pong");
372 assert_eq!(cvm(&frames[2])["nonce"], "keepalive-nonce");
373 }
374
375 #[tokio::test]
376 async fn close_omits_last_chunk_index_when_no_chunks() {
377 let log = frame_log();
378 let writer = writer_with(log.clone());
379 writer.close().await.unwrap();
380
381 let frames = log.lock().unwrap();
382 assert_eq!(frames.len(), 2);
383 assert_eq!(frame_type(&frames[1]), "close");
384 assert!(!cvm(&frames[1])
385 .as_object()
386 .unwrap()
387 .contains_key("lastChunkIndex"));
388 }
389
390 #[tokio::test]
391 async fn close_includes_last_chunk_index_after_chunks() {
392 let log = frame_log();
393 let writer = writer_with(log.clone());
394 writer.write("hello".to_string()).await.unwrap();
395 writer.write("world".to_string()).await.unwrap();
396 writer.close().await.unwrap();
397
398 let frames = log.lock().unwrap();
399 assert_eq!(frames.len(), 4);
400 assert_eq!(frame_type(&frames[3]), "close");
401 assert_eq!(cvm(&frames[3])["lastChunkIndex"], 1);
402 }
403
404 #[tokio::test]
405 async fn lifecycle_hooks_fire_after_terminal_frames() {
406 let lifecycle = Arc::new(StdMutex::new(Vec::<String>::new()));
407 let log = frame_log();
408
409 let lc = lifecycle.clone();
410 let on_close: OnCloseHook = Arc::new(move || {
411 let lc = lc.clone();
412 Box::pin(async move {
413 lc.lock().unwrap().push("close".to_string());
414 })
415 });
416 let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
417 progress_token: "token-hooks".to_string(),
418 publish_frame: recording_publisher(log.clone()),
419 content_type: None,
420 on_close: Some(on_close),
421 on_abort: None,
422 });
423 writer.close().await.unwrap();
424 assert_eq!(frame_type(log.lock().unwrap().last().unwrap()), "close");
425 assert_eq!(*lifecycle.lock().unwrap(), vec!["close"]);
426
427 let lc = lifecycle.clone();
428 let on_abort: OnAbortHook = Arc::new(move |reason: Option<String>| {
429 let lc = lc.clone();
430 Box::pin(async move {
431 lc.lock()
432 .unwrap()
433 .push(format!("abort:{}", reason.unwrap_or_default()));
434 })
435 });
436 let abort_writer = OpenStreamWriter::new(OpenStreamWriterOptions {
437 progress_token: "token-hooks-abort".to_string(),
438 publish_frame: recording_publisher(log.clone()),
439 content_type: None,
440 on_close: None,
441 on_abort: Some(on_abort),
442 });
443 abort_writer.abort(Some("done".to_string())).await.unwrap();
444 let frames = log.lock().unwrap();
445 assert_eq!(frame_type(frames.last().unwrap()), "abort");
446 assert_eq!(cvm(frames.last().unwrap())["reason"], "done");
447 assert_eq!(*lifecycle.lock().unwrap(), vec!["close", "abort:done"]);
448 }
449
450 #[tokio::test]
451 async fn publishes_abort_before_running_abort_hook() {
452 let events = Arc::new(StdMutex::new(Vec::<String>::new()));
453
454 let ev = events.clone();
455 let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
456 let ev = ev.clone();
457 Box::pin(async move {
458 ev.lock()
459 .unwrap()
460 .push(format!("publish:{}", frame_type(&frame)));
461 Ok(EventId::all_zeros())
462 })
463 });
464 let ev = events.clone();
465 let on_abort: OnAbortHook = Arc::new(move |reason: Option<String>| {
466 let ev = ev.clone();
467 Box::pin(async move {
468 ev.lock()
469 .unwrap()
470 .push(format!("abort:{}", reason.unwrap_or_default()));
471 })
472 });
473 let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
474 progress_token: "token-abort-order".to_string(),
475 publish_frame: publish,
476 content_type: None,
477 on_close: None,
478 on_abort: Some(on_abort),
479 });
480
481 writer.abort(Some("ordered".to_string())).await.unwrap();
482 assert_eq!(
483 *events.lock().unwrap(),
484 vec!["publish:abort", "abort:ordered"]
485 );
486 }
487
488 #[tokio::test]
489 async fn retries_start_when_first_start_publish_fails() {
490 let log = frame_log();
491 let fail_start = Arc::new(AtomicBool::new(true));
492
493 let f = log.clone();
494 let fs = fail_start.clone();
495 let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
496 let f = f.clone();
497 let fs = fs.clone();
498 Box::pin(async move {
499 if frame_type(&frame) == "start" && fs.swap(false, Ordering::SeqCst) {
500 return Err(Error::Transport("relay unavailable".to_string()));
501 }
502 f.lock().unwrap().push(frame);
503 Ok(EventId::all_zeros())
504 })
505 });
506 let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
507 progress_token: "token-start-retry".to_string(),
508 publish_frame: publish,
509 content_type: None,
510 on_close: None,
511 on_abort: None,
512 });
513
514 assert!(writer.start().await.is_err());
515 writer.write("hello".to_string()).await.unwrap();
516 assert_eq!(frame_types(&log), vec!["start", "chunk"]);
517 }
518
519 #[tokio::test]
520 async fn runs_close_cleanup_when_close_publish_fails() {
521 let lifecycle = Arc::new(StdMutex::new(Vec::<String>::new()));
522 let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
523 Box::pin(async move {
524 if frame_type(&frame) == "close" {
525 return Err(Error::Transport("close publish failed".to_string()));
526 }
527 Ok(EventId::all_zeros())
528 })
529 });
530 let lc = lifecycle.clone();
531 let on_close: OnCloseHook = Arc::new(move || {
532 let lc = lc.clone();
533 Box::pin(async move {
534 lc.lock().unwrap().push("close".to_string());
535 })
536 });
537 let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
538 progress_token: "token-close-fail".to_string(),
539 publish_frame: publish,
540 content_type: None,
541 on_close: Some(on_close),
542 on_abort: None,
543 });
544
545 assert!(writer.close().await.is_err());
546 assert!(!writer.is_active());
547 assert_eq!(*lifecycle.lock().unwrap(), vec!["close"]);
548 }
549
550 #[tokio::test]
551 async fn runs_abort_cleanup_when_abort_publish_fails() {
552 let lifecycle = Arc::new(StdMutex::new(Vec::<String>::new()));
553 let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
554 Box::pin(async move {
555 if frame_type(&frame) == "abort" {
556 return Err(Error::Transport("abort publish failed".to_string()));
557 }
558 Ok(EventId::all_zeros())
559 })
560 });
561 let lc = lifecycle.clone();
562 let on_abort: OnAbortHook = Arc::new(move |reason: Option<String>| {
563 let lc = lc.clone();
564 Box::pin(async move {
565 lc.lock()
566 .unwrap()
567 .push(format!("abort:{}", reason.unwrap_or_default()));
568 })
569 });
570 let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
571 progress_token: "token-abort-fail".to_string(),
572 publish_frame: publish,
573 content_type: None,
574 on_close: None,
575 on_abort: Some(on_abort),
576 });
577
578 assert!(writer.abort(Some("cleanup".to_string())).await.is_err());
579 assert!(!writer.is_active());
580 assert_eq!(*lifecycle.lock().unwrap(), vec!["abort:cleanup"]);
581 }
582
583 #[tokio::test]
584 async fn abort_deactivates_without_waiting_for_a_stuck_write() {
585 let lifecycle = Arc::new(StdMutex::new(Vec::<String>::new()));
586 let reached = Arc::new(tokio::sync::Notify::new());
587 let gate = Arc::new(tokio::sync::Notify::new());
588
589 let r = reached.clone();
590 let g = gate.clone();
591 let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
592 let r = r.clone();
593 let g = g.clone();
594 Box::pin(async move {
595 if frame_type(&frame) == "chunk" {
596 r.notify_one();
599 g.notified().await;
600 }
601 Ok(EventId::all_zeros())
602 })
603 });
604 let lc = lifecycle.clone();
605 let on_abort: OnAbortHook = Arc::new(move |reason: Option<String>| {
606 let lc = lc.clone();
607 Box::pin(async move {
608 lc.lock()
609 .unwrap()
610 .push(format!("abort:{}", reason.unwrap_or_default()));
611 })
612 });
613 let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
614 progress_token: "token-stuck".to_string(),
615 publish_frame: publish,
616 content_type: None,
617 on_close: None,
618 on_abort: Some(on_abort),
619 });
620
621 let writer2 = writer.clone();
622 let write_handle = tokio::spawn(async move { writer2.write("hello".to_string()).await });
623
624 reached.notified().await;
626
627 writer
629 .abort(Some("stuck publish".to_string()))
630 .await
631 .unwrap();
632 assert!(!writer.is_active());
633 assert_eq!(*lifecycle.lock().unwrap(), vec!["abort:stuck publish"]);
634
635 gate.notify_one();
637 write_handle.await.unwrap().unwrap();
638 }
639
640 #[tokio::test]
641 async fn serializes_concurrent_writes_before_close() {
642 let log = frame_log();
643 let f = log.clone();
644 let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| {
645 let f = f.clone();
646 Box::pin(async move {
647 if frame_type(&frame) == "chunk" {
650 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
651 }
652 f.lock().unwrap().push(frame);
653 Ok(EventId::all_zeros())
654 })
655 });
656 let writer = OpenStreamWriter::new(OpenStreamWriterOptions {
657 progress_token: "token-concurrent".to_string(),
658 publish_frame: publish,
659 content_type: None,
660 on_close: None,
661 on_abort: None,
662 });
663
664 let (a, b, c) = tokio::join!(
665 writer.write("hello".to_string()),
666 writer.write("world".to_string()),
667 writer.close()
668 );
669 a.unwrap();
670 b.unwrap();
671 c.unwrap();
672
673 let frames = log.lock().unwrap();
674 let types: Vec<String> = frames.iter().map(frame_type).collect();
675 assert_eq!(types, vec!["start", "chunk", "chunk", "close"]);
676 assert_eq!(progress_of(&frames[1]), 2);
677 assert_eq!(cvm(&frames[1])["chunkIndex"], 0);
678 assert_eq!(cvm(&frames[1])["data"], "hello");
679 assert_eq!(progress_of(&frames[2]), 3);
680 assert_eq!(cvm(&frames[2])["chunkIndex"], 1);
681 assert_eq!(cvm(&frames[2])["data"], "world");
682 assert_eq!(progress_of(&frames[3]), 4);
683 assert_eq!(cvm(&frames[3])["lastChunkIndex"], 1);
684 }
685}