use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio_stream::Stream;
use crate::{Command, ProcessResult, ProcessRunner, Result};
type Completed<T> = (usize, Result<ProcessResult<T>>);
type CompletedFut<'a, T> = Pin<Box<dyn Future<Output = Completed<T>> + Send + 'a>>;
type Launch<'a, R, T> = fn(&'a R, usize, Command) -> CompletedFut<'a, T>;
struct Fanout<'a, R: ?Sized, T> {
runner: &'a R,
launch: Launch<'a, R, T>,
pending: VecDeque<(usize, Command)>,
active: Vec<CompletedFut<'a, T>>,
limit: usize,
total: usize,
}
impl<'a, R, T> Fanout<'a, R, T>
where
R: ProcessRunner + ?Sized,
{
fn new<I>(commands: I, concurrency: usize, runner: &'a R, launch: Launch<'a, R, T>) -> Self
where
I: IntoIterator<Item = Command>,
{
let pending: VecDeque<(usize, Command)> = commands.into_iter().enumerate().collect();
let total = pending.len();
Self {
runner,
launch,
pending,
active: Vec::new(),
limit: concurrency.max(1),
total,
}
}
}
impl<'a, R, T> Stream for Fanout<'a, R, T>
where
R: ProcessRunner + ?Sized,
{
type Item = Completed<T>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
let runner = this.runner;
while this.active.len() < this.limit {
match this.pending.pop_front() {
Some((idx, command)) => this.active.push((this.launch)(runner, idx, command)),
None => break,
}
}
let mut i = 0;
while i < this.active.len() {
match this.active[i].as_mut().poll(cx) {
Poll::Ready(done) => {
drop(this.active.swap_remove(i)); return Poll::Ready(Some(done));
}
Poll::Pending => i += 1,
}
}
if this.active.is_empty() && this.pending.is_empty() {
Poll::Ready(None)
} else {
Poll::Pending
}
}
}
async fn collect_in_order<'a, R, T>(mut fanout: Fanout<'a, R, T>) -> Vec<Result<ProcessResult<T>>>
where
R: ProcessRunner + ?Sized,
{
use tokio_stream::StreamExt;
let mut slots: Vec<Option<Result<ProcessResult<T>>>> =
(0..fanout.total).map(|_| None).collect();
while let Some((idx, result)) = fanout.next().await {
slots[idx] = Some(result);
}
slots
.into_iter()
.map(|slot| slot.expect("every command fills its slot before the fan-out ends"))
.collect()
}
fn text_fanout<'a, R, I>(commands: I, concurrency: usize, runner: &'a R) -> Fanout<'a, R, String>
where
R: ProcessRunner + ?Sized,
I: IntoIterator<Item = Command>,
{
let launch: Launch<'a, R, String> =
|r, idx, command| Box::pin(async move { (idx, r.output_string(&command).await) });
Fanout::new(commands, concurrency, runner, launch)
}
fn bytes_fanout<'a, R, I>(commands: I, concurrency: usize, runner: &'a R) -> Fanout<'a, R, Vec<u8>>
where
R: ProcessRunner + ?Sized,
I: IntoIterator<Item = Command>,
{
let launch: Launch<'a, R, Vec<u8>> =
|r, idx, command| Box::pin(async move { (idx, r.output_bytes(&command).await) });
Fanout::new(commands, concurrency, runner, launch)
}
pub async fn output_all<R, I>(
commands: I,
concurrency: usize,
runner: &R,
) -> Vec<Result<ProcessResult<String>>>
where
R: ProcessRunner + ?Sized,
I: IntoIterator<Item = Command>,
{
collect_in_order(text_fanout(commands, concurrency, runner)).await
}
pub async fn output_all_bytes<R, I>(
commands: I,
concurrency: usize,
runner: &R,
) -> Vec<Result<ProcessResult<Vec<u8>>>>
where
R: ProcessRunner + ?Sized,
I: IntoIterator<Item = Command>,
{
collect_in_order(bytes_fanout(commands, concurrency, runner)).await
}
#[must_use = "a stream does nothing until polled (e.g. with `StreamExt::next`)"]
pub fn output_stream<'a, R, I>(
commands: I,
concurrency: usize,
runner: &'a R,
) -> impl Stream<Item = Completed<String>> + Send + 'a
where
R: ProcessRunner + ?Sized,
I: IntoIterator<Item = Command> + 'a,
{
text_fanout(commands, concurrency, runner)
}
#[must_use = "a stream does nothing until polled (e.g. with `StreamExt::next`)"]
pub fn output_stream_bytes<'a, R, I>(
commands: I,
concurrency: usize,
runner: &'a R,
) -> impl Stream<Item = Completed<Vec<u8>>> + Send + 'a
where
R: ProcessRunner + ?Sized,
I: IntoIterator<Item = Command> + 'a,
{
bytes_fanout(commands, concurrency, runner)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{Reply, ScriptedRunner};
#[tokio::test]
async fn output_all_preserves_input_order() {
let runner = ScriptedRunner::new()
.on(["step", "0"], Reply::ok("zero"))
.on(["step", "1"], Reply::ok("one"))
.on(["step", "2"], Reply::ok("two"));
let cmds = vec![
Command::new("step").arg("0"),
Command::new("step").arg("1"),
Command::new("step").arg("2"),
];
let results = output_all(cmds, 2, &runner).await;
let stdout: Vec<&str> = results
.iter()
.map(|r| r.as_ref().expect("ok").stdout().as_str())
.collect();
assert_eq!(stdout, ["zero", "one", "two"]);
}
#[tokio::test]
async fn output_all_collects_all_even_with_a_failure_in_the_middle() {
let runner = ScriptedRunner::new()
.on(["step", "0"], Reply::ok("ok-0"))
.on(["step", "1"], Reply::fail(7, "boom"))
.on(["step", "2"], Reply::ok("ok-2"));
let cmds = vec![
Command::new("step").arg("0"),
Command::new("step").arg("1"),
Command::new("step").arg("2"),
];
let results = output_all(cmds, 3, &runner).await;
assert_eq!(results.len(), 3);
assert!(results[0].as_ref().unwrap().is_success());
assert_eq!(results[1].as_ref().unwrap().code(), Some(7));
assert!(results[2].as_ref().unwrap().is_success());
}
#[tokio::test]
async fn output_all_never_exceeds_and_actually_reaches_the_concurrency_cap() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone)]
struct ConcurrencyProbe {
active: Arc<AtomicUsize>,
peak: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl ProcessRunner for ConcurrencyProbe {
async fn output_string(&self, command: &Command) -> Result<ProcessResult<String>> {
let now = self.active.fetch_add(1, Ordering::SeqCst) + 1;
self.peak.fetch_max(now, Ordering::SeqCst);
for _ in 0..4 {
tokio::task::yield_now().await;
}
self.active.fetch_sub(1, Ordering::SeqCst);
Ok(ProcessResult::new(
command.program().to_string_lossy().into_owned(),
String::new(),
String::new(),
crate::result::Outcome::Exited(0),
None,
))
}
}
let probe = ConcurrencyProbe {
active: Arc::new(AtomicUsize::new(0)),
peak: Arc::new(AtomicUsize::new(0)),
};
let cmds: Vec<Command> = (0..10)
.map(|i| Command::new("x").arg(i.to_string()))
.collect();
let results = output_all(cmds, 3, &probe).await;
assert_eq!(results.len(), 10);
assert!(results.iter().all(|r| r.as_ref().unwrap().is_success()));
let peak = probe.peak.load(Ordering::SeqCst);
assert!(peak <= 3, "concurrency cap exceeded: peak {peak} > 3");
assert_eq!(
peak, 3,
"the cap must actually be reached (genuine overlap), got peak {peak}"
);
assert_eq!(
probe.active.load(Ordering::SeqCst),
0,
"all futures finished"
);
}
#[tokio::test]
async fn output_all_bytes_captures_raw_stdout_in_input_order() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone)]
struct BytesEcho {
peak: Arc<AtomicUsize>,
active: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl ProcessRunner for BytesEcho {
async fn output_string(&self, _command: &Command) -> Result<ProcessResult<String>> {
unreachable!("output_all_bytes must use output_bytes, not output_string")
}
async fn output_bytes(&self, command: &Command) -> Result<ProcessResult<Vec<u8>>> {
let now = self.active.fetch_add(1, Ordering::SeqCst) + 1;
self.peak.fetch_max(now, Ordering::SeqCst);
for _ in 0..4 {
tokio::task::yield_now().await;
}
self.active.fetch_sub(1, Ordering::SeqCst);
let arg = command.arguments()[0].to_string_lossy().into_owned();
Ok(ProcessResult::new(
command.program().to_string_lossy().into_owned(),
arg.into_bytes(),
String::new(),
crate::result::Outcome::Exited(0),
None,
))
}
}
let runner = BytesEcho {
peak: Arc::new(AtomicUsize::new(0)),
active: Arc::new(AtomicUsize::new(0)),
};
let cmds: Vec<Command> = (0..6)
.map(|i| Command::new("echo").arg(i.to_string()))
.collect();
let results = output_all_bytes(cmds, 2, &runner).await;
let bytes: Vec<Vec<u8>> = results
.iter()
.map(|r| r.as_ref().expect("ok").stdout().clone())
.collect();
let expected: Vec<Vec<u8>> = (0..6).map(|i| i.to_string().into_bytes()).collect();
assert_eq!(bytes, expected, "raw bytes preserved in input order");
let peak = runner.peak.load(Ordering::SeqCst);
assert!(
peak <= 2,
"concurrency cap exceeded for the bytes batch: {peak}"
);
assert_eq!(
peak, 2,
"the cap must actually be reached (genuine overlap), got {peak}"
);
}
#[tokio::test]
async fn output_all_on_an_empty_batch_is_an_empty_vec() {
let runner = ScriptedRunner::new().fallback(Reply::ok(""));
let results = output_all(Vec::new(), 4, &runner).await;
assert!(results.is_empty());
}
#[tokio::test]
async fn output_all_runs_more_commands_than_the_concurrency_cap() {
let mut runner = ScriptedRunner::new();
for i in 0..10 {
runner = runner.on(["x".to_string(), i.to_string()], Reply::ok(format!("n{i}")));
}
let cmds: Vec<Command> = (0..10)
.map(|i| Command::new("x").arg(i.to_string()))
.collect();
let results = output_all(cmds, 2, &runner).await;
let stdout: Vec<String> = results
.iter()
.map(|r| r.as_ref().expect("ok").stdout().clone())
.collect();
let expected: Vec<String> = (0..10).map(|i| format!("n{i}")).collect();
assert_eq!(stdout, expected);
}
#[tokio::test]
async fn output_stream_yields_in_completion_order_not_input_order() {
use crate::prelude::StreamExt;
#[derive(Clone)]
struct Paced;
#[async_trait::async_trait]
impl ProcessRunner for Paced {
async fn output_string(&self, command: &Command) -> Result<ProcessResult<String>> {
let yields: usize = command.arguments()[1]
.to_string_lossy()
.parse()
.expect("yield count");
for _ in 0..yields {
tokio::task::yield_now().await;
}
Ok(ProcessResult::new(
command.program().to_string_lossy().into_owned(),
command.arguments()[0].to_string_lossy().into_owned(),
String::new(),
crate::result::Outcome::Exited(0),
None,
))
}
}
let cmds = vec![
Command::new("job").arg("0").arg("8"),
Command::new("job").arg("1").arg("1"),
];
let mut stream = output_stream(cmds, 2, &Paced);
let mut order = Vec::new();
while let Some((idx, result)) = stream.next().await {
assert!(result.is_ok(), "no command errors in this batch");
order.push(idx);
}
assert_eq!(
order,
vec![1, 0],
"completion order: fast input-idx 1 before slow input-idx 0"
);
}
#[tokio::test]
async fn output_stream_respects_the_concurrency_cap() {
use crate::prelude::StreamExt;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone)]
struct ConcurrencyProbe {
active: Arc<AtomicUsize>,
peak: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl ProcessRunner for ConcurrencyProbe {
async fn output_string(&self, command: &Command) -> Result<ProcessResult<String>> {
let now = self.active.fetch_add(1, Ordering::SeqCst) + 1;
self.peak.fetch_max(now, Ordering::SeqCst);
for _ in 0..4 {
tokio::task::yield_now().await;
}
self.active.fetch_sub(1, Ordering::SeqCst);
Ok(ProcessResult::new(
command.program().to_string_lossy().into_owned(),
String::new(),
String::new(),
crate::result::Outcome::Exited(0),
None,
))
}
}
let probe = ConcurrencyProbe {
active: Arc::new(AtomicUsize::new(0)),
peak: Arc::new(AtomicUsize::new(0)),
};
let cmds: Vec<Command> = (0..10)
.map(|i| Command::new("x").arg(i.to_string()))
.collect();
let mut stream = output_stream(cmds, 3, &probe);
let mut count = 0;
while let Some((_idx, result)) = stream.next().await {
assert!(result.unwrap().is_success());
count += 1;
}
assert_eq!(count, 10, "every command is eventually yielded");
let peak = probe.peak.load(Ordering::SeqCst);
assert!(peak <= 3, "concurrency cap exceeded: peak {peak} > 3");
assert_eq!(
peak, 3,
"the cap must actually be reached (genuine overlap), got peak {peak}"
);
assert_eq!(
probe.active.load(Ordering::SeqCst),
0,
"all futures finished"
);
}
#[tokio::test]
async fn output_stream_does_not_short_circuit_on_a_failure() {
use crate::prelude::StreamExt;
let runner = ScriptedRunner::new()
.on(["step", "0"], Reply::ok("ok-0"))
.on(["step", "1"], Reply::fail(7, "boom"))
.on(["step", "2"], Reply::ok("ok-2"));
let cmds = vec![
Command::new("step").arg("0"),
Command::new("step").arg("1"),
Command::new("step").arg("2"),
];
let mut stream = output_stream(cmds, 3, &runner);
let mut codes: std::collections::BTreeMap<usize, Option<i32>> = Default::default();
while let Some((idx, result)) = stream.next().await {
codes.insert(idx, result.expect("no spawn failures here").code());
}
assert_eq!(codes.len(), 3, "all three commands completed");
assert_eq!(codes[&0], Some(0));
assert_eq!(codes[&1], Some(7));
assert_eq!(codes[&2], Some(0));
}
#[tokio::test]
async fn output_stream_on_an_empty_batch_yields_nothing() {
use crate::prelude::StreamExt;
let runner = ScriptedRunner::new().fallback(Reply::ok(""));
let mut stream = output_stream(Vec::new(), 4, &runner);
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn output_stream_drop_cancels_inflight_and_never_spawns_queued_commands() {
use crate::prelude::StreamExt;
use std::collections::BTreeSet;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
struct LiveGuard(Arc<AtomicUsize>);
impl Drop for LiveGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[derive(Clone)]
struct Blocker {
live: Arc<AtomicUsize>,
started: Arc<Mutex<BTreeSet<usize>>>,
}
#[async_trait::async_trait]
impl ProcessRunner for Blocker {
async fn output_string(&self, command: &Command) -> Result<ProcessResult<String>> {
let idx: usize = command.arguments()[0]
.to_string_lossy()
.parse()
.expect("index arg");
self.started.lock().expect("started lock").insert(idx);
self.live.fetch_add(1, Ordering::SeqCst);
let _guard = LiveGuard(self.live.clone());
std::future::pending::<()>().await;
unreachable!("pending() never resolves")
}
}
let blocker = Blocker {
live: Arc::new(AtomicUsize::new(0)),
started: Arc::new(Mutex::new(BTreeSet::new())),
};
let cmds: Vec<Command> = (0..5)
.map(|i| Command::new("block").arg(i.to_string()))
.collect();
let mut stream = output_stream(cmds, 2, &blocker);
let polled =
tokio::time::timeout(std::time::Duration::from_millis(100), stream.next()).await;
assert!(
polled.is_err(),
"every launched command blocks, so nothing is yielded"
);
assert_eq!(
blocker.live.load(Ordering::SeqCst),
2,
"exactly the cap's worth of commands are live"
);
drop(stream);
assert_eq!(
blocker.live.load(Ordering::SeqCst),
0,
"dropping the stream tore down every in-flight command"
);
let started = blocker.started.lock().expect("started lock").clone();
assert_eq!(
started,
BTreeSet::from([0, 1]),
"only the cap's worth of commands ever started; queued 2/3/4 never spawned"
);
}
#[tokio::test]
async fn output_stream_yielded_results_survive_a_mid_fanout_drop() {
use crate::prelude::StreamExt;
#[derive(Clone)]
struct OneFastOneBlocked;
#[async_trait::async_trait]
impl ProcessRunner for OneFastOneBlocked {
async fn output_string(&self, command: &Command) -> Result<ProcessResult<String>> {
if command.arguments()[0].to_string_lossy() == "0" {
return Ok(ProcessResult::new(
command.program().to_string_lossy().into_owned(),
"fast".to_owned(),
String::new(),
crate::result::Outcome::Exited(0),
None,
));
}
std::future::pending::<()>().await;
unreachable!("the blocked command never resolves")
}
}
let cmds = vec![Command::new("job").arg("0"), Command::new("job").arg("1")];
let mut stream = output_stream(cmds, 2, &OneFastOneBlocked);
let (idx, result) =
tokio::time::timeout(std::time::Duration::from_millis(100), stream.next())
.await
.expect("the fast command resolves before the timeout")
.expect("a yielded result, not end-of-stream");
assert_eq!(idx, 0, "the fast command is input idx 0");
drop(stream);
assert_eq!(
result.expect("the yielded result is intact").stdout(),
"fast"
);
}
#[tokio::test]
async fn output_stream_bytes_captures_raw_stdout_tagged_with_input_index() {
use crate::prelude::StreamExt;
#[derive(Clone)]
struct BytesEcho;
#[async_trait::async_trait]
impl ProcessRunner for BytesEcho {
async fn output_string(&self, _command: &Command) -> Result<ProcessResult<String>> {
unreachable!("output_stream_bytes must use output_bytes, not output_string")
}
async fn output_bytes(&self, command: &Command) -> Result<ProcessResult<Vec<u8>>> {
let arg = command.arguments()[0].to_string_lossy().into_owned();
Ok(ProcessResult::new(
command.program().to_string_lossy().into_owned(),
arg.into_bytes(),
String::new(),
crate::result::Outcome::Exited(0),
None,
))
}
}
let cmds: Vec<Command> = (0..6)
.map(|i| Command::new("echo").arg(i.to_string()))
.collect();
let mut stream = output_stream_bytes(cmds, 2, &BytesEcho);
let mut got: std::collections::BTreeMap<usize, Vec<u8>> = Default::default();
while let Some((idx, result)) = stream.next().await {
got.insert(idx, result.expect("ok").stdout().clone());
}
let expected: std::collections::BTreeMap<usize, Vec<u8>> =
(0..6).map(|i| (i, i.to_string().into_bytes())).collect();
assert_eq!(got, expected, "each input index maps to its own raw bytes");
}
}