use std::sync::Arc;
use std::time::Duration;
use base64::Engine as _;
use futures_util::Stream;
use super::sse::{ExecEvent, SseParser, decode};
use super::{HttpRequest, Transport};
use crate::error::{Error, ErrorKind, WireKind};
const RECONNECT_BACKOFF: [Duration; 5] = [
Duration::from_millis(250),
Duration::from_millis(500),
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
];
pub const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
const POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug)]
pub struct ExecResult {
pub exec_id: String,
pub phase: protocol::exec::Phase,
pub outcome: Option<protocol::exec::Outcome>,
}
impl From<protocol::exec::PollResponse> for ExecResult {
fn from(response: protocol::exec::PollResponse) -> Self {
Self {
exec_id: response.exec_id,
phase: response.phase,
outcome: response.result,
}
}
}
impl ExecResult {
pub fn done(&self) -> bool {
matches!(
self.phase,
protocol::exec::Phase::Exited | protocol::exec::Phase::Acked
)
}
pub fn exit_code(&self) -> Option<i32> {
self.outcome.as_ref().and_then(|outcome| outcome.exit_code)
}
pub fn succeeded(&self) -> bool {
self.exit_code() == Some(0)
}
pub fn stdout(&self) -> &str {
self.outcome
.as_ref()
.map_or("", |outcome| outcome.stdout.as_str())
}
pub fn stderr(&self) -> &str {
self.outcome
.as_ref()
.map_or("", |outcome| outcome.stderr.as_str())
}
}
pub type StdinAck = protocol::exec::StdinResponse;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StreamEnd {
pub reason: EndReason,
pub cursor: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EndReason {
Exited,
Stopped,
Cut,
}
#[derive(Clone, Debug)]
pub struct StreamOptions {
pub offset: u64,
pub reconnect: bool,
pub max_reconnects: u32,
pub error_on_gap: bool,
pub idle_timeout: Duration,
}
impl Default for StreamOptions {
fn default() -> Self {
Self {
offset: 0,
reconnect: true,
max_reconnects: 20,
error_on_gap: false,
idle_timeout: DEFAULT_STREAM_IDLE_TIMEOUT,
}
}
}
pub fn mint_exec_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_nanos() as u64)
.unwrap_or_default();
let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
format!("x-{:016x}", nanos ^ (sequence << 40))
}
pub struct ExecHandle {
transport: Arc<Transport>,
exec_id: String,
}
impl ExecHandle {
pub(crate) fn new(transport: Arc<Transport>, exec_id: String) -> Self {
Self { transport, exec_id }
}
pub fn exec_id(&self) -> &str {
&self.exec_id
}
pub async fn poll(&self) -> Result<ExecResult, Error> {
let response: protocol::exec::PollResponse = self
.transport
.send_json(HttpRequest::new(
"GET",
format!("/v1/exec/{}", self.exec_id),
))
.await?;
Ok(response.into())
}
pub async fn wait(&self, timeout: Duration) -> Result<ExecResult, Error> {
let deadline = tokio::time::Instant::now() + timeout;
let mut last_phase: Option<protocol::exec::Phase> = None;
loop {
match self.poll().await {
Ok(result) => {
if result.done() {
return Ok(result);
}
last_phase = Some(result.phase);
}
Err(err) if err.retryable() => {}
Err(err) => return Err(err),
}
let now = tokio::time::Instant::now();
if now >= deadline {
let phase = match last_phase {
Some(protocol::exec::Phase::Running) => "running",
Some(protocol::exec::Phase::Exited) => "exited",
Some(protocol::exec::Phase::Acked) => "acked",
None => "unknown",
};
return Err(Error::wire(
WireKind::ExecTimeout,
format!(
"exec {} was still {phase} after {}s; the record and its output \
are untouched and can be re-polled",
self.exec_id,
timeout.as_secs()
),
));
}
tokio::time::sleep(POLL_INTERVAL.min(deadline - now)).await;
}
}
pub fn stream(&self) -> impl Stream<Item = Result<ExecEvent, Error>> + '_ {
self.stream_with(StreamOptions::default())
}
pub fn stream_with(
&self,
options: StreamOptions,
) -> impl Stream<Item = Result<ExecEvent, Error>> + '_ {
futures_util::stream::unfold(
StreamState::Reconnect {
cursor: options.offset,
attempts: 0,
},
move |state| {
let options = options.clone();
async move { self.advance(state, &options).await }
},
)
}
pub async fn for_each_event<F>(
&self,
options: StreamOptions,
mut on_event: F,
) -> Result<StreamEnd, Error>
where
F: FnMut(ExecEvent) -> std::ops::ControlFlow<()>,
{
self.for_each_event_async(options, |event| std::future::ready(on_event(event)))
.await
}
pub async fn for_each_event_async<F, Fut>(
&self,
options: StreamOptions,
mut on_event: F,
) -> Result<StreamEnd, Error>
where
F: FnMut(ExecEvent) -> Fut,
Fut: std::future::Future<Output = std::ops::ControlFlow<()>>,
{
let mut state = StreamState::Reconnect {
cursor: options.offset,
attempts: 0,
};
let mut cursor = options.offset;
loop {
let Some((item, next)) = self.advance(state, &options).await else {
return Ok(StreamEnd {
reason: EndReason::Cut,
cursor,
});
};
if let Some(advanced) = next.cursor() {
cursor = advanced;
}
state = next;
let event = item?;
let terminal = matches!(event, ExecEvent::Exit(_));
if on_event(event).await.is_break() {
return Ok(StreamEnd {
reason: EndReason::Stopped,
cursor,
});
}
if terminal {
return Ok(StreamEnd {
reason: EndReason::Exited,
cursor,
});
}
}
}
async fn advance(
&self,
state: StreamState,
options: &StreamOptions,
) -> Option<(Result<ExecEvent, Error>, StreamState)> {
let mut state = state;
loop {
match state {
StreamState::Done => return None,
StreamState::Reconnect { cursor, attempts } => {
if attempts > 0 {
if !options.reconnect {
return None;
}
if attempts > options.max_reconnects {
return Some((
Err(Error::new(
ErrorKind::Retryable,
format!(
"the stream of {} dropped {attempts} times without \
an exit event; last good offset {cursor}",
self.exec_id
),
)),
StreamState::Done,
));
}
let backoff = RECONNECT_BACKOFF
[((attempts - 1) as usize).min(RECONNECT_BACKOFF.len() - 1)];
tokio::time::sleep(backoff).await;
}
match self.attach(cursor, options.idle_timeout).await {
Ok(attach) => {
state = StreamState::Attached {
attach: Box::new(attach),
cursor,
attempts,
};
}
Err(err) if err.retryable() && options.reconnect => {
state = StreamState::Reconnect {
cursor,
attempts: attempts + 1,
};
}
Err(err) => return Some((Err(err), StreamState::Done)),
}
}
StreamState::Attached {
mut attach,
mut cursor,
attempts,
} => match attach.next_event().await {
Ok(Some(ExecEvent::Output {
stream,
offset,
data,
})) => {
let end = offset + data.len() as u64;
cursor = cursor.max(end);
return Some((
Ok(ExecEvent::Output {
stream,
offset,
data,
}),
StreamState::Attached {
attach,
cursor,
attempts,
},
));
}
Ok(Some(ExecEvent::Gap { from, to })) => {
cursor = cursor.max(to);
let item = if options.error_on_gap {
Err(Error::wire(
WireKind::OutputGap,
format!("output bytes [{from}, {to}) are unrecoverable"),
))
} else {
Ok(ExecEvent::Gap { from, to })
};
let next = if options.error_on_gap {
StreamState::Done
} else {
StreamState::Attached {
attach,
cursor,
attempts,
}
};
return Some((item, next));
}
Ok(Some(ExecEvent::Exit(exit))) => {
return Some((Ok(ExecEvent::Exit(exit)), StreamState::Done));
}
Ok(None) => {
if !options.reconnect {
return None;
}
state = StreamState::Reconnect {
cursor,
attempts: attempts + 1,
};
}
Err(err) if err.retryable() && options.reconnect => {
state = StreamState::Reconnect {
cursor,
attempts: attempts + 1,
};
}
Err(err) => return Some((Err(err), StreamState::Done)),
},
}
}
}
async fn attach(&self, offset: u64, idle_timeout: Duration) -> Result<Attach, Error> {
let path = format!("/v1/exec/{}/stream?offset={offset}", self.exec_id);
let mut request = HttpRequest::new("GET", path.clone());
request
.headers
.push(("accept".into(), "text/event-stream".into()));
request.headers.extend(self.transport.headers(None).await?);
request.timeout = None;
let (head, chunks) = self
.transport
.backend
.open_stream(request, idle_timeout)
.await?;
head.error_for_status("GET", &path)?;
Ok(Attach {
chunks,
parser: SseParser::new(),
frames: std::collections::VecDeque::new(),
})
}
pub async fn write_stdin(&self, data: &[u8], eof: bool) -> Result<StdinAck, Error> {
let body = protocol::exec::StdinRequest {
data_b64: (!data.is_empty())
.then(|| base64::engine::general_purpose::STANDARD.encode(data)),
signal: eof.then(|| "eof".to_string()),
};
let mut request = HttpRequest::new("POST", format!("/v1/exec/{}/stdin", self.exec_id));
request
.headers
.push(("content-type".into(), "application/json".into()));
request.body = serde_json::to_vec(&body).map_err(|err| {
Error::invalid_arg(format!("the stdin body will not serialize: {err}"))
})?;
self.transport.send_json(request).await
}
pub async fn close_stdin(&self) -> Result<StdinAck, Error> {
self.write_stdin(&[], true).await
}
pub async fn ack(&self) -> Result<ExecResult, Error> {
let response: protocol::exec::PollResponse = self
.transport
.send_json(HttpRequest::new(
"POST",
format!("/v1/exec/{}/ack", self.exec_id),
))
.await?;
Ok(response.into())
}
pub async fn kill(&self) -> Result<bool, Error> {
let response: protocol::exec::KillResponse = self
.transport
.send_json(HttpRequest::new(
"POST",
format!("/v1/exec/{}/kill", self.exec_id),
))
.await?;
Ok(response.killed)
}
pub async fn wait_and_ack(&self, timeout: Duration) -> Result<ExecResult, Error> {
let done = self.wait(timeout).await?;
if done.phase == protocol::exec::Phase::Acked {
return Ok(done);
}
self.ack().await
}
}
impl std::fmt::Debug for ExecHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExecHandle")
.field("exec_id", &self.exec_id)
.finish()
}
}
struct Attach {
chunks: Box<dyn super::ChunkSource>,
parser: SseParser,
frames: std::collections::VecDeque<super::Frame>,
}
impl Attach {
async fn next_event(&mut self) -> Result<Option<ExecEvent>, Error> {
loop {
while let Some(frame) = self.frames.pop_front() {
if let Some(event) = decode(&frame)? {
return Ok(Some(event));
}
}
let Some(chunk) = self.chunks.next_chunk().await? else {
return Ok(None);
};
self.frames.extend(self.parser.feed(&chunk)?);
}
}
}
enum StreamState {
Reconnect {
cursor: u64,
attempts: u32,
},
Attached {
attach: Box<Attach>,
cursor: u64,
attempts: u32,
},
Done,
}
impl StreamState {
fn cursor(&self) -> Option<u64> {
match self {
StreamState::Reconnect { cursor, .. } | StreamState::Attached { cursor, .. } => {
Some(*cursor)
}
StreamState::Done => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::testing::{Recorder, Reply, session_with};
use futures_util::StreamExt as _;
fn output(offset: u64, bytes: &[u8]) -> Vec<u8> {
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
format!(
"event: output\ndata: {{\"offset\":{offset},\"stream\":\"stdout\",\
\"output\":\"{encoded}\"}}\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()
}
fn gap(from: u64, to: u64) -> Vec<u8> {
format!("event: gap\ndata: {{\"from\":{from},\"to\":{to}}}\n\n").into_bytes()
}
async fn collect(
handle: &ExecHandle,
options: StreamOptions,
) -> (Vec<u8>, Vec<Result<ExecEvent, Error>>) {
let mut bytes = Vec::new();
let mut events = Vec::new();
let mut stream = std::pin::pin!(handle.stream_with(options));
while let Some(item) = stream.next().await {
if let Ok(ExecEvent::Output { data, .. }) = &item {
bytes.extend_from_slice(data);
}
events.push(item);
}
(bytes, events)
}
fn requested_offset(request: &HttpRequest) -> u64 {
request
.path
.split("offset=")
.nth(1)
.expect("a stream request always carries an offset")
.parse()
.expect("the offset is a number")
}
#[tokio::test(start_paused = true)]
async fn a_cut_mid_stream_reconnects_at_the_cursor_losing_and_duplicating_nothing() {
let recorder = Recorder::with([
Reply::Chunks(200, vec![output(0, b"AAAA\n"), output(5, b"BBBB\n")]),
Reply::Chunks(200, vec![output(10, b"CCCC\n"), exit(15)]),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("resume");
let (bytes, events) = collect(&handle, StreamOptions::default()).await;
assert_eq!(
String::from_utf8_lossy(&bytes),
"AAAA\nBBBB\nCCCC\n",
"the two attaches did not reconstruct the output, so the resume \
duplicated or lost bytes at the seam"
);
assert!(
events.iter().all(Result::is_ok),
"a cut surfaced as an error rather than a reconnect: {events:#?}"
);
assert!(
matches!(events.last(), Some(Ok(ExecEvent::Exit(_)))),
"the stream did not end on the terminal event: {events:#?}"
);
let seen = recorder.requests();
assert_eq!(
seen.len(),
2,
"the cut did not produce exactly one reconnect"
);
assert_eq!(requested_offset(&seen[0]), 0);
assert_eq!(
requested_offset(&seen[1]),
10,
"the reconnect asked for the wrong byte, so the seam is wrong"
);
}
#[tokio::test(start_paused = true)]
async fn a_stream_that_ended_with_an_exit_event_does_not_reconnect() {
let recorder = Recorder::with([Reply::Chunks(200, vec![output(0, b"hi\n"), exit(3)])]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("finished");
let (bytes, _) = collect(&handle, StreamOptions::default()).await;
assert_eq!(bytes, b"hi\n");
assert_eq!(
recorder.requests().len(),
1,
"a finished stream was reattached, which would replay a completed exec \
forever"
);
}
#[tokio::test(start_paused = true)]
async fn a_gap_advances_the_cursor_so_a_reconnect_does_not_ask_for_evicted_bytes() {
let recorder = Recorder::with([
Reply::Chunks(200, vec![output(0, b"AA"), gap(2, 900)]),
Reply::Chunks(200, vec![output(900, b"ZZ"), exit(902)]),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("lagged");
let (bytes, events) = collect(&handle, StreamOptions::default()).await;
assert_eq!(bytes, b"AAZZ");
assert!(
events
.iter()
.any(|event| matches!(event, Ok(ExecEvent::Gap { from: 2, to: 900 }))),
"the gap was swallowed, so a truncated log reads as a complete one: \
{events:#?}"
);
assert_eq!(
requested_offset(&recorder.requests()[1]),
900,
"the reconnect asked for bytes the daemon had already evicted"
);
}
#[tokio::test(start_paused = true)]
async fn error_on_gap_surfaces_the_lost_range_as_an_error() {
let recorder = Recorder::with([Reply::Chunks(200, vec![output(0, b"AA"), gap(2, 900)])]);
let (session, _, _) = session_with(recorder);
let handle = session.exec("lagged");
let (_, events) = collect(
&handle,
StreamOptions {
error_on_gap: true,
..StreamOptions::default()
},
)
.await;
let err = events
.last()
.expect("an event")
.as_ref()
.expect_err("the gap is an error");
assert_eq!(err.wire_kind(), Some(WireKind::OutputGap));
assert!(err.to_string().contains("[2, 900)"), "{err}");
}
#[tokio::test(start_paused = true)]
async fn a_failed_attach_is_retried_and_the_reconnect_re_mints_an_expired_token() {
let recorder = Recorder::with([
Reply::Cut("connection reset"),
Reply::Chunks(200, vec![output(0, b"ok\n"), exit(3)]),
]);
let (session, auth, clock) = session_with(Arc::clone(&recorder));
let handle = session.exec("retried");
clock.advance(super::super::DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
let (bytes, events) = collect(&handle, StreamOptions::default()).await;
assert_eq!(bytes, b"ok\n");
assert!(events.iter().all(Result::is_ok), "{events:#?}");
assert_eq!(
auth.mint_count(),
1,
"the attach did not mint inside the request path"
);
assert_eq!(recorder.requests().len(), 2);
}
#[tokio::test(start_paused = true)]
async fn a_collected_exec_ends_the_stream_rather_than_reconnecting_forever() {
let recorder = Recorder::with([Reply::Body(404, b"{\"error\":\"unknown_exec\"}".to_vec())]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("gone");
let (_, events) = collect(&handle, StreamOptions::default()).await;
assert_eq!(events.len(), 1);
let err = events[0].as_ref().expect_err("404 is an error");
assert_eq!(err.wire_kind(), Some(WireKind::NotFound));
assert_eq!(
recorder.requests().len(),
1,
"a fatal status was reattached, which can never succeed"
);
}
#[tokio::test(start_paused = true)]
async fn a_stream_that_never_completes_gives_up_naming_its_last_good_offset() {
let mut replies = vec![Reply::Chunks(200, vec![output(0, b"AA")])];
for _ in 0..3 {
replies.push(Reply::Chunks(200, vec![]));
}
let recorder = Recorder::with(replies);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("flaky");
let (bytes, events) = collect(
&handle,
StreamOptions {
max_reconnects: 2,
..StreamOptions::default()
},
)
.await;
assert_eq!(bytes, b"AA");
let err = events
.last()
.expect("an event")
.as_ref()
.expect_err("running out of reconnects is an error");
assert!(err.retryable(), "the exec is still alive server-side");
assert!(
err.to_string().contains("last good offset 2"),
"the give-up must name where to resume: {err}"
);
assert_eq!(recorder.requests().len(), 3, "the budget was not respected");
}
#[tokio::test(start_paused = true)]
async fn reconnect_off_ends_the_stream_at_the_cut() {
let recorder = Recorder::with([Reply::Chunks(200, vec![output(0, b"AA")])]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("once");
let (bytes, events) = collect(
&handle,
StreamOptions {
reconnect: false,
..StreamOptions::default()
},
)
.await;
assert_eq!(bytes, b"AA");
assert!(events.iter().all(Result::is_ok));
assert_eq!(recorder.requests().len(), 1);
}
#[tokio::test(start_paused = true)]
async fn a_stream_can_start_at_an_offset_a_previous_process_left_off_at() {
let recorder = Recorder::with([Reply::Chunks(200, vec![output(64, b"tail"), exit(68)])]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("resumed");
let (bytes, _) = collect(
&handle,
StreamOptions {
offset: 64,
..StreamOptions::default()
},
)
.await;
assert_eq!(bytes, b"tail");
assert_eq!(requested_offset(&recorder.last()), 64);
}
#[tokio::test(start_paused = true)]
async fn wait_and_ack_returns_the_ack_result_that_carries_the_output() {
let recorder = Recorder::with([
Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
Reply::ok(serde_json::json!({"exec_id":"e1","phase":"exited"})),
Reply::ok(serde_json::json!({
"exec_id":"e1","phase":"exited","exit_code":0,"signal":null,
"stdout":"the output","stderr":"","truncated":false,
"writers_may_be_alive":false
})),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("e1");
let result = handle
.wait_and_ack(Duration::from_secs(30))
.await
.expect("waits then acks");
assert_eq!(result.stdout(), "the output");
assert!(result.succeeded());
let seen = recorder.requests();
assert_eq!(seen.len(), 3);
assert_eq!(seen[2].path, "/v1/exec/e1/ack");
assert_eq!(seen[2].method, "POST");
}
#[tokio::test(start_paused = true)]
async fn wait_and_ack_does_not_ack_an_already_acked_exec() {
let recorder = Recorder::with([Reply::ok(serde_json::json!({
"exec_id":"e1","phase":"acked","exit_code":0,"signal":null,
"stdout":"","stderr":"","truncated":false,"writers_may_be_alive":false
}))]);
let (session, _, _) = session_with(Arc::clone(&recorder));
session
.exec("e1")
.wait_and_ack(Duration::from_secs(30))
.await
.expect("an acked exec is done");
assert_eq!(
recorder.requests().len(),
1,
"a second ack went out, which the daemon answers 409"
);
}
#[tokio::test(start_paused = true)]
async fn a_dropped_connection_mid_wait_is_retried_rather_than_raised() {
let recorder = Recorder::with([
Reply::Cut("connection reset"),
Reply::ok(serde_json::json!({
"exec_id":"e1","phase":"exited","exit_code":3,"signal":null,
"stdout":"","stderr":"boom","truncated":false,"writers_may_be_alive":false
})),
]);
let (session, _, _) = session_with(recorder);
let result = session
.exec("e1")
.wait(Duration::from_secs(30))
.await
.expect("the retry lands");
assert_eq!(result.exit_code(), Some(3));
assert!(!result.succeeded());
assert_eq!(result.stderr(), "boom");
}
#[tokio::test(start_paused = true)]
async fn a_wait_timeout_says_the_record_can_be_re_polled() {
let replies: Vec<Reply> = (0..10)
.map(|_| Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})))
.collect();
let recorder = Recorder::with(replies);
let (session, _, _) = session_with(recorder);
let err = session
.exec("e1")
.wait(Duration::from_secs(3))
.await
.expect_err("the exec never finishes");
assert_eq!(err.wire_kind(), Some(WireKind::ExecTimeout));
assert_eq!(err.kind(), ErrorKind::Timeout);
assert!(err.to_string().contains("still running"), "{err}");
assert!(err.to_string().contains("re-polled"), "{err}");
}
#[tokio::test]
async fn a_stdin_write_carries_base64_and_the_eof_signal() {
let recorder = Recorder::with([
Reply::ok(serde_json::json!({"exec_id":"e1","written":3,"eof":true})),
Reply::ok(serde_json::json!({"exec_id":"e1","written":0,"eof":true})),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("e1");
let ack = handle.write_stdin(b"go\n", true).await.expect("writes");
assert_eq!(ack.written, 3);
assert!(ack.eof);
let body: serde_json::Value =
serde_json::from_slice(&recorder.last().body).expect("json body");
assert_eq!(body["data_b64"], "Z28K");
assert_eq!(body["signal"], "eof");
handle.close_stdin().await.expect("closes");
let body: serde_json::Value =
serde_json::from_slice(&recorder.last().body).expect("json body");
assert!(
body.get("data_b64").is_none_or(serde_json::Value::is_null),
"a bare close must not send an empty data field: {body}"
);
assert_eq!(body["signal"], "eof");
}
#[tokio::test]
async fn kill_reports_false_for_a_group_that_had_already_exited() {
let recorder = Recorder::with([
Reply::ok(serde_json::json!({"exec_id":"e1","killed":true})),
Reply::ok(serde_json::json!({"exec_id":"e1","killed":false})),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
assert!(session.kill("e1").await.expect("kills"));
assert!(!session.kill("e1").await.expect("already gone"));
assert_eq!(recorder.last().path, "/v1/exec/e1/kill");
}
#[tokio::test]
async fn a_running_poll_carries_no_outcome_and_is_not_an_error() {
let recorder = Recorder::with([Reply::ok(
serde_json::json!({"exec_id":"e1","phase":"running"}),
)]);
let (session, _, _) = session_with(recorder);
let result = session.exec("e1").poll().await.expect("polls");
assert!(!result.done());
assert!(result.outcome.is_none());
assert_eq!(result.stdout(), "");
assert_eq!(result.exit_code(), None);
assert!(!result.succeeded());
}
async fn drive(
handle: &ExecHandle,
options: StreamOptions,
) -> (Vec<ExecEvent>, Result<StreamEnd, Error>) {
let mut seen = Vec::new();
let end = handle
.for_each_event(options, |event| {
seen.push(event);
std::ops::ControlFlow::Continue(())
})
.await;
(seen, end)
}
#[tokio::test(start_paused = true)]
async fn the_driver_hands_every_event_to_the_callback_in_order() {
let recorder = Recorder::with([Reply::Chunks(
200,
vec![output(0, b"AAAAA"), output(5, b"BBBBB"), exit(10)],
)]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("ordered");
let (seen, end) = drive(&handle, StreamOptions::default()).await;
let end = end.expect("the stream completes");
let offsets: Vec<u64> = seen
.iter()
.filter_map(|event| match event {
ExecEvent::Output { offset, .. } => Some(*offset),
_ => None,
})
.collect();
assert_eq!(offsets, [0, 5], "the callback saw the frames out of order");
assert!(
matches!(seen.last(), Some(ExecEvent::Exit(_))),
"the terminal event has to be delivered, not merely observed: {seen:#?}"
);
assert_eq!(
end,
StreamEnd {
reason: EndReason::Exited,
cursor: 10,
},
"the end has to name the exit event and the total"
);
assert_eq!(recorder.requests().len(), 1);
}
#[tokio::test(start_paused = true)]
async fn control_flow_break_stops_the_stream_and_is_not_reported_as_a_cut() {
let recorder = Recorder::with([Reply::Chunks(
200,
vec![output(0, b"first\n"), output(6, b"second\n"), exit(13)],
)]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("halted");
let mut seen = Vec::new();
let end = handle
.for_each_event(StreamOptions::default(), |event| {
seen.push(event);
std::ops::ControlFlow::Break(())
})
.await
.expect("stopping is not a failure");
assert_eq!(
seen.len(),
1,
"the drive kept going after Break, so a caller that stopped reading is still \
being read to: {seen:#?}"
);
assert_eq!(
end,
StreamEnd {
reason: EndReason::Stopped,
cursor: 6,
},
"a caller's own stop must not read as a cut stream, and the cursor is where it \
stopped"
);
}
#[tokio::test(start_paused = true)]
async fn the_driver_reconnects_at_the_cursor_across_a_cut() {
let recorder = Recorder::with([
Reply::Chunks(200, vec![output(0, b"AAAA\n"), output(5, b"BBBB\n")]),
Reply::Chunks(200, vec![output(10, b"CCCC\n"), exit(15)]),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("resumed-driver");
let (seen, end) = drive(&handle, StreamOptions::default()).await;
let end = end.expect("the reconnect lands");
let mut bytes = Vec::new();
for event in &seen {
if let ExecEvent::Output { data, .. } = event {
bytes.extend_from_slice(data);
}
}
assert_eq!(
String::from_utf8_lossy(&bytes),
"AAAA\nBBBB\nCCCC\n",
"the two attaches did not reconstruct the output through the driver"
);
assert_eq!(end.reason, EndReason::Exited);
assert_eq!(end.cursor, 15);
let seen_requests = recorder.requests();
assert_eq!(seen_requests.len(), 2, "the cut produced no reconnect");
assert_eq!(
requested_offset(&seen_requests[1]),
10,
"the reconnect asked for the wrong byte, so the seam is wrong"
);
}
#[tokio::test(start_paused = true)]
async fn a_cut_with_reconnect_off_ends_the_drive_naming_the_cut() {
let recorder = Recorder::with([Reply::Chunks(200, vec![output(0, b"partial")])]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("truncated");
let (seen, end) = drive(
&handle,
StreamOptions {
reconnect: false,
..StreamOptions::default()
},
)
.await;
let end = end.expect("a cut is not an error when reconnect is off");
assert_eq!(seen.len(), 1);
assert!(
!seen.iter().any(|event| matches!(event, ExecEvent::Exit(_))),
"there was no terminal event to deliver"
);
assert_eq!(
end,
StreamEnd {
reason: EndReason::Cut,
cursor: 7,
},
"a stream with no exit event is a cut, and reporting anything else would let a \
caller pass a CI step on output it never received"
);
assert_eq!(recorder.requests().len(), 1);
}
#[tokio::test(start_paused = true)]
async fn a_fatal_status_ends_the_drive_with_an_error() {
let recorder = Recorder::with([Reply::Body(404, b"{\"error\":\"unknown_exec\"}".to_vec())]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("collected");
let (seen, end) = drive(&handle, StreamOptions::default()).await;
assert!(seen.is_empty(), "nothing was delivered: {seen:#?}");
let error = end.expect_err("a 404 on the attach is fatal");
assert_eq!(error.wire_kind(), Some(WireKind::NotFound));
assert_eq!(
recorder.requests().len(),
1,
"a fatal status was reattached, which can never succeed"
);
}
async fn drive_async(
handle: &ExecHandle,
options: StreamOptions,
) -> (Vec<ExecEvent>, Result<StreamEnd, Error>) {
let mut seen = Vec::new();
let end = handle
.for_each_event_async(options, |event| {
seen.push(event);
std::future::ready(std::ops::ControlFlow::Continue(()))
})
.await;
(seen, end)
}
#[tokio::test(start_paused = true)]
async fn the_async_driver_hands_every_event_to_the_callback_in_order() {
let recorder = Recorder::with([Reply::Chunks(
200,
vec![output(0, b"AAAAA"), output(5, b"BBBBB"), exit(10)],
)]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("ordered-async");
let (seen, end) = drive_async(&handle, StreamOptions::default()).await;
let end = end.expect("the stream completes");
let offsets: Vec<u64> = seen
.iter()
.filter_map(|event| match event {
ExecEvent::Output { offset, .. } => Some(*offset),
_ => None,
})
.collect();
assert_eq!(offsets, [0, 5], "the callback saw the frames out of order");
assert!(
matches!(seen.last(), Some(ExecEvent::Exit(_))),
"the terminal event has to be delivered, not merely observed: {seen:#?}"
);
assert_eq!(
end,
StreamEnd {
reason: EndReason::Exited,
cursor: 10,
},
"the end has to name the exit event and the total"
);
assert_eq!(recorder.requests().len(), 1);
}
#[tokio::test(start_paused = true)]
async fn an_async_callback_breaking_stops_the_stream_and_is_not_reported_as_a_cut() {
let recorder = Recorder::with([Reply::Chunks(
200,
vec![output(0, b"first\n"), output(6, b"second\n"), exit(13)],
)]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("halted-async");
let mut seen = Vec::new();
let end = handle
.for_each_event_async(StreamOptions::default(), |event| {
seen.push(event);
std::future::ready(std::ops::ControlFlow::Break(()))
})
.await
.expect("stopping is not a failure");
assert_eq!(
seen.len(),
1,
"the drive kept going after Break, so a consumer that stopped reading is still \
being read to: {seen:#?}"
);
assert_eq!(
end,
StreamEnd {
reason: EndReason::Stopped,
cursor: 6,
},
"a consumer's own stop must not read as a cut stream, and the cursor is where it \
stopped"
);
}
#[tokio::test(start_paused = true)]
async fn the_async_driver_reconnects_at_the_cursor_across_a_cut() {
let recorder = Recorder::with([
Reply::Chunks(200, vec![output(0, b"AAAA\n"), output(5, b"BBBB\n")]),
Reply::Chunks(200, vec![output(10, b"CCCC\n"), exit(15)]),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("resumed-async");
let (seen, end) = drive_async(&handle, StreamOptions::default()).await;
let end = end.expect("the reconnect lands");
let mut bytes = Vec::new();
for event in &seen {
if let ExecEvent::Output { data, .. } = event {
bytes.extend_from_slice(data);
}
}
assert_eq!(
String::from_utf8_lossy(&bytes),
"AAAA\nBBBB\nCCCC\n",
"the two attaches did not reconstruct the output through the async driver"
);
assert_eq!(end.reason, EndReason::Exited);
assert_eq!(end.cursor, 15);
let seen_requests = recorder.requests();
assert_eq!(seen_requests.len(), 2, "the cut produced no reconnect");
assert_eq!(
requested_offset(&seen_requests[1]),
10,
"the reconnect asked for the wrong byte, so the seam is wrong"
);
}
#[tokio::test(start_paused = true)]
async fn an_async_callback_that_awaits_between_events_loses_none_of_them() {
let recorder = Recorder::with([Reply::Chunks(
200,
vec![
output(0, b"AA"),
output(2, b"BB"),
output(4, b"CC"),
output(6, b"DD"),
exit(8),
],
)]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.exec("backpressured");
let (sender, mut receiver) = tokio::sync::mpsc::channel::<String>(1);
let consumer = tokio::spawn(async move {
let mut drained = Vec::new();
while let Some(described) = receiver.recv().await {
tokio::time::sleep(Duration::from_millis(50)).await;
drained.push(described);
}
drained
});
let end = handle
.for_each_event_async(StreamOptions::default(), |event| {
let described = match &event {
ExecEvent::Output { offset, data, .. } => {
format!("out@{offset}:{}", String::from_utf8_lossy(data))
}
ExecEvent::Gap { from, to } => format!("gap@{from}..{to}"),
ExecEvent::Exit(exit) => format!("exit:{:?}", exit.exit_code),
};
let sender = sender.clone();
async move {
match sender.send(described).await {
Ok(()) => std::ops::ControlFlow::Continue(()),
Err(_) => std::ops::ControlFlow::Break(()),
}
}
})
.await
.expect("the stream completes even though the consumer lags");
drop(sender);
let drained = consumer.await.expect("the consumer task does not panic");
assert_eq!(
drained,
[
"out@0:AA",
"out@2:BB",
"out@4:CC",
"out@6:DD",
"exit:Some(0)",
],
"a lagging capacity-1 consumer lost or reordered events, which is the failure the \
awaited callback exists to prevent"
);
assert_eq!(
end,
StreamEnd {
reason: EndReason::Exited,
cursor: 8,
},
"the ending must still name the exit event and the total"
);
}
}