Skip to main content

microvms_app/session/
split.rs

1// SPDX-License-Identifier: Apache-2.0
2//! One exec's output as two byte channels, stdout and stderr.
3//!
4//! # One drive, one cursor, two channels
5//!
6//! The daemon publishes stdout and stderr into **one** SSE stream with a `stream`
7//! discriminator per output frame, sharing **one** offset space. That's what makes the byte
8//! cursor work: a single cursor can't be split into two without inventing an ordering between
9//! them that the wire never stated. So [`ExecHandle::split`] runs one
10//! [`ExecHandle::for_each_event_async`] drive, reconnects and all, and routes each output
11//! frame to its side's channel. Order is kept within each side and isn't recoverable between
12//! them; a caller that needs the interleaving reads [`ExecHandle::stream_with`] instead.
13//!
14//! Both bindings' process shapes (Node's and Python's `Session.spawn`) read these channels,
15//! so the routing, the gap attribution and the gap policy are written once, here.
16//!
17//! # A gap is attributed to the next output frame's stream
18//!
19//! A `gap` frame carries no discriminator: the offset space is shared, so a gap is a hole in
20//! the combined stream and the daemon can't say which side's bytes were in it. The bytes that
21//! resumed after the hole came out of one side, and that side's log is the one with a hole in
22//! it, so a gap is held until the next output frame and takes its stream. A gap the stream
23//! ends on has nothing after it, and is recorded with no stream rather than guessed at.
24//!
25//! # The gap policy
26//!
27//! [`GapPolicy::Error`], the default, ends **both** channels with an
28//! [`WireKind::OutputGap`] error naming the range: both, because the shared offset space can't
29//! say which side lost the bytes, and erroring one would leave the other looking complete when
30//! it may be the truncated one. An error can't be ignored by the obvious consumer, a loop over
31//! one side's chunks, and a swallowed gap would hand it a contiguous-looking stream that's
32//! missing bytes. [`GapPolicy::Event`] records the gap in [`Split::gaps`] and keeps both
33//! channels open, for a caller that wants the surviving bytes more than the completeness
34//! guarantee (a log tail, a progress display).
35//!
36//! [`StreamOptions::error_on_gap`] is ignored here and always off: a drive that ended on the
37//! gap would lose the range's attribution and never fill [`Split::gaps`]. The gap arrives as
38//! an event and the policy is applied one layer up, in this module's drive.
39//!
40//! # One chunk per channel
41//!
42//! Each channel holds one chunk, for the reason `exec.rs` gives: the daemon's SSE body is the
43//! backpressure signal, and buffering here would defeat the cursor the drive reconnects at.
44//! Per channel rather than shared, so a slow stderr reader doesn't hold chunks stdout has
45//! already been handed. A channel nobody reads still stalls the drive once it holds its one
46//! chunk, the same bound a pipe has, so a caller reads both sides concurrently or drops the
47//! one it doesn't want. Dropping either receiver ends the drive, since both share it.
48
49use std::sync::{Arc, Mutex, PoisonError};
50
51use futures_util::future::BoxFuture;
52use protocol::exec::StreamKind;
53use tokio::sync::mpsc;
54
55use super::exec::{ExecHandle, StreamOptions};
56use super::sse::ExecEvent;
57use crate::error::{Error, WireKind};
58
59/// What a split drive does when the daemon reports evicted output. See the module docs.
60#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
61pub enum GapPolicy {
62    /// **The default.** A gap ends both channels with an `OutputGap` error naming the range.
63    #[default]
64    Error,
65    /// A gap is recorded in [`Split::gaps`] and both channels stay open.
66    Event,
67}
68
69impl GapPolicy {
70    /// Both policies, the default first.
71    pub const ALL: [GapPolicy; 2] = [GapPolicy::Error, GapPolicy::Event];
72
73    /// The name a binding takes it by: `"error"` or `"event"`.
74    pub const fn as_str(self) -> &'static str {
75        match self {
76            GapPolicy::Error => "error",
77            GapPolicy::Event => "event",
78        }
79    }
80}
81
82impl std::str::FromStr for GapPolicy {
83    type Err = Error;
84
85    /// A policy by its [`GapPolicy::as_str`] name, refused with `ERR_INVALID_ARG` otherwise.
86    fn from_str(name: &str) -> Result<Self, Error> {
87        GapPolicy::ALL
88            .into_iter()
89            .find(|policy| policy.as_str() == name)
90            .ok_or_else(|| {
91                Error::invalid_arg(format!(
92                    "gap policy {name:?} is not one of \"error\" or \"event\""
93                ))
94            })
95    }
96}
97
98/// One byte range the daemon couldn't replay, under [`GapPolicy::Event`].
99///
100/// `from` inclusive, `to` exclusive, so `to` is exactly the offset a resume would pass.
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct OutputGap {
103    /// The stream of the output frame that followed the gap, or `None` when the stream ended
104    /// on it. Never read off the gap frame, which carries no discriminator (module docs).
105    pub stream: Option<StreamKind>,
106    pub from: u64,
107    pub to: u64,
108}
109
110/// What a channel carries: a chunk of one side's bytes, or the error that ended it.
111pub type SplitItem = Result<Vec<u8>, Error>;
112
113/// The gaps a split drive has recorded so far, shared with the drive.
114#[derive(Clone, Debug, Default)]
115pub struct GapLog(Arc<Mutex<Vec<OutputGap>>>);
116
117impl GapLog {
118    /// Every gap recorded so far, oldest first. Empty under [`GapPolicy::Error`].
119    pub fn snapshot(&self) -> Vec<OutputGap> {
120        self.0
121            .lock()
122            .unwrap_or_else(PoisonError::into_inner)
123            .clone()
124    }
125
126    fn record(&self, gaps: impl IntoIterator<Item = OutputGap>) {
127        self.0
128            .lock()
129            .unwrap_or_else(PoisonError::into_inner)
130            .extend(gaps);
131    }
132}
133
134/// One exec's output as two channels, and the drive that fills them.
135///
136/// Built by [`ExecHandle::split`]. Nothing moves until [`Split::drive`] runs: the caller
137/// spawns it on its own runtime (each binding has one, and this crate chooses none). A
138/// channel closes, with no error, after the terminal `exit` event, and the drive ends then too.
139pub struct Split {
140    /// The child's standard output.
141    pub stdout: mpsc::Receiver<SplitItem>,
142    /// The child's standard error.
143    pub stderr: mpsc::Receiver<SplitItem>,
144    /// The gaps recorded under [`GapPolicy::Event`].
145    pub gaps: GapLog,
146    /// The one stream drive both channels share. Spawn it.
147    pub drive: BoxFuture<'static, ()>,
148}
149
150impl ExecHandle {
151    /// This exec's output as two byte channels over one drive. See the module docs.
152    ///
153    /// `options` is how the drive reads the stream (offset, reconnects, idle timeout), except
154    /// `error_on_gap`, which `policy` replaces. A drive error (a spent reconnect budget, a
155    /// refused reconnect) ends both channels with that error, so a truncated stream never
156    /// reads as a finished one.
157    pub fn split(self: Arc<Self>, options: StreamOptions, policy: GapPolicy) -> Split {
158        let (out_tx, stdout) = mpsc::channel(1);
159        let (err_tx, stderr) = mpsc::channel(1);
160        let gaps = GapLog::default();
161        let options = StreamOptions {
162            error_on_gap: false,
163            ..options
164        };
165        let drive = Box::pin(drive(self, options, policy, out_tx, err_tx, gaps.clone()));
166        Split {
167            stdout,
168            stderr,
169            gaps,
170            drive,
171        }
172    }
173}
174
175/// Routes one drive's events into the two channels.
176async fn drive(
177    handle: Arc<ExecHandle>,
178    options: StreamOptions,
179    policy: GapPolicy,
180    out_tx: mpsc::Sender<SplitItem>,
181    err_tx: mpsc::Sender<SplitItem>,
182    gaps: GapLog,
183) {
184    // Gaps seen and not yet attributed. Shared by handle rather than a plain local, because
185    // the callback's future is a plain type parameter that can't borrow the closure's
186    // captures (`for_each_event_async`'s docs), so state that spans events is shared.
187    let held: Arc<Mutex<Vec<(u64, u64)>>> = Arc::default();
188    let end = handle
189        .for_each_event_async(options, |event| {
190            // Cloned per event, for the same reason. One atomic increment each.
191            let out_tx = out_tx.clone();
192            let err_tx = err_tx.clone();
193            let gaps = gaps.clone();
194            let held = Arc::clone(&held);
195            async move {
196                match event {
197                    ExecEvent::Output { stream, data, .. } => {
198                        // Drained into its own statement, so the lock is released before the
199                        // send below awaits.
200                        let attributed: Vec<(u64, u64)> = held
201                            .lock()
202                            .unwrap_or_else(PoisonError::into_inner)
203                            .drain(..)
204                            .collect();
205                        gaps.record(attributed.into_iter().map(|(from, to)| OutputGap {
206                            stream: Some(stream),
207                            from,
208                            to,
209                        }));
210                        let side = match stream {
211                            StreamKind::Stdout => &out_tx,
212                            StreamKind::Stderr => &err_tx,
213                        };
214                        // Awaited, not `try_send`: the channel is full whenever its reader is
215                        // behind, which is the normal case, and dropping there would lose
216                        // output the cursor believes was delivered.
217                        match side.send(Ok(data)).await {
218                            Ok(()) => std::ops::ControlFlow::Continue(()),
219                            // The reader went away. Ending the drive is what stops a task
220                            // reading a body nobody reads.
221                            Err(_) => std::ops::ControlFlow::Break(()),
222                        }
223                    }
224                    ExecEvent::Gap { from, to } => match policy {
225                        GapPolicy::Error => {
226                            reject_both(&out_tx, &err_tx, gap_error(from, to)).await;
227                            // Nothing more goes into channels that have already errored.
228                            std::ops::ControlFlow::Break(())
229                        }
230                        GapPolicy::Event => {
231                            // Held: the next output frame names the stream it belongs to.
232                            held.lock()
233                                .unwrap_or_else(PoisonError::into_inner)
234                                .push((from, to));
235                            std::ops::ControlFlow::Continue(())
236                        }
237                    },
238                    // Nothing is sent for the terminal event: dropping the senders when the
239                    // drive returns is what closes both channels.
240                    ExecEvent::Exit(_) => std::ops::ControlFlow::Continue(()),
241                }
242            }
243        })
244        .await;
245
246    // A gap the stream ended on has no following frame to take a stream from.
247    let trailing: Vec<(u64, u64)> = held
248        .lock()
249        .unwrap_or_else(PoisonError::into_inner)
250        .drain(..)
251        .collect();
252    gaps.record(trailing.into_iter().map(|(from, to)| OutputGap {
253        stream: None,
254        from,
255        to,
256    }));
257
258    // A drive error reaches both readers, so a spent reconnect budget rejects a read rather
259    // than ending it: a silent end reads as complete output.
260    if let Err(error) = end {
261        reject_both(&out_tx, &err_tx, error).await;
262    }
263}
264
265/// The error a gap ends both channels with under [`GapPolicy::Error`].
266///
267/// Typed like [`StreamOptions::error_on_gap`]'s (`ERR_PLATFORM`, [`WireKind::OutputGap`]), so a
268/// caller tells lost output from a transport failure it could retry, and it names the range
269/// and where a resume starts.
270fn gap_error(from: u64, to: u64) -> Error {
271    Error::wire(
272        WireKind::OutputGap,
273        format!(
274            "output bytes [{from}, {to}) are unrecoverable: the daemon evicted them before this \
275             client read them. Resume from offset {to}, or read with the event gap policy to \
276             keep the surviving bytes instead."
277        ),
278    )
279}
280
281/// Ends both channels with one failure.
282///
283/// [`Error`] isn't `Clone`, so stderr gets [`twin`] of it and stdout the original, source and
284/// all. Both sends at once rather than one after the other: each channel holds one chunk, so
285/// while a stdout chunk sits unread a stdout-first send parks, and a caller reading stderr to
286/// the end before stdout would wait forever for a rejection that's never sent. A failed send
287/// means that reader is gone, and there's nobody left to tell.
288async fn reject_both(
289    out_tx: &mpsc::Sender<SplitItem>,
290    err_tx: &mpsc::Sender<SplitItem>,
291    error: Error,
292) {
293    let copy = twin(&error);
294    let _ = tokio::join!(out_tx.send(Err(error)), err_tx.send(Err(copy)));
295}
296
297/// The same failure again: its kind, its wire kind and its message.
298///
299/// Exact on all three, since [`Error::wire`] derives the kind from the wire kind and
300/// [`Error::new`] sets none. The source stays with the original.
301fn twin(error: &Error) -> Error {
302    match error.wire_kind() {
303        Some(wire) => Error::wire(wire, error.to_string()),
304        None => Error::new(error.kind(), error.to_string()),
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use std::time::Duration;
311
312    use base64::Engine as _;
313
314    use super::*;
315    use crate::error::ErrorKind;
316    use crate::session::testing::{Recorder, Reply, session_with};
317
318    /// One SSE `output` frame on `stream`.
319    fn output(stream: &str, offset: u64, bytes: &[u8]) -> Vec<u8> {
320        let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
321        format!(
322            "event: output\ndata: {{\"offset\":{offset},\"stream\":\"{stream}\",\
323             \"output\":\"{encoded}\"}}\n\n"
324        )
325        .into_bytes()
326    }
327
328    fn gap(from: u64, to: u64) -> Vec<u8> {
329        format!("event: gap\ndata: {{\"from\":{from},\"to\":{to}}}\n\n").into_bytes()
330    }
331
332    fn exit(total: u64) -> Vec<u8> {
333        format!(
334            "event: exit\ndata: {{\"exit_code\":0,\"signal\":null,\"truncated\":false,\
335             \"writers_may_be_alive\":false,\"offset\":{total}}}\n\n"
336        )
337        .into_bytes()
338    }
339
340    /// One side read to its end: the bytes it carried, and the error that ended it, if one did.
341    async fn drain(mut side: mpsc::Receiver<SplitItem>) -> (Vec<u8>, Option<Error>) {
342        let mut bytes = Vec::new();
343        while let Some(item) = side.recv().await {
344            match item {
345                Ok(chunk) => bytes.extend_from_slice(&chunk),
346                Err(error) => return (bytes, Some(error)),
347            }
348        }
349        (bytes, None)
350    }
351
352    /// A side that ended cleanly, as its bytes. Panics naming the error when it didn't.
353    fn clean(name: &str, side: (Vec<u8>, Option<Error>)) -> Vec<u8> {
354        if let Some(error) = side.1 {
355            panic!("{name} ended with an error: {error}");
356        }
357        side.0
358    }
359
360    /// Splits a scripted exec's stream under `policy`, reads both sides at once to their ends,
361    /// and waits for the drive.
362    async fn split_and_read(
363        replies: Vec<Reply>,
364        policy: GapPolicy,
365    ) -> (
366        (Vec<u8>, Option<Error>),
367        (Vec<u8>, Option<Error>),
368        Vec<OutputGap>,
369    ) {
370        let (session, _, _) = session_with(Recorder::with(replies));
371        let split = Arc::new(session.exec("x-split")).split(StreamOptions::default(), policy);
372        let drive = tokio::spawn(split.drive);
373        let (out, err) = tokio::join!(drain(split.stdout), drain(split.stderr));
374        drive.await.expect("the drive ran to its end");
375        (out, err, split.gaps.snapshot())
376    }
377
378    /// Each output frame goes to its own side, in order, and both close cleanly on `exit`.
379    #[tokio::test(start_paused = true)]
380    async fn each_output_frame_goes_to_its_own_side_and_both_close_on_exit() {
381        let replies = vec![Reply::Chunks(
382            200,
383            vec![
384                output("stdout", 0, b"out1 "),
385                output("stderr", 5, b"err1 "),
386                output("stdout", 10, b"out2"),
387                output("stderr", 14, b"err2"),
388                exit(18),
389            ],
390        )];
391        let (out, err, gaps) = split_and_read(replies, GapPolicy::Error).await;
392
393        assert_eq!(clean("stdout", out), b"out1 out2");
394        assert_eq!(clean("stderr", err), b"err1 err2");
395        assert!(gaps.is_empty());
396    }
397
398    /// **Under `Event`, a gap takes the stream of the output frame after it (#261).**
399    ///
400    /// The gap frame carries no discriminator, and the side whose bytes resumed after the
401    /// hole is the side with the hole in its log. Here stdout wrote before the gap and stderr
402    /// after it, so a gap attributed to the frame *before* it would name stdout.
403    ///
404    /// **Falsification**: record a held gap under stdout, the stream of the frame before it
405    /// here, instead of the next frame's, and the recorded stream is `Some(Stdout)`.
406    #[tokio::test(start_paused = true)]
407    async fn under_event_a_gap_takes_the_stream_of_the_frame_after_it() {
408        let replies = vec![Reply::Chunks(
409            200,
410            vec![
411                output("stdout", 0, b"before"),
412                gap(6, 900),
413                output("stderr", 900, b"after"),
414                exit(905),
415            ],
416        )];
417        let (out, err, gaps) = split_and_read(replies, GapPolicy::Event).await;
418
419        assert_eq!(
420            gaps,
421            [OutputGap {
422                stream: Some(StreamKind::Stderr),
423                from: 6,
424                to: 900
425            }],
426            "the gap names the side whose log has the hole"
427        );
428        assert_eq!(clean("stdout", out), b"before");
429        assert_eq!(clean("stderr", err), b"after");
430    }
431
432    /// **Under `Error`, a gap ends both sides with an `OutputGap` error naming the range.**
433    ///
434    /// Both, because the shared offset space can't say which side lost the bytes. The bytes
435    /// before the gap are still delivered, and nothing after it is.
436    ///
437    /// **Falsification**: send the gap's error to stdout alone and stderr ends cleanly, so
438    /// `err.1` is `None`.
439    #[tokio::test(start_paused = true)]
440    async fn under_error_a_gap_ends_both_sides_naming_the_range() {
441        let replies = vec![Reply::Chunks(
442            200,
443            vec![
444                output("stdout", 0, b"before"),
445                gap(6, 900),
446                output("stderr", 900, b"after"),
447                exit(905),
448            ],
449        )];
450        let (out, err, gaps) = split_and_read(replies, GapPolicy::Error).await;
451
452        assert_eq!(out.0, b"before", "the bytes before the gap were discarded");
453        assert_eq!(
454            err.0, b"",
455            "a byte after the gap was delivered into an errored side"
456        );
457        for (name, error) in [("stdout", out.1), ("stderr", err.1)] {
458            let error = error.unwrap_or_else(|| panic!("{name} ended cleanly with a hole in it"));
459            assert_eq!(
460                error.wire_kind(),
461                Some(WireKind::OutputGap),
462                "{name}: {error}"
463            );
464            assert_eq!(error.kind(), ErrorKind::Platform, "{name}: {error}");
465            let message = error.to_string();
466            assert!(message.contains("[6, 900)"), "{name}: the range: {message}");
467            assert!(
468                message.contains("offset 900"),
469                "{name}: where to resume: {message}"
470            );
471        }
472        assert!(gaps.is_empty(), "the error policy records nothing");
473    }
474
475    /// A gap the stream ends on has no frame after it, and is recorded with no stream.
476    #[tokio::test(start_paused = true)]
477    async fn a_gap_the_stream_ends_on_is_recorded_with_no_stream() {
478        let replies = vec![Reply::Chunks(
479            200,
480            vec![output("stdout", 0, b"AA"), gap(2, 900), exit(900)],
481        )];
482        let (out, err, gaps) = split_and_read(replies, GapPolicy::Event).await;
483
484        assert_eq!(
485            gaps,
486            [OutputGap {
487                stream: None,
488                from: 2,
489                to: 900
490            }]
491        );
492        assert_eq!(clean("stdout", out), b"AA");
493        assert_eq!(clean("stderr", err), b"");
494    }
495
496    /// A drive error ends both sides with the same code, wire kind and message.
497    ///
498    /// Here the stream is cut and the reconnect is refused with a 401, which no reconnect can
499    /// fix: both readers get `ERR_CREDENTIALS` and `Unauthorized`, so neither reads a cut
500    /// stream as a finished one.
501    #[tokio::test(start_paused = true)]
502    async fn a_drive_error_ends_both_sides_with_its_code_and_message() {
503        let replies = vec![
504            Reply::Chunks(200, vec![output("stdout", 0, b"AA")]),
505            Reply::Body(401, b"wrong token".to_vec()),
506        ];
507        let (out, err, _) = split_and_read(replies, GapPolicy::Error).await;
508
509        assert_eq!(out.0, b"AA");
510        let out = out.1.expect("stdout ended cleanly on a refused reconnect");
511        let err = err.1.expect("stderr ended cleanly on a refused reconnect");
512        for (name, error) in [("stdout", &out), ("stderr", &err)] {
513            assert_eq!(
514                error.wire_kind(),
515                Some(WireKind::Unauthorized),
516                "{name}: {error}"
517            );
518            assert_eq!(error.code(), "ERR_CREDENTIALS", "{name}: {error}");
519            assert!(error.to_string().contains("401"), "{name}: {error}");
520        }
521        assert_eq!(
522            out.to_string(),
523            err.to_string(),
524            "stderr's copy is the same failure"
525        );
526    }
527
528    /// **A stderr reader gets the drive error while a stdout chunk sits unread.**
529    ///
530    /// Each side holds one chunk. A stdout-first rejection parks behind the unread chunk, so a
531    /// caller reading stderr to its end before touching stdout would wait forever.
532    ///
533    /// **Falsification**: send the two rejections one after the other, stdout first, and this
534    /// times out: stderr never settles.
535    #[tokio::test(start_paused = true)]
536    async fn a_stderr_reader_gets_the_drive_error_while_a_stdout_chunk_sits_unread() {
537        let replies = vec![
538            Reply::Chunks(200, vec![output("stdout", 0, b"AA")]),
539            Reply::Body(401, b"wrong token".to_vec()),
540        ];
541        let (session, _, _) = session_with(Recorder::with(replies));
542        let split =
543            Arc::new(session.exec("x-split")).split(StreamOptions::default(), GapPolicy::Error);
544        let drive = tokio::spawn(split.drive);
545
546        let (bytes, error) = tokio::time::timeout(Duration::from_secs(60), drain(split.stderr))
547            .await
548            .expect("stderr never settled while stdout held a chunk");
549        assert!(bytes.is_empty());
550        assert_eq!(
551            error.map(|error| error.code()),
552            Some("ERR_CREDENTIALS"),
553            "stderr ended without the drive error"
554        );
555        let (bytes, error) = drain(split.stdout).await;
556        assert_eq!(bytes, b"AA", "stdout's unread chunk is still delivered");
557        assert!(error.is_some(), "stdout got the drive error too");
558        drive.await.expect("the drive ran to its end");
559    }
560
561    /// Dropping a side ends the drive rather than leaving a task reading a body nobody reads.
562    #[tokio::test(start_paused = true)]
563    async fn dropping_a_side_ends_the_drive() {
564        let replies = vec![Reply::Stalled(vec![
565            output("stdout", 0, b"AA"),
566            output("stdout", 2, b"BB"),
567        ])];
568        let (session, _, _) = session_with(Recorder::with(replies));
569        let split =
570            Arc::new(session.exec("x-split")).split(StreamOptions::default(), GapPolicy::Error);
571        drop(split.stdout);
572        tokio::time::timeout(Duration::from_secs(60), split.drive)
573            .await
574            .expect("the drive kept reading after its stdout reader went away");
575    }
576
577    /// The policies' names are the ones the bindings take, and anything else is refused.
578    #[test]
579    fn a_gap_policy_is_named_error_or_event() {
580        for policy in GapPolicy::ALL {
581            assert_eq!(policy.as_str().parse::<GapPolicy>().ok(), Some(policy));
582        }
583        assert_eq!(GapPolicy::default(), GapPolicy::Error);
584        let refused = "Error"
585            .parse::<GapPolicy>()
586            .expect_err("names are lowercase");
587        assert_eq!(refused.code(), "ERR_INVALID_ARG");
588        assert!(refused.to_string().contains("\"Error\""), "{refused}");
589    }
590
591    /// `error_on_gap` can't turn a split's gap into a drive error: the policy decides.
592    #[tokio::test(start_paused = true)]
593    async fn error_on_gap_is_replaced_by_the_policy() {
594        let (session, _, _) = session_with(Recorder::with([Reply::Chunks(
595            200,
596            vec![
597                output("stdout", 0, b"AA"),
598                gap(2, 9),
599                output("stdout", 9, b"ZZ"),
600                exit(11),
601            ],
602        )]));
603        let options = StreamOptions {
604            error_on_gap: true,
605            ..StreamOptions::default()
606        };
607        let split = Arc::new(session.exec("x-split")).split(options, GapPolicy::Event);
608        let drive = tokio::spawn(split.drive);
609        let (out, err) = tokio::join!(drain(split.stdout), drain(split.stderr));
610        drive.await.expect("the drive ran to its end");
611        assert_eq!(clean("stdout", out), b"AAZZ");
612        assert_eq!(clean("stderr", err), b"");
613        assert_eq!(split.gaps.snapshot().len(), 1);
614    }
615}