15 KiB
15 KiB
wl-webrtc Bug Analysis Report
Generated by Oracle audit on 2026-05-22. Total: 25 bugs.
Severity Legend
- 🔴 Critical — Crash, UB, or data corruption
- 🟠 High — Incorrect behavior, resource leak under normal usage
- 🟡 Medium — Wasted resources, logic errors with limited impact
- 🔵 Low-Medium — Fragile patterns, may cause issues under specific conditions
- ⚪ Low — Input validation, code smell, future-proofing
🔴 Critical (1)
#1: PipeWire helper thread Use-After-Free on mainloop quit ✅ Fixed
- Location:
src/cap_portal.rs:348-357, 359-363 - Description:
mainloop.as_raw_ptr()is cast tousize, an untracked thread is spawned that callspw_main_loop_quitthrough that raw pointer. Ifmainloop.run()returns or the PipeWire thread exits beforeCapPortal::dropsends shutdown, the helper can outlivemainloopand call into freed memory. - Fix: Remove the raw-pointer helper thread. Use a PipeWire loop event/eventfd/timer registered on the PipeWire loop, or store and join the helper thread with a lifetime guarantee that it exits before
mainloopis dropped. - Fixed in:
e40ef9e— Replaced detached helper thread + raw pointer with eventfd registered viaadd_io()on the PipeWire loop. Shutdown callback runs on the loop thread duringmainloop.run(), guaranteeing mainloop is alive. Drop order ensures IO source is unregistered before mainloop is destroyed.
🟠 High (4)
#2: PipeWire process callback can block indefinitely ✅ Fixed
- Location:
src/cap_portal.rs:320 - Description:
frame_tx.send(PwEvent::Frame(frame))is called from the PipeWire.processcallback on a bounded channel of size 3. If encoding stalls, this blocks the PipeWire callback and delaysstream.queue_raw_buffer(raw_buf). - Fix: Use
try_sendand drop frames on full, or use an unbounded channel with a backpressure counter. - Fixed in:
a09a423— Replacedsend()withtry_send()+AtomicU64drop counter. PipeWire.processcallback is now guaranteed non-blocking. Dropped frames are counted and logged every 30 occurrences.
#3: Portal PTS uses raw PipeWire timestamp as encoder frame-number timebase ✅ Fixed
- Location:
src/state_portal.rs:177,src/cap_portal.rs:279 - Description: PipeWire PTS (from
spa_meta_header.pts) is in nanoseconds (CLOCK_MONOTONIC), not frame-count units. It was assigned directly toAVFrame.ptswhile the encoder time_base is1/fps, causing the muxer to interpret timestamps as billions of frames and producing corrupted duration metadata. - Fix: Record the first frame's PipeWire PTS as a nanosecond base, compute elapsed nanoseconds for each subsequent frame, then convert to frame numbers via
elapsed_ns * fps / 1_000_000_000. Using elapsed time avoids i64 overflow on absolute timestamps. Invalid/negative PTS values are clamped to 0. - Fixed in:
ffb36b7— Addedfirst_pts_ns: Option<i64>toStatePortal. PTS conversion matches WLR path pattern (state.rs:525-527).
#4: AVDRMFrameDescriptor leaks if Portal encode fails ✅ Fixed
- Location:
src/state_portal.rs:183 - Description: The descriptor is allocated with
Box::into_rawat line 120 and manually recovered at lines 187-191. Butenc.encode_frame(&hw_frame)?returns early on error before recovery. This leaks the boxedAVDRMFrameDescriptor. - Fix: Wrap the descriptor pointer in a small RAII guard that reclaims the box on every return path, or recover the box before propagating the encode error.
- Fixed in:
2d448dc— MovedBox::from_rawdescriptor recovery from afterencode_frameto before it. Sinceav_hwframe_transfer_datahas already imported the DMA-BUF into the VAAPI surface by that point, the descriptor struct is safe to reclaim. Nowencode_frame's?early-return can no longer leak the descriptor.
#5: Portal can initialize encoder with zero or unknown format ✅ Fixed
- Location:
src/cap_portal.rs:306,src/state_portal.rs:68 - Description:
format_info.get().unwrap_or((0, 0, 0, 0))permits sending a frame with width, height, and format set to zero before format negotiation has completed.StatePortaltreats the first frame as authoritative and creates the encoder from those dimensions. - Fix: Do not emit
PwEvent::Frameuntil format info is present andspa_to_drm_fourccreturns nonzero; otherwise requeue or drop the buffer. - Fixed in:
b9e62d6— Replacedunwrap_or((0,0,0,0))withformat_info.get()Some/Nonecheck plus explicitwidth/height/format == 0guard, placed beforelibc::dup(fd)to avoid wasting a fd on dropped frames. Also partially fixes #15 (unsupported SPA format → drm_fourcc=0 is now rejected at source).
🟡 Medium (4)
#6: WLR timestamp offset is applied after rescaling using wrong units
- Location:
src/state.rs:525,src/avhw.rs:446 - Description: WLR computes absolute PTS in encoder ticks, then
drain_encoderrescales packet timestamps and subtractsstart_tsafterward. If the muxer stream timebase changes afteravformat_write_header, subtracting an encoder-timebasestart_tsfrom stream-timebase packet timestamps is wrong. - Fix: Subtract the first frame PTS before sending frames to the encoder/filter, or rescale
start_tswith the same source and destination timebases before subtracting.
#7: FFmpeg format context and IO leak on EncState::new error paths
- Location:
src/avhw.rs:256-323 - Description:
EncState::newallocatesAVFormatContextwithavformat_alloc_output_context2. After that, severalbail!calls (lines 279, 285, 293, 319) can return before the raw pointer is wrapped byOutput::wrapat line 323. This leaksfmt_ctx_ptrand, afteravio_open, the openedAVIOContext. - Fix: Introduce a local RAII guard for
AVFormatContextimmediately after allocation. The guard callsavio_closepifpbis non-null andavformat_free_contextunless ownership has been transferred. Disarm afterwrapsucceeds.
#8: FPS limiting happens after expensive WLR capture/allocation
- Location:
src/state.rs:383-549 - Description: In the WLR path,
queue_alloc_framestarts a screencopy request,on_frame_allocdallocates VAAPI surface, maps to DRM PRIME, duplicates DMA-BUF fds, creates Wayland buffer, and queues compositor copy. Only after the compositor signals ready doeson_copy_completeapply FPS limiting. Frames that will be dropped still consume all these resources. - Fix: Move FPS gating before
manager.capture_outputinqueue_alloc_frame. Tracklast_capture_requestedorlast_frame_emittedas anInstant, skip queueing if target interval has not elapsed.
#9: FpsLimit drops the second frame after every first encoded frame
- Location:
src/fps_limit.rs:19,src/state.rs:542,src/state_portal.rs:103 - Description: Both state machines bypass
FpsLimitfor the first frame. Because that first frame is never fed intoFpsLimit, the next frame callson_new_framewith emptyon_deck; it returnsNone, so the second frame is always dropped regardless of elapsed time. - Fix: Replace
FpsLimit<T>with a timestamp-only limiter that storeslast_emitted: Option<Instant>and returns a boolean. On the first encoded frame, setlast_emitted = Some(now).
#10: Intermediate stage left installed on fatal initialization errors
- Location:
src/state.rs:582, 724 - Description:
negotiate_formatandtry_finalize_outputusemem::replace(&mut self.stage, EncConstructionStage::Intermediate). Ifcreate_encoderfails or managers are missing,self.erroredis set butself.stageremainsIntermediate. Resources moved out are dropped implicitly, and future retry logic would operate on a meaningless state. - Fix: Replace raw
mem::replacetransitions with a helper that has explicit commit/rollback semantics. Build all fallible resources before moving the stage. When unavoidable, restore the old stage on failure or install a dedicatedFailedstage.
🔵 Low-Medium (5)
#11: av_buffersrc_parameters_set failure can leak cloned hw_frames_ctx
- Location:
src/avhw.rs:538-557 - Description:
frames_rgb.ref_clone()is assigned topar.hw_frames_ctx. Ifav_buffersrc_parameters_setfails, the clonedAVBufferRefmay not have transferred ownership, and the code does not callav_buffer_unrefon it. - Fix: Store the cloned reference in a local mutable pointer. On failure, call
av_buffer_unref(&mut cloned_ref).
#12: Portal session lifetime may be too short
- Location:
src/cap_portal.rs:83-134 - Description:
setup_portalcreates an ashpd Screencast proxy and session, returns only(OwnedFd, node_id), and drops both proxy and session when it returns. If ashpd closes the screencast session on drop, capture may end immediately. - Fix: Verify ashpd session drop semantics. If dropping the session closes the portal session, store the session handle inside
CapPortalfor the entire capture lifetime.
#13: Portal descriptor uses borrowed DMA-BUF fd without clear lifetime contract across FFmpeg import
- Location:
src/state_portal.rs:217 - Description:
build_drm_descriptorstoresframe.fd.as_raw_fd()intodesc.objects[0].fd. The fd is owned byPwDmaBufFrame. If FFmpeg/VAAPI retains the fd beyondav_hwframe_transfer_data,PwDmaBufFramedropping at end ofhandle_pw_framecan close an in-use fd. - Fix: Duplicate the fd for the descriptor if FFmpeg import is not guaranteed synchronous. Create an RAII owner that contains both the boxed descriptor and
OwnedFdduplicates.
#14: WLR duplicate DMA-BUF fds passed to Wayland rely on subtle OwnedFd::as_fd ownership
- Location:
src/state.rs:463-482 - Description:
on_frame_allocddupsobj.fd, wraps withOwnedFd::from_raw_fd, and passesfd_owned.as_fd()intoparams.add. The comment saysparams.add()takes ownership, but code passes aBorrowedFd.fd_ownedis dropped at end of loop iteration. - Fix: Confirm the generated signature for
ZwpLinuxBufferParamsV1::add. If ownership transfer is required, use the binding's owned-fd API or transfer withinto_raw_fd.
#15 (moved to Low): spa_to_drm_fourcc returns 0, downstream treats as usable
- Location:
src/cap_portal.rs:370-381 - Description: Unsupported SPA video formats map to
0. This value is stored informat_infoand later used inAVDRMFrameDescriptor.layers[0].formatwith no rejection path. - Fix: Make
spa_to_drm_fourccreturnOption<u32>. OnNone, send a fatalPwEvent::Erroror renegotiate.
⚪ Low (8)
#15: --fps 0 causes panic
- Location:
src/fps_limit.rs:12,src/avhw.rs:217 - Description:
1.0 / 0.0u32produces infinite duration. Encoder timebaseRational::new(1, 0)is also invalid. - Fix: Validate
args.fps > 0after parsing. Use a clap value parser.
#16: to_str().unwrap() panics on non-UTF-8 paths
- Location:
src/avhw.rs:24, 255 - Description:
drm_device.to_str().unwrap()andoutput_path.to_str().unwrap()panic on non-UTF-8 paths. - Fix: Replace with
path.to_str().ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))?.
#17: Portal shutdown swallows encoder errors
- Location:
src/state_portal.rs:198-209,src/main.rs:227 - Description:
StatePortal::shutdown()catches and logs flush errors but returns().run_portal_pipewirealways returnsOk(())even if trailer writing failed. - Fix: Change
shutdownto returnResult<()>. Propagate inrun_portal_pipewire.
#18: WLR shutdown discards buffered FPS-limiter frame
- Location:
src/main.rs:167,src/state.rs:546 - Description:
state.fps_limit.flush()return value is ignored. The limiter storesS::Frame::default()(not real frame data), so no actual frame is lost, but the design is misleading. - Fix: Use timestamp-only limiter (see #9). If keeping FpsLimit, log the discarded flush value.
#19: StatePortal drops first PipeWire frame after format negotiation
- Location:
src/state_portal.rs:56-82 - Description: First
PwEvent::Frameis used only for encoder creation, thendrop(frame). The first actual image is not encoded, creating a startup gap and interacting with FPS limiter issue #9. - Fix: After encoder creation, pass the same frame to
handle_pw_frame(frame)instead of dropping. Or set FPS limiter baseline correctly.
#20: CapPortal::Drop can block indefinitely on join
- Location:
src/cap_portal.rs:137-144 - Description:
DropsendsPwCmd::Shutdownthen unconditionallyjoin()s. If the command channel send fails orpw_main_loop_quitdoesn't wake the loop,joinblocks forever. - Fix: Replace helper thread with PW-loop-integrated wakeup. If keeping thread design, add a bounded shutdown timeout.
#21: pw::deinit() called from worker thread with process-wide implications
- Location:
src/cap_portal.rs:155, 361 - Description:
pw::init()/pw::deinit()are process-global. Deinit inside a worker thread is fragile if future code adds another PipeWire user. - Fix: Treat PW init as process-level lifecycle. Init once at startup, deinit once at shutdown, or omit explicit deinit.
#22: PipeWire callback assumes first data item contains complete DMA-BUF image
- Location:
src/cap_portal.rs:260 - Description: Only reads
datas[0]. Multi-plane formats (NV12 etc.) would produce corrupted frames. - Fix: Validate that negotiated DRM format is single-plane RGB/XRGB before emitting frames.
#24: CapPortal stores Tokio runtime but not portal session objects
- Location:
src/cap_portal.rs:31 - Description:
rt: Runtimeis stored but not used aftersetup_portal. The runtime does not keepashpdproxy/session alive. - Fix: Remove
rtif not needed, or store actual session/proxy handles alongside it.
#25: poll_and_encode collapses all channel errors into "no event"
- Location:
src/state_portal.rs:49-53 - Description:
try_recverrors are all mapped toOk(false). A disconnected channel (PipeWire thread crashed) is indistinguishable from an empty channel. - Fix: Match on
RecvErrorvariant. OnDisconnected, setself.errored = true.
Summary
| Severity | Count |
|---|---|
| Critical | 1 |
| High | 4 |
| Medium | 5 |
| Low-Medium | 5 |
| Low | 10 |
| Total | 25 |
Recommended Fix Priority
- #1 (Critical UAF) — PipeWire mainloop shutdown race
- #4 (High leak) — AVDRMFrameDescriptor leak on encode error
- #2 (High blocking) — PipeWire process callback blocking
- #5 (High validation) — Zero-format encoder init
- #3 (High PTS) — Portal PTS unit mismatch