fix(webrtc): SO_SNDBUF 2MB + VBV rate limiting + stats integration

P0 - UDP send buffer: set SO_SNDBUF=2MB to prevent EAGAIN on large IDR
frames (218KB/256KB keyframes caused 18+ EAGAIN bursts). Actual Linux
buffer 4096KB confirmed.

P1 - VBV rate limiting: cap rc_max_rate=bitrate and rc_buffer_size=
bitrate/4 for WebRTC encode path, preventing oversized IDR frames.

Stats: integrate PipelineStats into cap_portal (dropped_count), state.rs
(wlroots path), webrtc.rs (browser getStats enhancement + stats panel).
This commit is contained in:
dailz
2026-06-07 16:55:07 +08:00
parent 029fe13e37
commit aae030f309
3 changed files with 231 additions and 39 deletions
+153 -28
View File
@@ -19,11 +19,13 @@ const HTML_PAGE: &str = r#"<!DOCTYPE html>
video{max-width:90vw;max-height:80vh;border:1px solid #333}
#status{margin:12px;font-size:14px;color:#aaa}
#debug{position:fixed;bottom:8px;left:8px;font-size:11px;color:#666;max-width:90vw;white-space:pre-wrap}
#stats-panel{position:fixed;top:8px;right:8px;background:rgba(0,0,0,0.7);color:#0f0;font:11px monospace;padding:6px 10px;border-radius:4px;z-index:100;pointer-events:none;max-width:90vw;white-space:pre;line-height:1.5}
</style></head>
<body>
<div id="status">Connecting...</div>
<video id="video" autoplay playsinline muted></video>
<pre id="debug"></pre>
<div id="stats-panel"></div>
<script>
const status = document.getElementById('status');
const video = document.getElementById('video');
@@ -51,25 +53,92 @@ function preferH264(sdp) {
}
function installStatsLogger(peer) {
const panel = document.getElementById('stats-panel');
let prev = null;
const intervalSecs = 1;
setInterval(() => {
if (peer !== pc) return;
const v = video;
log(`video: readyState=${v.readyState} currentTime=${v.currentTime.toFixed(2)} ` +
`paused=${v.paused} width=${v.videoWidth} height=${v.videoHeight} ` +
`srcObject=${v.srcObject ? 'yes' : 'no'}`);
peer.getStats().then(stats => {
let rtp = null, rtt = null, codecStr = '';
let freezeCount = null, totalFreezesDuration = null;
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
log(`RTP-in: packetsReceived=${report.packetsReceived} packetsLost=${report.packetsLost} ` +
`bytesReceived=${report.bytesReceived} framesDecoded=${report.framesDecoded} ` +
`framesDropped=${report.framesDropped} codecId=${report.codecId}`);
}
if (report.type === 'codec' && report.mimeType && report.mimeType.includes('H264')) {
log(`Codec: ${report.mimeType} ${report.payloadType} sdpFmtpLine=${report.sdpFmtpLine}`);
if (report.type === 'inbound-rtp' && report.kind === 'video') rtp = report;
if (report.type === 'codec' && report.mimeType && report.mimeType.includes('H264'))
codecStr = report.mimeType + ' ' + (report.payloadType || '');
// candidate-pair: feature-detect 'selected' property
if (report.type === 'candidate-pair') {
const isSel = ('selected' in report) ? report.selected : report.state === 'succeeded';
if (isSel && typeof report.currentRoundTripTime === 'number') rtt = report.currentRoundTripTime;
}
});
// Freeze stats (feature-detect)
if (rtp && typeof rtp.freezeCount !== 'undefined') {
freezeCount = rtp.freezeCount;
totalFreezesDuration = rtp.totalFreezesDuration;
}
if (!rtp) return;
const cur = {
framesDecoded: rtp.framesDecoded || 0,
framesDropped: rtp.framesDropped || 0,
framesPerSecond: rtp.framesPerSecond || 0,
packetsLost: rtp.packetsLost || 0,
jitter: rtp.jitter || 0,
bytesReceived: rtp.bytesReceived || 0,
totalDecodeTime: rtp.totalDecodeTime || 0,
jitterBufferDelay: rtp.jitterBufferDelay || 0,
jitterBufferEmittedCount: rtp.jitterBufferEmittedCount || 0,
freezeCount: freezeCount,
totalFreezesDuration: totalFreezesDuration,
rtt: rtt,
};
// Raw log to debug element (backward compat)
log('RTP-in: decoded=' + cur.framesDecoded + ' lost=' + cur.packetsLost +
' bytes=' + cur.bytesReceived + ' fps=' + cur.framesPerSecond +
(codecStr ? ' codec=' + codecStr : ''));
if (!prev) { prev = cur; return; }
// Compute deltas
const dFrames = cur.framesDecoded - prev.framesDecoded;
const dDropped = cur.framesDropped - prev.framesDropped;
const dLost = cur.packetsLost - prev.packetsLost;
const dBytes = cur.bytesReceived - prev.bytesReceived;
const dDecodeTime = cur.totalDecodeTime - prev.totalDecodeTime;
const dJitterBufDelay = cur.jitterBufferDelay - prev.jitterBufferDelay;
const dJitterBufCount = cur.jitterBufferEmittedCount - prev.jitterBufferEmittedCount;
const kbps = Math.round(dBytes * 8 / intervalSecs / 1000);
const decodeMs = dFrames > 0 ? (dDecodeTime / dFrames * 1000).toFixed(1) : '—';
const jitterBufMs = dJitterBufCount > 0 ? (dJitterBufDelay / dJitterBufCount * 1000).toFixed(1) : '—';
const jitterMs = (cur.jitter * 1000).toFixed(1);
const rttMs = cur.rtt !== null ? (cur.rtt * 1000).toFixed(1) : null;
let line = 'FPS:' + cur.framesPerSecond +
' Decoded:' + cur.framesDecoded + '(+' + dFrames + ')' +
' Dropped:' + cur.framesDropped + (dDropped > 0 ? '(+' + dDropped + ')' : '') +
' Lost:' + dLost +
' Jitter:' + jitterMs + 'ms' +
(rttMs !== null ? ' RTT:' + rttMs + 'ms' : '') +
' Decode:' + decodeMs + 'ms' +
' JBuf:' + jitterBufMs + 'ms';
if (freezeCount !== null) {
const dFreeze = cur.freezeCount - (prev.freezeCount || 0);
if (cur.freezeCount > 0 || dFreeze > 0)
line += ' Freeze:' + cur.freezeCount + '(+' + dFreeze + ')';
}
line += ' ' + kbps + 'kbps';
panel.textContent = line;
prev = cur;
}).catch(() => {});
}, 2000);
}, intervalSecs * 1000);
}
function connect() {
@@ -178,12 +247,16 @@ impl WebRtcState {
HTML_PAGE.len(),
HTML_PAGE
);
let _ = stream.write_all(resp.as_bytes());
if let Err(e) = stream.write_all(resp.as_bytes()) {
tracing::debug!("HTTP write error: {e}");
}
} else if req_str.starts_with("POST /sdp") {
let body = extract_body(&req_str);
if body.is_empty() {
let resp = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nempty body";
let _ = stream.write_all(resp.as_bytes());
if let Err(e) = stream.write_all(resp.as_bytes()) {
tracing::debug!("HTTP write error: {e}");
}
continue;
}
@@ -206,17 +279,23 @@ impl WebRtcState {
answer_json.len(),
answer_json
);
let _ = stream.write_all(resp.as_bytes());
if let Err(e) = stream.write_all(resp.as_bytes()) {
tracing::debug!("HTTP write error: {e}");
}
}
Err(e) => {
tracing::error!("SDP offer handling failed: {e}");
let resp = "HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n";
let _ = stream.write_all(resp.as_bytes());
if let Err(e) = stream.write_all(resp.as_bytes()) {
tracing::debug!("HTTP write error: {e}");
}
}
}
} else {
let resp = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
let _ = stream.write_all(resp.as_bytes());
if let Err(e) = stream.write_all(resp.as_bytes()) {
tracing::debug!("HTTP write error: {e}");
}
}
}
Ok(handled)
@@ -225,7 +304,7 @@ impl WebRtcState {
pub fn poll_rtc(&mut self) -> Result<()> {
if let Some(inner) = self.inner.as_mut() {
if inner.poll_rtc()? {
tracing::warn!("WebRTC connection closed/failed; clearing connection state");
tracing::info!("WebRTC connection closed; clearing connection state");
self.inner = None;
}
}
@@ -252,7 +331,7 @@ impl WebRtcState {
false
};
if should_destroy {
tracing::warn!("WebRTC connection failed during write; clearing connection state");
tracing::info!("WebRTC connection failed during write; clearing connection state");
self.inner = None;
}
Ok(())
@@ -270,10 +349,49 @@ impl WebRtcInner {
let socket = UdpSocket::bind("0.0.0.0:0")?;
socket.set_nonblocking(true)?;
// Increase UDP send buffer to absorb IDR frame bursts (256KB IDR → ~145 RTP
// packets in a single poll_rtc loop). Default Linux wmem is ~208KB which
// causes EAGAIN on large keyframes. 2MB comfortably buffers several IDRs.
const SND_BUF_REQ: usize = 2 * 1024 * 1024;
// SAFETY: fd is a valid UDP socket; setsockopt/getsockopt with SOL_SOCKET +
// SO_SNDBUF are safe on Linux. We check the return value and log the actual
// kernel-assigned buffer (Linux may cap at wmem_max and/or double the value).
unsafe {
let fd = std::os::unix::io::AsRawFd::as_raw_fd(&socket);
let val: libc::c_int = SND_BUF_REQ as libc::c_int;
let ret = libc::setsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_SNDBUF,
&val as *const libc::c_int as *const libc::c_void,
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
);
if ret < 0 {
tracing::warn!("setsockopt SO_SNDBUF failed (errno {})", std::io::Error::last_os_error());
}
let mut actual: libc::c_int = 0;
let mut actual_len: libc::socklen_t = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
let gret = libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_SNDBUF,
&mut actual as *mut libc::c_int as *mut libc::c_void,
&mut actual_len,
);
if gret == 0 {
tracing::info!(
"UDP send buffer: requested {}KB, actual {}KB",
SND_BUF_REQ / 1024,
actual / 1024,
);
}
}
let local_addr = socket.local_addr()?;
let lan_ip = local_ip().unwrap_or_else(|| {
tracing::warn!("Failed to detect LAN IP, falling back to 127.0.0.1");
tracing::debug!("Failed to detect LAN IP, falling back to 127.0.0.1");
"127.0.0.1".to_string()
});
let candidate_addr: SocketAddr = format!("{lan_ip}:{}", local_addr.port()).parse()?;
@@ -320,7 +438,7 @@ impl WebRtcInner {
let mid = match self.video_mid {
Some(m) => m,
None => {
tracing::warn!("discover_video_params: no video_mid yet");
tracing::debug!("discover_video_params: no video_mid yet");
return;
}
};
@@ -344,13 +462,20 @@ impl WebRtcInner {
loop {
match self.rtc.poll_output() {
Ok(Output::Transmit(t)) => {
tracing::info!("TX {} bytes -> {}", t.contents.len(), t.destination);
tracing::trace!("TX {} bytes -> {}", t.contents.len(), t.destination);
if let Err(e) = self.socket.send_to(&t.contents, t.destination) {
tracing::warn!("UDP send error: {e}");
if e.kind() == std::io::ErrorKind::WouldBlock {
tracing::debug!(
"UDP send WouldBlock ({} bytes) — send buffer full",
t.contents.len(),
);
} else {
tracing::warn!("UDP send error to {}: {e}", t.destination);
}
}
}
Ok(Output::Event(e)) => {
tracing::info!("RTC event: {e:?}");
tracing::debug!("RTC event: {e:?}");
match &e {
Event::Connected => {
tracing::info!("WebRTC connected!");
@@ -400,7 +525,7 @@ impl WebRtcInner {
Ok((n, source)) => {
recv_count += 1;
if recv_count <= 5 {
tracing::info!("UDP recv {} bytes from {}", n, source);
tracing::trace!("UDP recv {} bytes from {}", n, source);
}
let input = Input::Receive(
Instant::now(),
@@ -438,14 +563,14 @@ impl WebRtcInner {
let mid = match self.video_mid {
Some(m) => m,
None => {
tracing::warn!("write_h264: no video_mid");
tracing::debug!("write_h264: no video_mid");
return Ok(false);
}
};
let pt = match self.video_pt {
Some(p) => p,
None => {
tracing::warn!("write_h264: no video_pt");
tracing::debug!("write_h264: no video_pt");
return Ok(false);
}
};
@@ -474,7 +599,7 @@ impl WebRtcInner {
let writer = match self.rtc.writer(mid) {
Some(w) => w,
None => {
tracing::warn!("write_h264: no writer for mid={mid}");
tracing::debug!("write_h264: no writer for mid={mid}");
return Ok(false);
}
};