shutdown() fired twice on exit (explicit call in main.rs + Drop impl), causing "Total: N frames" and "StatePortal shutdown complete" to log twice 23us apart. Add `shutdown_started: bool` guard at function entry.
Plain bool (not AtomicBool) because &mut self already grants exclusive access. Guard is set BEFORE cleanup so Drop re-entry during panic unwinding is suppressed.
Includes scripts/test_shutdown_idempotency.sh as a live regression test (requires Wayland session). Verified PASS on KWin: both lines print exactly once.
encode_thread_loop hardcoded sws_us=0 and output_bytes=0, making the
stats dashboard show zeros for those columns. The previous encode_us
was measured around the entire encode_cpu_frame call, mixing sws +
encode work into one bucket.
Add SwEncodeTiming { sws_us, encode_us, output_bytes } to SwEncEncode.
- drain_encoder returns Result<usize> (total encoded bytes), accumulated
before the Muxer/Channel match so branch duplication and multi-packet
drain are both handled correctly. output_bytes = bytes produced by
libavcodec even if downstream delivery drops them.
- encode_cpu_frame resets last_timing to Default at entry (so early
returns from disconnect/pause/dedup never report stale prior values),
measures sws_us around sws_scale, measures encode_us around
avcodec_send_frame + drain, then stores the complete snapshot.
- take_timing() uses mem::take to return and clear in one step.
- flush() ignores the byte count from drain_encoder.
- state_portal encode_thread_loop calls take_timing() for real values.
WebRTC client bandwidth estimate now drives both encoder bitrate and
resolution tier selection, replacing the previous static-target encoder.
- webrtc.rs: enable str0m BWE (seeded at 5 Mbps), surface
EgressBitrateEstimate + KeyframeRequest events, expose
get_bwe_estimate() / set_need_keyframe()
- state_portal.rs: wire bitrate/resolution channels between the WebRTC
thread and the encode thread; tier ladder [1440p, 1080p, 720p] with
downscale at 60% budget and upscale hysteresis (120% sustained 10s)
- avhw.rs: SwEncImport::poll_resolution_commands() rebuilds the import
filter graph on UpdateResolution; SwEncEncode::recreate_encoder()
rebuilds sws/enc_video/yuv_frame atomically; hash_sampled_y_plane()
skips duplicate frames; VBV x264opts cap IDR bursts; H.264 level 4.0
(muxer) / 4.2 (WebRTC)
- state.rs: sync wlr-screencopy GOP to fps*2 max 20 for parity
- fix: drain bitrate_rx + resolution_rx BEFORE the stride check in
encode_cpu_frame() so the new (smaller-stride) frame produced after
a resolution change does not hit the stale (larger) enc_width and
crash the encode thread
- WebRTC GOP widened to fps*2 max 20 (was fps/2 max 10)
- Move WebRTC send to dedicated wl-webrtc-webrtc thread (was inline in main loop)
- Reduce frame_rx 16→1, input_tx 2→1 (drop-on-full), webrtc_tx 32→2
- Recv_timeout 10ms→2ms to reduce pipeline latency
- Fix sent_gap_p95 stats bug: compute gap at actual send time in WebRTC
thread instead of batch-draining at snapshot time (was always 0.0ms)
- High profile via AVCodecContext.profile, veryfast preset, 5x bitrate
- Stats drain via sent_gap channel with record_send_from_thread()
- Shutdown: drop input_tx → join encode → drop webrtc_tx → join webrtc
shutdown() calls enc.flush() → drain_encoder() → tx.send() on a
crossbeam bounded(32) channel. If the channel is full and the
receiver (webrtc_rx) is alive but not being drained, send() blocks
forever — a self-deadlock since both ends belong to the same struct.
Two-layer fix:
- avhw.rs: replace tx.send() with tx.try_send(); handle Full (drop
frame) and Disconnected (set flag) separately.
- state_portal.rs: drop webrtc_rx before flushing in shutdown() so
try_send returns Disconnected immediately.
Regression tests added for the channel semantics.
- Replace 'let _ = tx.send()' with proper error handling: log warning,
set webrtc_disconnected flag, and break drain loop on SendError
- Add Arc<AtomicBool> webrtc_paused shared between State/StatePortal
and SwEncState, synced from wrtc.is_connected() in poll_webrtc()
- Skip encoding in encode_filtered_frame() when paused or disconnected
- Drain and discard stale channel frames on disconnect
- Resume encoding automatically on WebRTC reconnection
- Add src/webrtc.rs: HTTP signaling server + str0m Sans-IO WebRTC transport
with H.264 Annex-B → RTP packetization and key-frame request handling
- avhw: introduce FrameOutput enum (Muxer | Channel) so SwEncState can
output to either MP4 muxer or crossbeam channel for WebRTC
- cap_portal: support portal session restore tokens (PersistMode::ExplicitlyRevoked)
to skip re-authorization dialog; add --no-persist flag to force fresh dialog
- args: make --output optional when --port is used for WebRTC mode
- state_portal: integrate WebRTC pipeline (encoder channel → RTP forwarding)
with shorter GOP for WebRTC (fps/2, min 10)
- main: redirect tracing to stderr; validate --output or --port required
- Add dependencies: str0m 0.20, serde_json 1, dirs 6
- Add Drop impl for StatePortal to flush encoder on drop (bug #2)
- Use enc.take() in shutdown() to prevent double-flush of write_trailer
- Null out data[0] after Box::from_raw recovery to avoid dangling pointer
- Extract compute_pts() for testable PTS calculation
- Add 8 tests: PTS calculation, DRM device resolution, descriptor building
BUG-2 (HIGH): SHM Buffer event caused permanent hang
In the ZwlrScreencopyFrameV1 dispatcher, receiving a SHM Buffer event
left in_flight_surface stuck at AllocQueued forever, preventing
queue_alloc_frame() from requesting new frames.
Fix: treat Buffer as a metadata offer (v3 protocol), wait for
BufferDone to decide failure, and add AllocQueued state guard to
LinuxDmabuf handler.
BUG-3 (MEDIUM): Portal backend picked wrong GPU on multi-GPU systems
state_portal.rs hardcoded /dev/dri/renderD128 then renderD129, which
selects the wrong GPU when PipeWire uses a different device.
Fix: extract find_drm_render_nodes() as shared utility; defer DRM
device selection to first PipeWire frame; test each candidate with
av_hwframe_transfer_data to find the GPU that can actually import
the DMA-BUF frame.
BUG-4 (LOW): VAAPI device context created twice unnecessarily
try_finalize_output() created an AvHwDevCtx stored in EverythingButFmt,
but negotiate_format() discarded it (_hw_device_ctx) and EncState::new
created a new one.
Fix: thread the existing hw_device_ctx through negotiate_format() and
create_encoder() to EncState::new() which reuses it when provided.
Add comprehensive Chinese documentation comments to cap_portal,
main, and state_portal modules covering architecture, lifecycle,
and data flow for each component.
PipeWire spa_meta_header.pts is CLOCK_MONOTONIC in nanoseconds, but the
encoder expects frame-number units (time_base = 1/fps). The raw nanosecond
value was assigned directly to AVFrame.pts, causing the encoder/muxer to
interpret timestamps as billions of frames, producing corrupted duration
metadata and broken rate control.
Fix: record the first frame's 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 (~10^18 ns).
Matches the WLR path pattern (state.rs:525-527) which converts microseconds
to frame numbers for the same encoder.
Add a second capture backend for compositors without wlr-screencopy
(KWin, GNOME, etc.) using the xdg-desktop-portal ScreenCast interface
and PipeWire DMA-BUF streaming.
New files:
- src/backend_detect.rs: auto-detect wlr-screencopy vs portal backend
- src/cap_portal.rs: Portal session setup + PipeWire DMA-BUF thread
- src/state_portal.rs: StatePortal encoder pipeline (DMA-BUF → VAAPI)
Changes:
- Cargo.toml: add ashpd 0.13, tokio 1, pipewire 0.9, libspa 0.9,
crossbeam-channel 0.5
- src/args.rs: add --backend CLI flag
- src/avhw.rs: extract create_encoder() from inline State code
- src/main.rs: route to portal or wlr-screencopy based on backend
- src/state.rs: fix params.destroy() on dup failure, cleanup
in_flight_surface on copy fail, use create_encoder()
- tests/integration_test.rs: add --backend flag tests