Repository navigation
perf(recording): drain macOS encoder autorelease pools and recycle video frames - #3084
ryder777777 wants to merge 1 commit into
Conversation
|
@ryder777777 is attempting to deploy a commit to the Cap Software Inc Team on Vercel. A member of the Team first needs to authorize it. |
| if reorder_buf.len() > reorder_window | ||
| && let Some((seq, oldest)) = reorder_buf.pop_first() | ||
| { | ||
| *next_encode_seq = seq.saturating_add(1); | ||
| if emit(oldest, frame_pool)? { |
There was a problem hiding this comment.
Delayed camera frames disappear
With two workers, one can hold sequence 0 while the other finishes 1, 2, and 3. This branch then exceeds reorder_window, encodes 1–3, and advances next_encode_seq. When 0 arrives, encode_converted_frame rejects its older timestamp, losing a valid frame even though neither queue dropped it.
Worker count does not limit how many frames another worker can finish first. Track which sequences were actually dropped, or limit workers from running ahead, rather than treating a full buffer as proof that a missing frame was dropped.
Knowledge Base Used: Capture and recording pipeline
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/output_pipeline/async_camera.rs
Line: 151-155
Comment:
**Delayed camera frames disappear**
With two workers, one can hold sequence 0 while the other finishes 1, 2, and 3. This branch then exceeds `reorder_window`, encodes 1–3, and advances `next_encode_seq`. When 0 arrives, `encode_converted_frame` rejects its older timestamp, losing a valid frame even though neither queue dropped it.
Worker count does not limit how many frames another worker can finish first. Track which sequences were actually dropped, or limit workers from running ahead, rather than treating a full buffer as proof that a missing frame was dropped.
**Knowledge Base Used:** [Capture and recording pipeline](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/capture-and-recording-pipeline.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 311332b — try_submit now only assigns sequence numbers when input_tx accepts the frame, worker-side conversion errors and full-output_tx drops explicitly record skipped sequences (advance_past_skipped), and worker run-ahead is bounded before submission instead of evicting delayed in-flight sequences from reorder_buf.
| let stats = pool.stats(); | ||
| let in_flight = stats | ||
| .frames_received | ||
| .saturating_sub(stats.frames_converted + stats.frames_dropped); | ||
| if encoded_this_call == 0 | ||
| && backlog > 10 | ||
| && in_flight > (self.reorder_window as u64).saturating_add(4) |
There was a problem hiding this comment.
in_flight subtracts counters that overlap. A frame increments frames_converted before delivery, then also increments frames_dropped if the output queue is full. Enough output drops make this calculation report zero despite queued work, disabling the intended short wait under load.
Conversion errors have the opposite effect: neither counter advances, leaving a permanent apparent backlog. Count unfinished conversions separately from dropped output frames, and count conversion failures as finished work.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/output_pipeline/async_camera.rs
Line: 416-421
Comment:
**Backlog counts overlap**
`in_flight` subtracts counters that overlap. A frame increments `frames_converted` before delivery, then also increments `frames_dropped` if the output queue is full. Enough output drops make this calculation report zero despite queued work, disabling the intended short wait under load.
Conversion errors have the opposite effect: neither counter advances, leaving a permanent apparent backlog. Count unfinished conversions separately from dropped output frames, and count conversion failures as finished work.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 311332b — added an explicit in_flight atomic counter on AsyncConverterPool (incremented on accepted try_submit and decremented via RAII guard when a worker finishes converting, dropping, or erroring on each item), and updated both AsyncCameraMp4Muxer and drain_with_timeout to read pool.in_flight().
| let twenty_sends_elapsed = paced_start.elapsed(); | ||
| // Previously, 20 calls after >10 drops slept 20 * 5ms = >=100ms unconditionally. | ||
| assert!( | ||
| twenty_sends_elapsed < Duration::from_millis(80), | ||
| "send_video_frame after burst drops took {twenty_sends_elapsed:?} (expected non-blocking in_flight tracking)" |
There was a problem hiding this comment.
The 80 ms limit measures frame allocation, encoding, and thread scheduling as well as the unwanted waits. A slow or busy machine can exceed it with correct code. The burst also does not establish that more than ten frames were dropped.
Test the wait decision with controlled counters and a fake receiver instead. Keep elapsed-time measurements in the ignored benchmark.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/tests/recording_memory_pressure.rs
Line: 168-172
Comment:
**Busy machines fail the test**
The 80 ms limit measures frame allocation, encoding, and thread scheduling as well as the unwanted waits. A slow or busy machine can exceed it with correct code. The burst also does not establish that more than ten frames were dropped.
Test the wait decision with controlled counters and a fake receiver instead. Keep elapsed-time measurements in the ignored benchmark.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Fixed in 311332b — removed wall-clock timing assertions from the CI test suite and replaced them with deterministic barrier/gate tests covering delayed peer workers (0..=4 preserved in exact order) plus >10 input drops, >10 output drops, and >10 conversion errors (pool.in_flight() == 0).
| "frames 1..=4 must wait for delayed sequence 0 rather than skipping it: {emitted_pts:?}" | ||
| ); | ||
| assert_eq!(reorder_buf.len(), 4); | ||
| assert_eq!(pool.in_flight(), 1); |
There was a problem hiding this comment.
worker_loop sends each result before InFlightGuard lowers in_flight. Receiving the fourth peer frame therefore does not guarantee this counter is 1; it can still be 2.
If this assertion fails, sequence 0 is still blocked on release_rx.recv(). Dropping pool then waits forever for that worker before release_tx is dropped. Wait for worker completion before checking the counter, and release the gate during failure cleanup. The counter assertion at line 200 has the same race.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/tests/recording_memory_pressure.rs
Line: 179
Comment:
**Counter check can hang tests**
`worker_loop` sends each result before `InFlightGuard` lowers `in_flight`. Receiving the fourth peer frame therefore does not guarantee this counter is 1; it can still be 2.
If this assertion fails, sequence 0 is still blocked on `release_rx.recv()`. Dropping `pool` then waits forever for that worker before `release_tx` is dropped. Wait for worker completion before checking the counter, and release the gate during failure cleanup. The counter assertion at line 200 has the same race.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 4d42b01 — bounded HoldFirstFrameConverter/GateAndFailConverter waits so panics can never hang join(), and waited for worker B's InFlightGuard to settle after its output_tx send before checking pool.in_flight() == 1.
There was a problem hiding this comment.
Good catch on the second in_flight == 0 check and drop order — fixed in 50efb92 (ReleaseOnDrop declared after pool so unwinding always releases worker A first, and drain_with_timeout joins workers before asserting pool.in_flight() == 0).
| }) | ||
| .unwrap(); | ||
| } | ||
| assert!(pool.try_submit(tail, next_submit_seq).unwrap()); |
There was a problem hiding this comment.
Tail submission can fail randomly
Emptying the output queue does not mean the input queue has room. The worker may still have two negative-PTS frames queued after the preceding submission loop. If it is paused there, this one-shot try_submit returns Ok(false) and fails the test even though the pool is working correctly.
Wait for the earlier work to finish or retry the tail submission, as the preceding loops do.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/tests/recording_memory_pressure.rs
Line: 299
Comment:
**Tail submission can fail randomly**
Emptying the output queue does not mean the input queue has room. The worker may still have two negative-PTS frames queued after the preceding submission loop. If it is paused there, this one-shot `try_submit` returns `Ok(false)` and fails the test even though the pool is working correctly.
Wait for the earlier work to finish or retry the tail submission, as the preceding loops do.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 4d42b01 — wrapped the final valid frame submission in a bounded retry loop while draining output_rx so residual queued error frames on input_tx cannot cause a transient Ok(false).
|
Thanks for digging into this. Before we can take it, a few things need to change:
Once it's narrowed down, we'll approve the workflows and test it. |
… in-place - Wrap per-frame and retry AVFoundation Objective-C work in cidre::objc::ar_pool across AVFoundationMp4Muxer, AVFoundationCameraMuxer, MacOSFragmentedM4SMuxer, MacOSFragmentedM4SCameraMuxer, and OutOfProcessFragmentedM4SMuxer. - Switch camera capture output dispatch queue to dispatch::Queue::serial_with_ar_pool() and wrap CameraInfo::formats_impl in objc::ar_pool. - Reuse FramePool's backing ffmpeg::frame::Video in-place via queue_frame_reusable / encode_frame_reusable with av_frame_make_writable copy-on-write safety guards.
50efb92 to
f55dc6d
Compare
|
Good catch on
Ready whenever you want to kick off macOS CI and compare on hardware. |
/claim #109
Closes #109
Summary
Narrows the fix to the macOS recording paths that run in Studio and Instant recordings (with and without camera):
Drain Objective-C autorelease pools on pool-less capture & encoder worker threads:
cidre::objc::ar_poolacrossAVFoundationMp4MuxerandAVFoundationCameraMuxer(crates/recording/src/output_pipeline/macos.rs),MacOSFragmentedM4SMuxerandMacOSFragmentedM4SCameraMuxer(macos_fragmented_m4s.rs), andOutOfProcessFragmentedM4SMuxer(oop_fragmented_m4s.rs).AVCaptureVideoDataOutput's sample-buffer delegate queue incrates/camera/src/macos.rsfromdispatch::Queue::new()(DISPATCH_AUTORELEASE_FREQUENCY_INHERIT) todispatch::Queue::serial_with_ar_pool()(matchingscap-screencapturekit) and wrapsCameraInfo::formats_implinobjc::ar_pool.Reuse
ffmpeg::frame::Videoplane buffers in-place across frames:MacOSFragmentedM4SMuxer,MacOSFragmentedM4SCameraMuxer, andOutOfProcessFragmentedM4SMuxerfromframe_pool.take_frame()(which forced a freshAVFramebuffer allocation every frame) toframe_pool.get_frame()paired withSegmentedVideoEncoder::queue_frame_reusable/H264PacketEncoder::encode_frame_reusable.FramePool::get_frame,H264Encoder::queue_frame_to_reusable,H264Encoder::queue_frame_reusable, andH264PacketEncoder::encode_frame_reusablewithav_frame_make_writableso retained encoder reference buffers detach safely without overwriting in-flight frames.Verification
cargo fmt --check(Rust1.88.0)cargo test --locked -p cap-enc-ffmpeg(212 passed, includingreusable_queue_and_encode_preserve_plane_backing_and_detach_when_retained)cargo clippy --locked -p cap-enc-ffmpeg -p cap-camera --all-targets -- -D warnings