#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::fmt;
use std::future::Future;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use processkit::prelude::StreamExt;
use processkit::{
CancellationToken, CliClient, Command, Error, ErrorReason, IntoCommand, JobRunner,
OutputBufferPolicy, OverflowMode, ProcessResult, ProcessRunner, Result,
};
pub use processkit::ProcessEvent;
pub type ProgressCallback<'a> = dyn FnMut(ProcessEvent) + Send + 'a;
pub async fn run_with_progress<R: ProcessRunner + ?Sized>(
runner: &R,
command: &Command,
progress: &mut ProgressCallback<'_>,
) -> Result<()> {
run_with_progress_within(runner, command, progress, OutputBudget::unlimited()).await
}
pub async fn run_with_progress_within<R: ProcessRunner + ?Sized>(
runner: &R,
command: &Command,
progress: &mut ProgressCallback<'_>,
budget: OutputBudget,
) -> Result<()> {
let program = command.program().to_string_lossy().into_owned();
let ok_codes = command
.configured_ok_codes()
.map_or_else(|| vec![0], <[i32]>::to_vec);
let absolute_timeout = command.configured_timeout();
let inactivity_timeout = command.configured_inactivity_timeout();
let started = Instant::now();
let mut run = runner.start(command).await?;
let mut events = run.events()?;
let mut stdout = RetainedStream::new(budget);
let mut stderr = RetainedStream::new(budget);
let forward = async {
let mut callback_active = true;
while let Some(event) = events.next().await {
let target = match &event {
ProcessEvent::Stdout(_) => Some(&mut stdout),
ProcessEvent::Stderr(_) => Some(&mut stderr),
_ => None,
};
if let (Some(target), Some(line)) = (target, event.text()) {
target.push(line);
}
if callback_active
&& std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| progress(event)))
.is_err()
{
callback_active = false;
}
}
};
let (_, finished) = tokio::join!(forward, run.finish());
let finished = finished?;
let timeout = if finished.outcome.inactivity_timed_out() {
inactivity_timeout
} else {
absolute_timeout
};
let truncated = finished.stderr_truncated || stdout.truncated() || stderr.truncated();
ProcessResult::from_parts(
program,
stdout.into_string(),
stderr.into_string(),
finished.outcome,
timeout,
started.elapsed(),
truncated,
0,
0,
ok_codes,
)
.ensure_success()
.map(drop)
}
enum RetainedStream {
All(String),
Tail(RetainedTail),
}
struct RetainedTail {
lines: VecDeque<String>,
bytes: usize,
max_bytes: Option<usize>,
max_lines: Option<usize>,
truncated: bool,
}
impl RetainedStream {
fn new(budget: OutputBudget) -> Self {
if budget.is_unlimited() {
Self::All(String::new())
} else {
Self::Tail(RetainedTail {
lines: VecDeque::new(),
bytes: 0,
max_bytes: budget.max_bytes(),
max_lines: budget.max_lines(),
truncated: false,
})
}
}
fn push(&mut self, line: &str) {
match self {
Self::All(text) => {
if !text.is_empty() {
text.push('\n');
}
text.push_str(line);
}
Self::Tail(tail) => tail.push(line),
}
}
fn truncated(&self) -> bool {
match self {
Self::All(_) => false,
Self::Tail(tail) => tail.truncated,
}
}
fn into_string(self) -> String {
match self {
Self::All(text) => text,
Self::Tail(tail) => {
let mut text = String::with_capacity(tail.bytes);
for line in &tail.lines {
if !text.is_empty() {
text.push('\n');
}
text.push_str(line);
}
text
}
}
}
}
impl RetainedTail {
fn push(&mut self, line: &str) {
let line = match self.max_bytes {
Some(max) if line.len() > max => {
self.truncated = true;
let mut start = line.len() - max;
while !line.is_char_boundary(start) {
start += 1;
}
&line[start..]
}
_ => line,
};
self.bytes += line.len() + usize::from(!self.lines.is_empty());
self.lines.push_back(line.to_string());
while self.max_lines.is_some_and(|max| self.lines.len() > max) {
self.drop_oldest();
}
while self.max_bytes.is_some_and(|max| self.bytes > max) && self.lines.len() > 1 {
self.drop_oldest();
}
}
fn drop_oldest(&mut self) {
if let Some(dropped) = self.lines.pop_front() {
self.bytes -= dropped.len() + usize::from(!self.lines.is_empty());
self.truncated = true;
}
}
}
pub mod credentials;
pub use credentials::{
Credential, CredentialProvider, CredentialRequest, CredentialService, EnvToken, FnProvider,
GitCredentialHelper, Secret, StaticCredential, git_credential_helper, https_host, provider_fn,
};
pub mod logging;
pub use logging::{
CommandObserver, CommandRecord, CommandStatus, LoggingRunner, StderrObserver, redact_args,
redact_value,
};
#[cfg(feature = "serde")]
#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
pub mod json {
use processkit::{Error, Result};
use serde::Deserialize;
use serde::de::DeserializeOwned;
pub fn null_to_empty<'de, D>(deserializer: D) -> ::core::result::Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
}
pub fn from_json<T: DeserializeOwned>(program: &str, json: &str) -> Result<T> {
serde_json::from_str(json).map_err(|e| Error::parse(program, e.to_string()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputBudget {
max_bytes: Option<usize>,
max_lines: Option<usize>,
}
impl OutputBudget {
pub const fn unlimited() -> Self {
Self {
max_bytes: None,
max_lines: None,
}
}
pub const fn bytes(max_bytes: usize) -> Self {
Self {
max_bytes: Some(max_bytes),
max_lines: None,
}
}
#[must_use]
pub const fn with_max_lines(mut self, max_lines: usize) -> Self {
self.max_lines = Some(max_lines);
self
}
pub const fn is_unlimited(&self) -> bool {
self.max_bytes.is_none() && self.max_lines.is_none()
}
pub const fn max_bytes(&self) -> Option<usize> {
self.max_bytes
}
pub const fn max_lines(&self) -> Option<usize> {
self.max_lines
}
pub fn content_policy(&self) -> Option<OutputBufferPolicy> {
if self.is_unlimited() {
return None;
}
let mut policy = match self.max_lines {
Some(lines) => OutputBufferPolicy::fail_loud(lines),
None => OutputBufferPolicy::unbounded().with_overflow(OverflowMode::Error),
};
if let Some(bytes) = self.max_bytes {
policy = policy.with_max_bytes(bytes);
}
Some(policy)
}
pub fn diagnostic_policy(&self) -> Option<OutputBufferPolicy> {
if self.is_unlimited() {
return None;
}
let mut policy = match self.max_lines {
Some(lines) => OutputBufferPolicy::bounded(lines),
None => OutputBufferPolicy::unbounded(),
};
if let Some(bytes) = self.max_bytes {
policy = policy.with_max_bytes(bytes);
}
Some(policy)
}
}
impl Default for OutputBudget {
fn default() -> Self {
Self::unlimited()
}
}
#[macro_export]
macro_rules! at_forwarders {
(
$view:ident, $field:ident, $client:literal,
bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
$( raw { $( fn $rn:ident( $($ra:ident: $rt:ty),* $(,)? ) -> $rr:ty => $rtgt:ident; )* } )?
) => {
impl<'a, R: ::processkit::ProcessRunner> $view<'a, R> {
$(
#[doc = concat!("Bound form of [`", $client, "`]'s `", stringify!($bn), "`.")]
pub async fn $bn(&self, $($ba: $bt),*) -> $br {
self.$field.$bn($($ba),*).await
}
)*
$(
#[doc = concat!("Bound form of [`", $client, "`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
pub async fn $dn(&self, $($da: $dt),*) -> $dr {
self.$field.$dn(self.dir, $($da),*).await
}
)*
$($(
#[doc = concat!(
"Bound form of [`", $client, "`]'s `", stringify!($rn),
"` raw escape hatch — runs the given argv **in the bound `dir`** \
(forwards to the client's `", stringify!($rtgt), "`). For the \
process-cwd escape hatch, call `", stringify!($rn),
"` on [`", $client, "`] directly."
)]
pub async fn $rn(&self, $($ra: $rt),*) -> $rr {
self.$field.$rtgt(self.dir, $($ra),*).await
}
)*)?
}
};
}
#[macro_export]
macro_rules! raw_run_forwarders {
(
$name:ident, $binary:literal, $args_example:literal, $in_infers:literal, $in_flag_note:literal $(,)?
) => {
impl<R: ::processkit::ProcessRunner> $name<R> {
#[doc = concat!(
"Run `", $binary, " <args>` over string slices — `", $binary, ".run_args(&[",
$args_example, "])` without allocating a `Vec<String>`. Inherent (not on the \
object-safe trait), so it can take `&[&str]`; forwards to the same path as [`",
stringify!($name), "Api::run`]."
)]
pub async fn run_args(&self, args: &[&str]) -> ::processkit::Result<String> {
self.core.run(args).await
}
#[doc = concat!(
"Like [`run_args`](", stringify!($name), "::run_args) but never errors on a \
non-zero exit (mirrors [`", stringify!($name), "Api::run_raw`])."
)]
pub async fn run_raw_args(
&self,
args: &[&str],
) -> ::processkit::Result<::processkit::ProcessResult<String>> {
self.core.output_string(args).await
}
#[doc = concat!(
"Run `", $binary, " <args>` **in `dir`** (the process is spawned with `dir` as \
its working directory", $in_infers, "), returning trimmed stdout — the dir-bound \
twin of the process-cwd [`run`](", stringify!($name), "Api::run). This is what [`",
stringify!($name), "At::run`] forwards to; call [`run`](", stringify!($name),
"Api::run) on the client for the process-cwd escape hatch. Argv is forwarded \
verbatim (", $in_flag_note, ")."
)]
pub async fn run_in(
&self,
dir: &::std::path::Path,
args: &[String],
) -> ::processkit::Result<String> {
self.core.run(self.core.command_in(dir, args)).await
}
#[doc = concat!(
"Like [`run_in`](", stringify!($name), "::run_in) but never errors on a non-zero \
exit — the dir-bound twin of [`run_raw`](", stringify!($name), "Api::run_raw). \
What [`", stringify!($name), "At::run_raw`] forwards to."
)]
pub async fn run_raw_in(
&self,
dir: &::std::path::Path,
args: &[String],
) -> ::processkit::Result<::processkit::ProcessResult<String>> {
self.core
.output_string(self.core.command_in(dir, args))
.await
}
#[doc = concat!(
"Like [`run_args`](", stringify!($name), "::run_args) but **bound to `dir`** — the \
`&[&str]` twin of [`run_in`](", stringify!($name), "::run_in). What [`",
stringify!($name), "At::run_args`] forwards to."
)]
pub async fn run_args_in(
&self,
dir: &::std::path::Path,
args: &[&str],
) -> ::processkit::Result<String> {
self.core.run(self.core.command_in(dir, args)).await
}
#[doc = concat!(
"Like [`run_raw_args`](", stringify!($name), "::run_raw_args) but **bound to \
`dir`** — the `&[&str]` twin of [`run_raw_in`](", stringify!($name),
"::run_raw_in). What [`", stringify!($name), "At::run_raw_args`] forwards to."
)]
pub async fn run_raw_args_in(
&self,
dir: &::std::path::Path,
args: &[&str],
) -> ::processkit::Result<::processkit::ProcessResult<String>> {
self.core
.output_string(self.core.command_in(dir, args))
.await
}
}
};
}
#[macro_export]
macro_rules! managed_client {
(
$(#[$meta:meta])*
$vis:vis struct $name:ident => $binary:expr
$(, token_env = ($svc:expr, $var:expr) )?
$(, scrub_env = [ $($scrub:expr),* $(,)? ] )?
$(,)?
) => {
$(#[$meta])*
$vis struct $name<R: ::processkit::ProcessRunner = ::processkit::JobRunner> {
core: $crate::ManagedClient<R>,
}
impl<R: ::processkit::ProcessRunner> ::core::fmt::Debug for $name<R> {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
f.debug_struct(stringify!($name))
.field("core", &self.core)
.finish()
}
}
impl $name<::processkit::JobRunner> {
pub fn new() -> Self {
Self { core: $crate::ManagedClient::new($binary)
$(.with_token_env($svc, $var))?
$($(.default_env_remove($scrub))*)?
}
}
}
impl ::core::default::Default for $name<::processkit::JobRunner> {
fn default() -> Self {
Self::new()
}
}
impl<R: ::processkit::ProcessRunner> $name<R> {
pub fn with_runner(runner: R) -> Self {
Self {
core: $crate::ManagedClient::with_runner($binary, runner)
$(.with_token_env($svc, $var))?
$($(.default_env_remove($scrub))*)?,
}
}
pub fn default_timeout(mut self, timeout: ::core::time::Duration) -> Self {
self.core = self.core.default_timeout(timeout);
self
}
pub fn default_env(
mut self,
key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
value: impl ::core::convert::AsRef<::std::ffi::OsStr>,
) -> Self {
self.core = self.core.default_env(key, value);
self
}
pub fn default_env_remove(
mut self,
key: impl ::core::convert::AsRef<::std::ffi::OsStr>,
) -> Self {
self.core = self.core.default_env_remove(key);
self
}
pub fn default_cancel_on(mut self, token: ::processkit::CancellationToken) -> Self {
self.core = self.core.default_cancel_on(token);
self
}
pub fn default_output_budget(mut self, budget: $crate::OutputBudget) -> Self {
self.core = self.core.default_output_budget(budget);
self
}
}
};
}
pub fn reject_flag_like(program: &str, what: &str, value: &str) -> Result<()> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.starts_with('-') || value.contains('\0') {
return Err(Error::spawn(
program,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"{what} {value:?} would be parsed as a flag (or is empty / contains NUL) — \
refusing to pass it as a positional argument"
),
),
));
}
Ok(())
}
pub fn clone_dest_cleanable(dest: &Path) -> bool {
match std::fs::read_dir(dest) {
Err(err) => err.kind() == std::io::ErrorKind::NotFound, Ok(mut entries) => entries.next().is_none(), }
}
pub fn cleanup_failed_clone_dest(dest: &Path, cleanable: bool) {
if cleanable {
let _ = std::fs::remove_dir_all(dest);
}
}
pub const FETCH_ATTEMPTS: u32 = 3;
pub const FETCH_BACKOFF: Duration = Duration::from_millis(500);
pub const FETCH_TIMEOUT_GRACE: Duration = Duration::from_secs(2);
pub fn apply_fetch_completion_policy(command: Command) -> Command {
let command = command
.timeout_grace(FETCH_TIMEOUT_GRACE)
.cancel_grace(FETCH_TIMEOUT_GRACE);
#[cfg(windows)]
{
command.windows_graceful_ctrl_break()
}
#[cfg(not(windows))]
{
command
}
}
const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
const TRANSIENT_FETCH_MARKERS: &[&str] = &[
"could not resolve host",
"couldn't resolve host",
"temporary failure in name resolution",
"connection timed out",
"connection refused",
"operation timed out",
"network is unreachable",
"failed to connect",
"could not read from remote repository",
"the remote end hung up",
"early eof",
"rpc failed",
];
fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
let ErrorReason::Exit { stdout, stderr, .. } = err.reason() else {
return false;
};
let out = stdout.to_ascii_lowercase();
let errt = stderr.to_ascii_lowercase();
markers.iter().any(|m| out.contains(m) || errt.contains(m))
}
pub fn is_merge_conflict(err: &Error) -> bool {
exit_output_matches(err, CONFLICT_MARKERS)
}
pub fn is_nothing_to_commit(err: &Error) -> bool {
exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
}
pub fn is_transient_fetch_error(err: &Error) -> bool {
err.is_transient() || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
}
const LOCK_CONTENTION_MARKERS: &[&str] = &[
"index.lock",
"failed to lock working copy",
"failed to lock operation heads store",
];
pub fn is_lock_contention(err: &Error) -> bool {
if exit_output_matches(err, &["refs/"]) {
return false;
}
exit_output_matches(err, LOCK_CONTENTION_MARKERS)
}
pub fn is_invalid_input(err: &Error) -> bool {
matches!(
err.reason(),
ErrorReason::Spawn { source, .. } if source.kind() == std::io::ErrorKind::InvalidInput
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct RetryPolicy {
pub attempts: u32,
pub base_backoff: Duration,
pub max_backoff: Duration,
pub jitter: bool,
}
impl RetryPolicy {
pub const fn none() -> Self {
Self {
attempts: 1,
base_backoff: Duration::ZERO,
max_backoff: Duration::ZERO,
jitter: false,
}
}
pub const fn lock_contention() -> Self {
Self {
attempts: 5,
base_backoff: Duration::from_millis(25),
max_backoff: Duration::from_millis(500),
jitter: true,
}
}
pub fn attempts(mut self, attempts: u32) -> Self {
self.attempts = attempts.max(1);
self
}
pub fn base_backoff(mut self, backoff: Duration) -> Self {
self.base_backoff = backoff;
self
}
pub fn max_backoff(mut self, max: Duration) -> Self {
self.max_backoff = max;
self
}
pub fn with_jitter(mut self, jitter: bool) -> Self {
self.jitter = jitter;
self
}
}
impl Default for RetryPolicy {
fn default() -> Self {
Self::none()
}
}
fn backoff_for(policy: &RetryPolicy, retry_index: u32) -> Duration {
if policy.base_backoff.is_zero() {
return Duration::ZERO;
}
let base = policy.base_backoff.as_nanos();
let scaled = base.saturating_mul(1u128 << retry_index.min(20));
let capped = if policy.max_backoff.is_zero() {
scaled
} else {
scaled.min(policy.max_backoff.as_nanos())
};
let delay = Duration::from_nanos(capped.min(u64::MAX as u128) as u64);
if policy.jitter {
full_jitter(delay)
} else {
delay
}
}
fn full_jitter(max: Duration) -> Duration {
use std::hash::{BuildHasher, Hasher};
let nanos = max.as_nanos();
if nanos == 0 {
return Duration::ZERO;
}
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u64(nanos as u64);
let r = hasher.finish() as u128;
Duration::from_nanos((r % (nanos + 1)).min(u64::MAX as u128) as u64)
}
fn cancelled_error(last_err: &Error) -> Error {
ErrorReason::Cancelled {
program: last_err.program().unwrap_or_default().to_owned(),
}
.into()
}
pub async fn retry_async<T, Fut>(
policy: &RetryPolicy,
cancel: Option<&CancellationToken>,
should_retry: impl Fn(&Error) -> bool,
mut op: impl FnMut() -> Fut,
) -> Result<T>
where
Fut: Future<Output = Result<T>>,
{
let attempts = policy.attempts.max(1);
for attempt in 1..=attempts {
match op().await {
Ok(value) => return Ok(value),
Err(err) => {
if attempt == attempts || !should_retry(&err) {
return Err(err);
}
let delay = backoff_for(policy, attempt - 1);
match cancel {
Some(token) => {
if !delay.is_zero() {
let _ = token.run_until_cancelled(tokio::time::sleep(delay)).await;
}
if token.is_cancelled() {
return Err(cancelled_error(&err));
}
}
None => {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
}
}
}
}
unreachable!("the loop returns on the final attempt")
}
pub struct ManagedClient<R: ProcessRunner = JobRunner> {
inner: CliClient<R>,
retry: RetryPolicy,
credentials: Option<Arc<dyn CredentialProvider>>,
token_env: Option<(CredentialService, &'static str)>,
expected_host: Option<String>,
cancel: Option<CancellationToken>,
output_budget: OutputBudget,
inactivity_timeout: Option<Duration>,
}
impl<R: ProcessRunner> fmt::Debug for ManagedClient<R> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ManagedClient")
.field("inner", &self.inner)
.field("retry", &self.retry)
.field("credentials", &self.credentials.is_some())
.field("token_env", &self.token_env)
.field("expected_host", &self.expected_host)
.field("has_cancel", &self.cancel.is_some())
.field("output_budget", &self.output_budget)
.field("inactivity_timeout", &self.inactivity_timeout)
.finish()
}
}
impl ManagedClient<JobRunner> {
pub fn new(program: impl AsRef<OsStr>) -> Self {
Self {
inner: CliClient::new(program),
retry: RetryPolicy::none(),
credentials: None,
token_env: None,
expected_host: None,
cancel: None,
output_budget: OutputBudget::unlimited(),
inactivity_timeout: None,
}
}
}
impl<R: ProcessRunner> ManagedClient<R> {
pub fn with_runner(program: impl AsRef<OsStr>, runner: R) -> Self {
Self {
inner: CliClient::with_runner(program, runner),
retry: RetryPolicy::none(),
credentials: None,
token_env: None,
expected_host: None,
cancel: None,
output_budget: OutputBudget::unlimited(),
inactivity_timeout: None,
}
}
pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
self.retry = policy;
self
}
pub fn retry_policy(&self) -> RetryPolicy {
self.retry
}
#[must_use]
pub fn with_credentials(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
self.credentials = Some(provider);
self
}
#[must_use]
pub fn with_token_env(mut self, service: CredentialService, var: &'static str) -> Self {
self.token_env = Some((service, var));
self
}
#[must_use]
pub fn with_expected_host(mut self, host: impl Into<String>) -> Self {
self.expected_host = Some(host.into());
self
}
#[must_use]
pub fn has_credentials(&self) -> bool {
self.credentials.is_some()
}
pub async fn resolve_credential(
&self,
service: CredentialService,
host: Option<&str>,
) -> Result<Option<Credential>> {
let Some(provider) = &self.credentials else {
return Ok(None);
};
let request = CredentialRequest { service, host };
let credential = provider.credential(&request).await?;
let Some(credential) = credential else {
return Ok(None);
};
credential.validate_for_resolution()?;
if credential.secret().expose().trim().is_empty() {
return Ok(None);
}
Ok(Some(credential))
}
async fn prepare(&self, call: impl IntoCommand<R>) -> Result<Command> {
let cmd = call.into_command(&self.inner);
let Some((service, var)) = self.token_env else {
return Ok(cmd);
};
match self
.resolve_credential(service, self.expected_host.as_deref())
.await?
{
Some(cred) => Ok(cmd.env(var, cred.secret().expose())),
None => Ok(cmd),
}
}
pub fn default_timeout(mut self, timeout: Duration) -> Self {
self.inner = self.inner.default_timeout(timeout);
self
}
pub fn default_inactivity_timeout(mut self, timeout: Duration) -> Self {
self.inactivity_timeout = Some(timeout);
self
}
pub fn default_env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
self.inner = self.inner.default_env(key, value);
self
}
pub fn default_env_remove(mut self, key: impl AsRef<OsStr>) -> Self {
self.inner = self.inner.default_env_remove(key);
self
}
pub fn default_cancel_on(mut self, token: CancellationToken) -> Self {
self.inner = self.inner.default_cancel_on(token.clone());
self.cancel = Some(token);
self
}
pub fn default_output_budget(mut self, budget: OutputBudget) -> Self {
self.output_budget = budget;
self
}
pub fn output_budget(&self) -> OutputBudget {
self.output_budget
}
pub fn budget_diagnostics(&self, cmd: Command) -> Command {
match self.output_budget.diagnostic_policy() {
Some(policy) => cmd.output_buffer(policy),
None => cmd,
}
}
pub fn command<I, S>(&self, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.inner.command(args)
}
pub fn command_in<I, S>(&self, dir: &Path, args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.inner.command_in(dir, args)
}
pub fn runner(&self) -> &R {
self.inner.runner()
}
pub async fn run(&self, call: impl IntoCommand<R>) -> Result<String> {
let cmd = self.prepare(call).await?;
retry_async(
&self.retry,
self.cancel.as_ref(),
is_lock_contention,
|| self.inner.run(cmd.clone()),
)
.await
}
pub async fn run_unit(&self, call: impl IntoCommand<R>) -> Result<()> {
let cmd = self.prepare(call).await?;
retry_async(
&self.retry,
self.cancel.as_ref(),
is_lock_contention,
|| self.inner.run_unit(cmd.clone()),
)
.await
}
pub async fn run_with_progress(
&self,
call: impl IntoCommand<R>,
progress: &mut ProgressCallback<'_>,
) -> Result<()> {
self.run_with_progress_within(call, progress, self.output_budget)
.await
}
pub async fn run_with_progress_within(
&self,
call: impl IntoCommand<R>,
progress: &mut ProgressCallback<'_>,
budget: OutputBudget,
) -> Result<()> {
let mut cmd = self.prepare(call).await?;
if cmd.configured_inactivity_timeout().is_none()
&& let Some(timeout) = self.inactivity_timeout
{
cmd = cmd.inactivity_timeout(timeout);
}
crate::run_with_progress_within(self.inner.runner(), &cmd, progress, budget).await
}
pub async fn output_string(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<String>> {
let cmd = self.prepare(call).await?;
self.inner.output_string(cmd).await
}
pub async fn output_bytes(&self, call: impl IntoCommand<R>) -> Result<ProcessResult<Vec<u8>>> {
let cmd = self.prepare(call).await?;
self.inner.output_bytes(cmd).await
}
pub async fn run_untrimmed(&self, call: impl IntoCommand<R>) -> Result<String> {
self.run_untrimmed_within(call, self.output_budget).await
}
pub async fn run_untrimmed_within(
&self,
call: impl IntoCommand<R>,
budget: OutputBudget,
) -> Result<String> {
let cmd = self.prepare(call).await?;
let cmd = match budget.content_policy() {
Some(policy) => cmd.output_buffer(policy),
None => cmd,
};
let bytes = self
.inner
.output_bytes(cmd)
.await?
.ensure_success()?
.into_stdout();
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub async fn probe(&self, call: impl IntoCommand<R>) -> Result<bool> {
let cmd = self.prepare(call).await?;
retry_async(
&self.retry,
self.cancel.as_ref(),
is_lock_contention,
|| self.inner.probe(cmd.clone()),
)
.await
}
pub async fn exit_code(&self, call: impl IntoCommand<R>) -> Result<i32> {
let cmd = self.prepare(call).await?;
retry_async(
&self.retry,
self.cancel.as_ref(),
is_lock_contention,
|| self.inner.exit_code(cmd.clone()),
)
.await
}
pub async fn parse<T>(
&self,
call: impl IntoCommand<R>,
parser: impl FnOnce(&str) -> T + Send,
) -> Result<T>
where
T: Send,
{
let cmd = self.prepare(call).await?;
self.inner.parse(cmd, parser).await
}
pub async fn parse_bytes<T>(
&self,
call: impl IntoCommand<R>,
parser: impl FnOnce(&[u8]) -> T + Send,
) -> Result<T>
where
T: Send,
{
let cmd = self.prepare(call).await?;
let bytes = self
.inner
.output_bytes(cmd)
.await?
.ensure_success()?
.into_stdout();
Ok(parser(&bytes))
}
pub async fn try_parse<T>(
&self,
call: impl IntoCommand<R>,
parser: impl FnOnce(&str) -> Result<T> + Send,
) -> Result<T>
where
T: Send,
{
let cmd = self.prepare(call).await?;
self.inner.try_parse(cmd, parser).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::testing::{Reply, ScriptedRunner};
use proptest::prelude::*;
use std::sync::Mutex;
struct UnvalidatedProvider(Credential);
#[async_trait::async_trait]
impl CredentialProvider for UnvalidatedProvider {
async fn credential(&self, _request: &CredentialRequest<'_>) -> Result<Option<Credential>> {
Ok(Some(self.0.clone()))
}
}
#[tokio::test]
async fn streamed_run_replays_scripted_lifecycle_and_preserves_failure_output() {
let runner = ScriptedRunner::new().on(
["tool", "network-op"],
Reply::fail(23, "remote rejected").with_stdout("sent objects"),
);
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
let mut progress = move |event| sink.lock().unwrap().push(event);
let err = run_with_progress(
&runner,
&Command::new("tool").arg("network-op"),
&mut progress,
)
.await
.expect_err("the scripted non-zero exit stays an error");
drop(progress);
assert!(matches!(
err.reason(),
ErrorReason::Exit { code: 23, stdout, stderr, .. }
if stdout == "sent objects" && stderr == "remote rejected"
));
let events = seen.lock().unwrap();
assert!(matches!(events.first(), Some(ProcessEvent::Started { .. })));
assert!(
events
.iter()
.any(|event| event.text() == Some("sent objects"))
);
assert!(
events
.iter()
.any(|event| event.text() == Some("remote rejected"))
);
assert!(matches!(
events.last(),
Some(ProcessEvent::Exited(outcome)) if outcome.code() == Some(23)
));
}
#[test]
fn fetch_completion_policy_records_grace_and_platform_soft_trigger() {
let command = apply_fetch_completion_policy(Command::new("git").arg("fetch"));
let debug = format!("{command:?}");
assert!(debug.contains("timeout_grace: Some(2s)"), "{debug}");
assert!(debug.contains("cancel_grace: Some(2s)"), "{debug}");
#[cfg(windows)]
assert!(
debug.contains("windows_graceful_ctrl_break: true"),
"Windows network commands must opt into CTRL_BREAK: {debug}"
);
#[cfg(not(windows))]
assert!(
debug.contains("windows_graceful_ctrl_break: false"),
"the Windows-only trigger must remain a Unix no-op: {debug}"
);
}
#[tokio::test(start_paused = true)]
async fn managed_client_stream_watchdog_is_disabled_by_default() {
let client = ManagedClient::with_runner(
"tool",
ScriptedRunner::new().on(
["tool", "network-op"],
Reply::lines(["late"]).with_line_delay(Duration::from_secs(10)),
),
);
assert!(
client
.command(["network-op"])
.configured_inactivity_timeout()
.is_none(),
"the new watchdog must not alter an unconfigured command"
);
let mut progress = |_event: ProcessEvent| {};
client
.run_with_progress(client.command(["network-op"]), &mut progress)
.await
.expect("the default-disabled stream remains compatible with slow output");
}
#[tokio::test(start_paused = true)]
async fn managed_client_stream_watchdog_distinguishes_inactivity_from_deadline() {
let delayed = || {
ScriptedRunner::new().on(
["tool", "network-op"],
Reply::lines(["late"]).with_line_delay(Duration::from_secs(10)),
)
};
let watchdog = ManagedClient::with_runner("tool", delayed())
.default_inactivity_timeout(Duration::from_secs(3));
let mut progress = |_event: ProcessEvent| {};
let err = watchdog
.run_with_progress(watchdog.command(["network-op"]), &mut progress)
.await
.expect_err("the inactivity watchdog must fire before the delayed line");
assert!(matches!(
err.reason(),
ErrorReason::Timeout {
timeout,
inactivity: true,
..
} if *timeout == Duration::from_secs(3)
));
let deadline = ManagedClient::with_runner("tool", delayed())
.default_timeout(Duration::from_secs(3))
.default_inactivity_timeout(Duration::from_secs(30));
let err = deadline
.run_with_progress(deadline.command(["network-op"]), &mut progress)
.await
.expect_err("the absolute deadline must remain a distinct outcome");
assert!(matches!(
err.reason(),
ErrorReason::Timeout {
timeout,
inactivity: false,
..
} if *timeout == Duration::from_secs(3)
));
}
#[tokio::test(start_paused = true)]
async fn managed_client_stream_watchdog_resets_for_legitimately_slow_output() {
let client = ManagedClient::with_runner(
"tool",
ScriptedRunner::new().on(
["tool", "network-op"],
Reply::lines(["one", "two", "three"]).with_line_delay(Duration::from_secs(2)),
),
)
.default_inactivity_timeout(Duration::from_secs(3));
let mut progress = |_event: ProcessEvent| {};
client
.run_with_progress(client.command(["network-op"]), &mut progress)
.await
.expect("each output line resets the watchdog");
}
#[tokio::test(start_paused = true)]
async fn managed_client_stream_watchdog_preserves_explicit_command_timeout() {
let client = ManagedClient::with_runner(
"tool",
ScriptedRunner::new().on(
["tool", "network-op"],
Reply::lines(["late"]).with_line_delay(Duration::from_secs(10)),
),
)
.default_inactivity_timeout(Duration::from_secs(3));
let command = client
.command(["network-op"])
.inactivity_timeout(Duration::from_secs(30));
assert_eq!(
command.configured_inactivity_timeout(),
Some(Duration::from_secs(30))
);
let mut progress = |_event: ProcessEvent| {};
client
.run_with_progress(command, &mut progress)
.await
.expect("the client default must not override a per-command watchdog");
}
#[tokio::test]
async fn streamed_run_late_cancel_before_first_finish_observation_is_cancelled() {
let token = CancellationToken::new();
let runner =
ScriptedRunner::new().on(["tool", "network-op"], Reply::ok("").with_stdout("done\n"));
let mut run = runner
.start(
&Command::new("tool")
.arg("network-op")
.cancel_on(token.clone()),
)
.await
.expect("scripted start");
let mut events = run.events().expect("events stream");
token.cancel();
let forward = async { while events.next().await.is_some() {} };
let (_, finished) = tokio::join!(forward, run.finish());
assert!(
matches!(
finished.as_ref().map_err(Error::reason),
Err(ErrorReason::Cancelled { program }) if program == "tool"
),
"first finish observation must classify the fired token: {finished:?}"
);
}
#[tokio::test]
async fn streamed_run_isolates_a_panicking_progress_callback() {
use std::sync::atomic::{AtomicUsize, Ordering};
let runner = ScriptedRunner::new().on(
["tool", "network-op"],
Reply::ok("out").with_stderr("progress"),
);
let calls = Arc::new(AtomicUsize::new(0));
let seen = Arc::clone(&calls);
let mut progress = move |_event| {
seen.fetch_add(1, Ordering::SeqCst);
panic!("broken UI callback");
};
run_with_progress(
&runner,
&Command::new("tool").arg("network-op"),
&mut progress,
)
.await
.expect("the process outcome wins over a callback panic");
assert_eq!(calls.load(Ordering::SeqCst), 1, "callback is disabled");
}
#[test]
fn rejects_empty_and_leading_dash() {
assert!(reject_flag_like("git", "branch name", "-evil").is_err());
assert!(reject_flag_like("git", "branch name", "").is_err());
assert!(reject_flag_like("git", "branch name", " ").is_err());
assert!(reject_flag_like("git", "branch name", "\t").is_err());
assert!(reject_flag_like("git", "branch name", "feature").is_ok());
assert!(reject_flag_like("git", "remote", " --upload-pack=evil").is_err());
assert!(reject_flag_like("git", "remote", "\t-x").is_err());
assert!(reject_flag_like("git", "path", "a\0b").is_err());
assert!(reject_flag_like("git", "branch name", " feature").is_ok());
let err = reject_flag_like("jj", "revset", "--remote").unwrap_err();
assert!(matches!(err.reason(), ErrorReason::Spawn { program, .. } if program == "jj"));
}
#[test]
fn classifies_merge_conflict() {
let on_stdout = Error::exit("git", 1, "CONFLICT (content): Merge conflict in a.rs", "");
let on_stderr = Error::exit(
"git",
1,
"",
"Automatic merge failed; fix conflicts and then commit",
);
let unrelated = Error::exit("git", 128, "", "fatal: not a git repository");
assert!(is_merge_conflict(&on_stdout));
assert!(is_merge_conflict(&on_stderr));
assert!(!is_merge_conflict(&unrelated));
assert!(!is_nothing_to_commit(&on_stdout));
}
#[test]
fn classifies_nothing_to_commit_and_transient_fetch() {
let nothing = Error::exit("git", 1, "nothing to commit, working tree clean", "");
assert!(is_nothing_to_commit(¬hing));
let dns = Error::exit(
"git",
128,
"",
"fatal: unable to access 'https://x/': Could not resolve host: x",
);
assert!(is_transient_fetch_error(&dns));
assert!(!is_transient_fetch_error(¬hing));
let timeout = Error::timeout("git", Duration::from_secs(10), "", "");
assert!(!is_transient_fetch_error(&timeout));
}
#[test]
fn classifies_io_transient_as_fetch_retryable() {
let interrupted =
Error::spawn("git", std::io::Error::from(std::io::ErrorKind::Interrupted));
assert!(
interrupted.is_transient(),
"processkit treats Interrupted as a transient io error"
);
assert!(is_transient_fetch_error(&interrupted));
let missing = Error::spawn("git", std::io::Error::from(std::io::ErrorKind::NotFound));
assert!(!is_transient_fetch_error(&missing));
}
#[test]
fn classifies_on_large_output_past_the_old_4kib_cap() {
let padding = "noise line that says nothing\n".repeat(500); let conflict = Error::exit(
"git",
1,
format!("{padding}CONFLICT (content): Merge conflict in late.rs"),
"",
);
assert!(
is_merge_conflict(&conflict),
"a conflict marker past 4 KiB must still classify"
);
let transient = Error::exit(
"git",
128,
"",
format!("{padding}fatal: unable to access: Could not resolve host: x"),
);
assert!(is_transient_fetch_error(&transient));
}
#[test]
fn unfamiliar_error_variants_are_not_classified() {
let not_ready = Error::from(ErrorReason::NotReady {
program: "git".into(),
timeout: Duration::from_secs(5),
});
let unsupported = Error::from(ErrorReason::Unsupported {
operation: "suspend".into(),
});
for err in [¬_ready, &unsupported] {
assert!(!is_merge_conflict(err));
assert!(!is_nothing_to_commit(err));
assert!(!is_transient_fetch_error(err));
}
}
#[test]
fn cancelled_is_not_transient_or_otherwise_classified() {
let cancelled = Error::from(ErrorReason::Cancelled {
program: "git".into(),
});
assert!(!is_transient_fetch_error(&cancelled));
assert!(!is_merge_conflict(&cancelled));
assert!(!is_nothing_to_commit(&cancelled));
}
#[test]
fn signalled_is_terminal_not_transient() {
let signalled = Error::signalled(
"git",
Some(15),
"",
"fatal: unable to access: Could not resolve host: x",
);
assert!(!signalled.is_transient());
assert!(!is_transient_fetch_error(&signalled));
assert!(!is_merge_conflict(&signalled));
assert!(!is_nothing_to_commit(&signalled));
}
fn exit(program: &str, code: i32, stderr: &str) -> Error {
Error::exit(program, code, "", stderr)
}
#[test]
fn classifies_lock_contention() {
let lock_failures = [
exit(
"git",
128,
"fatal: Unable to create '/r/.git/index.lock': File exists.",
),
exit(
"git",
128,
"fatal: Konnte '/r/.git/index.lock' nicht erstellen: Datei existiert bereits",
),
exit("jj", 1, "Error: Failed to lock working copy"),
exit("jj", 1, "Error: Failed to lock operation heads store"),
];
for e in &lock_failures {
assert!(is_lock_contention(e), "should be lock contention: {e:?}");
assert!(!is_transient_fetch_error(e), "not a fetch error: {e:?}");
}
let not_locks = [
exit("git", 1, "CONFLICT (content): Merge conflict in a.rs"),
exit("git", 1, "error: pathspec 'x' did not match any file(s)"),
exit("git", 128, "fatal: not a git repository"),
exit(
"git",
1,
"error: cannot lock ref 'refs/heads/x': reference already exists",
),
exit(
"git",
128,
"Unable to create '/r/.git/packed-refs.lock': File exists.",
),
exit(
"git",
128,
"error: cannot lock ref 'refs/heads/index': Unable to create \
'/r/.git/refs/heads/index.lock': File exists.",
),
Error::timeout("git", Duration::from_secs(1), "", ""),
];
for e in ¬_locks {
assert!(
!is_lock_contention(e),
"should NOT be lock contention: {e:?}"
);
}
}
#[test]
fn classifies_invalid_input_from_the_guards() {
let rejected = reject_flag_like("git", "reference", "-x").unwrap_err();
assert!(
is_invalid_input(&rejected),
"guard rejection is invalid input"
);
assert!(is_invalid_input(
&reject_flag_like("git", "x", "").unwrap_err()
));
let not_input = [
Error::spawn("git", std::io::Error::from(std::io::ErrorKind::NotFound)),
exit("git", 1, "fatal: not a git repository"),
Error::timeout("git", Duration::from_secs(1), "", ""),
];
for e in ¬_input {
assert!(!is_invalid_input(e), "should NOT be invalid input: {e:?}");
}
}
#[test]
fn clone_dest_cleanable_requires_proven_absence_or_emptiness() {
use vcs_testkit::TempDir;
let absent_parent = TempDir::new("clone-dest-absent");
let absent = absent_parent.path().join("absent");
assert!(clone_dest_cleanable(&absent));
let empty = TempDir::new("clone-dest-empty");
assert!(clone_dest_cleanable(empty.path()));
let nonempty = TempDir::new("clone-dest-nonempty");
std::fs::write(nonempty.path().join("keep.txt"), b"user data").expect("write file");
assert!(!clone_dest_cleanable(nonempty.path()));
let file_parent = TempDir::new("clone-dest-file");
let file = file_parent.path().join("not-a-dir");
std::fs::write(&file, b"not a directory").expect("write file");
let err = std::fs::read_dir(&file).expect_err("read_dir on a file fails");
assert_ne!(
err.kind(),
std::io::ErrorKind::NotFound,
"must be a genuine NotADirectory-style failure, not NotFound"
);
assert!(!clone_dest_cleanable(&file));
cleanup_failed_clone_dest(&file, false);
assert!(
file.is_file(),
"cleanup must not touch a non-cleanable dest"
);
}
#[test]
fn backoff_is_exponential_capped_and_zero_without_base() {
let p = RetryPolicy::none()
.attempts(6)
.base_backoff(Duration::from_millis(10))
.max_backoff(Duration::from_millis(80));
assert_eq!(backoff_for(&p, 0), Duration::from_millis(10));
assert_eq!(backoff_for(&p, 1), Duration::from_millis(20));
assert_eq!(backoff_for(&p, 2), Duration::from_millis(40));
assert_eq!(backoff_for(&p, 3), Duration::from_millis(80));
assert_eq!(
backoff_for(&p, 4),
Duration::from_millis(80),
"capped at max"
);
assert_eq!(
backoff_for(&RetryPolicy::none(), 3),
Duration::ZERO,
"no base → no wait"
);
}
#[test]
fn jitter_stays_within_cap_and_decorrelates() {
let p = RetryPolicy::none()
.attempts(8)
.base_backoff(Duration::from_millis(10))
.max_backoff(Duration::from_millis(80))
.with_jitter(true);
let cap = Duration::from_millis(80);
let mut seen = std::collections::HashSet::new();
for _ in 0..1000 {
let d = backoff_for(&p, 3);
assert!(
d <= cap,
"jittered backoff {d:?} must stay within the cap {cap:?}"
);
seen.insert(d.as_nanos());
}
assert!(
seen.len() > 1,
"full jitter must produce a spread of delays, not a constant"
);
assert_eq!(
backoff_for(&RetryPolicy::none().with_jitter(true), 2),
Duration::ZERO
);
}
#[tokio::test]
async fn retry_async_retries_then_succeeds_and_respects_the_predicate() {
use std::sync::atomic::{AtomicU32, Ordering};
let policy = RetryPolicy::none().attempts(4);
let lock = || {
exit(
"git",
128,
"Unable to create '/r/.git/index.lock': File exists.",
)
};
let calls = AtomicU32::new(0);
let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
let n = calls.fetch_add(1, Ordering::SeqCst);
let lock = lock();
async move { if n < 2 { Err(lock) } else { Ok(n) } }
})
.await;
assert_eq!(out.unwrap(), 2);
assert_eq!(calls.load(Ordering::SeqCst), 3, "1 try + 2 retries");
let calls = AtomicU32::new(0);
let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
calls.fetch_add(1, Ordering::SeqCst);
async { Err(exit("git", 1, "real, deterministic failure")) }
})
.await;
assert!(out.is_err());
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"non-retryable → single attempt"
);
let calls = AtomicU32::new(0);
let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
calls.fetch_add(1, Ordering::SeqCst);
async { Err(exit("git", 128, "index.lock': File exists")) }
})
.await;
assert!(out.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 4, "all attempts used");
}
fn lock_err() -> Error {
exit(
"git",
128,
"Unable to create '/r/.git/index.lock': File exists.",
)
}
fn err_reason<T>(out: &Result<T>) -> Option<&ErrorReason> {
out.as_ref().err().map(Error::reason)
}
#[tokio::test(start_paused = true)]
async fn cancel_before_backoff_aborts_without_waiting_or_retrying() {
use std::sync::atomic::{AtomicU32, Ordering};
let token = CancellationToken::new();
token.cancel(); let policy = RetryPolicy::none()
.attempts(5)
.base_backoff(Duration::from_secs(3600)); let calls = AtomicU32::new(0);
let start = tokio::time::Instant::now();
let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
calls.fetch_add(1, Ordering::SeqCst);
async { Err(lock_err()) }
})
.await;
assert!(
matches!(err_reason(&out), Some(ErrorReason::Cancelled { program }) if program == "git"),
"a fired token aborts with a program-named Cancelled, got {out:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"one attempt ran; the cancel launched no retry"
);
assert_eq!(
start.elapsed(),
Duration::ZERO,
"the backoff was cut short — no virtual time elapsed"
);
}
#[tokio::test(start_paused = true)]
async fn cancel_during_backoff_wakes_early_and_does_not_retry() {
use std::sync::atomic::{AtomicU32, Ordering};
let token = CancellationToken::new();
let policy = RetryPolicy::none()
.attempts(5)
.base_backoff(Duration::from_secs(3600)); let calls = AtomicU32::new(0);
let start = tokio::time::Instant::now();
let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
let n = calls.fetch_add(1, Ordering::SeqCst);
let token = token.clone();
async move {
if n == 0 {
tokio::spawn(async move { token.cancel() });
}
Err(lock_err())
}
})
.await;
assert!(
matches!(err_reason(&out), Some(ErrorReason::Cancelled { program }) if program == "git"),
"a cancel during the sleep aborts with Cancelled, got {out:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"cancel woke the sleep early — no second attempt"
);
assert_eq!(
start.elapsed(),
Duration::ZERO,
"woke on the cancel, not after the 1 h delay"
);
}
#[tokio::test(start_paused = true)]
async fn cancel_right_before_next_attempt_aborts() {
use std::sync::atomic::{AtomicU32, Ordering};
let token = CancellationToken::new();
let policy = RetryPolicy::none().attempts(5); let calls = AtomicU32::new(0);
let out: Result<u32> = retry_async(&policy, Some(&token), is_lock_contention, || {
let n = calls.fetch_add(1, Ordering::SeqCst);
let token = token.clone();
async move {
if n == 0 {
token.cancel();
}
Err(lock_err())
}
})
.await;
assert!(
matches!(err_reason(&out), Some(ErrorReason::Cancelled { program }) if program == "git"),
"a cancel observed before the next attempt aborts with Cancelled, got {out:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"the guard stopped attempt #2 from launching"
);
}
#[tokio::test]
async fn no_token_backoff_is_unchanged() {
use std::sync::atomic::{AtomicU32, Ordering};
let policy = RetryPolicy::none().attempts(3); let calls = AtomicU32::new(0);
let out: Result<u32> = retry_async(&policy, None, is_lock_contention, || {
calls.fetch_add(1, Ordering::SeqCst);
async { Err(lock_err()) }
})
.await;
assert!(
matches!(err_reason(&out), Some(ErrorReason::Exit { .. })),
"last error is the lock exit, not Cancelled"
);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"all attempts used with no token"
);
}
#[tokio::test]
async fn retrying_client_resolves_credential_opt_in() {
let client = ManagedClient::new("git");
assert!(!client.has_credentials());
assert!(
client
.resolve_credential(CredentialService::Git, None)
.await
.unwrap()
.is_none(),
"no provider → ambient (None)"
);
let client = client.with_credentials(Arc::new(StaticCredential::token("t0k")));
assert!(client.has_credentials());
let got = client
.resolve_credential(CredentialService::Git, None)
.await
.unwrap()
.expect("provider yields a credential");
assert_eq!(got.secret().expose(), "t0k");
}
#[tokio::test]
async fn resolve_credential_treats_empty_secret_as_ambient() {
for blank in ["", " ", "\t"] {
let client = ManagedClient::new("git")
.with_credentials(Arc::new(StaticCredential::token(blank)));
for service in [CredentialService::GitHub, CredentialService::Git] {
assert!(
client
.resolve_credential(service, None)
.await
.unwrap()
.is_none(),
"blank secret {blank:?} → ambient (None) for {service:?}"
);
}
}
}
#[tokio::test]
async fn resolve_credential_rejects_crlf_secret_before_blank_fallback() {
for bad_secret in ["\r", "\n", "\t\r ", " \n\t"] {
for service in [CredentialService::GitHub, CredentialService::Git] {
let client = ManagedClient::new("git")
.with_credentials(Arc::new(UnvalidatedProvider(Credential::token(bad_secret))));
let error = client
.resolve_credential(service, None)
.await
.expect_err("CR/LF secret must not become ambient auth");
assert!(
is_invalid_input(&error),
"credential rejection must be InvalidInput: {error:?}"
);
assert!(error.to_string().contains("secret"));
}
}
}
#[tokio::test]
async fn resolve_credential_rejects_malformed_username_before_blank_fallback() {
for blank_secret in ["", " ", "\t"] {
for bad_username in ["alice\r", "alice\n", "alice\t\r ", "alice \n\t"] {
let client = ManagedClient::new("git").with_credentials(Arc::new(
UnvalidatedProvider(Credential::userpass(bad_username, blank_secret)),
));
let error = client
.resolve_credential(CredentialService::Git, None)
.await
.expect_err("malformed username must not become ambient auth");
assert!(
is_invalid_input(&error),
"credential rejection must be InvalidInput: {error:?}"
);
assert!(error.to_string().contains("username"));
}
}
}
#[tokio::test]
async fn resolve_credential_routes_on_request_host() {
let provider = provider_fn(|r: &CredentialRequest<'_>| {
Ok(match r.host {
Some("github.com") => Some(Credential::token("saas")),
Some("ghe.example.com") => Some(Credential::token("ent")),
_ => None,
})
});
let client = ManagedClient::new("gh").with_credentials(Arc::new(provider));
let resolve =
|host: Option<&'static str>| client.resolve_credential(CredentialService::GitHub, host);
assert_eq!(
resolve(Some("github.com"))
.await
.unwrap()
.unwrap()
.secret()
.expose(),
"saas"
);
assert_eq!(
resolve(Some("ghe.example.com"))
.await
.unwrap()
.unwrap()
.secret()
.expose(),
"ent"
);
assert!(
resolve(Some("other.example")).await.unwrap().is_none(),
"a host the provider doesn't place → ambient (None), not a wrong secret"
);
assert!(
resolve(None).await.unwrap().is_none(),
"an absent host → ambient (None)"
);
}
#[tokio::test]
async fn resolve_credential_propagates_provider_error_fail_closed() {
let provider = provider_fn(|_r: &CredentialRequest<'_>| {
Err(Error::spawn(
"vault",
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "vault unreachable"),
))
});
let client = ManagedClient::new("gh").with_credentials(Arc::new(provider));
for host in [Some("github.com"), None] {
assert!(
client
.resolve_credential(CredentialService::GitHub, host)
.await
.is_err(),
"provider error must propagate (fail-closed), host={host:?}"
);
}
}
#[test]
fn output_budget_default_is_unlimited() {
let b = OutputBudget::default();
assert!(b.is_unlimited());
assert_eq!(b, OutputBudget::unlimited());
assert_eq!(b.max_bytes(), None);
assert_eq!(b.max_lines(), None);
assert!(b.content_policy().is_none());
assert!(b.diagnostic_policy().is_none());
}
#[test]
fn output_budget_bytes_projects_to_both_policies() {
let b = OutputBudget::bytes(4096);
assert!(!b.is_unlimited());
assert_eq!(b.max_bytes(), Some(4096));
let content = b
.content_policy()
.expect("a byte budget yields a content policy");
assert_eq!(
content.overflow,
OverflowMode::Error,
"content is fail-loud"
);
assert_eq!(content.max_bytes, Some(4096));
assert_eq!(content.max_lines, None);
let diag = b
.diagnostic_policy()
.expect("a byte budget yields a diagnostic policy");
assert_eq!(
diag.overflow,
OverflowMode::DropOldest,
"diagnostics keep the tail, never OutputTooLarge"
);
assert_eq!(diag.max_bytes, Some(4096));
}
#[test]
fn output_budget_with_max_lines_composes() {
let b = OutputBudget::bytes(4096).with_max_lines(200);
assert_eq!(b.max_lines(), Some(200));
let content = b.content_policy().unwrap();
assert_eq!(content.max_lines, Some(200));
assert_eq!(content.max_bytes, Some(4096));
assert_eq!(content.overflow, OverflowMode::Error);
let diag = b.diagnostic_policy().unwrap();
assert_eq!(diag.max_lines, Some(200));
assert_eq!(diag.max_bytes, Some(4096));
assert_eq!(diag.overflow, OverflowMode::DropOldest);
}
#[test]
fn managed_client_default_output_budget_round_trips() {
let client = ManagedClient::new("git");
assert!(client.output_budget().is_unlimited());
let client = client.default_output_budget(OutputBudget::bytes(1 << 20));
assert_eq!(client.output_budget(), OutputBudget::bytes(1 << 20));
}
#[tokio::test]
async fn content_budget_counts_raw_stdout_bytes_verbatim() {
let out = "abc\ndef\n";
assert_eq!(out.len(), 8);
let client = ManagedClient::with_runner(
"tool",
ScriptedRunner::new().on(["tool", "read"], Reply::ok(out)),
);
let got = client
.run_untrimmed_within(client.command(["read"]), OutputBudget::bytes(8))
.await
.expect("stdout exactly on the cap is within budget");
assert_eq!(got, out);
match client
.run_untrimmed_within(client.command(["read"]), OutputBudget::bytes(6))
.await
.map_err(Error::into_reason)
{
Err(ErrorReason::OutputTooLarge {
max_bytes,
total_bytes,
max_lines,
total_lines,
..
}) => {
assert_eq!(max_bytes, Some(6), "the allowed ceiling");
assert_eq!(total_bytes, 8, "raw pipe bytes, both terminators charged");
assert_eq!(max_lines, None);
assert_eq!(total_lines, 0);
}
other => panic!("expected OutputTooLarge, got {other:?}"),
}
}
#[tokio::test]
async fn content_budget_charges_stderr_line_terminators() {
let warnings = "warn one\nwarn two\n";
assert_eq!(warnings.len(), 18);
let client = ManagedClient::with_runner(
"tool",
ScriptedRunner::new().on(["tool", "read"], Reply::ok("ok\n").with_stderr(warnings)),
);
match client
.run_untrimmed_within(client.command(["read"]), OutputBudget::bytes(17))
.await
.map_err(Error::into_reason)
{
Err(ErrorReason::OutputTooLarge {
max_bytes,
total_bytes,
total_lines,
..
}) => {
assert_eq!(max_bytes, Some(17), "the allowed ceiling");
assert_eq!(total_bytes, 18, "raw stderr bytes, terminators charged");
assert_eq!(total_lines, 2, "every line is counted, dropped or not");
}
other => panic!("expected OutputTooLarge from stderr, got {other:?}"),
}
let got = client
.run_untrimmed_within(client.command(["read"]), OutputBudget::bytes(18))
.await
.expect("stderr exactly on the cap is within budget");
assert_eq!(got, "ok\n");
}
async fn streamed_failure(reply: Reply, budget: OutputBudget) -> Error {
let runner = ScriptedRunner::new().on(["tool", "network-op"], reply);
let mut progress = |_event: ProcessEvent| {};
run_with_progress_within(
&runner,
&Command::new("tool").arg("network-op"),
&mut progress,
budget,
)
.await
.expect_err("the scripted non-zero exit stays an error")
}
fn exit_streams(err: &Error) -> (&str, &str) {
match err.reason() {
ErrorReason::Exit { stdout, stderr, .. } => (stdout, stderr),
other => panic!("expected a structured Exit, got {other:?}"),
}
}
#[tokio::test]
async fn streamed_tail_counts_the_bytes_it_retains() {
let stream = "aaa\nbbb\nccc\n";
assert_eq!(stream.len(), 12, "raw pipe bytes");
let err = streamed_failure(Reply::fail(1, stream), OutputBudget::bytes(11)).await;
assert_eq!(
exit_streams(&err).1,
"aaa\nbbb\nccc",
"11 bytes is the whole retained tail — on the cap, not past it"
);
let err = streamed_failure(Reply::fail(1, stream), OutputBudget::bytes(10)).await;
assert_eq!(
exit_streams(&err).1,
"bbb\nccc",
"one byte less drops exactly the oldest line"
);
}
#[tokio::test]
async fn streamed_budget_bounds_both_streams_and_keeps_the_tail_classifiable() {
const CAP: usize = 128;
let mut stdout = String::new();
let mut stderr = String::new();
for i in 0..500 {
stdout.push_str(&format!("Receiving objects: {i}% (0/0)\n"));
stderr.push_str(&format!("remote: Counting objects: {i}\n"));
}
stdout.push_str("fatal: the remote end hung up unexpectedly\n");
stderr.push_str("fatal: early EOF\n");
assert!(stdout.len() > 20 * CAP && stderr.len() > 20 * CAP);
let err = streamed_failure(
Reply::fail(128, stderr).with_stdout(stdout),
OutputBudget::bytes(CAP),
)
.await;
let (out, err_text) = exit_streams(&err);
assert!(out.len() <= CAP, "stdout bounded: {} bytes", out.len());
assert!(
err_text.len() <= CAP,
"stderr bounded independently: {} bytes",
err_text.len()
);
assert!(!out.contains("Receiving objects: 0%"), "oldest dropped");
assert!(
!err_text.contains("Counting objects: 0\n"),
"oldest dropped"
);
assert!(out.ends_with("fatal: the remote end hung up unexpectedly"));
assert!(err_text.ends_with("fatal: early EOF"));
assert!(
matches!(err.reason(), ErrorReason::Exit { code: 128, .. }),
"a truncated capture is still promoted to a structured exit error"
);
assert!(
is_transient_fetch_error(&err),
"the retained tail still carries the transient marker"
);
}
#[tokio::test]
async fn streamed_budget_keeps_lock_contention_classifiable() {
let mut stderr = String::new();
for i in 0..200 {
stderr.push_str(&format!("warning: unable to rmdir stale-{i}\n"));
}
stderr.push_str("fatal: Unable to create '/w/.git/index.lock': File exists.\n");
let err = streamed_failure(Reply::fail(128, stderr), OutputBudget::bytes(96)).await;
let retained = exit_streams(&err).1;
assert!(retained.len() <= 96, "bounded: {} bytes", retained.len());
assert!(is_lock_contention(&err), "retained tail: {retained:?}");
}
#[tokio::test]
async fn streamed_budget_truncates_without_failing_the_run() {
use std::sync::atomic::{AtomicUsize, Ordering};
let mut stream = String::new();
for i in 0..400 {
stream.push_str(&format!("Receiving objects: {i}% (0/0)\n"));
}
let runner = ScriptedRunner::new().on(
["tool", "network-op"],
Reply::ok(stream.clone()).with_stderr(stream),
);
let delivered = Arc::new(AtomicUsize::new(0));
let seen = Arc::clone(&delivered);
let mut progress = move |event: ProcessEvent| {
if matches!(event, ProcessEvent::Stdout(_)) {
seen.fetch_add(1, Ordering::SeqCst);
}
};
run_with_progress_within(
&runner,
&Command::new("tool").arg("network-op"),
&mut progress,
OutputBudget::bytes(64),
)
.await
.expect("a bounded stream is truncated, not failed");
drop(progress);
assert_eq!(
delivered.load(Ordering::SeqCst),
400,
"the ceiling bounds retention, not what the callback is shown"
);
}
#[tokio::test]
async fn streamed_tail_cuts_an_over_cap_line_on_a_char_boundary() {
let line = format!("{}☃fatal: early EOF", "a".repeat(10));
assert_eq!(line.len(), 29);
assert!(!line.is_char_boundary(11));
let err = streamed_failure(Reply::fail(128, line), OutputBudget::bytes(18)).await;
let retained = exit_streams(&err).1;
assert_eq!(
retained, "fatal: early EOF",
"the tail survives; the straddling char is dropped whole"
);
assert!(retained.len() <= 18);
assert!(is_transient_fetch_error(&err));
}
#[tokio::test]
async fn streamed_tail_honours_a_line_ceiling() {
let err = streamed_failure(
Reply::fail(1, "one\ntwo\nthree\nfour\nfive\n"),
OutputBudget::bytes(1024).with_max_lines(2),
)
.await;
assert_eq!(exit_streams(&err).1, "four\nfive");
}
#[tokio::test]
async fn streamed_run_without_a_budget_retains_everything() {
let lines: Vec<String> = (0..300)
.map(|i| format!("remote: Counting objects: {i}"))
.collect();
let expected = lines.join("\n");
let stream = format!("{expected}\n");
assert!(stream.len() > 8000);
let runner = ScriptedRunner::new().on(
["tool", "network-op"],
Reply::fail(1, stream.clone()).with_stdout(stream),
);
let mut progress = |_event: ProcessEvent| {};
let err = run_with_progress(
&runner,
&Command::new("tool").arg("network-op"),
&mut progress,
)
.await
.expect_err("the scripted non-zero exit stays an error");
assert_eq!(exit_streams(&err), (expected.as_str(), expected.as_str()));
let client = ManagedClient::with_runner("tool", runner);
assert!(client.output_budget().is_unlimited());
let err = client
.run_with_progress(client.command(["network-op"]), &mut progress)
.await
.expect_err("the scripted non-zero exit stays an error");
assert_eq!(exit_streams(&err), (expected.as_str(), expected.as_str()));
}
#[tokio::test]
async fn managed_client_streams_within_its_budget() {
let runner =
ScriptedRunner::new().on(["tool", "network-op"], Reply::fail(1, "aaa\nbbb\nccc\n"));
let client = ManagedClient::with_runner("tool", runner)
.default_output_budget(OutputBudget::bytes(7));
let mut progress = |_event: ProcessEvent| {};
let err = client
.run_with_progress(client.command(["network-op"]), &mut progress)
.await
.expect_err("the scripted non-zero exit stays an error");
assert_eq!(
exit_streams(&err).1,
"bbb\nccc",
"the client default applies"
);
let err = client
.run_with_progress_within(
client.command(["network-op"]),
&mut progress,
OutputBudget::bytes(3),
)
.await
.expect_err("the scripted non-zero exit stays an error");
assert_eq!(exit_streams(&err).1, "ccc", "a tighter per-call cap wins");
let err = client
.run_with_progress_within(
client.command(["network-op"]),
&mut progress,
OutputBudget::unlimited(),
)
.await
.expect_err("the scripted non-zero exit stays an error");
assert_eq!(
exit_streams(&err).1,
"aaa\nbbb\nccc",
"and so does lifting the cap for one call"
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn a_streamed_tail_stays_within_its_budget_whatever_the_stream(
lines in prop::collection::vec("[a-z ☃]{0,16}", 1..40),
max_bytes in 1usize..48,
line_cap in prop::option::of(1usize..12),
) {
let budget = match line_cap {
Some(max_lines) => OutputBudget::bytes(max_bytes).with_max_lines(max_lines),
None => OutputBudget::bytes(max_bytes),
};
let mut retained = RetainedStream::new(budget);
for line in &lines {
retained.push(line);
}
let text = retained.into_string();
prop_assert!(
text.len() <= max_bytes,
"retained {} bytes over a {max_bytes}-byte cap: {text:?}",
text.len()
);
if let Some(max_lines) = line_cap {
prop_assert!(text.split('\n').count() <= max_lines);
}
let last = lines.last().expect("the strategy generates at least one line");
if last.len() <= max_bytes {
prop_assert!(
text.ends_with(last.as_str()),
"the newest line {last:?} fits the cap but is not the tail of {text:?}"
);
}
}
}
}