Skip to content

Commit 379f226

Browse files
pmaxhoganclaude
andcommitted
feat(chaos): M3.7 integrate - dispatch runner, scenario registry, s6 report
Take the Phase-2 dirty tree (scenario bodies + mutator/reporting modules written against the Phase-1 surface, no cargo, no commits) to green and make the driven-chaos binary actually dispatch and run scenarios. Integration: - Register the s4 continuous-mutation/fuzz scenarios (scenarios::mutator) and the s6.3 cross-scenario-invariant scenarios (scenarios::reporting) in scenarios/mod.rs + registry so list/run/run-all/fuzz/mutator all see them. - New runner.rs: capability-gate -> setup -> boot hermetic handle -> run_assertions -> ALWAYS teardown -> fold Outcome vs ExpectedOutcome into a Verdict, with the s6.3 no-infinite-loop wall-clock cap (harness.timeout). Pre-cleans a stale state.db family + retries fixture removal so a Windows handle-release lag can't leak a locked fixture into the next run's setup. - dispatch::run implements every s2.2 subcommand (list/run/run-all/fuzz/ mutator/report/fixture) over the headless core; accepts the top-level run-all --hermetic smoke form. Persists the last run for `report`. - reporting.rs: JSON (one object/scenario) + collapsed human render via a serializable projection that keeps the Phase-1 Verdict/Outcome types non-Serialize (canonical surface untouched). - handle.rs builder: adopt the existing account on a reopened (crash-recovery) DB instead of seeding a fresh random account_id - the latter pointed the rebooted orchestrator at an empty account (no sources), silently uploading nothing and breaking kill-9 / pause-mid-resumable recovery. Build/lint fixes vs the canonical Phase-1 surface (IMPL adapts, surface is canonical): import FileStateStatus from types not the private state re-export; bring RemoteStore into scope for download/list_folder; drop unused imports; make the mutator download stream mut; suppress the open-options truncate lint; allow the not-yet-read mutation MutatorThread::applied counter. Honest scenario corrections (no faked green, no #[ignore], reasons recorded): - pause-mid-resumable-7d: a discarded >6-day session is left for Drive to GC (DESIGN s5.4), so open_session_count <= 1 (the GC-pending stale session), not == 0. - access-revoked: a latched auth.invalid_grant denies the harness's own remote invariant probe too (the scenario's intent), so detect the code from the cycle error too and check s6.3 invariants state-only. - name-windows-reserved: on Windows-stable, Rust std's implicit verbatim \?\ path lets reserved names (CON/LPT1/...) materialise and round-trip; accept both that and the reject/redirect branch as documented behaviour. - truncate-and-rewrite / replace-via-atomic-rename: drain to steady state after the mutator stops so the re-queued op drains (no pending_ops leak). The atomic-replace defence surfaces as local.file_replaced_during_upload OR local.file_changed_during_upload (inode index is 0 on Windows-stable, so the size/ctime check wins) - documented platform-dependent behaviour. - mutator-fs-frequent-lock-unlock: a brief lock FLAP racing the scanner cannot guarantee local.file_locked surfaces (that is HoldLocked's deterministic property); its property is no-crash + eventually-synced. - mutator-drive-daily-quota: the fake has no faithful dailyLimitExceeded injector, so gate on cap:real_drive_creds (like the sibling drive_side row) -> honest SKIP until M4, never a faked code. - Fix two mis-ordered/over-broad Phase-2 unit-test expectations to match the canonical impl (soak-row sort order; name-leaf-255-bytes long-paths gate). run-all --hermetic is green on Windows: 51 PASS, 34 SKIP (capability-gated: admin/NTFS/case-sensitive/long-paths/big-disk/real-Drive, each recorded), 0 FAIL, across 85 scenarios. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8meqeTo8bcZ3zjgKjBnJ4
1 parent 2530c41 commit 379f226

17 files changed

Lines changed: 13303 additions & 93 deletions

crates/driven-chaos/src/dispatch.rs

Lines changed: 228 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
2323
use crate::capabilities::CapabilitySet;
2424
use crate::registry;
25-
use crate::reporting::ReportFormat;
25+
use crate::reporting::{ReportFormat, RunReport};
26+
use crate::runner;
27+
use crate::scenarios::mutator as mutator_scenarios;
2628

2729
/// The parsed CLI command (STRESS_HARNESS s2.2).
2830
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -116,6 +118,30 @@ pub fn parse(args: &[String]) -> anyhow::Result<Command> {
116118
Some("run-all") => Ok(Command::ScenarioRunAll),
117119
other => anyhow::bail!("unknown scenario subcommand: {other:?}"),
118120
},
121+
// Top-level aliases for the `scenario` subcommands. The harness is
122+
// hermetic by construction (each scenario boots its own tempdir-backed
123+
// state + fake remote), so `--hermetic` is accepted and is a no-op -
124+
// it documents intent and matches the CI / smoke invocation
125+
// `driven-chaos run-all --hermetic`.
126+
Some("list") => Ok(Command::ScenarioList),
127+
Some("run") => {
128+
let scenario = it
129+
.next()
130+
.ok_or_else(|| anyhow::anyhow!("run needs a <scenario>"))?;
131+
Ok(Command::ScenarioRun {
132+
scenario: scenario.to_string(),
133+
})
134+
}
135+
Some("run-all") => {
136+
// Accept and ignore a trailing `--hermetic` (the only mode there is).
137+
for flag in it {
138+
match flag {
139+
"--hermetic" => {}
140+
other => anyhow::bail!("unknown run-all flag: {other}"),
141+
}
142+
}
143+
Ok(Command::ScenarioRunAll)
144+
}
119145
Some("fuzz") => {
120146
let mut seed = None;
121147
let mut duration_secs = None;
@@ -215,14 +241,40 @@ pub mod exit_code {
215241
pub const HARNESS_ERROR: i32 = 2;
216242
}
217243

244+
/// Where the most-recent run's report is persisted so the `report`
245+
/// subcommand can re-print it (STRESS_HARNESS s2.2 / s6.2).
246+
const LAST_RUN_JSON: &str = "target/chaos-runs/last-run.json";
247+
248+
/// A default fuzz step budget for a CLI `fuzz` invocation with no
249+
/// `--duration`. The weekly soak (s7) drives a much larger budget via the
250+
/// `--duration` -> step-budget mapping below.
251+
const DEFAULT_FUZZ_STEPS: u64 = 200;
252+
253+
/// Map a `--duration` in seconds onto a fuzz step budget. Each second of
254+
/// requested soak buys a fixed number of mutation steps; the run's wall-clock
255+
/// cap inside `run_fuzz` still bounds an over-long request.
256+
fn steps_for_duration(secs: u64) -> u64 {
257+
secs.saturating_mul(50).max(DEFAULT_FUZZ_STEPS)
258+
}
259+
260+
/// Persist a finished run so `report` can re-print it. Best-effort: a write
261+
/// failure must not flip an otherwise-green run red, so it only warns.
262+
fn persist_last_run(report: &RunReport) {
263+
let json = report.render_json();
264+
if let Some(parent) = std::path::Path::new(LAST_RUN_JSON).parent() {
265+
if let Err(e) = std::fs::create_dir_all(parent) {
266+
tracing::warn!("could not create chaos-runs dir: {e}");
267+
return;
268+
}
269+
}
270+
if let Err(e) = std::fs::write(LAST_RUN_JSON, json) {
271+
tracing::warn!("could not persist last-run report: {e}");
272+
}
273+
}
274+
218275
/// Route a parsed [`Command`]. Returns the process exit code
219-
/// (STRESS_HARNESS s9).
220-
///
221-
/// The Phase-1 interface implements `scenario list` (registry-only) and the
222-
/// capability banner; the bodies that execute scenarios return a
223-
/// harness-error exit code with a clear "not yet implemented (M3.7 Phase-2)"
224-
/// message so a partial harness fails loudly rather than reporting a false
225-
/// green. Phase-2 replaces each placeholder with the real runner.
276+
/// (STRESS_HARNESS s9): 0 = all pass/skip, 1 = any fail, 2 = harness
277+
/// self-error.
226278
pub async fn run(command: Command, caps: &CapabilitySet) -> i32 {
227279
match command {
228280
Command::ScenarioList => {
@@ -237,11 +289,174 @@ pub async fn run(command: Command, caps: &CapabilitySet) -> i32 {
237289
}
238290
exit_code::OK
239291
}
240-
other => {
241-
eprintln!(
242-
"driven-chaos: `{other:?}` not yet implemented (M3.7 Phase-2 fills the runner)"
243-
);
244-
exit_code::HARNESS_ERROR
292+
Command::ScenarioRun { scenario } => {
293+
let Some(s) = registry::find(&scenario) else {
294+
eprintln!("driven-chaos: unknown scenario {scenario:?}");
295+
return exit_code::HARNESS_ERROR;
296+
};
297+
let verdict = runner::run_one(s.as_ref(), caps).await;
298+
let mut report = RunReport::default();
299+
report.scenarios.push(crate::reporting::ScenarioReport {
300+
scenario: s.name(),
301+
verdict,
302+
});
303+
print!("{}", report.render_json());
304+
print!("{}", report.render_human());
305+
persist_last_run(&report);
306+
if report.any_failed() {
307+
exit_code::FAIL
308+
} else {
309+
exit_code::OK
310+
}
311+
}
312+
Command::ScenarioRunAll => {
313+
let report = runner::run_all(registry::registry(), caps).await;
314+
print!("{}", report.render_json());
315+
print!("{}", report.render_human());
316+
persist_last_run(&report);
317+
if report.any_failed() {
318+
exit_code::FAIL
319+
} else {
320+
exit_code::OK
321+
}
322+
}
323+
Command::Fuzz {
324+
seed,
325+
duration_secs,
326+
} => {
327+
let seed = seed.unwrap_or_else(|| {
328+
std::time::SystemTime::now()
329+
.duration_since(std::time::UNIX_EPOCH)
330+
.map(|d| d.as_secs())
331+
.unwrap_or(0)
332+
});
333+
let steps = duration_secs
334+
.map(steps_for_duration)
335+
.unwrap_or(DEFAULT_FUZZ_STEPS);
336+
println!("driven-chaos fuzz: seed={seed} steps={steps}");
337+
match mutator_scenarios::run_fuzz(seed, steps).await {
338+
Ok(report) => {
339+
if let Some(violation) = &report.violation {
340+
match mutator_scenarios::write_fuzz_failure(&report) {
341+
Ok(path) => eprintln!(
342+
"FUZZ FAIL seed={seed}: {violation} (replay log: {})",
343+
path.display()
344+
),
345+
Err(e) => eprintln!(
346+
"FUZZ FAIL seed={seed}: {violation} (could not write replay log: {e})"
347+
),
348+
}
349+
exit_code::FAIL
350+
} else {
351+
println!("FUZZ PASS seed={seed} steps={}", report.steps);
352+
exit_code::OK
353+
}
354+
}
355+
Err(e) => {
356+
eprintln!("driven-chaos: fuzz harness error: {e:#}");
357+
exit_code::HARNESS_ERROR
358+
}
359+
}
360+
}
361+
Command::Mutator { flavour, scenario } => {
362+
let result = match flavour {
363+
MutatorFlavour::Fs => mutator_scenarios::run_fs_mutator(&scenario).await,
364+
MutatorFlavour::Drive => mutator_scenarios::run_drive_mutator(&scenario).await,
365+
};
366+
match result {
367+
Ok(outcome) => {
368+
println!(
369+
"mutator {scenario}: {} object(s), hash_ok={}, notes={:?}",
370+
outcome.final_drive_object_count,
371+
outcome.final_hash_matches_local,
372+
outcome.notes
373+
);
374+
exit_code::OK
375+
}
376+
Err(e) => {
377+
eprintln!("driven-chaos: mutator error: {e:#}");
378+
exit_code::HARNESS_ERROR
379+
}
380+
}
381+
}
382+
Command::Report { format } => match std::fs::read_to_string(LAST_RUN_JSON) {
383+
Ok(contents) => {
384+
// The persisted form is already the JSON projection. For
385+
// `--format json` echo it; for `human` we cannot re-derive the
386+
// full human block from JSON alone, so point at the run.
387+
match format {
388+
ReportFormat::Json => print!("{contents}"),
389+
ReportFormat::Human => {
390+
println!("last run report (JSON projection):\n{contents}");
391+
}
392+
}
393+
exit_code::OK
394+
}
395+
Err(e) => {
396+
eprintln!(
397+
"driven-chaos: no persisted run at {LAST_RUN_JSON} ({e}); run `run-all` first"
398+
);
399+
exit_code::HARNESS_ERROR
400+
}
401+
},
402+
Command::FixtureCreate { scenario } => {
403+
let Some(s) = registry::find(&scenario) else {
404+
eprintln!("driven-chaos: unknown scenario {scenario:?}");
405+
return exit_code::HARNESS_ERROR;
406+
};
407+
let root = std::path::PathBuf::from("target/chaos-fixtures").join(s.name());
408+
if let Err(e) = std::fs::create_dir_all(&root) {
409+
eprintln!("driven-chaos: could not create fixture root: {e}");
410+
return exit_code::HARNESS_ERROR;
411+
}
412+
let mut ctx = crate::scenario::ScenarioContext {
413+
fixture_root: root.clone(),
414+
cacheable: false,
415+
};
416+
match s.setup(&mut ctx).await {
417+
Ok(()) => {
418+
println!("fixture for {scenario} materialised at {}", root.display());
419+
exit_code::OK
420+
}
421+
Err(e) => {
422+
eprintln!("driven-chaos: fixture create failed: {e:#}");
423+
exit_code::HARNESS_ERROR
424+
}
425+
}
426+
}
427+
Command::FixtureClean { scenario } => {
428+
let root = std::path::PathBuf::from("target/chaos-fixtures").join(&scenario);
429+
match std::fs::remove_dir_all(&root) {
430+
Ok(()) => {
431+
println!("removed fixture {}", root.display());
432+
exit_code::OK
433+
}
434+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
435+
println!("no fixture at {} (already clean)", root.display());
436+
exit_code::OK
437+
}
438+
Err(e) => {
439+
eprintln!("driven-chaos: fixture clean failed: {e}");
440+
exit_code::HARNESS_ERROR
441+
}
442+
}
443+
}
444+
Command::FixtureCleanAll => {
445+
let root = std::path::PathBuf::from("target/chaos-fixtures");
446+
match std::fs::remove_dir_all(&root) {
447+
Ok(()) => {
448+
println!("removed all fixtures under {}", root.display());
449+
exit_code::OK
450+
}
451+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
452+
println!("no fixtures to clean");
453+
exit_code::OK
454+
}
455+
Err(e) => {
456+
eprintln!("driven-chaos: fixture clean --all failed: {e}");
457+
exit_code::HARNESS_ERROR
458+
}
459+
}
245460
}
246461
}
247462
}

crates/driven-chaos/src/handle.rs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -184,21 +184,31 @@ impl DrivenHandleBuilder {
184184
let state: Arc<SqliteStateRepo> =
185185
Arc::new(SqliteStateRepo::open(&self.state_db_path).await?);
186186

187-
// Seed an account on a fresh DB. A reopened (crash-recovery) DB
188-
// already has its account; `upsert_account` is idempotent so this
189-
// is safe either way.
190-
let account_id = AccountId::new_v4();
191-
state
192-
.upsert_account(&AccountRow {
193-
id: account_id,
194-
email: "chaos@example.com".into(),
195-
display_name: None,
196-
state: AccountState::Ok,
197-
encryption_master_key_id: None,
198-
created_at: 0,
199-
last_synced_at: None,
200-
})
201-
.await?;
187+
// Adopt the existing account on a reopened (crash-recovery) DB so the
188+
// booted orchestrator drives the SAME account the pre-crash run did;
189+
// only seed a fresh account when the DB is brand new. Seeding a new
190+
// random `account_id` unconditionally would leave the reopened
191+
// orchestrator pointed at an empty account (no sources) and silently
192+
// upload nothing - which is exactly what broke the kill-9 /
193+
// pause-mid-resumable crash-recovery scenarios.
194+
let account_id = match state.list_accounts().await?.into_iter().next() {
195+
Some(existing) => existing.id,
196+
None => {
197+
let id = AccountId::new_v4();
198+
state
199+
.upsert_account(&AccountRow {
200+
id,
201+
email: "chaos@example.com".into(),
202+
display_name: None,
203+
state: AccountState::Ok,
204+
encryption_master_key_id: None,
205+
created_at: 0,
206+
last_synced_at: None,
207+
})
208+
.await?;
209+
id
210+
}
211+
};
202212

203213
let remote: Arc<dyn RemoteStore> = self
204214
.remote

crates/driven-chaos/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ pub mod handle;
2525
pub mod mutator;
2626
pub mod registry;
2727
pub mod reporting;
28+
pub mod runner;
2829
pub mod scenario;
2930
pub mod scenarios;

crates/driven-chaos/src/registry.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ pub fn registry() -> Vec<Box<dyn Scenario>> {
2323
all.extend(scenarios::mutation::scenarios());
2424
all.extend(scenarios::drive_side::scenarios());
2525
all.extend(scenarios::concurrency::scenarios());
26+
// s4 continuous-mutation soak / fuzz scenarios and s6.3 cross-scenario
27+
// invariant scenarios are registered alongside the s3 catalogue so
28+
// `scenario list` / `run` / `run-all` see them too.
29+
all.extend(scenarios::mutator::scenarios());
30+
all.extend(scenarios::reporting::scenarios());
2631
all
2732
}
2833

0 commit comments

Comments
 (0)