1use serde::{Deserialize, Serialize};
15
16use crate::incident::IncidentReasonCode;
17use crate::ssh_utils::is_retryable_transport_error_text;
18
19pub const DEFAULT_MAX_TRANSFER_ATTEMPTS: u32 = 3;
21pub const RSYNC_EXIT_VANISHED: i32 = 24;
23pub const RSYNC_EXIT_PARTIAL_ERROR: i32 = 23;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum RsyncFailureClass {
30 Success,
32 VanishedFile,
35 RetryableTransport,
37 DiskFull,
39 PermissionDenied,
41 PartialCorruption,
43 Fatal,
45}
46
47impl RsyncFailureClass {
48 #[must_use]
50 pub const fn is_success(self) -> bool {
51 matches!(self, Self::Success)
52 }
53
54 #[must_use]
56 pub const fn incident_reason(self) -> Option<IncidentReasonCode> {
57 match self {
58 Self::VanishedFile => Some(IncidentReasonCode::RsyncVanishedFile),
59 Self::DiskFull => Some(IncidentReasonCode::DiskFull),
60 _ => None,
61 }
62 }
63
64 #[must_use]
67 pub fn retry_decision(self, attempt: u32, max_attempts: u32) -> RetryDecision {
68 let exhausted = attempt >= max_attempts;
69 match self {
70 Self::Success => RetryDecision::done("transfer succeeded"),
71 Self::VanishedFile if !exhausted => {
74 RetryDecision::retry(0, "source file vanished; retrying (ignoring vanished path)")
75 }
76 Self::VanishedFile => RetryDecision {
77 should_retry: false,
78 backoff_ms: 0,
79 ignorable: true,
80 reason: "vanished files persisted across retries; ignoring (benign)",
81 },
82 Self::RetryableTransport if !exhausted => RetryDecision::retry(
83 backoff_ms(attempt),
84 "retryable transport error; retrying with backoff",
85 ),
86 Self::PartialCorruption if !exhausted => {
87 RetryDecision::retry(backoff_ms(attempt), "partial transfer; retrying")
88 }
89 Self::DiskFull => RetryDecision::fatal("destination disk full"),
91 Self::PermissionDenied => RetryDecision::fatal("permission denied"),
92 Self::Fatal => RetryDecision::fatal("fatal transfer error"),
93 _ => RetryDecision::fatal("retry attempts exhausted"),
94 }
95 }
96}
97
98fn backoff_ms(attempt: u32) -> u64 {
100 let shift = attempt.saturating_sub(1).min(5);
101 250u64.saturating_mul(1u64 << shift)
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106pub struct RetryDecision {
107 pub should_retry: bool,
108 pub backoff_ms: u64,
109 pub ignorable: bool,
112 pub reason: &'static str,
113}
114
115impl RetryDecision {
116 const fn retry(backoff_ms: u64, reason: &'static str) -> Self {
117 Self {
118 should_retry: true,
119 backoff_ms,
120 ignorable: false,
121 reason,
122 }
123 }
124 const fn done(reason: &'static str) -> Self {
125 Self {
126 should_retry: false,
127 backoff_ms: 0,
128 ignorable: true,
129 reason,
130 }
131 }
132 const fn fatal(reason: &'static str) -> Self {
133 Self {
134 should_retry: false,
135 backoff_ms: 0,
136 ignorable: false,
137 reason,
138 }
139 }
140}
141
142#[must_use]
146pub fn classify_rsync_outcome(exit_code: i32, stderr: &str) -> RsyncFailureClass {
147 if exit_code == 0 {
148 return RsyncFailureClass::Success;
149 }
150 let lower = stderr.to_ascii_lowercase();
151 if exit_code == RSYNC_EXIT_VANISHED || lower.contains("vanished") {
152 return RsyncFailureClass::VanishedFile;
153 }
154 if lower.contains("no space left") || lower.contains("disk quota exceeded") {
155 return RsyncFailureClass::DiskFull;
156 }
157 if lower.contains("permission denied") {
158 return RsyncFailureClass::PermissionDenied;
159 }
160 if is_retryable_transport_error_text(stderr) {
161 return RsyncFailureClass::RetryableTransport;
162 }
163 if exit_code == RSYNC_EXIT_PARTIAL_ERROR {
164 return RsyncFailureClass::PartialCorruption;
165 }
166 RsyncFailureClass::Fatal
167}
168
169#[must_use]
173pub fn is_ephemeral_path(path: &str) -> bool {
174 const EPHEMERAL_SUFFIXES: &[&str] = &[
175 "/.git/index.lock",
176 "/.git/HEAD.lock",
177 "/.git/config.lock",
178 "/.git/packed-refs.lock",
179 "/.cargo-lock",
180 "/.rch.lock",
181 ];
182 const EPHEMERAL_SUBSTRINGS: &[&str] = &[
183 "/.git/objects/tmp_", "/.git/refs/", "/incremental/", ];
187 let p = path.trim_end_matches('/');
188 if EPHEMERAL_SUFFIXES.iter().any(|s| p.ends_with(s)) {
189 return true;
190 }
191 if p.ends_with(".lock") && p.contains("/.git/") {
193 return true;
194 }
195 EPHEMERAL_SUBSTRINGS.iter().any(|s| p.contains(s))
196}
197
198#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
202pub struct FirstFailure {
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub path: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub command: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub detail: Option<String>,
209}
210
211impl FirstFailure {
212 pub fn record(&mut self, path: Option<&str>, command: Option<&str>, detail: &str) {
214 if self.detail.is_none() {
215 self.path = path.map(str::to_string);
216 self.command = command.map(str::to_string);
217 self.detail = Some(detail.to_string());
218 }
219 }
220
221 #[must_use]
222 pub fn is_set(&self) -> bool {
223 self.detail.is_some()
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn vanished_file_classified_from_exit_24() {
233 assert_eq!(
234 classify_rsync_outcome(24, ""),
235 RsyncFailureClass::VanishedFile
236 );
237 }
238
239 #[test]
240 fn vanished_file_classified_from_stderr() {
241 let class = classify_rsync_outcome(23, "file has vanished: \"/proj/.git/index.lock\"");
242 assert_eq!(class, RsyncFailureClass::VanishedFile);
243 assert_eq!(
244 class.incident_reason(),
245 Some(IncidentReasonCode::RsyncVanishedFile)
246 );
247 }
248
249 #[test]
250 fn success_disk_permission_and_fatal() {
251 assert_eq!(classify_rsync_outcome(0, ""), RsyncFailureClass::Success);
252 assert_eq!(
253 classify_rsync_outcome(11, "rsync: write failed: No space left on device (28)"),
254 RsyncFailureClass::DiskFull
255 );
256 assert_eq!(
257 classify_rsync_outcome(23, "rsync: mkdir failed: Permission denied (13)"),
258 RsyncFailureClass::PermissionDenied
259 );
260 assert_eq!(
261 classify_rsync_outcome(23, "some unrecognized partial error"),
262 RsyncFailureClass::PartialCorruption
263 );
264 assert_eq!(
265 classify_rsync_outcome(99, "totally unknown"),
266 RsyncFailureClass::Fatal
267 );
268 }
269
270 #[test]
271 fn retryable_transport_detected() {
272 let class = classify_rsync_outcome(255, "ssh: connect to host: Connection timed out");
274 assert_eq!(class, RsyncFailureClass::RetryableTransport);
275 }
276
277 #[test]
278 fn vanished_file_retries_then_becomes_ignorable() {
279 let v = RsyncFailureClass::VanishedFile;
280 let d1 = v.retry_decision(1, 3);
281 assert!(d1.should_retry);
282 assert_eq!(d1.backoff_ms, 0); let d3 = v.retry_decision(3, 3);
285 assert!(!d3.should_retry);
286 assert!(d3.ignorable);
287 }
288
289 #[test]
290 fn retryable_transport_backs_off_then_stops() {
291 let t = RsyncFailureClass::RetryableTransport;
292 let d1 = t.retry_decision(1, 3);
293 assert!(d1.should_retry && d1.backoff_ms == 250);
294 let d2 = t.retry_decision(2, 3);
295 assert!(d2.should_retry && d2.backoff_ms == 500);
296 let d3 = t.retry_decision(3, 3);
297 assert!(!d3.should_retry && !d3.ignorable);
298 }
299
300 #[test]
301 fn fatal_classes_never_retry() {
302 assert!(
303 !RsyncFailureClass::DiskFull
304 .retry_decision(1, 3)
305 .should_retry
306 );
307 assert!(
308 !RsyncFailureClass::PermissionDenied
309 .retry_decision(1, 3)
310 .should_retry
311 );
312 assert!(!RsyncFailureClass::Fatal.retry_decision(1, 3).should_retry);
313 }
314
315 #[test]
316 fn ephemeral_paths_detected() {
317 assert!(is_ephemeral_path("/proj/.git/index.lock"));
318 assert!(is_ephemeral_path("/proj/.git/refs/heads/main.lock"));
319 assert!(is_ephemeral_path("/proj/target/debug/incremental/foo"));
320 assert!(is_ephemeral_path("/proj/.git/objects/tmp_abc"));
321 assert!(!is_ephemeral_path("/proj/src/lib.rs"));
323 assert!(!is_ephemeral_path("/proj/Cargo.toml"));
324 assert!(!is_ephemeral_path("/proj/.gitignore"));
325 }
326
327 #[test]
328 fn first_failure_keeps_the_first() {
329 let mut f = FirstFailure::default();
330 assert!(!f.is_set());
331 f.record(Some("/a"), Some("rsync ..."), "first error");
332 f.record(Some("/b"), Some("retry ..."), "second error");
333 assert!(f.is_set());
334 assert_eq!(f.path.as_deref(), Some("/a"));
335 assert_eq!(f.detail.as_deref(), Some("first error"));
336 }
337
338 #[test]
339 fn serde_roundtrips() {
340 let class = RsyncFailureClass::VanishedFile;
341 let v = serde_json::to_value(class).unwrap();
342 assert_eq!(v, "vanished_file");
343 let mut f = FirstFailure::default();
344 f.record(Some("/a"), None, "boom");
345 let back: FirstFailure = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap();
346 assert_eq!(f, back);
347 }
348}