1use std::path::{Path, PathBuf};
21
22use crate::incident::IncidentReasonCode;
23use crate::types::{WorkerConfig, WorkerId};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum FaultScenario {
28 DaemonSocketRefused,
30 StaleSocket,
32 WorkerUnreachable,
34 WrongWorkerBinaryPath,
36 MissingRustup,
38 MissingWasmTarget,
40 DiskBytesExhausted,
42 DiskInodesExhausted,
44 TelemetryGap,
46 ActiveProjectExclusion,
48 RsyncVanishedFile,
50 ArtifactMissingRewrittenTarget,
52 QueuedJobOrphan,
54 SourceChangedWhileProofQueued,
56}
57
58impl FaultScenario {
59 #[must_use]
61 pub const fn as_str(self) -> &'static str {
62 match self {
63 Self::DaemonSocketRefused => "daemon_socket_refused",
64 Self::StaleSocket => "stale_socket",
65 Self::WorkerUnreachable => "worker_unreachable",
66 Self::WrongWorkerBinaryPath => "wrong_worker_binary_path",
67 Self::MissingRustup => "missing_rustup",
68 Self::MissingWasmTarget => "missing_wasm_target",
69 Self::DiskBytesExhausted => "disk_bytes_exhausted",
70 Self::DiskInodesExhausted => "disk_inodes_exhausted",
71 Self::TelemetryGap => "telemetry_gap",
72 Self::ActiveProjectExclusion => "active_project_exclusion",
73 Self::RsyncVanishedFile => "rsync_vanished_file",
74 Self::ArtifactMissingRewrittenTarget => "artifact_missing_rewritten_target",
75 Self::QueuedJobOrphan => "queued_job_orphan",
76 Self::SourceChangedWhileProofQueued => "source_changed_while_proof_queued",
77 }
78 }
79
80 #[must_use]
83 pub const fn reason_code(self) -> IncidentReasonCode {
84 match self {
85 Self::DaemonSocketRefused | Self::StaleSocket => {
86 IncidentReasonCode::DaemonSocketRefused
87 }
88 Self::WorkerUnreachable => IncidentReasonCode::NoAdmissibleWorkers,
89 Self::WrongWorkerBinaryPath => IncidentReasonCode::WrongUserPathWorkerBinary,
90 Self::MissingRustup | Self::MissingWasmTarget => {
91 IncidentReasonCode::MissingRuntimeToolchainTarget
92 }
93 Self::DiskBytesExhausted | Self::DiskInodesExhausted => IncidentReasonCode::DiskFull,
94 Self::TelemetryGap => IncidentReasonCode::TelemetryStale,
95 Self::ActiveProjectExclusion => IncidentReasonCode::ActiveProjectExclusion,
96 Self::RsyncVanishedFile => IncidentReasonCode::RsyncVanishedFile,
97 Self::ArtifactMissingRewrittenTarget => IncidentReasonCode::ArtifactMiss,
98 Self::QueuedJobOrphan => IncidentReasonCode::QueueAmbiguity,
99 Self::SourceChangedWhileProofQueued => IncidentReasonCode::ProofRefusal,
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct SimulatedDiskStats {
108 pub total_bytes: u64,
109 pub available_bytes: u64,
110 pub total_inodes: u64,
111 pub available_inodes: u64,
112}
113
114impl SimulatedDiskStats {
115 const BIG_BYTES: u64 = 1 << 40; const BIG_INODES: u64 = 10_000_000;
117
118 #[must_use]
120 pub fn healthy() -> Self {
121 Self {
122 total_bytes: Self::BIG_BYTES,
123 available_bytes: Self::BIG_BYTES / 2,
124 total_inodes: Self::BIG_INODES,
125 available_inodes: Self::BIG_INODES / 2,
126 }
127 }
128
129 #[must_use]
131 pub fn bytes_exhausted() -> Self {
132 Self {
133 available_bytes: 0,
134 ..Self::healthy()
135 }
136 }
137
138 #[must_use]
140 pub fn inodes_exhausted() -> Self {
141 Self {
142 available_inodes: 0,
143 ..Self::healthy()
144 }
145 }
146
147 #[must_use]
148 pub fn is_bytes_exhausted(&self) -> bool {
149 self.available_bytes == 0
150 }
151
152 #[must_use]
153 pub fn is_inodes_exhausted(&self) -> bool {
154 self.available_inodes == 0
155 }
156}
157
158pub const MACH_O_64_MAGIC: [u8; 4] = [0xCF, 0xFA, 0xED, 0xFE];
162pub const ELF_MAGIC: [u8; 4] = [0x7F, b'E', b'L', b'F'];
164
165pub struct FaultFixture {
168 scenario: FaultScenario,
169 dir: tempfile::TempDir,
170 disk_stats: Option<SimulatedDiskStats>,
171 worker: Option<WorkerConfig>,
172}
173
174impl FaultFixture {
175 pub fn inject(scenario: FaultScenario) -> std::io::Result<Self> {
178 let dir = tempfile::tempdir()?;
179 let root = dir.path().to_path_buf();
180 let mut disk_stats = None;
181 let mut worker = None;
182
183 match scenario {
184 FaultScenario::DaemonSocketRefused => {
185 }
187 FaultScenario::StaleSocket => {
188 std::fs::write(root.join("rch.sock"), b"stale")?;
190 }
191 FaultScenario::WorkerUnreachable => {
192 worker = Some(unreachable_worker());
193 }
194 FaultScenario::WrongWorkerBinaryPath => {
195 std::fs::create_dir_all(root.join("bin"))?;
197 std::fs::write(root.join("bin/rch-wkr"), MACH_O_64_MAGIC)?;
198 }
199 FaultScenario::MissingRustup => {
200 std::fs::create_dir_all(root.join("bin"))?;
202 }
203 FaultScenario::MissingWasmTarget => {
204 std::fs::create_dir_all(root.join("toolchain/lib/rustlib"))?;
206 std::fs::create_dir_all(
207 root.join("toolchain/lib/rustlib/x86_64-unknown-linux-gnu"),
208 )?;
209 }
210 FaultScenario::DiskBytesExhausted => {
211 disk_stats = Some(SimulatedDiskStats::bytes_exhausted());
212 }
213 FaultScenario::DiskInodesExhausted => {
214 disk_stats = Some(SimulatedDiskStats::inodes_exhausted());
215 }
216 FaultScenario::TelemetryGap => {
217 }
219 FaultScenario::ActiveProjectExclusion => {
220 std::fs::create_dir_all(root.join("project"))?;
222 std::fs::write(root.join("project/.rch-exclude"), b"excluded")?;
223 }
224 FaultScenario::RsyncVanishedFile => {
225 std::fs::create_dir_all(root.join("src"))?;
226 std::fs::write(root.join("src/vanishing.rs"), b"// will vanish\n")?;
227 }
228 FaultScenario::ArtifactMissingRewrittenTarget => {
229 std::fs::create_dir_all(root.join(".rch-target-rewritten/debug"))?;
232 }
233 FaultScenario::QueuedJobOrphan => {
234 std::fs::write(root.join("job.record"), b"job_id=orphan-1 pid=0\n")?;
236 }
237 FaultScenario::SourceChangedWhileProofQueued => {
238 std::fs::create_dir_all(root.join("src"))?;
239 std::fs::write(root.join("src/lib.rs"), b"pub fn v() -> u32 { 1 }\n")?;
240 }
241 }
242
243 tracing::info!(
244 target: "rch::e2e::fault",
245 scenario = scenario.as_str(),
246 reason_code = scenario.reason_code().code(),
247 root = %root.display(),
248 "fault.fixture.setup",
249 );
250
251 Ok(Self {
252 scenario,
253 dir,
254 disk_stats,
255 worker,
256 })
257 }
258
259 #[must_use]
261 pub fn scenario(&self) -> FaultScenario {
262 self.scenario
263 }
264
265 #[must_use]
267 pub fn root(&self) -> &Path {
268 self.dir.path()
269 }
270
271 #[must_use]
273 pub fn socket_path(&self) -> PathBuf {
274 self.root().join("rch.sock")
275 }
276
277 #[must_use]
279 pub fn worker_binary_path(&self) -> PathBuf {
280 self.root().join("bin/rch-wkr")
281 }
282
283 #[must_use]
285 pub fn path_dir(&self) -> PathBuf {
286 self.root().join("bin")
287 }
288
289 #[must_use]
291 pub fn disk_stats(&self) -> Option<SimulatedDiskStats> {
292 self.disk_stats
293 }
294
295 #[must_use]
297 pub fn worker(&self) -> Option<&WorkerConfig> {
298 self.worker.as_ref()
299 }
300
301 #[must_use]
303 pub fn telemetry_signals(&self) -> crate::telemetry_explain::TelemetrySignals {
304 crate::telemetry_explain::TelemetrySignals {
305 ever_received_sample: false,
306 poller_behind: false,
307 ..crate::telemetry_explain::TelemetrySignals::default()
308 }
309 }
310
311 #[must_use]
313 pub fn source_file(&self) -> PathBuf {
314 match self.scenario {
315 FaultScenario::RsyncVanishedFile => self.root().join("src/vanishing.rs"),
316 _ => self.root().join("src/lib.rs"),
317 }
318 }
319
320 pub fn vanish_source(&self) -> std::io::Result<()> {
323 let path = self.source_file();
324 debug_assert!(
325 path.starts_with(self.root()),
326 "vanish must stay in temp root"
327 );
328 std::fs::remove_file(&path)?;
329 tracing::info!(
330 target: "rch::e2e::fault",
331 scenario = self.scenario.as_str(),
332 path = %path.display(),
333 "fault.fixture.source_vanished",
334 );
335 Ok(())
336 }
337
338 pub fn mutate_source(&self) -> std::io::Result<()> {
341 let path = self.source_file();
342 debug_assert!(
343 path.starts_with(self.root()),
344 "mutate must stay in temp root"
345 );
346 std::fs::write(&path, b"pub fn v() -> u32 { 2 }\n")?;
347 tracing::info!(
348 target: "rch::e2e::fault",
349 scenario = self.scenario.as_str(),
350 path = %path.display(),
351 "fault.fixture.source_mutated",
352 );
353 Ok(())
354 }
355}
356
357impl Drop for FaultFixture {
358 fn drop(&mut self) {
359 tracing::info!(
361 target: "rch::e2e::fault",
362 scenario = self.scenario.as_str(),
363 root = %self.dir.path().display(),
364 "fault.fixture.teardown",
365 );
366 }
367}
368
369fn unreachable_worker() -> WorkerConfig {
372 WorkerConfig {
373 id: WorkerId("unreachable".to_string()),
374 host: "192.0.2.1".to_string(),
375 user: "rch".to_string(),
376 ..WorkerConfig::default()
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 const ALL: &[FaultScenario] = &[
385 FaultScenario::DaemonSocketRefused,
386 FaultScenario::StaleSocket,
387 FaultScenario::WorkerUnreachable,
388 FaultScenario::WrongWorkerBinaryPath,
389 FaultScenario::MissingRustup,
390 FaultScenario::MissingWasmTarget,
391 FaultScenario::DiskBytesExhausted,
392 FaultScenario::DiskInodesExhausted,
393 FaultScenario::TelemetryGap,
394 FaultScenario::ActiveProjectExclusion,
395 FaultScenario::RsyncVanishedFile,
396 FaultScenario::ArtifactMissingRewrittenTarget,
397 FaultScenario::QueuedJobOrphan,
398 FaultScenario::SourceChangedWhileProofQueued,
399 ];
400
401 #[test]
402 fn every_scenario_injects_and_cleans_up_within_temp_root() {
403 for &scenario in ALL {
404 let root: PathBuf;
405 {
406 let fx = FaultFixture::inject(scenario).expect("inject");
407 root = fx.root().to_path_buf();
408 assert!(root.exists(), "{}: root exists", scenario.as_str());
409 assert!(root.starts_with(std::env::temp_dir()));
411 }
412 assert!(
414 !root.exists(),
415 "{}: root cleaned on drop",
416 scenario.as_str()
417 );
418 }
419 }
420
421 #[test]
422 fn scenario_ids_and_reason_codes_are_distinct_and_mapped() {
423 let mut ids: Vec<&str> = ALL.iter().map(|s| s.as_str()).collect();
424 ids.sort_unstable();
425 let n = ids.len();
426 ids.dedup();
427 assert_eq!(n, ids.len(), "scenario ids must be unique");
428 for &s in ALL {
430 assert!(!s.reason_code().code().is_empty());
431 }
432 }
433
434 #[test]
435 fn daemon_socket_refused_has_no_socket() {
436 let fx = FaultFixture::inject(FaultScenario::DaemonSocketRefused).unwrap();
437 assert!(
438 !fx.socket_path().exists(),
439 "socket must be absent (refused)"
440 );
441 }
442
443 #[test]
444 fn stale_socket_is_a_file_not_a_listener() {
445 let fx = FaultFixture::inject(FaultScenario::StaleSocket).unwrap();
446 assert!(fx.socket_path().is_file());
447 }
448
449 #[test]
450 fn wrong_worker_binary_is_mach_o_not_elf() {
451 let fx = FaultFixture::inject(FaultScenario::WrongWorkerBinaryPath).unwrap();
452 let bytes = std::fs::read(fx.worker_binary_path()).unwrap();
453 assert_eq!(&bytes[..4], &MACH_O_64_MAGIC);
454 assert_ne!(&bytes[..4], &ELF_MAGIC);
455 }
456
457 #[test]
458 fn missing_rustup_path_dir_has_no_rustup() {
459 let fx = FaultFixture::inject(FaultScenario::MissingRustup).unwrap();
460 assert!(fx.path_dir().is_dir());
461 assert!(!fx.path_dir().join("rustup").exists());
462 }
463
464 #[test]
465 fn disk_exhaustion_stats_are_distinct() {
466 let bytes = FaultFixture::inject(FaultScenario::DiskBytesExhausted).unwrap();
467 let s = bytes.disk_stats().unwrap();
468 assert!(s.is_bytes_exhausted() && !s.is_inodes_exhausted());
469
470 let inodes = FaultFixture::inject(FaultScenario::DiskInodesExhausted).unwrap();
471 let s = inodes.disk_stats().unwrap();
472 assert!(s.is_inodes_exhausted() && !s.is_bytes_exhausted());
473
474 assert!(!SimulatedDiskStats::healthy().is_bytes_exhausted());
475 }
476
477 #[test]
478 fn worker_unreachable_provides_test_net_worker() {
479 let fx = FaultFixture::inject(FaultScenario::WorkerUnreachable).unwrap();
480 let w = fx.worker().expect("worker present");
481 assert_eq!(w.host, "192.0.2.1");
482 }
483
484 #[test]
485 fn telemetry_gap_signals_never_received() {
486 let fx = FaultFixture::inject(FaultScenario::TelemetryGap).unwrap();
487 let signals = fx.telemetry_signals();
488 assert!(!signals.ever_received_sample);
489 }
490
491 #[test]
492 fn rsync_vanished_file_removes_only_its_own_source() {
493 let fx = FaultFixture::inject(FaultScenario::RsyncVanishedFile).unwrap();
494 let src = fx.source_file();
495 assert!(src.exists());
496 fx.vanish_source().unwrap();
497 assert!(!src.exists());
498 assert!(fx.root().exists());
500 }
501
502 #[test]
503 fn source_changed_while_proof_queued_mutates_content() {
504 let fx = FaultFixture::inject(FaultScenario::SourceChangedWhileProofQueued).unwrap();
505 let before = std::fs::read_to_string(fx.source_file()).unwrap();
506 fx.mutate_source().unwrap();
507 let after = std::fs::read_to_string(fx.source_file()).unwrap();
508 assert_ne!(before, after);
509 }
510
511 #[test]
512 fn artifact_missing_rewritten_target_has_dir_but_no_artifact() {
513 let fx = FaultFixture::inject(FaultScenario::ArtifactMissingRewrittenTarget).unwrap();
514 assert!(fx.root().join(".rch-target-rewritten/debug").is_dir());
515 assert!(
516 !fx.root()
517 .join(".rch-target-rewritten/debug/libfixture.rlib")
518 .exists()
519 );
520 }
521}