Skip to content

Commit cae19dc

Browse files
pmaxhoganclaude
andcommitted
ci(chaos): windows-only PR gate, 3-OS on tags, fuzz/soak local + recheck-2 fixes
Maintainer budget decision: trim chaos Actions spend. - chaos-hermetic + chaos-fake-drive run windows-only on PR/main pushes (Windows is the primary platform); the full 3-OS matrix runs ONLY on `v*` tag pushes via a startsWith(github.ref,'refs/tags/') matrix switch. - Remove the weekly 6h soak cron + in-CI fuzz. The long fuzz + soak-gated massive-input rows run locally via `just chaos-soak` / `just chaos-fuzz`. - justfile: add chaos-fake-drive + chaos-soak recipes. Folds in the two fixable M3.7 recheck round-2 (final) codex findings: - fuzz --duration was silently capped at the 60s SCENARIO_WALL_CAP. run_fuzz now takes an explicit wall_cap so `fuzz --duration D` soaks by wall-clock for D (a local --duration 6h actually soaks 6h); fuzz-smoke stays bounded by a small step budget + 60s. Verified: --duration 8s ran 2038 steps over 8.6s. - central duplicate-client_op_uuid check counts over list_folder_with_trashed (including trashed), so "create two for one op then trash one" is caught; legitimate trash-then-recreate uses distinct uuids so never false-positives. The remaining recheck-2 findings (deferred-create pending-op exemption, daily-quota midnight-resume = M4, uncapped setup, huge-file cacheable) are documented as accepted residuals in design/CODEX_NOTES.md (recheck cap reached; none are regressions or affect the green gates). Gates: fmt; clippy -D warnings; test --workspace; hermetic 56 PASS/29 SKIP/0 FAIL; fault-injection 27 PASS/0 FAIL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8meqeTo8bcZ3zjgKjBnJ4
1 parent 9abb3bd commit cae19dc

6 files changed

Lines changed: 180 additions & 101 deletions

File tree

.github/workflows/chaos.yml

Lines changed: 34 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,39 @@ name: Chaos
22

33
# Driven stress / chaos harness (design/STRESS_HARNESS.md s7).
44
#
5+
# COST POLICY (maintainer budget): the chaos jobs run WINDOWS-ONLY on every PR
6+
# and push to main (Windows is the primary target platform). The full 3-OS
7+
# matrix [ubuntu, macos, windows] runs ONLY on `v*` tag pushes (release gates),
8+
# where the extra macOS/Windows runner cost is worth the release-time coverage.
9+
# The weekly 6-hour fuzz soak is NOT run in CI - it is a local/on-demand task
10+
# (`just chaos-fuzz` / `just chaos-soak`); the bounded `fuzz-smoke` row still
11+
# runs in the per-PR sweep. Unix-shaped rows (POSIX perms, case-sensitive FS)
12+
# are exercised on the ubuntu leg of the tag-push 3-OS run and locally on Linux.
13+
#
514
# Jobs:
6-
# chaos-hermetic - every PR, 3-OS matrix [ubuntu, macos, windows]. Runs
7-
# every scenario whose requires() is satisfiable on a stock
8-
# non-elevated runner of that OS; the rest SKIP cleanly.
9-
# Linux + macOS exercise the Unix-shaped rows; Windows adds
10-
# the NTFS / Win32-shaped rows. None need real Drive or
11-
# elevation (the admin / VSS / real-Drive rows SKIP).
12-
# ROADMAP M3.7 acceptance: Linux + macOS + Windows.
13-
# chaos-fake-drive - every PR, 3-OS matrix. The dedicated fault-injection gate
14-
# (STRESS_HARNESS s7 / ROADMAP M3.7): runs ONLY the s3.7
15-
# Drive-side hazards + s4.2/s5 drive-side mutator faults
16-
# against InMemoryRemoteStore (`run-all --fault-injection`).
17-
# These rows are also in the hermetic sweep; this focused
18-
# gate gives the separately-named, faster fault-injection
19-
# check the acceptance requires.
15+
# chaos-hermetic - every PR/push: windows-only; on a `v*` tag: 3-OS matrix.
16+
# Runs every scenario whose requires() is satisfiable on a
17+
# stock non-elevated runner; the rest SKIP cleanly.
18+
# chaos-fake-drive - every PR/push: windows-only; on a `v*` tag: 3-OS matrix.
19+
# The dedicated fault-injection gate (STRESS_HARNESS s7):
20+
# runs ONLY the s3.7 Drive-side hazards + s4.2/s5 drive-side
21+
# mutator faults against InMemoryRemoteStore
22+
# (`run-all --fault-injection`).
2023
# chaos-real-drive - WIRED but SKIPPED until M4: gated on the
2124
# DRIVEN_E2E_REFRESH_TOKEN secret, which is absent until
2225
# the GoogleDriveStore (M4) lands. It skips cleanly, it
2326
# does NOT fail (STRESS_HARNESS s7 "Gating on M4").
24-
# chaos-soak - weekly cron: a long seeded fuzz run. Informational.
27+
#
28+
# The long fuzz soak + soak-gated massive-input rows (million-files-nested,
29+
# tiny-files-100k) run LOCALLY via the justfile (`just chaos-soak`,
30+
# `just chaos-fuzz`), not in CI.
2531

2632
on:
2733
push:
2834
branches: [main]
35+
tags: ["v*"]
2936
pull_request:
3037
branches: [main]
31-
schedule:
32-
# Weekly soak, Monday 06:00 UTC (STRESS_HARNESS s7 chaos-soak).
33-
- cron: "0 6 * * 1"
3438

3539
concurrency:
3640
group: ${{ github.workflow }}-${{ github.ref }}
@@ -47,17 +51,14 @@ env:
4751
jobs:
4852
chaos-hermetic:
4953
name: chaos hermetic (${{ matrix.os }})
50-
# Skip the PR run on the scheduled trigger; the soak job covers cron.
51-
if: github.event_name != 'schedule'
5254
strategy:
5355
fail-fast: false
5456
matrix:
55-
# ROADMAP M3.7 acceptance: the hermetic harness must pass on all three
56-
# OS families. Each runner capability-gates its own scenario set - Linux
57-
# + macOS run the Unix-shaped rows, Windows runs the NTFS / Win32 rows;
58-
# the admin / VSS / real-Drive rows SKIP cleanly on every unelevated
59-
# runner (recorded, never red).
60-
os: [ubuntu-latest, macos-latest, windows-latest]
57+
# WINDOWS-ONLY on a normal PR/push (the primary platform, cheapest
58+
# coverage); the FULL 3-OS matrix only on a `v*` tag push, where Linux +
59+
# macOS add the Unix-shaped rows for the release gate. The admin / VSS /
60+
# real-Drive rows SKIP cleanly on every unelevated runner (never red).
61+
os: ${{ startsWith(github.ref, 'refs/tags/') && fromJSON('["ubuntu-latest","macos-latest","windows-latest"]') || fromJSON('["windows-latest"]') }}
6162
runs-on: ${{ matrix.os }}
6263
steps:
6364
- uses: actions/checkout@v4
@@ -70,23 +71,22 @@ jobs:
7071
libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev javascriptcoregtk-4.1
7172
- uses: Swatinem/rust-cache@v2
7273
# run-all capability-gates every scenario: on a stock non-elevated runner
73-
# the admin / NTFS / real-Drive rows SKIP (recorded, never red); the rest
74-
# run against the headless core + InMemoryRemoteStore.
74+
# the admin / real-Drive rows SKIP (recorded, never red); the rest run
75+
# against the headless core + InMemoryRemoteStore.
7576
# Exit code: 0 = all pass/skip, 1 = any fail (STRESS_HARNESS s9).
7677
- name: chaos run-all (hermetic)
7778
run: cargo run -p driven-chaos -- run-all --hermetic
7879

7980
chaos-fake-drive:
8081
name: chaos fake-drive (${{ matrix.os }})
81-
# The dedicated fault-injection gate (ROADMAP M3.7 acceptance: a distinct
82-
# chaos-fake-drive job on Linux + macOS + Windows). Runs ONLY the s3.7 /
83-
# s4.2 / s5 fault-injection subset against InMemoryRemoteStore; capability
84-
# gated rows (the daily-quota real-Drive path) SKIP cleanly, never red.
85-
if: github.event_name != 'schedule'
82+
# The dedicated fault-injection gate (STRESS_HARNESS s7). Runs ONLY the
83+
# s3.7 / s4.2 / s5 fault-injection subset against InMemoryRemoteStore;
84+
# capability-gated rows SKIP cleanly, never red. Same cost policy as
85+
# hermetic: windows-only normally, 3-OS on a `v*` tag.
8686
strategy:
8787
fail-fast: false
8888
matrix:
89-
os: [ubuntu-latest, macos-latest, windows-latest]
89+
os: ${{ startsWith(github.ref, 'refs/tags/') && fromJSON('["ubuntu-latest","macos-latest","windows-latest"]') || fromJSON('["windows-latest"]') }}
9090
runs-on: ${{ matrix.os }}
9191
steps:
9292
- uses: actions/checkout@v4
@@ -129,37 +129,3 @@ jobs:
129129
# throwaway Drive folder; everything else runs as in the hermetic job.
130130
- name: chaos run-all (real Drive)
131131
run: cargo run -p driven-chaos -- run-all --hermetic
132-
133-
chaos-soak:
134-
name: chaos soak (weekly fuzz)
135-
# Weekly cron only; informational, does not gate merges (STRESS_HARNESS s7).
136-
if: github.event_name == 'schedule'
137-
runs-on: ubuntu-latest
138-
# Opt into the soak-grade massive-input rows (million-files-nested,
139-
# tiny-files-100k); they SKIP in the per-PR matrix and run here.
140-
env:
141-
DRIVEN_CHAOS_SOAK: "1"
142-
steps:
143-
- uses: actions/checkout@v4
144-
- uses: dtolnay/rust-toolchain@stable
145-
- name: Install Linux Tauri deps
146-
run: |
147-
sudo apt-get update
148-
sudo apt-get install -y libwebkit2gtk-4.1-dev libxdo-dev libssl-dev \
149-
libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev javascriptcoregtk-4.1
150-
- uses: Swatinem/rust-cache@v2
151-
# First the soak-grade massive-input scenarios (skipped in PR), then the
152-
# long seeded fuzz run. Any invariant violation writes
153-
# target/chaos-fuzz-failures/<seed>.json for bit-reproducible replay
154-
# (STRESS_HARNESS s4.3).
155-
- name: chaos run-all (soak-gated rows)
156-
run: cargo run -p driven-chaos -- run-all --hermetic
157-
- name: chaos fuzz (seeded soak)
158-
run: cargo run -p driven-chaos -- fuzz --seed ${{ github.run_id }} --duration 30m
159-
- name: upload fuzz failure replays
160-
if: failure()
161-
uses: actions/upload-artifact@v4
162-
with:
163-
name: chaos-fuzz-failures
164-
path: target/chaos-fuzz-failures/
165-
if-no-files-found: ignore

crates/driven-chaos/src/dispatch.rs

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -257,18 +257,12 @@ pub mod exit_code {
257257
/// subcommand can re-print it (STRESS_HARNESS s2.2 / s6.2).
258258
const LAST_RUN_JSON: &str = "target/chaos-runs/last-run.json";
259259

260-
/// A default fuzz step budget for a CLI `fuzz` invocation with no
261-
/// `--duration`. The weekly soak (s7) drives a much larger budget via the
262-
/// `--duration` -> step-budget mapping below.
260+
/// A default fuzz step budget for a CLI `fuzz` invocation with no `--duration`
261+
/// (a bare `fuzz` stays quick: this many steps under the 60s safety cap). A
262+
/// `fuzz --duration D` instead soaks by wall-clock for D with a very large step
263+
/// budget (see the `Fuzz` arm in [`run`]).
263264
const DEFAULT_FUZZ_STEPS: u64 = 200;
264265

265-
/// Map a `--duration` in seconds onto a fuzz step budget. Each second of
266-
/// requested soak buys a fixed number of mutation steps; the run's wall-clock
267-
/// cap inside `run_fuzz` still bounds an over-long request.
268-
fn steps_for_duration(secs: u64) -> u64 {
269-
secs.saturating_mul(50).max(DEFAULT_FUZZ_STEPS)
270-
}
271-
272266
/// Persist a finished run so `report` can re-print it. Best-effort: a write
273267
/// failure must not flip an otherwise-green run red, so it only warns.
274268
fn persist_last_run(report: &RunReport) {
@@ -372,11 +366,20 @@ pub async fn run(command: Command, caps: &CapabilitySet) -> i32 {
372366
.map(|d| d.as_secs())
373367
.unwrap_or(0)
374368
});
375-
let steps = duration_secs
376-
.map(steps_for_duration)
377-
.unwrap_or(DEFAULT_FUZZ_STEPS);
378-
println!("driven-chaos fuzz: seed={seed} steps={steps}");
379-
match mutator_scenarios::run_fuzz(seed, steps).await {
369+
// `--duration D` governs the run by WALL-CLOCK: soak for D with a
370+
// very large step budget so the duration is the binding bound (a
371+
// local `fuzz --duration 6h` actually soaks 6h). Without a duration,
372+
// the run is bounded by a small default step budget + the 60s safety
373+
// cap so a bare `fuzz` invocation stays quick.
374+
let (steps, wall_cap) = match duration_secs {
375+
Some(secs) => (u64::MAX, std::time::Duration::from_secs(secs)),
376+
None => (DEFAULT_FUZZ_STEPS, mutator_scenarios::SCENARIO_WALL_CAP),
377+
};
378+
println!(
379+
"driven-chaos fuzz: seed={seed} step_budget={steps} wall_cap={}s",
380+
wall_cap.as_secs()
381+
);
382+
match mutator_scenarios::run_fuzz(seed, steps, wall_cap).await {
380383
Ok(report) => {
381384
if let Some(violation) = &report.violation {
382385
match mutator_scenarios::write_fuzz_failure(&report) {

crates/driven-chaos/src/scenarios/mutator.rs

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ pub fn scenarios() -> Vec<Box<dyn Scenario>> {
108108
/// bounded ITERATION count + tiny intervals rather than the 5-minute soak
109109
/// default (which is reserved for the `fuzz --duration` CLI path). This cap is
110110
/// a backstop the driver can enforce; exceeding it is `harness.timeout`.
111-
const SCENARIO_WALL_CAP: Duration = Duration::from_secs(60);
111+
pub const SCENARIO_WALL_CAP: Duration = Duration::from_secs(60);
112112

113113
/// Cycles a registered soak scenario interleaves with mutation (small, fast,
114114
/// deterministic - not the 5-min soak).
@@ -1260,12 +1260,19 @@ pub struct FuzzReport {
12601260
/// sequence of filesystem mutations interleaved with sync cycles against a
12611261
/// synthesised source tree, then assert the s6.3 post-condition invariants.
12621262
///
1263-
/// `step_budget` bounds the run (the CLI maps `--duration` onto a step budget
1264-
/// so a registered run stays fast and a soak run goes long). On any invariant
1265-
/// violation the returned report carries `violation = Some(..)` and the full
1266-
/// mutation log, which the caller writes to
1263+
/// `step_budget` and `wall_cap` BOTH bound the run, whichever is hit first: the
1264+
/// registered `fuzz-smoke` row passes a small step budget + the 60s
1265+
/// [`SCENARIO_WALL_CAP`] so it stays fast, while the `fuzz --duration D` CLI
1266+
/// path passes a very large step budget + `wall_cap = D` so a local soak
1267+
/// actually runs for the requested wall-clock (`--duration 6h` soaks 6h, not
1268+
/// 60s). On any invariant violation the returned report carries
1269+
/// `violation = Some(..)` and the full mutation log, which the caller writes to
12671270
/// `target/chaos-fuzz-failures/<seed>.json` for bit-reproducible replay.
1268-
pub async fn run_fuzz(seed: u64, step_budget: u64) -> anyhow::Result<FuzzReport> {
1271+
pub async fn run_fuzz(
1272+
seed: u64,
1273+
step_budget: u64,
1274+
wall_cap: Duration,
1275+
) -> anyhow::Result<FuzzReport> {
12691276
let mut rng = Rng::new(seed);
12701277
let state_dir = tempfile::tempdir()?;
12711278
let root_dir = tempfile::tempdir()?;
@@ -1283,7 +1290,7 @@ pub async fn run_fuzz(seed: u64, step_budget: u64) -> anyhow::Result<FuzzReport>
12831290

12841291
let started = std::time::Instant::now();
12851292
for step_index in 0..step_budget {
1286-
if started.elapsed() > SCENARIO_WALL_CAP {
1293+
if started.elapsed() > wall_cap {
12871294
break;
12881295
}
12891296
let step = pick_step(&mut rng);
@@ -1478,7 +1485,9 @@ impl Scenario for FuzzSmokeScenario {
14781485
}
14791486

14801487
async fn run_assertions(&self, _handle: &DrivenHandle) -> anyhow::Result<Outcome> {
1481-
let report = run_fuzz(self.seed, self.steps).await?;
1488+
// The registered smoke row stays bounded by BOTH a small step budget
1489+
// and the 60s wall cap, so it can never balloon into a long CI step.
1490+
let report = run_fuzz(self.seed, self.steps, SCENARIO_WALL_CAP).await?;
14821491
if let Some(reason) = &report.violation {
14831492
// Persist the failure for bit-reproducible replay, then fail.
14841493
let path = write_fuzz_failure(&report)?;
@@ -1584,7 +1593,11 @@ mod tests {
15841593
/// real headless core).
15851594
#[tokio::test]
15861595
async fn fuzz_smoke_holds_invariants() {
1587-
let report = run_fuzz(0xABCD_1234, 80).await.expect("fuzz run");
1596+
// 80 steps complete in well under the 60s wall cap, so the step budget
1597+
// is the binding bound and the full budget runs.
1598+
let report = run_fuzz(0xABCD_1234, 80, SCENARIO_WALL_CAP)
1599+
.await
1600+
.expect("fuzz run");
15881601
assert_eq!(report.steps, 80, "ran the full budget");
15891602
assert!(
15901603
report.violation.is_none(),

crates/driven-chaos/src/scenarios/reporting.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,15 +200,19 @@ pub async fn assert_invariants(
200200
// leaves the remote in a LATCHED fault state (e.g. auth.invalid_grant, which
201201
// the fake applies to read calls too) can still have its terminal-state
202202
// invariants verified instead of the sweep itself erroring on the fault.
203-
let live: Vec<_> = remote
204-
.list_folder_with_trashed(folder_id)
205-
.into_iter()
206-
.filter(|e| !e.trashed)
207-
.collect();
203+
let all: Vec<_> = remote.list_folder_with_trashed(folder_id);
204+
let live: Vec<_> = all.iter().filter(|e| !e.trashed).cloned().collect();
208205
let live_object_count = live.len() as u64;
209206

207+
// No duplicate Drive objects per client_op_uuid (s6.3). Count over ALL
208+
// objects INCLUDING trashed ones: "two objects created for one op, then one
209+
// trashed" is still evidence of a duplicate-create bug, so filtering trashed
210+
// out before counting would hide it. Each upload op stamps a FRESH
211+
// client_op_uuid, so a legitimate trash-then-recreate carries two distinct
212+
// uuids and never collides here; only a genuine duplicate create does.
213+
// (Mirrors the mutator checker, which already counts with-trashed.)
210214
let mut uuid_counts: HashMap<String, u64> = HashMap::new();
211-
for entry in &live {
215+
for entry in &all {
212216
if let Some(uuid) = entry.app_properties.get(CLIENT_OP_UUID_KEY) {
213217
*uuid_counts.entry(uuid.clone()).or_insert(0) += 1;
214218
}

0 commit comments

Comments
 (0)