use std::sync::{Arc, Mutex, PoisonError};
use futures_util::future::BoxFuture;
use protocol::exec::StreamKind;
use tokio::sync::mpsc;
use super::exec::{ExecHandle, StreamOptions};
use super::sse::ExecEvent;
use crate::error::{Error, WireKind};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum GapPolicy {
#[default]
Error,
Event,
}
impl GapPolicy {
pub const ALL: [GapPolicy; 2] = [GapPolicy::Error, GapPolicy::Event];
pub const fn as_str(self) -> &'static str {
match self {
GapPolicy::Error => "error",
GapPolicy::Event => "event",
}
}
}
impl std::str::FromStr for GapPolicy {
type Err = Error;
fn from_str(name: &str) -> Result<Self, Error> {
GapPolicy::ALL
.into_iter()
.find(|policy| policy.as_str() == name)
.ok_or_else(|| {
Error::invalid_arg(format!(
"gap policy {name:?} is not one of \"error\" or \"event\""
))
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OutputGap {
pub stream: Option<StreamKind>,
pub from: u64,
pub to: u64,
}
pub type SplitItem = Result<Vec<u8>, Error>;
#[derive(Clone, Debug, Default)]
pub struct GapLog(Arc<Mutex<Vec<OutputGap>>>);
impl GapLog {
pub fn snapshot(&self) -> Vec<OutputGap> {
self.0
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn record(&self, gaps: impl IntoIterator<Item = OutputGap>) {
self.0
.lock()
.unwrap_or_else(PoisonError::into_inner)
.extend(gaps);
}
}
pub struct Split {
pub stdout: mpsc::Receiver<SplitItem>,
pub stderr: mpsc::Receiver<SplitItem>,
pub gaps: GapLog,
pub drive: BoxFuture<'static, ()>,
}
impl ExecHandle {
pub fn split(self: Arc<Self>, options: StreamOptions, policy: GapPolicy) -> Split {
let (out_tx, stdout) = mpsc::channel(1);
let (err_tx, stderr) = mpsc::channel(1);
let gaps = GapLog::default();
let options = StreamOptions {
error_on_gap: false,
..options
};
let drive = Box::pin(drive(self, options, policy, out_tx, err_tx, gaps.clone()));
Split {
stdout,
stderr,
gaps,
drive,
}
}
}
async fn drive(
handle: Arc<ExecHandle>,
options: StreamOptions,
policy: GapPolicy,
out_tx: mpsc::Sender<SplitItem>,
err_tx: mpsc::Sender<SplitItem>,
gaps: GapLog,
) {
let held: Arc<Mutex<Vec<(u64, u64)>>> = Arc::default();
let end = handle
.for_each_event_async(options, |event| {
let out_tx = out_tx.clone();
let err_tx = err_tx.clone();
let gaps = gaps.clone();
let held = Arc::clone(&held);
async move {
match event {
ExecEvent::Output { stream, data, .. } => {
let attributed: Vec<(u64, u64)> = held
.lock()
.unwrap_or_else(PoisonError::into_inner)
.drain(..)
.collect();
gaps.record(attributed.into_iter().map(|(from, to)| OutputGap {
stream: Some(stream),
from,
to,
}));
let side = match stream {
StreamKind::Stdout => &out_tx,
StreamKind::Stderr => &err_tx,
};
match side.send(Ok(data)).await {
Ok(()) => std::ops::ControlFlow::Continue(()),
Err(_) => std::ops::ControlFlow::Break(()),
}
}
ExecEvent::Gap { from, to } => match policy {
GapPolicy::Error => {
reject_both(&out_tx, &err_tx, gap_error(from, to)).await;
std::ops::ControlFlow::Break(())
}
GapPolicy::Event => {
held.lock()
.unwrap_or_else(PoisonError::into_inner)
.push((from, to));
std::ops::ControlFlow::Continue(())
}
},
ExecEvent::Exit(_) => std::ops::ControlFlow::Continue(()),
}
}
})
.await;
let trailing: Vec<(u64, u64)> = held
.lock()
.unwrap_or_else(PoisonError::into_inner)
.drain(..)
.collect();
gaps.record(trailing.into_iter().map(|(from, to)| OutputGap {
stream: None,
from,
to,
}));
if let Err(error) = end {
reject_both(&out_tx, &err_tx, error).await;
}
}
fn gap_error(from: u64, to: u64) -> Error {
Error::wire(
WireKind::OutputGap,
format!(
"output bytes [{from}, {to}) are unrecoverable: the daemon evicted them before this \
client read them. Resume from offset {to}, or read with the event gap policy to \
keep the surviving bytes instead."
),
)
}
async fn reject_both(
out_tx: &mpsc::Sender<SplitItem>,
err_tx: &mpsc::Sender<SplitItem>,
error: Error,
) {
let copy = twin(&error);
let _ = tokio::join!(out_tx.send(Err(error)), err_tx.send(Err(copy)));
}
fn twin(error: &Error) -> Error {
match error.wire_kind() {
Some(wire) => Error::wire(wire, error.to_string()),
None => Error::new(error.kind(), error.to_string()),
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use base64::Engine as _;
use super::*;
use crate::error::ErrorKind;
use crate::session::testing::{Recorder, Reply, session_with};
fn output(stream: &str, offset: u64, bytes: &[u8]) -> Vec<u8> {
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
format!(
"event: output\ndata: {{\"offset\":{offset},\"stream\":\"{stream}\",\
\"output\":\"{encoded}\"}}\n\n"
)
.into_bytes()
}
fn gap(from: u64, to: u64) -> Vec<u8> {
format!("event: gap\ndata: {{\"from\":{from},\"to\":{to}}}\n\n").into_bytes()
}
fn exit(total: u64) -> Vec<u8> {
format!(
"event: exit\ndata: {{\"exit_code\":0,\"signal\":null,\"truncated\":false,\
\"writers_may_be_alive\":false,\"offset\":{total}}}\n\n"
)
.into_bytes()
}
async fn drain(mut side: mpsc::Receiver<SplitItem>) -> (Vec<u8>, Option<Error>) {
let mut bytes = Vec::new();
while let Some(item) = side.recv().await {
match item {
Ok(chunk) => bytes.extend_from_slice(&chunk),
Err(error) => return (bytes, Some(error)),
}
}
(bytes, None)
}
fn clean(name: &str, side: (Vec<u8>, Option<Error>)) -> Vec<u8> {
if let Some(error) = side.1 {
panic!("{name} ended with an error: {error}");
}
side.0
}
async fn split_and_read(
replies: Vec<Reply>,
policy: GapPolicy,
) -> (
(Vec<u8>, Option<Error>),
(Vec<u8>, Option<Error>),
Vec<OutputGap>,
) {
let (session, _, _) = session_with(Recorder::with(replies));
let split = Arc::new(session.exec("x-split")).split(StreamOptions::default(), policy);
let drive = tokio::spawn(split.drive);
let (out, err) = tokio::join!(drain(split.stdout), drain(split.stderr));
drive.await.expect("the drive ran to its end");
(out, err, split.gaps.snapshot())
}
#[tokio::test(start_paused = true)]
async fn each_output_frame_goes_to_its_own_side_and_both_close_on_exit() {
let replies = vec![Reply::Chunks(
200,
vec![
output("stdout", 0, b"out1 "),
output("stderr", 5, b"err1 "),
output("stdout", 10, b"out2"),
output("stderr", 14, b"err2"),
exit(18),
],
)];
let (out, err, gaps) = split_and_read(replies, GapPolicy::Error).await;
assert_eq!(clean("stdout", out), b"out1 out2");
assert_eq!(clean("stderr", err), b"err1 err2");
assert!(gaps.is_empty());
}
#[tokio::test(start_paused = true)]
async fn under_event_a_gap_takes_the_stream_of_the_frame_after_it() {
let replies = vec![Reply::Chunks(
200,
vec![
output("stdout", 0, b"before"),
gap(6, 900),
output("stderr", 900, b"after"),
exit(905),
],
)];
let (out, err, gaps) = split_and_read(replies, GapPolicy::Event).await;
assert_eq!(
gaps,
[OutputGap {
stream: Some(StreamKind::Stderr),
from: 6,
to: 900
}],
"the gap names the side whose log has the hole"
);
assert_eq!(clean("stdout", out), b"before");
assert_eq!(clean("stderr", err), b"after");
}
#[tokio::test(start_paused = true)]
async fn under_error_a_gap_ends_both_sides_naming_the_range() {
let replies = vec![Reply::Chunks(
200,
vec![
output("stdout", 0, b"before"),
gap(6, 900),
output("stderr", 900, b"after"),
exit(905),
],
)];
let (out, err, gaps) = split_and_read(replies, GapPolicy::Error).await;
assert_eq!(out.0, b"before", "the bytes before the gap were discarded");
assert_eq!(
err.0, b"",
"a byte after the gap was delivered into an errored side"
);
for (name, error) in [("stdout", out.1), ("stderr", err.1)] {
let error = error.unwrap_or_else(|| panic!("{name} ended cleanly with a hole in it"));
assert_eq!(
error.wire_kind(),
Some(WireKind::OutputGap),
"{name}: {error}"
);
assert_eq!(error.kind(), ErrorKind::Platform, "{name}: {error}");
let message = error.to_string();
assert!(message.contains("[6, 900)"), "{name}: the range: {message}");
assert!(
message.contains("offset 900"),
"{name}: where to resume: {message}"
);
}
assert!(gaps.is_empty(), "the error policy records nothing");
}
#[tokio::test(start_paused = true)]
async fn a_gap_the_stream_ends_on_is_recorded_with_no_stream() {
let replies = vec![Reply::Chunks(
200,
vec![output("stdout", 0, b"AA"), gap(2, 900), exit(900)],
)];
let (out, err, gaps) = split_and_read(replies, GapPolicy::Event).await;
assert_eq!(
gaps,
[OutputGap {
stream: None,
from: 2,
to: 900
}]
);
assert_eq!(clean("stdout", out), b"AA");
assert_eq!(clean("stderr", err), b"");
}
#[tokio::test(start_paused = true)]
async fn a_drive_error_ends_both_sides_with_its_code_and_message() {
let replies = vec![
Reply::Chunks(200, vec![output("stdout", 0, b"AA")]),
Reply::Body(401, b"wrong token".to_vec()),
];
let (out, err, _) = split_and_read(replies, GapPolicy::Error).await;
assert_eq!(out.0, b"AA");
let out = out.1.expect("stdout ended cleanly on a refused reconnect");
let err = err.1.expect("stderr ended cleanly on a refused reconnect");
for (name, error) in [("stdout", &out), ("stderr", &err)] {
assert_eq!(
error.wire_kind(),
Some(WireKind::Unauthorized),
"{name}: {error}"
);
assert_eq!(error.code(), "ERR_CREDENTIALS", "{name}: {error}");
assert!(error.to_string().contains("401"), "{name}: {error}");
}
assert_eq!(
out.to_string(),
err.to_string(),
"stderr's copy is the same failure"
);
}
#[tokio::test(start_paused = true)]
async fn a_stderr_reader_gets_the_drive_error_while_a_stdout_chunk_sits_unread() {
let replies = vec![
Reply::Chunks(200, vec![output("stdout", 0, b"AA")]),
Reply::Body(401, b"wrong token".to_vec()),
];
let (session, _, _) = session_with(Recorder::with(replies));
let split =
Arc::new(session.exec("x-split")).split(StreamOptions::default(), GapPolicy::Error);
let drive = tokio::spawn(split.drive);
let (bytes, error) = tokio::time::timeout(Duration::from_secs(60), drain(split.stderr))
.await
.expect("stderr never settled while stdout held a chunk");
assert!(bytes.is_empty());
assert_eq!(
error.map(|error| error.code()),
Some("ERR_CREDENTIALS"),
"stderr ended without the drive error"
);
let (bytes, error) = drain(split.stdout).await;
assert_eq!(bytes, b"AA", "stdout's unread chunk is still delivered");
assert!(error.is_some(), "stdout got the drive error too");
drive.await.expect("the drive ran to its end");
}
#[tokio::test(start_paused = true)]
async fn dropping_a_side_ends_the_drive() {
let replies = vec![Reply::Stalled(vec![
output("stdout", 0, b"AA"),
output("stdout", 2, b"BB"),
])];
let (session, _, _) = session_with(Recorder::with(replies));
let split =
Arc::new(session.exec("x-split")).split(StreamOptions::default(), GapPolicy::Error);
drop(split.stdout);
tokio::time::timeout(Duration::from_secs(60), split.drive)
.await
.expect("the drive kept reading after its stdout reader went away");
}
#[test]
fn a_gap_policy_is_named_error_or_event() {
for policy in GapPolicy::ALL {
assert_eq!(policy.as_str().parse::<GapPolicy>().ok(), Some(policy));
}
assert_eq!(GapPolicy::default(), GapPolicy::Error);
let refused = "Error"
.parse::<GapPolicy>()
.expect_err("names are lowercase");
assert_eq!(refused.code(), "ERR_INVALID_ARG");
assert!(refused.to_string().contains("\"Error\""), "{refused}");
}
#[tokio::test(start_paused = true)]
async fn error_on_gap_is_replaced_by_the_policy() {
let (session, _, _) = session_with(Recorder::with([Reply::Chunks(
200,
vec![
output("stdout", 0, b"AA"),
gap(2, 9),
output("stdout", 9, b"ZZ"),
exit(11),
],
)]));
let options = StreamOptions {
error_on_gap: true,
..StreamOptions::default()
};
let split = Arc::new(session.exec("x-split")).split(options, GapPolicy::Event);
let drive = tokio::spawn(split.drive);
let (out, err) = tokio::join!(drain(split.stdout), drain(split.stderr));
drive.await.expect("the drive ran to its end");
assert_eq!(clean("stdout", out), b"AAZZ");
assert_eq!(clean("stderr", err), b"");
assert_eq!(split.gaps.snapshot().len(), 1);
}
}