Skip to main content

cranpose_services/
content.rs

1//! Streaming content: the single shape every byte source hands the application.
2//!
3//! File picking, incoming shares, document intents, dropped files and media
4//! sources all resolve to a [`ContentHandle`]. A handle carries
5//! [`ContentMetadata`] and opens a [`ContentReader`] that yields chunks, so a
6//! multi-gigabyte media file and a two-byte clipboard drop travel the same API
7//! and neither forces the application to buffer a whole payload.
8//!
9//! Folders resolve to a [`ContentFolderRef`]. Their trees are consumed through
10//! [`folder_files`], which returns a [`ContentStream`]: an asynchronous stream
11//! whose `next` future wakes its collector when the provider discovers another
12//! file. Nothing here polls.
13
14use std::cell::RefCell;
15use std::collections::VecDeque;
16use std::future::Future;
17use std::pin::Pin;
18use std::rc::Rc;
19use std::task::{Context, Poll, Waker};
20
21/// Chunk size the framework's own readers hand back per [`ContentReader::read_chunk`].
22pub const DEFAULT_CHUNK_LEN: usize = 256 * 1024;
23
24/// A future produced by a content operation. It borrows the content it reads
25/// from, so no implementation has to clone a handle into every call.
26pub type ContentFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
27
28/// Errors produced while reading or writing content.
29#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
30pub enum ContentError {
31    /// The content no longer exists at its source.
32    #[error("content not found: {0}")]
33    NotFound(String),
34    /// The provider refused the read or write.
35    #[error("content access denied: {0}")]
36    PermissionDenied(String),
37    /// Any other provider failure.
38    #[error("content i/o failed: {0}")]
39    Io(String),
40    /// The operation is not offered by this platform or build.
41    #[error("{0} is not supported on this platform")]
42    Unsupported(&'static str),
43}
44
45/// Everything a provider can state about a piece of content without reading it.
46#[derive(Clone, Debug, Default, PartialEq, Eq)]
47pub struct ContentMetadata {
48    /// Display name, usually the last path or URI component.
49    pub name: String,
50    /// MIME type when the provider reports one.
51    pub mime_type: Option<String>,
52    /// Byte length when the provider reports one.
53    pub len: Option<u64>,
54    /// Last-modified time in milliseconds since the Unix epoch, when known.
55    pub modified_millis: Option<u64>,
56    /// Provider-scoped identifier: a path, a `content://` URI, a blob name.
57    /// Not guaranteed to be openable outside its provider.
58    pub identifier: String,
59}
60
61impl ContentMetadata {
62    /// Metadata carrying only a display name.
63    pub fn named(name: impl Into<String>) -> Self {
64        let name = name.into();
65        Self {
66            identifier: name.clone(),
67            name,
68            ..Self::default()
69        }
70    }
71
72    /// Sets the MIME type.
73    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
74        self.mime_type = Some(mime_type.into());
75        self
76    }
77
78    /// Sets the byte length.
79    pub fn with_len(mut self, len: u64) -> Self {
80        self.len = Some(len);
81        self
82    }
83
84    /// Sets the last-modified time in milliseconds since the Unix epoch.
85    pub fn with_modified_millis(mut self, modified_millis: u64) -> Self {
86        self.modified_millis = Some(modified_millis);
87        self
88    }
89
90    /// Sets the provider-scoped identifier.
91    pub fn with_identifier(mut self, identifier: impl Into<String>) -> Self {
92        self.identifier = identifier.into();
93        self
94    }
95
96    /// The lowercase extension of [`name`](Self::name), without the dot.
97    pub fn extension(&self) -> Option<String> {
98        let (_, extension) = self.name.rsplit_once('.')?;
99        if extension.is_empty() {
100            None
101        } else {
102            Some(extension.to_ascii_lowercase())
103        }
104    }
105}
106
107/// A pull reader over one piece of content.
108///
109/// Each [`read_chunk`](ContentReader::read_chunk) resolves to the next chunk of
110/// bytes, or `None` once the content is exhausted. Chunk sizes are chosen by the
111/// provider; framework readers use [`DEFAULT_CHUNK_LEN`].
112pub trait ContentReader {
113    /// Resolves to the next chunk, or `None` at end of content.
114    fn read_chunk(&self) -> ContentFuture<'_, Result<Option<Vec<u8>>, ContentError>>;
115}
116
117/// Shared handle to a [`ContentReader`].
118pub type ContentReaderRef = Rc<dyn ContentReader>;
119
120/// One readable piece of content from any source.
121pub trait Content {
122    /// What the provider states about this content without reading it.
123    fn metadata(&self) -> ContentMetadata;
124
125    /// Opens a chunked reader over the bytes.
126    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>>;
127
128    /// Reads every byte. The default drains [`open`](Content::open); providers
129    /// already holding the bytes override it to avoid the copy.
130    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
131        Box::pin(async move {
132            let reader = self.open().await?;
133            drain_reader(&reader).await
134        })
135    }
136}
137
138/// Shared handle to a [`Content`].
139pub type ContentHandle = Rc<dyn Content>;
140
141/// Reads a reader to its end.
142pub async fn drain_reader(reader: &ContentReaderRef) -> Result<Vec<u8>, ContentError> {
143    let mut bytes = Vec::new();
144    while let Some(chunk) = reader.read_chunk().await? {
145        bytes.extend_from_slice(&chunk);
146    }
147    Ok(bytes)
148}
149
150/// A writable destination for streamed content.
151pub trait ContentSink {
152    /// Appends `bytes` to the destination.
153    fn write_chunk(&self, bytes: Vec<u8>) -> ContentFuture<'_, Result<(), ContentError>>;
154
155    /// Commits the destination. Dropping a sink without finishing it discards
156    /// whatever the provider allows it to discard.
157    fn finish(&self) -> ContentFuture<'_, Result<(), ContentError>>;
158}
159
160/// Shared handle to a [`ContentSink`].
161pub type ContentSinkRef = Rc<dyn ContentSink>;
162
163/// Writes every byte of `bytes` into `sink` and commits it.
164pub async fn write_all(sink: &ContentSinkRef, bytes: Vec<u8>) -> Result<(), ContentError> {
165    sink.write_chunk(bytes).await?;
166    sink.finish().await
167}
168
169/// One child of a [`ContentFolder`].
170pub enum ContentEntry {
171    /// A readable file.
172    File(ContentHandle),
173    /// A nested folder.
174    Folder(ContentFolderRef),
175}
176
177impl ContentEntry {
178    /// The child's metadata regardless of kind.
179    pub fn metadata(&self) -> ContentMetadata {
180        match self {
181            ContentEntry::File(file) => file.metadata(),
182            ContentEntry::Folder(folder) => folder.metadata(),
183        }
184    }
185}
186
187/// A folder granted by a provider.
188pub trait ContentFolder {
189    /// What the provider states about this folder.
190    fn metadata(&self) -> ContentMetadata;
191
192    /// The immediate children.
193    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>>;
194
195    /// A provider-native stream over every file in the tree, when the provider
196    /// discovers files incrementally. Providers that enumerate eagerly return
197    /// `None` and [`folder_files`] walks them instead.
198    fn stream_files(&self) -> Option<ContentStreamRef> {
199        None
200    }
201}
202
203/// Shared handle to a [`ContentFolder`].
204pub type ContentFolderRef = Rc<dyn ContentFolder>;
205
206/// An asynchronous stream of content.
207///
208/// [`next`](ContentStream::next) resolves once the next item is available; the
209/// producer wakes the pending future, so collectors never poll.
210pub trait ContentStream {
211    /// Resolves to the next item, or `None` once the stream is exhausted.
212    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>>;
213
214    /// How many items the provider has produced so far, when it counts them.
215    fn produced(&self) -> Option<usize> {
216        None
217    }
218}
219
220/// Shared handle to a [`ContentStream`].
221pub type ContentStreamRef = Rc<dyn ContentStream>;
222
223/// Streams every file in `folder`'s tree, using the provider's native stream
224/// when it has one and a depth-first walk otherwise.
225pub fn folder_files(folder: ContentFolderRef) -> ContentStreamRef {
226    folder
227        .stream_files()
228        .unwrap_or_else(|| Rc::new(WalkStream::new(folder)))
229}
230
231/// Collects an entire stream. Convenient for tests and small trees; production
232/// collectors consume [`ContentStream::next`] incrementally.
233pub async fn collect_stream(stream: &ContentStreamRef) -> Result<Vec<ContentHandle>, ContentError> {
234    let mut items = Vec::new();
235    while let Some(item) = stream.next().await? {
236        items.push(item);
237    }
238    Ok(items)
239}
240
241/// Depth-first walk over a folder that enumerates eagerly.
242struct WalkStream {
243    pending: RefCell<Vec<ContentFolderRef>>,
244    ready: RefCell<VecDeque<ContentHandle>>,
245    produced: std::cell::Cell<usize>,
246}
247
248impl WalkStream {
249    fn new(root: ContentFolderRef) -> Self {
250        Self {
251            pending: RefCell::new(vec![root]),
252            ready: RefCell::new(VecDeque::new()),
253            produced: std::cell::Cell::new(0),
254        }
255    }
256}
257
258impl ContentStream for WalkStream {
259    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>> {
260        Box::pin(async move {
261            loop {
262                if let Some(file) = self.ready.borrow_mut().pop_front() {
263                    self.produced.set(self.produced.get() + 1);
264                    return Ok(Some(file));
265                }
266                let Some(folder) = self.pending.borrow_mut().pop() else {
267                    return Ok(None);
268                };
269                for entry in folder.entries().await? {
270                    match entry {
271                        ContentEntry::File(file) => self.ready.borrow_mut().push_back(file),
272                        ContentEntry::Folder(child) => self.pending.borrow_mut().push(child),
273                    }
274                }
275            }
276        })
277    }
278
279    fn produced(&self) -> Option<usize> {
280        Some(self.produced.get())
281    }
282}
283
284/// The producing half of a [`ContentStream`] fed by a platform callback.
285///
286/// A backend that receives items from outside the composition — an Android
287/// enumeration callback, a drop target, a download — pushes into the channel and
288/// the pending collector future is woken. This is the replacement for every
289/// "drain what is ready each frame" contract.
290pub struct ContentChannel {
291    shared: Rc<ChannelShared>,
292}
293
294struct ChannelShared {
295    ready: RefCell<VecDeque<ContentHandle>>,
296    error: RefCell<Option<ContentError>>,
297    closed: std::cell::Cell<bool>,
298    produced: std::cell::Cell<usize>,
299    waker: RefCell<Option<Waker>>,
300}
301
302impl ChannelShared {
303    fn wake(&self) {
304        if let Some(waker) = self.waker.borrow_mut().take() {
305            waker.wake();
306        }
307    }
308}
309
310impl Default for ContentChannel {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316impl ContentChannel {
317    /// Creates an open channel.
318    pub fn new() -> Self {
319        Self {
320            shared: Rc::new(ChannelShared {
321                ready: RefCell::new(VecDeque::new()),
322                error: RefCell::new(None),
323                closed: std::cell::Cell::new(false),
324                produced: std::cell::Cell::new(0),
325                waker: RefCell::new(None),
326            }),
327        }
328    }
329
330    /// The consuming half, handed to the application.
331    pub fn stream(&self) -> ContentStreamRef {
332        Rc::new(ChannelStream {
333            shared: Rc::clone(&self.shared),
334        })
335    }
336
337    /// Publishes one item and wakes a pending collector.
338    pub fn push(&self, content: ContentHandle) {
339        if self.shared.closed.get() {
340            return;
341        }
342        self.shared.ready.borrow_mut().push_back(content);
343        self.shared.wake();
344    }
345
346    /// Ends the stream with an error and wakes a pending collector.
347    pub fn fail(&self, error: ContentError) {
348        if self.shared.closed.get() {
349            return;
350        }
351        *self.shared.error.borrow_mut() = Some(error);
352        self.shared.closed.set(true);
353        self.shared.wake();
354    }
355
356    /// Ends the stream normally and wakes a pending collector.
357    pub fn close(&self) {
358        if self.shared.closed.get() {
359            return;
360        }
361        self.shared.closed.set(true);
362        self.shared.wake();
363    }
364
365    /// Whether the stream has been ended.
366    pub fn is_closed(&self) -> bool {
367        self.shared.closed.get()
368    }
369}
370
371struct ChannelStream {
372    shared: Rc<ChannelShared>,
373}
374
375impl ContentStream for ChannelStream {
376    fn next(&self) -> ContentFuture<'_, Result<Option<ContentHandle>, ContentError>> {
377        Box::pin(ChannelNext {
378            shared: Rc::clone(&self.shared),
379        })
380    }
381
382    fn produced(&self) -> Option<usize> {
383        Some(self.shared.produced.get())
384    }
385}
386
387struct ChannelNext {
388    shared: Rc<ChannelShared>,
389}
390
391impl Future for ChannelNext {
392    type Output = Result<Option<ContentHandle>, ContentError>;
393
394    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
395        if let Some(item) = self.shared.ready.borrow_mut().pop_front() {
396            self.shared.produced.set(self.shared.produced.get() + 1);
397            return Poll::Ready(Ok(Some(item)));
398        }
399        if let Some(error) = self.shared.error.borrow_mut().take() {
400            return Poll::Ready(Err(error));
401        }
402        if self.shared.closed.get() {
403            return Poll::Ready(Ok(None));
404        }
405        *self.shared.waker.borrow_mut() = Some(context.waker().clone());
406        Poll::Pending
407    }
408}
409
410/// Content already held in memory: clipboard drops, web `File` payloads, tests.
411pub struct BytesContent {
412    metadata: ContentMetadata,
413    bytes: Rc<Vec<u8>>,
414}
415
416impl BytesContent {
417    /// Wraps `bytes` under `metadata`, filling in the byte length.
418    pub fn new(metadata: ContentMetadata, bytes: Vec<u8>) -> Self {
419        let len = bytes.len() as u64;
420        Self {
421            metadata: ContentMetadata {
422                len: Some(len),
423                ..metadata
424            },
425            bytes: Rc::new(bytes),
426        }
427    }
428
429    /// Wraps `bytes` under a display name.
430    pub fn named(name: impl Into<String>, bytes: Vec<u8>) -> Self {
431        Self::new(ContentMetadata::named(name), bytes)
432    }
433
434    /// A shared handle to this content.
435    pub fn handle(self) -> ContentHandle {
436        Rc::new(self)
437    }
438}
439
440impl Content for BytesContent {
441    fn metadata(&self) -> ContentMetadata {
442        self.metadata.clone()
443    }
444
445    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>> {
446        let bytes = Rc::clone(&self.bytes);
447        Box::pin(async move {
448            Ok(Rc::new(BytesReader {
449                bytes,
450                offset: std::cell::Cell::new(0),
451            }) as ContentReaderRef)
452        })
453    }
454
455    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
456        let bytes = Rc::clone(&self.bytes);
457        Box::pin(async move { Ok(bytes.as_ref().clone()) })
458    }
459}
460
461struct BytesReader {
462    bytes: Rc<Vec<u8>>,
463    offset: std::cell::Cell<usize>,
464}
465
466impl ContentReader for BytesReader {
467    fn read_chunk(&self) -> ContentFuture<'_, Result<Option<Vec<u8>>, ContentError>> {
468        Box::pin(async move {
469            let start = self.offset.get();
470            if start >= self.bytes.len() {
471                return Ok(None);
472            }
473            let end = (start + DEFAULT_CHUNK_LEN).min(self.bytes.len());
474            self.offset.set(end);
475            Ok(Some(self.bytes[start..end].to_vec()))
476        })
477    }
478}
479
480/// A folder whose children are already known.
481pub struct ReadyFolder {
482    metadata: ContentMetadata,
483    entries: RefCell<Option<Vec<ContentEntry>>>,
484}
485
486impl ReadyFolder {
487    /// Wraps already-enumerated `entries` under `metadata`.
488    pub fn new(metadata: ContentMetadata, entries: Vec<ContentEntry>) -> Self {
489        Self {
490            metadata,
491            entries: RefCell::new(Some(entries)),
492        }
493    }
494
495    /// A shared handle to this folder.
496    pub fn handle(self) -> ContentFolderRef {
497        Rc::new(self)
498    }
499}
500
501impl ContentFolder for ReadyFolder {
502    fn metadata(&self) -> ContentMetadata {
503        self.metadata.clone()
504    }
505
506    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>> {
507        Box::pin(async move {
508            self.entries
509                .borrow_mut()
510                .take()
511                .ok_or_else(|| ContentError::Io("folder entries already consumed".into()))
512        })
513    }
514}
515
516/// Turns a provider URI into readable content.
517///
518/// Every source that names content by URI rather than handing over bytes —
519/// a document intent, a shared item, a dropped file, a media source — resolves
520/// through this, so applications never learn what a `content://`, `file://` or
521/// blob URI means on the current platform.
522pub trait ContentResolver {
523    /// Resolves `uri`, or `None` when this platform cannot open it.
524    fn resolve(&self, uri: &str) -> Option<ContentHandle>;
525}
526
527/// Shared handle to a [`ContentResolver`].
528pub type ContentResolverRef = Rc<dyn ContentResolver>;
529
530thread_local! {
531    static PLATFORM_RESOLVER: RefCell<Option<ContentResolverRef>> = const { RefCell::new(None) };
532}
533
534/// Installs the platform content resolver (Android's `ContentResolver`, the
535/// iOS document store, the browser's blob registry).
536pub fn set_platform_content_resolver(resolver: ContentResolverRef) {
537    PLATFORM_RESOLVER.with(|cell| *cell.borrow_mut() = Some(resolver));
538}
539
540/// Removes any installed platform content resolver.
541pub fn clear_platform_content_resolver() {
542    PLATFORM_RESOLVER.with(|cell| *cell.borrow_mut() = None);
543}
544
545/// Resolves `uri` through the platform resolver, falling back to the local
546/// filesystem for `file://` URIs and plain paths.
547pub fn resolve_content(uri: &str) -> Option<ContentHandle> {
548    if let Some(resolver) = PLATFORM_RESOLVER.with(|cell| cell.borrow().clone()) {
549        if let Some(content) = resolver.resolve(uri) {
550            return Some(content);
551        }
552    }
553    #[cfg(not(target_arch = "wasm32"))]
554    {
555        let path = uri.strip_prefix("file://").unwrap_or(uri);
556        if !path.contains("://") {
557            return Some(file_content(path));
558        }
559    }
560    let _ = uri;
561    None
562}
563
564#[cfg(not(target_arch = "wasm32"))]
565pub use file::{file_content, file_folder, FileContent, FileFolder, FileSink};
566
567#[cfg(not(target_arch = "wasm32"))]
568mod file;
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    fn block<T>(future: impl Future<Output = T>) -> T {
575        pollster::block_on(future)
576    }
577
578    #[test]
579    fn metadata_reports_the_lowercase_extension() {
580        let metadata = ContentMetadata::named("Scan.PNG");
581        assert_eq!(metadata.extension().as_deref(), Some("png"));
582        assert_eq!(ContentMetadata::named("noext").extension(), None);
583        assert_eq!(ContentMetadata::named("trailing.").extension(), None);
584    }
585
586    #[test]
587    fn metadata_carries_what_a_provider_knew_about_the_item() {
588        let metadata = ContentMetadata::named("Scan.png")
589            .with_len(2_048)
590            .with_modified_millis(1_700_000_000_000)
591            .with_identifier("content://provider/17")
592            .with_mime_type("image/png");
593
594        assert_eq!(metadata.name, "Scan.png");
595        assert_eq!(metadata.len, Some(2_048));
596        assert_eq!(metadata.modified_millis, Some(1_700_000_000_000));
597        assert_eq!(metadata.identifier, "content://provider/17");
598        assert_eq!(metadata.mime_type.as_deref(), Some("image/png"));
599        // A size or a timestamp a provider did not report stays unknown rather
600        // than becoming zero; an item with no provider-scoped identifier is
601        // identified by its name, which is the only handle there is on it.
602        let bare = ContentMetadata::named("Scan.png");
603        assert_eq!(bare.len, None);
604        assert_eq!(bare.modified_millis, None);
605        assert_eq!(bare.mime_type, None);
606        assert_eq!(bare.identifier, "Scan.png");
607    }
608
609    #[test]
610    fn a_channel_reports_when_it_has_ended_and_ignores_what_arrives_after() {
611        let channel = ContentChannel::new();
612        assert!(!channel.is_closed());
613
614        channel.close();
615        assert!(channel.is_closed());
616
617        // A producer that has not noticed yet must not be able to reopen it.
618        channel.push(BytesContent::named("late.bin", vec![1]).handle());
619        channel.fail(ContentError::Unsupported("after close"));
620        assert!(channel.is_closed());
621
622        let stream = channel.stream();
623        assert!(
624            block(stream.next())
625                .expect("a closed channel ends cleanly")
626                .is_none(),
627            "a channel closed before anything was pushed must yield nothing"
628        );
629    }
630
631    #[test]
632    fn bytes_content_streams_in_chunks_and_reads_whole() {
633        let payload = vec![7u8; DEFAULT_CHUNK_LEN + 11];
634        let content = BytesContent::named("blob.bin", payload.clone()).handle();
635        assert_eq!(content.metadata().len, Some(payload.len() as u64));
636
637        let chunks = block(async {
638            let reader = content.open().await.unwrap();
639            let mut sizes = Vec::new();
640            while let Some(chunk) = reader.read_chunk().await.unwrap() {
641                sizes.push(chunk.len());
642            }
643            sizes
644        });
645        assert_eq!(chunks, vec![DEFAULT_CHUNK_LEN, 11]);
646        assert_eq!(block(content.read_all()).unwrap(), payload);
647    }
648
649    #[test]
650    fn walking_a_tree_yields_every_file_depth_first() {
651        let nested = ReadyFolder::new(
652            ContentMetadata::named("nested"),
653            vec![ContentEntry::File(
654                BytesContent::named("b.txt", b"b".to_vec()).handle(),
655            )],
656        )
657        .handle();
658        let root = ReadyFolder::new(
659            ContentMetadata::named("root"),
660            vec![
661                ContentEntry::File(BytesContent::named("a.txt", b"a".to_vec()).handle()),
662                ContentEntry::Folder(nested),
663            ],
664        )
665        .handle();
666
667        let stream = folder_files(root);
668        let files = block(collect_stream(&stream)).unwrap();
669        let names: Vec<String> = files.iter().map(|file| file.metadata().name).collect();
670        assert_eq!(names, vec!["a.txt", "b.txt"]);
671        assert_eq!(stream.produced(), Some(2));
672    }
673
674    #[test]
675    fn a_channel_wakes_its_collector_instead_of_being_polled() {
676        let channel = Rc::new(ContentChannel::new());
677        let stream = channel.stream();
678
679        let mut future = Box::pin(stream.next());
680        let waker = Waker::noop().clone();
681        let mut context = Context::from_waker(&waker);
682        assert!(future.as_mut().poll(&mut context).is_pending());
683
684        channel.push(BytesContent::named("late.txt", b"late".to_vec()).handle());
685        let Poll::Ready(Ok(Some(item))) = future.as_mut().poll(&mut context) else {
686            panic!("the pushed item should have completed the pending collector");
687        };
688        assert_eq!(item.metadata().name, "late.txt");
689
690        channel.close();
691        assert!(matches!(block(stream.next()), Ok(None)));
692        assert_eq!(stream.produced(), Some(1));
693    }
694
695    #[test]
696    fn a_failed_channel_reports_the_error_once_then_ends() {
697        let channel = ContentChannel::new();
698        let stream = channel.stream();
699        channel.fail(ContentError::Io("provider died".into()));
700        let Err(failure) = block(stream.next()) else {
701            panic!("a failed channel reports its error to the collector");
702        };
703        assert_eq!(failure, ContentError::Io("provider died".into()));
704        assert!(matches!(block(stream.next()), Ok(None)));
705    }
706}