Fix Cast Streaming teardown crash, bitrate collapse, and HLS latency

Three independent problems found by reading the mirroring paths end to end.

1. Segfault on Cast session teardown. CastStreamSender::SchedulePoll
   self-reschedules every 100ms with a raw `this` and was never cancelled,
   so the one task that can be scheduled to run *after* an already-queued
   teardown task would dereference the just-reset `environment` unique_ptr
   (Environment::task_runner() dereferences a member immediately) --- a hard
   null deref on openscreen's TaskRunner thread. TaskRunnerImpl's shutdown
   has an explicit flushing phase and PlatformClientPosix::ShutDown()'s quit
   task queues behind whatever is already pending, so this is a race the
   teardown path can lose. Latch a `shutting_down` atomic before posting
   teardown and check it in the poll and in every other posted task.

2. Encoder bitrate collapsing to the floor within seconds. The control loop
   set `target = 0.85 * estimate` once a second unconditionally. openscreen's
   BandwidthEstimator deliberately under-estimates capacity whenever the
   transmit rate is below it and documents the required TCP-like response;
   multiplying the target by <=0.85 every second instead walks 4000 kbps past
   1500 in ~6s and pins it at the floor on a healthy LAN. Replaced with
   proper AIMD (hold on a zero/unknown estimate, back off below it, probe up
   10%/s otherwise), clamped to 1000..8000 kbps, with unit tests.

3. DLNA/HLS latency. Segment length is max(target-duration, GOP), so
   target-duration=2 with a 2s GOP gave 2s segments, ~6s of renderer buffer,
   plus 3 segments of stale video waited for before handover. 1s segments
   (GOP halved to make that reachable), shorter playlist, and wait for 2
   segments instead of 3.

Also hardened two paths into openscreen's fatal OSP_CHECK on strictly
increasing RTP timestamps: pull_encoded_frame no longer substitutes 0 for a
missing PTS (it skips the buffer), and facade.cc drops non-monotonic capture
times at the FFI boundary. Either could previously abort the daemon outright.

CastMirrorSession now owns the Arc<CastStreamSender> instead of leaving its
lifetime to whichever detached pump thread dropped the last clone, so the
blocking FFI destroy happens at a defined point in stop() with the pump
joins ordered around it.
This commit is contained in:
Breadway 2026-08-05 19:04:03 +08:00
parent 9c6fc61f53
commit 5b49955e33
4 changed files with 299 additions and 44 deletions

View file

@ -44,6 +44,34 @@ struct CastStreamSender {
bool have_origin = false; bool have_origin = false;
int64_t origin_capture_time_us = 0; int64_t origin_capture_time_us = 0;
// The capture timestamp of the last frame actually handed to
// Sender::EnqueueFrame. openscreen enforces strictly-increasing RTP
// timestamps with a *fatal* OSP_CHECK_GT (sender_impl.cc), not an error
// return -- so a single frame arriving with a non-increasing capture time
// (a buffer with no PTS, which the GStreamer side substitutes 0 for; a
// clock reset on portal source change; any encoder that ever reorders
// output) would abort the whole process. Dropping such a frame instead
// costs at most one frame of video. Only touched on the TaskRunner
// thread.
bool have_last_capture_time = false;
int64_t last_capture_time_us = 0;
// Set by breadcast_caststream_sender_destroy *before* it posts its
// teardown task, and checked by the self-rescheduling poll below.
//
// Without this, the poll task (posted with a 100ms delay, so it is the one
// task that can be scheduled to run *after* an already-queued teardown
// task) dereferences `environment` after teardown has reset it -- a null
// `unique_ptr<Environment>`, whose `task_runner()` accessor immediately
// dereferences a member -- i.e. a hard SIGSEGV on openscreen's TaskRunner
// thread. If it lands even later it is a use-after-free of `this`, since
// destroy() `delete`s this struct once the teardown task completes.
// TaskRunnerImpl's shutdown has an explicit "flushing phase" that keeps
// running runnable tasks, and PlatformClientPosix::ShutDown()'s quit task
// is queued *behind* whatever is already pending, so this is a race the
// teardown path can and does lose.
std::atomic<bool> shutting_down{false};
// negotiated uses acquire/release so that once // negotiated uses acquire/release so that once
// breadcast_caststream_sender_enqueue_frame observes it true (from an // breadcast_caststream_sender_enqueue_frame observes it true (from an
// arbitrary caller thread), `session->video_sender()` is guaranteed // arbitrary caller thread), `session->video_sender()` is guaranteed
@ -62,8 +90,14 @@ struct CastStreamSender {
BreadcastOnPictureLostFn rust_on_picture_lost = nullptr; BreadcastOnPictureLostFn rust_on_picture_lost = nullptr;
void SchedulePoll() { void SchedulePoll() {
if (shutting_down.load(std::memory_order_acquire) || !environment) {
return;
}
environment->task_runner().PostTaskWithDelay( environment->task_runner().PostTaskWithDelay(
[this] { [this] {
if (shutting_down.load(std::memory_order_acquire)) {
return;
}
if (session && session->video_sender()) { if (session && session->video_sender()) {
needs_key_frame.store(session->video_sender()->NeedsKeyFrame(), needs_key_frame.store(session->video_sender()->NeedsKeyFrame(),
std::memory_order_relaxed); std::memory_order_relaxed);
@ -186,6 +220,10 @@ CastStreamSender* breadcast_caststream_sender_create(
void breadcast_caststream_sender_negotiate(CastStreamSender* sender) { void breadcast_caststream_sender_negotiate(CastStreamSender* sender) {
sender->environment->task_runner().PostTask([sender] { sender->environment->task_runner().PostTask([sender] {
if (sender->shutting_down.load(std::memory_order_acquire) ||
!sender->session) {
return;
}
sender->session->Negotiate(); sender->session->Negotiate();
sender->SchedulePoll(); sender->SchedulePoll();
}); });
@ -202,6 +240,10 @@ void breadcast_caststream_sender_on_message(CastStreamSender* sender,
auto ns = std::make_shared<std::string>(message_namespace, message_namespace_len); auto ns = std::make_shared<std::string>(message_namespace, message_namespace_len);
auto body = std::make_shared<std::string>(message, message_len); auto body = std::make_shared<std::string>(message, message_len);
sender->environment->task_runner().PostTask([sender, source, ns, body] { sender->environment->task_runner().PostTask([sender, source, ns, body] {
if (sender->shutting_down.load(std::memory_order_acquire) ||
!sender->message_port) {
return;
}
sender->message_port->DeliverMessage(*source, *ns, *body); sender->message_port->DeliverMessage(*source, *ns, *body);
}); });
} }
@ -225,15 +267,30 @@ int32_t breadcast_caststream_sender_enqueue_frame(CastStreamSender* sender,
using namespace openscreen; using namespace openscreen;
using namespace openscreen::cast; using namespace openscreen::cast;
if (sender->shutting_down.load(std::memory_order_acquire) ||
!sender->session) {
return;
}
Sender* video_sender = sender->session->video_sender(); Sender* video_sender = sender->session->video_sender();
if (!video_sender) { if (!video_sender) {
return; return;
} }
// See `last_capture_time_us`: openscreen aborts the process (fatal
// OSP_CHECK, not an error return) if RTP timestamps ever fail to
// strictly increase, so a non-monotonic capture time has to be dropped
// here rather than passed through.
if (sender->have_last_capture_time &&
capture_time_us <= sender->last_capture_time_us) {
return;
}
if (!sender->have_origin) { if (!sender->have_origin) {
sender->have_origin = true; sender->have_origin = true;
sender->origin_capture_time_us = capture_time_us; sender->origin_capture_time_us = capture_time_us;
} }
sender->have_last_capture_time = true;
sender->last_capture_time_us = capture_time_us;
const FrameId frame_id = video_sender->GetNextFrameId(); const FrameId frame_id = video_sender->GetNextFrameId();
const FrameId referenced_frame_id = const FrameId referenced_frame_id =
@ -277,6 +334,12 @@ void breadcast_caststream_sender_destroy(CastStreamSender* sender) {
if (!sender) { if (!sender) {
return; return;
} }
// Latched *before* the teardown task is posted so the self-rescheduling
// poll (see CastStreamSender::shutting_down) stops re-arming itself and
// no longer touches `environment`/`session` -- both of which the teardown
// task below is about to reset out from under it.
sender->shutting_down.store(true, std::memory_order_release);
// These must be torn down on the TaskRunner thread (they hold raw // These must be torn down on the TaskRunner thread (they hold raw
// references into it and into `environment`), so hop over there and block // references into it and into `environment`), so hop over there and block
// until it's done before shutting the TaskRunner itself down. // until it's done before shutting the TaskRunner itself down.

View file

@ -62,15 +62,37 @@ pub fn build_video_pipeline(video_node_id: u32, output_dir: &Path) -> Result<gst
// (stretches to 16:9) — correctness/compatibility first, an // (stretches to 16:9) — correctness/compatibility first, an
// aspect-preserving scale (letterbox via `videoscale // aspect-preserving scale (letterbox via `videoscale
// add-borders=true`) is a follow-up, not a blocker. // add-borders=true`) is a follow-up, not a blocker.
// HLS segment sizing is *the* dominant term in this path's end-to-end
// latency, and the two knobs are coupled: a segment can only be cut on a
// key frame, so the real segment duration is `max(target-duration,
// GOP length)` no matter what `target-duration` says. With the previous
// `target-duration=2` + `key-int-max=60` (60 frames / 30fps = a 2s GOP),
// segments were 2s, and a renderer that buffers the customary three of
// them before starting playback sits ~6s behind live -- on top of
// however much of the playlist it decides to start from. `dlna_mirror.rs`
// then waited for 3 segments to exist before even handing over the URL,
// adding another ~6s of already-stale content.
//
// 1s segments (GOP dropped to 30 frames to make that actually
// achievable) roughly halve that. Going below 1s is not worth it here:
// MPEG-TS + a per-segment key frame means shorter segments cost real
// bitrate, and classic (non-LL) HLS clients don't reliably honour
// sub-second target durations anyway. Genuinely low latency on this path
// needs LL-HLS, which `hlssink3` does not implement -- the Cast
// Streaming path (`build_video_pipeline_for_streaming`) is the
// low-latency answer, and this one is the compatibility answer.
//
// `playlist-length`/`max-files` shrink to match so the playlist doesn't
// advertise a long backlog of stale segments for a client to start from.
let pipeline_str = format!( let pipeline_str = format!(
"pipewiresrc path={video_node_id} do-timestamp=true ! \ "pipewiresrc path={video_node_id} do-timestamp=true ! \
videoconvert ! videoscale ! videorate ! \ videoconvert ! videoscale ! videorate ! \
video/x-raw,format=NV12,width=1280,height=720,framerate=30/1 ! \ video/x-raw,format=NV12,width=1280,height=720,framerate=30/1 ! \
vah264enc bitrate=4000 key-int-max=60 rate-control=cbr ! \ vah264enc bitrate=4000 key-int-max=30 rate-control=cbr ! \
video/x-h264,profile=main ! \ video/x-h264,profile=main ! \
h264parse config-interval=1 ! \ h264parse config-interval=1 ! \
hlssink.video \ hlssink.video \
hlssink3 name=hlssink target-duration=2 playlist-length=6 max-files=10" hlssink3 name=hlssink target-duration=1 playlist-length=3 max-files=6"
); );
let element = gst::parse::launch(&pipeline_str).context("failed to parse GStreamer pipeline")?; let element = gst::parse::launch(&pipeline_str).context("failed to parse GStreamer pipeline")?;
@ -160,22 +182,34 @@ pub fn build_video_pipeline_for_streaming(
/// Pulls one complete Annex-B H.264 access unit from `appsink`, blocking /// Pulls one complete Annex-B H.264 access unit from `appsink`, blocking
/// until one is available. Returns `None` once the pipeline reaches EOS or /// until one is available. Returns `None` once the pipeline reaches EOS or
/// the sink otherwise stops (e.g. pipeline torn down from another thread). /// the sink otherwise stops (e.g. pipeline torn down from another thread).
///
/// A buffer with no PTS is skipped (this pulls the next one instead) rather
/// than reported with a substituted timestamp, as an earlier version did
/// with `.unwrap_or(0)`. That substitution was actively dangerous rather
/// than merely imprecise: openscreen derives the frame's RTP timestamp from
/// this value and enforces strict monotonicity with a *fatal* `OSP_CHECK`
/// (`sender_impl.cc`'s `OSP_CHECK_GT(frame.rtp_timestamp, ...)`), not an
/// error return -- so a single PTS-less buffer part-way into a session would
/// abort the whole daemon. `facade.cc` independently drops non-monotonic
/// capture times as a second line of defence; neither guard makes the other
/// redundant, since `enqueue_frame` is a public FFI entry point that has to
/// hold up against any caller.
pub fn pull_encoded_frame(appsink: &gst_app::AppSink) -> Result<Option<(Vec<u8>, bool, i64)>> { pub fn pull_encoded_frame(appsink: &gst_app::AppSink) -> Result<Option<(Vec<u8>, bool, i64)>> {
let sample = match appsink.pull_sample() { loop {
Ok(sample) => sample, let sample = match appsink.pull_sample() {
Err(_) if appsink.is_eos() => return Ok(None), Ok(sample) => sample,
Err(e) => bail!("appsink pull_sample failed: {e}"), Err(_) if appsink.is_eos() => return Ok(None),
}; Err(e) => bail!("appsink pull_sample failed: {e}"),
let buffer = sample.buffer().context("pulled sample had no buffer")?; };
let map = buffer.map_readable().context("failed to map sample buffer readable")?; let buffer = sample.buffer().context("pulled sample had no buffer")?;
let is_key_frame = !buffer.flags().contains(gst::BufferFlags::DELTA_UNIT); let Some(capture_time_us) = buffer.pts().map(|t| t.useconds() as i64) else {
// `.unwrap_or(0)` rather than propagating a missing PTS as an error: tracing::debug!("skipped an encoded frame with no PTS");
// CastStreamSender::enqueue_frame only needs monotonically-increasing, continue;
// real-elapsed-time-proportional values (see its doc comment) -- an };
// occasional buffer with no PTS shouldn't abort an otherwise-live let map = buffer.map_readable().context("failed to map sample buffer readable")?;
// stream over it. let is_key_frame = !buffer.flags().contains(gst::BufferFlags::DELTA_UNIT);
let capture_time_us = buffer.pts().map(|t| t.useconds() as i64).unwrap_or(0); return Ok(Some((map.as_slice().to_vec(), is_key_frame, capture_time_us)));
Ok(Some((map.as_slice().to_vec(), is_key_frame, capture_time_us))) }
} }
/// Sends an upstream "force key unit" event from `appsink`, propagating to /// Sends an upstream "force key unit" event from `appsink`, propagating to

View file

@ -24,11 +24,43 @@ use rust_cast::channels::receiver::CastDeviceApp;
use crate::daemon::DaemonCommand; use crate::daemon::DaemonCommand;
/// The encoder's starting target bitrate, in kbps -- must match the
/// `bitrate=` property `build_video_pipeline_for_streaming` builds the
/// `vah264enc` with, since [`bitrate_control_step`] treats it as the value
/// already in effect at t=0.
const INITIAL_BITRATE_KBPS: u32 = 4000;
/// Never encode below this. 720p30 below roughly 1 Mbps is a wall of
/// blocking artifacts -- if the link genuinely can't carry that, dropping
/// frames is a better failure mode than shipping unwatchable video.
const MIN_BITRATE_KBPS: u32 = 1000;
/// Never encode above this, regardless of how much headroom the estimator
/// reports. Matches `VideoParams::default().max_bitrate_bps`, i.e. what the
/// OFFER told the receiver to expect.
const MAX_BITRATE_KBPS: u32 = 8000;
pub struct CastMirrorSession { pub struct CastMirrorSession {
pipeline: gst::Pipeline, pipeline: gst::Pipeline,
session: CastSession, session: CastSession,
capture: Option<CaptureSession>, capture: Option<CaptureSession>,
threads: Vec<std::thread::JoinHandle<()>>, /// The FFI Cast Streaming session. Held here (rather than only inside
/// the pump-thread closures, as an earlier version did) so its
/// `Drop` -- which calls `breadcast_caststream_sender_destroy` and
/// blocks until openscreen's threads stop -- happens at an explicit,
/// deterministic point in [`Self::stop`], instead of "whichever
/// detached pump thread happened to drop the last `Arc`."
sender: Option<Arc<CastStreamSender>>,
/// Forwards inbound CASTV2 `urn:x-cast:com.google.cast.webrtc` messages
/// (the ANSWER) into the FFI session. Ends when [`CastSession::stop`]
/// closes the raw-message channel. Holds an `Arc<CastStreamSender>`.
message_pump: Option<std::thread::JoinHandle<()>>,
/// Forwards outbound FFI events (the OFFER) onto the CASTV2 connection.
/// Ends only once the `CastStreamSender` itself is dropped (that is what
/// closes the event channel), so it must be joined *after* `sender` is
/// dropped, not before -- joining it first would deadlock.
event_pump: Option<std::thread::JoinHandle<()>>,
/// Pulls encoded frames from the appsink into the FFI session. Ends on
/// pipeline EOS/flush. Holds an `Arc<CastStreamSender>`.
frame_pump: Option<std::thread::JoinHandle<()>>,
} }
impl CastMirrorSession { impl CastMirrorSession {
@ -78,9 +110,7 @@ impl CastMirrorSession {
.context("failed to start the Cast Streaming session")?; .context("failed to start the Cast Streaming session")?;
let sender = Arc::new(sender); let sender = Arc::new(sender);
let mut threads = Vec::new(); let message_pump = {
threads.push({
let sender = sender.clone(); let sender = sender.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
while let Some(msg) = raw_messages.recv() { while let Some(msg) = raw_messages.recv() {
@ -89,10 +119,10 @@ impl CastMirrorSession {
} }
} }
}) })
}); };
let negotiated = Arc::new(AtomicBool::new(false)); let negotiated = Arc::new(AtomicBool::new(false));
threads.push({ let event_pump = {
let session = session.clone(); let session = session.clone();
let negotiated = negotiated.clone(); let negotiated = negotiated.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
@ -109,7 +139,7 @@ impl CastMirrorSession {
} }
} }
}) })
}); };
tracing::info!(device = %device.name, "sending Cast Streaming OFFER"); tracing::info!(device = %device.name, "sending Cast Streaming OFFER");
sender.negotiate(); sender.negotiate();
@ -127,8 +157,9 @@ impl CastMirrorSession {
pipeline.set_state(gst::State::Playing).context("failed to start the encode pipeline")?; pipeline.set_state(gst::State::Playing).context("failed to start the encode pipeline")?;
tracing::info!(device = %device.name, "mirroring started"); tracing::info!(device = %device.name, "mirroring started");
threads.push({ let frame_pump = {
let device_name = device.name.clone(); let device_name = device.name.clone();
let sender = sender.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = frame_pump_loop(&appsink, &encoder, &sender); let result = frame_pump_loop(&appsink, &encoder, &sender);
if let Err(e) = result { if let Err(e) = result {
@ -140,15 +171,35 @@ impl CastMirrorSession {
// thread than drop the notification. // thread than drop the notification.
let _ = daemon_tx.blocking_send(DaemonCommand::SessionEnded); let _ = daemon_tx.blocking_send(DaemonCommand::SessionEnded);
}) })
}); };
Ok(Self { pipeline, session, capture: Some(capture), threads }) Ok(Self {
pipeline,
session,
capture: Some(capture),
sender: Some(sender),
message_pump: Some(message_pump),
event_pump: Some(event_pump),
frame_pump: Some(frame_pump),
})
} }
/// Tears down the session: stops the pipeline (which unblocks the frame /// Tears down the session. Order matters and is not interchangeable:
/// pump thread's blocking `appsink.pull_sample()` call), stops the ///
/// CASTV2 session (which ends its io thread, closing the channels the /// 1. Pipeline to `Null` -- unblocks the frame pump's blocking
/// other two pump threads block on), then joins every thread. /// `appsink.pull_sample()`, so it can exit and release its
/// `Arc<CastStreamSender>`.
/// 2. Stop the CASTV2 session -- ends its io thread, closing the
/// raw-message channel the message pump blocks on, so it too can exit
/// and release its `Arc`.
/// 3. Join those two. After this, no thread is calling into the FFI
/// session and `self.sender` holds the only remaining `Arc`.
/// 4. Drop `self.sender` -- runs `breadcast_caststream_sender_destroy`
/// (blocking until openscreen's threads stop) at a point where
/// nothing else can be mid-call into it, and closes the FFI event
/// channel.
/// 5. Only *then* join the event pump, which blocks on that channel and
/// would deadlock if joined before step 4.
pub async fn stop(mut self) { pub async fn stop(mut self) {
if let Err(e) = self.pipeline.set_state(gst::State::Null) { if let Err(e) = self.pipeline.set_state(gst::State::Null) {
tracing::warn!(error = ?e, "failed to stop the encode pipeline cleanly"); tracing::warn!(error = ?e, "failed to stop the encode pipeline cleanly");
@ -161,31 +212,93 @@ impl CastMirrorSession {
tracing::warn!(error = ?e, "failed to cleanly close the portal capture session"); tracing::warn!(error = ?e, "failed to cleanly close the portal capture session");
} }
} }
for thread in self.threads.drain(..) {
// These threads all end once the pipeline/session teardown join_pump(self.frame_pump.take(), "frame").await;
// above propagates to them (see this method's own doc comment) join_pump(self.message_pump.take(), "message").await;
// -- `spawn_blocking` just keeps `.join()`'s wait off the async
// runtime's worker threads. // Step 4: the blocking FFI teardown, kept off the async runtime's
if let Err(panic) = tokio::task::spawn_blocking(move || thread.join()).await { // worker threads for the same reason the joins are.
tracing::warn!(error = ?panic, "mirror session pump thread join task panicked"); if let Some(sender) = self.sender.take() {
} let _ = tokio::task::spawn_blocking(move || drop(sender)).await;
} }
join_pump(self.event_pump.take(), "event").await;
} }
} }
/// `spawn_blocking` just keeps `.join()`'s wait off the async runtime's
/// worker threads.
async fn join_pump(handle: Option<std::thread::JoinHandle<()>>, what: &str) {
let Some(handle) = handle else { return };
if let Err(panic) = tokio::task::spawn_blocking(move || handle.join()).await {
tracing::warn!(pump = what, error = ?panic, "mirror session pump thread join task panicked");
}
}
/// One step of the encoder-bitrate congestion-control loop: given the
/// currently-applied target and openscreen's latest bandwidth estimate,
/// returns the new target in kbps.
///
/// openscreen's `BandwidthEstimator` deliberately *under*-estimates capacity
/// whenever the transmit rate is below it (see its class comment in
/// `vendor/openscreen/cast/streaming/impl/bandwidth_estimator.h`), and
/// prescribes a TCP-like response: cut hard when the estimate is below the
/// current target, ramp back up *gradually* when it's above. An earlier
/// version of this loop instead did `target = 0.85 * estimate` every second
/// unconditionally, which multiplies the target by <= 0.85 once a second
/// with no way back up -- 4000 kbps collapses past 1500 within ~6 seconds
/// and pins at the floor, which is exactly the "low quality / compression
/// artifacts" symptom, on a perfectly healthy LAN.
///
/// An estimate of 0 means "not enough recent data to say" (documented
/// return value), and must leave the target alone rather than be treated as
/// a zero-bandwidth link.
fn bitrate_control_step(current_kbps: u32, estimate_bps: i32) -> u32 {
if estimate_bps <= 0 {
return current_kbps;
}
let estimate_kbps = (estimate_bps / 1000) as u32;
let next = if estimate_kbps < current_kbps {
// Below target: back off immediately to just under the estimate.
((estimate_kbps as f64) * 0.85) as u32
} else {
// Headroom: probe upward by 10% per second, not straight to the
// estimate -- the estimate is a lower bound, and jumping to it
// oscillates.
current_kbps + current_kbps / 10
};
next.clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS)
}
fn frame_pump_loop( fn frame_pump_loop(
appsink: &gstreamer_app::AppSink, appsink: &gstreamer_app::AppSink,
encoder: &gst::Element, encoder: &gst::Element,
sender: &CastStreamSender, sender: &CastStreamSender,
) -> Result<()> { ) -> Result<()> {
let mut last_bitrate_update = std::time::Instant::now(); let mut last_bitrate_update = std::time::Instant::now();
let mut current_kbps = INITIAL_BITRATE_KBPS;
// `needs_key_frame()` is a snapshot of an atomic the C++ side only
// refreshes every 100ms, so it stays true for several frames after a
// request has already been sent upstream. Firing a force-key-unit event
// per frame in that window makes the encoder emit a burst of IDRs, which
// under CBR eats the whole bitrate budget and produces a visible quality
// dip on every picture-loss report. One request per refresh window is
// enough.
let mut last_key_frame_request: Option<std::time::Instant> = None;
loop { loop {
let Some((data, is_key_frame, capture_time_us)) = pull_encoded_frame(appsink)? else { let Some((data, is_key_frame, capture_time_us)) = pull_encoded_frame(appsink)? else {
return Ok(()); // EOS -- pipeline was set to Null, or the portal source ended return Ok(()); // EOS -- pipeline was set to Null, or the portal source ended
}; };
if sender.needs_key_frame() && !is_key_frame { if sender.needs_key_frame()
&& !is_key_frame
&& last_key_frame_request.is_none_or(|t| t.elapsed() >= std::time::Duration::from_millis(250))
{
request_key_frame(appsink); request_key_frame(appsink);
last_key_frame_request = Some(std::time::Instant::now());
}
if is_key_frame {
last_key_frame_request = None;
} }
if let Err(e) = sender.enqueue_frame(&data, is_key_frame, capture_time_us) { if let Err(e) = sender.enqueue_frame(&data, is_key_frame, capture_time_us) {
@ -193,10 +306,49 @@ fn frame_pump_loop(
} }
if last_bitrate_update.elapsed() >= std::time::Duration::from_secs(1) { if last_bitrate_update.elapsed() >= std::time::Duration::from_secs(1) {
let bps = sender.estimated_bandwidth_bps(); let next_kbps = bitrate_control_step(current_kbps, sender.estimated_bandwidth_bps());
let target_kbps = ((bps as f64 * 0.85) / 1000.0).max(500.0) as u32; if next_kbps != current_kbps {
set_video_bitrate_kbps(encoder, target_kbps); current_kbps = next_kbps;
set_video_bitrate_kbps(encoder, current_kbps);
}
last_bitrate_update = std::time::Instant::now(); last_bitrate_update = std::time::Instant::now();
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_zero_estimate_leaves_the_target_alone() {
assert_eq!(bitrate_control_step(4000, 0), 4000);
assert_eq!(bitrate_control_step(4000, -1), 4000);
}
#[test]
fn headroom_ramps_up_gradually_and_is_capped() {
assert_eq!(bitrate_control_step(4000, 20_000_000), 4400);
assert_eq!(bitrate_control_step(MAX_BITRATE_KBPS, 20_000_000), MAX_BITRATE_KBPS);
}
#[test]
fn a_low_estimate_backs_off_but_not_below_the_floor() {
assert_eq!(bitrate_control_step(4000, 2_000_000), 1700);
assert_eq!(bitrate_control_step(4000, 100_000), MIN_BITRATE_KBPS);
}
/// The regression this loop exists to prevent: a *steady* estimate at
/// roughly the current encode rate must hold the target there (AIMD
/// oscillates a little around it, which is fine), not ratchet it down
/// once per second the way `target = 0.85 * estimate` did -- that
/// reached the floor in about a dozen iterations.
#[test]
fn a_steady_estimate_does_not_spiral_downward() {
let mut kbps = 4000;
for _ in 0..60 {
kbps = bitrate_control_step(kbps, 4_000_000);
assert!(kbps >= 3000, "target spiralled down to {kbps} kbps on a steady 4 Mbps estimate");
}
}
}

View file

@ -81,7 +81,13 @@ impl DlnaMirrorSession {
.context("failed to start the HLS HTTP server")?; .context("failed to start the HLS HTTP server")?;
let stream_url = http.url(lan_ip, "playlist.m3u8"); let stream_url = http.url(lan_ip, "playlist.m3u8");
wait_for_playlist_segments(&output_dir.join("playlist.m3u8"), 3, Duration::from_secs(20)) // Two segments, not three: this is a "don't hand the renderer a 404
// playlist" guard, and every segment waited for here is a segment of
// already-stale video sitting between the renderer and live (see
// `build_video_pipeline`'s note on HLS latency). Two is the minimum
// that still proves the encoder is genuinely producing output rather
// than having emitted one segment and stalled.
wait_for_playlist_segments(&output_dir.join("playlist.m3u8"), 2, Duration::from_secs(20))
.await .await
.context("encode pipeline never produced playable HLS segments")?; .context("encode pipeline never produced playable HLS segments")?;