Skip to content

test(verify): chaos suite for every engine, and fixes for the 58 crashes and hangs it found - #2709

Merged
richiemcilroy merged 15 commits into
mainfrom
ci/verify-chaos
Oct 19, 2026
Merged

richiemcilroy merged 15 commits into
mainfrom
ci/verify-chaos

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

A new engine-verify suite, chaos, feeds hostile device data, files, projects and settings through the recording, editor, playback, export and recovery engines, one probe per child process, and fails on any panic, abort, signal, Objective-C exception, hang or success with an empty output. Its first run on main found 58 failing probes from 19 root causes. This PR adds the suite and fixes all of them.

The suite

  • crates/verify/tests/chaos.rs + chaos_support/: 764 probes in six areas (chaos/record/, chaos/files/, chaos/export/, chaos/editor/, chaos/fragments/, chaos/fuzz/). Each probe re-runs the test binary as a child, so an abort or a hang ends the child, not the run; a panic hook records panics on every thread. Fuzzing is seeded and failing seeds are shrunk; the failure prints its repro.
  • Registered in scripts/verify/suites.json for macOS (fast and full tiers); run.sh builds cap-muxer for it like the crash suite.
  • MicrophoneFeed's synthetic test handle gains push_raw so probes can deliver raw callback buffers in any format, rate and channel count; the synthetic camera and microphone gain remove_input so actor probes exercise a real device loss (RemoveInput), separately from probes where frames just stop.
  • Every export that reports success must have written playable media, whatever its input. Coverage floors are the exact case counts (fast 477, full 764), so a dropped group fails the gate.

Fixes

Recording (each of these killed the recording or its audio track):

  • Empty or partial microphone callbacks. AudioInfo::wrap_frame copied the callback's bytes into a frame sized for whole samples only: a callback with no whole sample (CoreAudio sends empty buffers around device changes) panicked on a frame with no planes, and one ending mid-sample copied past the frame. Partial trailing samples are now dropped and empty callbacks give an empty frame.
  • Audio input that changes format, layout or rate mid-stream. BufferedResampler::add_frame unwrapped swr's "Input changed" error. It now rebuilds the resampler for the new input and keeps the output, and drops (with a warning) frames no resampler accepts, such as 0 channels or rate 0. Its check is exactly the one run() makes, so unchanged streams take the same path. On a rate change the encoder's stored timestamps (first/last pts and the resampler's minimum next pts) are rescaled to the new rate, so 10 s at 48 kHz followed by 8 kHz input continues at 10 s instead of jumping to 60 s.
  • macOS screen/camera buffers that grow or change format mid-recording. copy_plane_data copied past the pooled frame (allocated at the size the encoder was opened with). The copy is now bounded by both planes, so every buffer that fits copies byte for byte as before and a larger one is cropped; a buffer whose pixel format differs from the frame's is rejected (every such case either panicked or wrote garbage before).

Opening, editing and exporting damaged projects:

  • Single-segment projects with an unreadable display or camera file panicked in ProjectRecordingsMeta::new (expect); they now return the same error multi-segment projects do.
  • A timeline clip pointing at a recording segment that does not exist indexed out of bounds in the editor preview and in export. Export now validates the timeline first (RenderingError::MissingRecordingSegment / InvalidTimelineSegment for non-finite or backwards clips); the editor preview stops at that clip like it does when no segment is found.
  • Clip speeds that are zero, non-finite or vanishingly small (the editor offers 0.25x to 8x) stretched the timeline toward infinite duration and hung playback and export; they read as 1x on load.
  • A clip ending more than a minute past its recording (end = 1e300) made export render billions of frames and the editor precompute zoom for all of them; the editor and exporter pull such an end back to the recording's duration when they load the project. Clips within a minute of their recording are untouched.
  • A recording meta fps of 0 (recovery's "not recorded" marker) left the editor with no frame; the decoders use the legacy 30 fps for it. The meta value itself is unchanged, so recovery still detects the capture rate.
  • A NUL byte in a meta media path panicked inside rust-ffmpeg's C-string conversion; paths are cleaned on load.
  • Background padding that is NaN, infinite or negative produced a zero-sized render target (wgpu validation panic); it is clamped to the editor's 0 to 100 range.
  • Output sizes past the device's 8192 px texture limit (a padded 6K recording exported at its own size) or below 4x2 failed to allocate render targets; the output is scaled to fit and floored at 4x2. Every size that rendered before is unchanged.
  • Integer overflows on hostile values in the AVAssetReader frame cache window, zoom sample counts, base-size rounding and the H.264 bitrate (capped at 1 Gbit/s).
  • An empty timeline (or one whose clips are all zero length) and a 0 fps MOV export reported success with an empty file; they now fail with "There is nothing to export" / a frame-rate error.

Performance:

  • Text segments were re-shaped on every frame. The text layer now reuses last frame's shaped buffer when the content, attributes, alignment, metrics, wrap width and colours are unchanged. A 6,000-glyph emoji string shapes in 1.5 s once and then costs 1.5 ms per frame (debug build); a GPU test checks reused shaping renders byte-identical to fresh shaping and that edits re-shape.

Verification

  • scripts/verify/run.sh full --suites chaos on macOS: 760 of 764 probes pass (main: 702 pass, 58 fail).
  • New unit tests for each fix's edge cases: empty/partial callbacks for packed and planar layouts, resampler input changes and rejected frames, bounded plane copies including degenerate strides and huge heights, timescale repair, clip clamping, NUL paths, text shaping reuse.
  • chaos/files/text-hostile uses 600 characters of NUL / right-to-left override / emoji at font weight 100 rather than 6,000: at weight 100 every such glyph runs a full font-fallback search (about 45 ms each in a debug build, linear), so 6,000 only measured the debug build's speed.
  • cargo test for the touched crates, cargo clippy and cargo fmt clean.

RetriggerView in GreptileConfidence Score: 5/5

The latest change appears safe to merge, with one non-blocking gap left in the mixed-audio probe.

Findings

  1. P2 Empty audio fails the suite ▶
Fix with agent prompt
### Issue 1
crates/verify/tests/chaos_support/record.rs:undefined-722
`empty-buffers` supplies no audio samples, but `ok()` sets `expect_media` to `true`. The Ogg path can finish cleanly without audio packets, which this probe reports as `wrong-success`. This creates a false failure in the chaos gate.

Set `expect_media` to `false` for the single-source empty-buffer cases. Keep the mixed-source case strict because its second source supplies audio.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR adds isolated chaos probes across recording, editing, export and recovery, along with fixes for crashes and hangs they exposed.

  • Recording keeps going when device audio or video data changes.
  • Damaged projects open without sending editor previews into runaway work.
  • Exports reject empty work and keep render sizes and encoding values bounded.
  • Text layers reuse shaped text when their visual inputs stay the same.
  • The chaos suite runs engine probes in isolated child processes.
  • Recording probes drive hostile audio and video through real capture pipelines.
  • Project probes send damaged recordings and settings through editor and export paths.

Reviews (4) · Last reviewed commit: "test(verify): expect no audio from the e..." · Reviewed by Greptile

@vercel

vercel Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cap-status Ready Ready Preview Oct 19, 2026 12:14pm UTC

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

Comment on lines +106 to +108
if !self.accepts(&frame) && !self.rebuild_for(&frame) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Audio jumps after rate changes

Rebuilding the resampler changes the units of input timestamps, but min_next_pts still holds a sample count from the old rate. After ten seconds at 48 kHz, switching to 8 kHz clamps the next frame to roughly 480,000 samples instead of 80,000. Rescaling that timestamp puts the audio near 60 seconds and can insert a long silent gap.

AudioEncoderBase::send_frame also stamps the first changed frame using the old rate. Keep timestamp tracking in fixed units, or convert all stored timestamps when the input rate changes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/enc-ffmpeg/src/audio/buffered_resampler.rs
Line: 106-108

Comment:
**Audio jumps after rate changes**

Rebuilding the resampler changes the units of input timestamps, but `min_next_pts` still holds a sample count from the old rate. After ten seconds at 48 kHz, switching to 8 kHz clamps the next frame to roughly 480,000 samples instead of 80,000. Rescaling that timestamp puts the audio near 60 seconds and can insert a long silent gap.

`AudioEncoderBase::send_frame` also stamps the first changed frame using the old rate. Keep timestamp tracking in fixed units, or convert all stored timestamps when the input rate changes.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +1070 to +1073
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
if !expect_media {
return Ok(Verdict::Ok(format!("{size} bytes")));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty exports pass the suite

Every fuzz export passes expect_media = false, so this branch returns Verdict::Ok even when the output file is missing or empty. export_config also reports success without checking the output. These probes can therefore miss the wrong-success failures the suite is meant to catch.

Use a shared output check after a successful export. Damaged input may return an error, but success should still produce a readable file with frames.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/verify/tests/chaos_support/project.rs
Line: 1070-1073

Comment:
**Empty exports pass the suite**

Every fuzz export passes `expect_media = false`, so this branch returns `Verdict::Ok` even when the output file is missing or empty. `export_config` also reports success without checking the output. These probes can therefore miss the wrong-success failures the suite is meant to catch.

Use a shared output check after a successful export. Damaged input may return an error, but success should still produce a readable file with frames.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Err(_) => ("panicked", String::new()),
};
log_line(&["verdict", &seed.to_string(), kind, &one_line(&text)]);
let _ = std::fs::remove_dir_all(&dir);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Saved failure files disappear

The child deletes each seed directory after writing its verdict, even when CAP_VERIFY_CHAOS_KEEP_SCRATCH=1. The flag only prevents the parent from deleting the outer directory. Developers lose the damaged inputs and outputs from completed panic and wrong-success failures.

Honor the keep flag here too.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/verify/tests/chaos_support/harness.rs
Line: 224

Comment:
**Saved failure files disappear**

The child deletes each seed directory after writing its verdict, even when `CAP_VERIFY_CHAOS_KEEP_SCRATCH=1`. The flag only prevents the parent from deleting the outer directory. Developers lose the damaged inputs and outputs from completed panic and wrong-success failures.

Honor the keep flag here too.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread scripts/verify/suites.json Outdated
Comment on lines +158 to +162
"fast": {
"macos": 0
},
"full": {
"macos": 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing chaos runs pass

Both chaos case floors are zero, so the check in floor.py passes even when no chaos cases ran. Skipped tests or dropped groups can silently remove coverage without failing this gate.

Set nonzero floors based on the expected fast and full case counts.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/verify/suites.json
Line: 158-162

Comment:
**Missing chaos runs pass**

Both chaos case floors are zero, so the check in `floor.py` passes even when no chaos cases ran. Skipped tests or dropped groups can silently remove coverage without failing this gate.

Set nonzero floors based on the expected fast and full case counts.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +324 to +330
Step::DropCamera => {
camera = None;
Ok(())
}
Step::DropMic => {
mic = None;
Ok(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Disconnect probes only stop frames

DropCamera and DropMic only drop the local synthetic handles. The recording keeps cloned feed locks, and neither synthetic handle has a drop action that disconnects its feed. These probes stop sending frames but never exercise a device disconnect.

Add an explicit disconnect operation and use it here, keeping frame-stall probes separate.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/verify/tests/chaos_support/actors.rs
Line: 324-330

Comment:
**Disconnect probes only stop frames**

`DropCamera` and `DropMic` only drop the local synthetic handles. The recording keeps cloned feed locks, and neither synthetic handle has a drop action that disconnects its feed. These probes stop sending frames but never exercise a device disconnect.

Add an explicit disconnect operation and use it here, keeping frame-stall probes separate.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split into two kinds: camera-stalls / mic-stalls (frames stop, the feed stays) and camera-disconnects / mic-disconnects, which send the feed's RemoveInput through a new synthetic remove_input, the message the app sends when a device goes away. The native stream dying below the feed (CoreAudio/AVFoundation) is not reachable without a real device, so that layer stays out of the synthetic suite.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That distinction is correct, and the updated probes now cover it: camera-stalls/mic-stalls drop only the producer handle, while camera-disconnects/mic-disconnects call remove_input() and exercise the feed’s RemoveInput path. Keeping native CoreAudio/AVFoundation device loss out of this synthetic suite is appropriate since it cannot be reproduced without a real device. With that split, this finding is resolved.

let root = ctx.path("fx");
copy_dir(&fixture()?, &root)?;
let studio = root.join("studio");
let n = mutate_tree(ctx, &studio.join("content"), 11)?;
let root = ctx.path("fx");
copy_dir(&fixture()?, &root)?;
let content = root.join("instant/content");
let n = mutate_tree(ctx, &content, 12)?;
30,
target(|ctx| {
let dir = project::prepare(ctx).context("prepare")?;
let n = mutate_json_file(ctx, &dir.join("project-config.json"), 1)?;
90,
target(|ctx| {
let dir = project::prepare(ctx).context("prepare")?;
let n = mutate_json_file(ctx, &dir.join("project-config.json"), 2)?;
90,
target(|ctx| {
let dir = project::prepare(ctx).context("prepare")?;
let n = mutate_json_file(ctx, &dir.join("project-config.json"), 3)?;
90,
target(|ctx| {
let dir = project::prepare(ctx).context("prepare")?;
let n = mutate_json_file(ctx, &dir.join(SEGMENT).join("cursor.json"), 6)?;
90,
target(|ctx| {
let dir = project::prepare(ctx).context("prepare")?;
let n = mutate_bytes_file(ctx, &dir.join(SEGMENT).join("display.mp4"), 7)?;
90,
target(|ctx| {
let dir = project::prepare(ctx).context("prepare")?;
let n = mutate_bytes_file(ctx, &dir.join(SEGMENT).join("camera.mp4"), 8)?;
90,
target(|ctx| {
let dir = project::prepare(ctx).context("prepare")?;
let n = mutate_bytes_file(ctx, &dir.join(SEGMENT).join("audio-input.ogg"), 9)?;
30,
target(|ctx| {
let dir = project::prepare(ctx).context("prepare")?;
let n = mutate_bytes_file(ctx, &dir.join(SEGMENT).join("display.mp4"), 10)?;
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

for f in &mut empty {
f.samples = 0;
}
out.push(ok("empty-buffers", empty));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty audio fails the suite

empty-buffers supplies no audio samples, but ok() sets expect_media to true. The Ogg path can finish cleanly without audio packets, which this probe reports as wrong-success. This creates a false failure in the chaos gate.

Set expect_media to false for the single-source empty-buffer cases. Keep the mixed-source case strict because its second source supplies audio.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/verify/tests/chaos_support/record.rs
Line: 722

Comment:
**Empty audio fails the suite**

`empty-buffers` supplies no audio samples, but `ok()` sets `expect_media` to `true`. The Ogg path can finish cleanly without audio packets, which this probe reports as `wrong-success`. This creates a false failure in the chaos gate.

Set `expect_media` to `false` for the single-source empty-buffer cases. Keep the mixed-source case strict because its second source supplies audio.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found 2 potential issues (2 medium).

View 2 other findings in Capy.

Open in Capy

Comment on lines +47 to +56
let previous_rate = self.resampler.input().rate;
let input_rate = if frame.rate() > 0 {
frame.rate()
} else {
previous_rate
};
self.inner
.rescale_input_rate(f64::from(previous_rate), f64::from(input_rate));
self.inner
.update_pts_with_rate(&mut frame, timestamp, input_rate);
.update_pts_with_rate(&mut frame, timestamp, f64::from(input_rate));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rejected rate changes leave encoder timestamps in the wrong units

Medium Severity

When a frame has a new positive rate but an unsupported format or layout, send_frame rescales first_pts and last_frame_pts before BufferedResampler::rebuild_for rejects it. The resampler retains its old input rate, so the next valid frame at that old rate skips rescaling even though the encoder state now uses the rejected rate. For example, a 48 kHz stream starting at timestamp 1 s, followed by a rejected 8 kHz frame and then valid 48 kHz input, retains first_pts=8000 instead of 48000, inserting about 0.83 s of silence and shifting subsequent audio. Ensure the resampler accepts the new input before committing timestamp changes, or track the timestamp state's rate independently of the resampler.

Open in Capy

});
break;
}
next = after;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crashes between fuzz seeds can disappear from the results

Medium Severity

A background engine thread can abort after a seed's verdict is logged but before the next seed's start event, including during scratch cleanup. In that case unfinished is None, and when more seeds remain, this branch advances next without recording the abnormal exit. Earlier verdicts remain passes and a successful replacement child can make the entire probe pass despite SIGABRT, SIGSEGV, or an Objective-C exception. Record abnormal termination against the last completed seed before restarting, not only when after >= end.

Open in Capy

@richiemcilroy
richiemcilroy merged commit 0e5f343 into main Oct 19, 2026
94 of 97 checks passed

This branch was successfully deployed

2 active deployments
Preview – cap-web — 67dcd155 Deployed Oct 19, 2026 by vercel[bot]
Preview – cap-status — 67dcd155 Deployed Oct 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants