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}