use serde::{Deserialize, Serialize};
use crate::incident::IncidentReasonCode;
use crate::ssh_utils::is_retryable_transport_error_text;
pub const DEFAULT_MAX_TRANSFER_ATTEMPTS: u32 = 3;
pub const RSYNC_EXIT_VANISHED: i32 = 24;
pub const RSYNC_EXIT_PARTIAL_ERROR: i32 = 23;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RsyncFailureClass {
Success,
VanishedFile,
RetryableTransport,
DiskFull,
PermissionDenied,
PartialCorruption,
Fatal,
}
impl RsyncFailureClass {
#[must_use]
pub const fn is_success(self) -> bool {
matches!(self, Self::Success)
}
#[must_use]
pub const fn incident_reason(self) -> Option<IncidentReasonCode> {
match self {
Self::VanishedFile => Some(IncidentReasonCode::RsyncVanishedFile),
Self::DiskFull => Some(IncidentReasonCode::DiskFull),
_ => None,
}
}
#[must_use]
pub fn retry_decision(self, attempt: u32, max_attempts: u32) -> RetryDecision {
let exhausted = attempt >= max_attempts;
match self {
Self::Success => RetryDecision::done("transfer succeeded"),
Self::VanishedFile if !exhausted => {
RetryDecision::retry(0, "source file vanished; retrying (ignoring vanished path)")
}
Self::VanishedFile => RetryDecision {
should_retry: false,
backoff_ms: 0,
ignorable: true,
reason: "vanished files persisted across retries; ignoring (benign)",
},
Self::RetryableTransport if !exhausted => RetryDecision::retry(
backoff_ms(attempt),
"retryable transport error; retrying with backoff",
),
Self::PartialCorruption if !exhausted => {
RetryDecision::retry(backoff_ms(attempt), "partial transfer; retrying")
}
Self::DiskFull => RetryDecision::fatal("destination disk full"),
Self::PermissionDenied => RetryDecision::fatal("permission denied"),
Self::Fatal => RetryDecision::fatal("fatal transfer error"),
_ => RetryDecision::fatal("retry attempts exhausted"),
}
}
}
fn backoff_ms(attempt: u32) -> u64 {
let shift = attempt.saturating_sub(1).min(5);
250u64.saturating_mul(1u64 << shift)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryDecision {
pub should_retry: bool,
pub backoff_ms: u64,
pub ignorable: bool,
pub reason: &'static str,
}
impl RetryDecision {
const fn retry(backoff_ms: u64, reason: &'static str) -> Self {
Self {
should_retry: true,
backoff_ms,
ignorable: false,
reason,
}
}
const fn done(reason: &'static str) -> Self {
Self {
should_retry: false,
backoff_ms: 0,
ignorable: true,
reason,
}
}
const fn fatal(reason: &'static str) -> Self {
Self {
should_retry: false,
backoff_ms: 0,
ignorable: false,
reason,
}
}
}
#[must_use]
pub fn classify_rsync_outcome(exit_code: i32, stderr: &str) -> RsyncFailureClass {
if exit_code == 0 {
return RsyncFailureClass::Success;
}
let lower = stderr.to_ascii_lowercase();
if exit_code == RSYNC_EXIT_VANISHED || lower.contains("vanished") {
return RsyncFailureClass::VanishedFile;
}
if lower.contains("no space left") || lower.contains("disk quota exceeded") {
return RsyncFailureClass::DiskFull;
}
if lower.contains("permission denied") {
return RsyncFailureClass::PermissionDenied;
}
if is_retryable_transport_error_text(stderr) {
return RsyncFailureClass::RetryableTransport;
}
if exit_code == RSYNC_EXIT_PARTIAL_ERROR {
return RsyncFailureClass::PartialCorruption;
}
RsyncFailureClass::Fatal
}
#[must_use]
pub fn is_ephemeral_path(path: &str) -> bool {
const EPHEMERAL_SUFFIXES: &[&str] = &[
"/.git/index.lock",
"/.git/HEAD.lock",
"/.git/config.lock",
"/.git/packed-refs.lock",
"/.cargo-lock",
"/.rch.lock",
];
const EPHEMERAL_SUBSTRINGS: &[&str] = &[
"/.git/objects/tmp_", "/.git/refs/", "/incremental/", ];
let p = path.trim_end_matches('/');
if EPHEMERAL_SUFFIXES.iter().any(|s| p.ends_with(s)) {
return true;
}
if p.ends_with(".lock") && p.contains("/.git/") {
return true;
}
EPHEMERAL_SUBSTRINGS.iter().any(|s| p.contains(s))
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FirstFailure {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl FirstFailure {
pub fn record(&mut self, path: Option<&str>, command: Option<&str>, detail: &str) {
if self.detail.is_none() {
self.path = path.map(str::to_string);
self.command = command.map(str::to_string);
self.detail = Some(detail.to_string());
}
}
#[must_use]
pub fn is_set(&self) -> bool {
self.detail.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vanished_file_classified_from_exit_24() {
assert_eq!(
classify_rsync_outcome(24, ""),
RsyncFailureClass::VanishedFile
);
}
#[test]
fn vanished_file_classified_from_stderr() {
let class = classify_rsync_outcome(23, "file has vanished: \"/proj/.git/index.lock\"");
assert_eq!(class, RsyncFailureClass::VanishedFile);
assert_eq!(
class.incident_reason(),
Some(IncidentReasonCode::RsyncVanishedFile)
);
}
#[test]
fn success_disk_permission_and_fatal() {
assert_eq!(classify_rsync_outcome(0, ""), RsyncFailureClass::Success);
assert_eq!(
classify_rsync_outcome(11, "rsync: write failed: No space left on device (28)"),
RsyncFailureClass::DiskFull
);
assert_eq!(
classify_rsync_outcome(23, "rsync: mkdir failed: Permission denied (13)"),
RsyncFailureClass::PermissionDenied
);
assert_eq!(
classify_rsync_outcome(23, "some unrecognized partial error"),
RsyncFailureClass::PartialCorruption
);
assert_eq!(
classify_rsync_outcome(99, "totally unknown"),
RsyncFailureClass::Fatal
);
}
#[test]
fn retryable_transport_detected() {
let class = classify_rsync_outcome(255, "ssh: connect to host: Connection timed out");
assert_eq!(class, RsyncFailureClass::RetryableTransport);
}
#[test]
fn vanished_file_retries_then_becomes_ignorable() {
let v = RsyncFailureClass::VanishedFile;
let d1 = v.retry_decision(1, 3);
assert!(d1.should_retry);
assert_eq!(d1.backoff_ms, 0); let d3 = v.retry_decision(3, 3);
assert!(!d3.should_retry);
assert!(d3.ignorable);
}
#[test]
fn retryable_transport_backs_off_then_stops() {
let t = RsyncFailureClass::RetryableTransport;
let d1 = t.retry_decision(1, 3);
assert!(d1.should_retry && d1.backoff_ms == 250);
let d2 = t.retry_decision(2, 3);
assert!(d2.should_retry && d2.backoff_ms == 500);
let d3 = t.retry_decision(3, 3);
assert!(!d3.should_retry && !d3.ignorable);
}
#[test]
fn fatal_classes_never_retry() {
assert!(
!RsyncFailureClass::DiskFull
.retry_decision(1, 3)
.should_retry
);
assert!(
!RsyncFailureClass::PermissionDenied
.retry_decision(1, 3)
.should_retry
);
assert!(!RsyncFailureClass::Fatal.retry_decision(1, 3).should_retry);
}
#[test]
fn ephemeral_paths_detected() {
assert!(is_ephemeral_path("/proj/.git/index.lock"));
assert!(is_ephemeral_path("/proj/.git/refs/heads/main.lock"));
assert!(is_ephemeral_path("/proj/target/debug/incremental/foo"));
assert!(is_ephemeral_path("/proj/.git/objects/tmp_abc"));
assert!(!is_ephemeral_path("/proj/src/lib.rs"));
assert!(!is_ephemeral_path("/proj/Cargo.toml"));
assert!(!is_ephemeral_path("/proj/.gitignore"));
}
#[test]
fn first_failure_keeps_the_first() {
let mut f = FirstFailure::default();
assert!(!f.is_set());
f.record(Some("/a"), Some("rsync ..."), "first error");
f.record(Some("/b"), Some("retry ..."), "second error");
assert!(f.is_set());
assert_eq!(f.path.as_deref(), Some("/a"));
assert_eq!(f.detail.as_deref(), Some("first error"));
}
#[test]
fn serde_roundtrips() {
let class = RsyncFailureClass::VanishedFile;
let v = serde_json::to_value(class).unwrap();
assert_eq!(v, "vanished_file");
let mut f = FirstFailure::default();
f.record(Some("/a"), None, "boom");
let back: FirstFailure = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap();
assert_eq!(f, back);
}
}