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;
20
21use futures::future::BoxFuture;
22use tokio::sync::Mutex;
23
24use super::frame::OpenStreamFrame;
25use super::session::PublishFrame;
26
27/// Lifecycle hook fired after a terminal `close` frame is published.
28///
29/// Currently inert; the server transport will wire it to flush a deferred final response.
30pub type OnCloseHook = Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
31
32/// Lifecycle hook fired after a terminal `abort` frame is published (with the
33/// advisory reason).
34///
35/// Currently inert; the server transport will wire it to flush a deferred final response.
36pub type OnAbortHook = Arc<dyn Fn(Option<String>) -> BoxFuture<'static, ()> + Send + Sync>;
37
38/// Construction options for an [`OpenStreamWriter`].
39pub struct OpenStreamWriterOptions {
40    /// The stream id (stringified `progressToken`).
41    pub progress_token: String,
42    /// Outbound publisher (same seam as the session).
43    pub publish_frame: PublishFrame,
44    /// Optional advisory `start` content type (writer-settable, receiver-ignored).
45    pub content_type: Option<String>,
46    /// Fired after a terminal `close` frame is published.
47    pub on_close: Option<OnCloseHook>,
48    /// Fired after a terminal `abort` frame is published.
49    pub on_abort: Option<OnAbortHook>,
50}
51
52/// State mutated only under the op `Mutex` (serialized publishes).
53struct WriterOpState {
54    /// Next `chunkIndex` to assign (touched only by `write`).
55    chunk_index: u64,
56    /// Control-frame nonce counter (touched only by `ping`).
57    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    /// Serializes `write`/`close`/`ping`/`pong` so call order == wire order.
67    op: Mutex<WriterOpState>,
68    /// Monotonic outer `progress`, shared so `abort` can mint one without the op
69    /// lock. Frames use `fetch_add(1) + 1` → 1, 2, 3, …
70    progress: AtomicU64,
71    /// Liveness flag, **outside** the op lock so `abort` never blocks on a stuck
72    /// write. `false` once closed or aborted.
73    active: AtomicBool,
74    /// Whether the lazy `start` frame has been published.
75    started: AtomicBool,
76}
77
78/// Minimal CEP-41 producer/writer.
79#[derive(Clone)]
80pub struct OpenStreamWriter {
81    inner: Arc<WriterInner>,
82}
83
84impl OpenStreamWriter {
85    /// Create a new writer from explicit options.
86    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    /// The stream id (stringified `progressToken`).
106    pub fn progress_token(&self) -> &str {
107        &self.inner.progress_token
108    }
109
110    /// Whether the writer is still live (not yet closed/aborted).
111    ///
112    /// `true` for any freshly-created writer; see [`has_started`](Self::has_started).
113    pub fn is_active(&self) -> bool {
114        self.inner.active.load(Ordering::SeqCst)
115    }
116
117    /// Whether the writer has begun streaming by publishing its `start` frame.
118    ///
119    /// Distinct from [`is_active`](Self::is_active): used to tell apart writers a
120    /// tool actually streams through from ones created only because the request
121    /// carried a progress token (the response-deferral guard).
122    pub fn has_started(&self) -> bool {
123        self.inner.started.load(Ordering::SeqCst)
124    }
125
126    /// Mint the next monotonic outer `progress` value (1, 2, 3, …).
127    fn next_progress(&self) -> u64 {
128        self.inner.progress.fetch_add(1, Ordering::SeqCst) + 1
129    }
130
131    /// Publish the lazy `start` frame on first use. Caller MUST hold the op lock.
132    /// On a publish failure `started` stays `false`, so a later op retries.
133    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    /// Explicitly publish the `start` frame (idempotent; lazy on first `write`).
151    pub async fn start(&self) -> crate::Result<()> {
152        let _op = self.inner.op.lock().await;
153        self.start_internal().await
154    }
155
156    /// Publish one ordered `chunk` frame, starting the stream lazily.
157    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    /// Publish a keepalive `ping` carrying a fresh `{token}:{n}` nonce.
173    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    /// Publish a `pong` echoing the peer's `ping` nonce.
191    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    /// Close the stream gracefully. Declares `lastChunkIndex` iff any chunks were
207    /// written. Runs [`on_close`](OpenStreamWriterOptions::on_close) **after** the
208    /// frame is published (even on publish failure); propagates the publish error.
209    pub async fn close(&self) -> crate::Result<()> {
210        let op = self.inner.op.lock().await;
211        self.start_internal().await?;
212        // Claim the terminal transition atomically; lose to a racing abort.
213        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    /// Abort the stream (terminal). Claims the terminal transition without the op
234    /// lock, so it never waits on a stuck `write`. Runs
235    /// [`on_abort`](OpenStreamWriterOptions::on_abort) **after** the frame is
236    /// published (even on publish failure); propagates the publish error.
237    /// Idempotent: a second `abort` (or one after `close`) is a no-op.
238    pub async fn abort(&self, reason: Option<String>) -> crate::Result<()> {
239        // Claim the terminal transition; no op lock → never blocks on a write.
240        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    /// A publish closure that records every frame and returns a dummy id.
274    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        &notification.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        // Control frames do not start the stream.
330        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                    // Signal that the write has reached the (stuck) chunk publish,
597                    // then block until released.
598                    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        // Wait until the write is parked inside the stuck chunk publish.
625        reached.notified().await;
626
627        // Abort must complete without acquiring the op lock the stuck write holds.
628        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        // Release the stuck write so the spawned task can finish cleanly.
636        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                // A small delay on chunks would expose any interleaving if the op
648                // lock did not serialize build+publish.
649                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}