fix: tighten UVC freshness path

This commit is contained in:
Brad Stein 2026-05-03 18:28:21 -03:00
parent 6236292f56
commit 365876a152
10 changed files with 190 additions and 11 deletions

View File

@ -99,6 +99,10 @@ path.
defaults, preventing browsers from receiving frames outside negotiated caps.
- [x] Make the UVC control helper answer probe/commit requests from the same
live descriptor so Firefox/Chrome negotiation matches server frame output.
- [x] Bound UVC helper buffering and stale MJPEG replay so server-to-host video
freshness can be tightened without changing the bundled sync architecture.
- [x] Make direct UVC/UAC output-delay application absolute by default so stale
legacy calibration does not keep a hidden multi-second video delay alive.
- [x] Continue reporting client timing and sink handoff diagnostics from bundled packets.
- [ ] Add bundled-mode counters for first bundle, first audio push, first video feed,
dropped stale bundles, and bundle queue age.

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.19.4"
version = "0.19.5"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.19.4"
version = "0.19.5"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.19.4"
version = "0.19.5"
dependencies = [
"anyhow",
"base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.19.4"
version = "0.19.5"
edition = "2024"
[dependencies]

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.19.4"
version = "0.19.5"
edition = "2024"
build = "build.rs"

View File

@ -176,6 +176,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_MIC_TEST_SOURCE_DESC` | client media capture/playback override |
| `LESAVKA_MOUSE_DEVICE` | input routing/clipboard override |
| `LESAVKA_OUTPUT_DELAY_APPLY` | manual direct UVC/UAC probe override; apply the measured server output-delay correction through the calibration API when the probe gates pass |
| `LESAVKA_OUTPUT_DELAY_APPLY_MODE` | manual direct UVC/UAC probe override; `absolute` sets the active output-path baseline to the measured device delay, while `relative` preserves legacy nudge behavior |
| `LESAVKA_OUTPUT_DELAY_CALIBRATION` | manual direct UVC/UAC probe override; emit `output-delay-calibration.json` from a lab-attached USB host capture of server-generated signatures, defaults to enabled |
| `LESAVKA_OUTPUT_DELAY_GAIN` | manual direct UVC/UAC probe override; scales measured output-delay correction before applying, defaults to `1.0` |
| `LESAVKA_OUTPUT_DELAY_MAX_ABS_SKEW_MS` | manual direct UVC/UAC probe safety limit; refuses to apply/save implausibly large measured device skew, defaults to `5000` |
@ -278,6 +279,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_UVC_APP_BLOCK` | server hardware/device override |
| `LESAVKA_UVC_BLOCKING` | server hardware/device override |
| `LESAVKA_UVC_BULK` | server hardware/device override |
| `LESAVKA_UVC_BUFFER_COUNT` | UVC helper freshness override; number of queued gadget output buffers, defaults to `2` for live-call freshness |
| `LESAVKA_UVC_BY_PATH_ROOT` | server hardware/device override |
| `LESAVKA_UVC_CODEC` | server hardware/device override |
| `LESAVKA_UVC_CTRL_BIN` | server hardware/device override |
@ -289,8 +291,10 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_UVC_EXTERNAL` | server hardware/device override |
| `LESAVKA_UVC_FALLBACK` | server hardware/device override |
| `LESAVKA_UVC_FPS` | server hardware/device override |
| `LESAVKA_UVC_FRAME_MAX_AGE_MS` | UVC helper freshness override; stale spooled MJPEG frames older than this are not replayed, defaults to `1000`; `0` disables TTL |
| `LESAVKA_UVC_FRAME_SIZE` | server hardware/device override |
| `LESAVKA_UVC_HEIGHT` | server hardware/device override |
| `LESAVKA_UVC_IDLE_PUMP_MS` | UVC helper freshness override; idle poll sleep while pumping host-returned buffers, defaults to `2` |
| `LESAVKA_UVC_INTERVAL` | server hardware/device override |
| `LESAVKA_UVC_LIMIT_PCT` | server hardware/device override |
| `LESAVKA_UVC_MAXBURST` | server hardware/device override |

View File

@ -55,6 +55,7 @@ REMOTE_EXPECT_CAM_OUTPUT=${REMOTE_EXPECT_CAM_OUTPUT:-uvc}
REMOTE_EXPECT_UVC_CODEC=${REMOTE_EXPECT_UVC_CODEC:-mjpeg}
LESAVKA_OUTPUT_DELAY_CALIBRATION=${LESAVKA_OUTPUT_DELAY_CALIBRATION:-1}
LESAVKA_OUTPUT_DELAY_APPLY=${LESAVKA_OUTPUT_DELAY_APPLY:-0}
LESAVKA_OUTPUT_DELAY_APPLY_MODE=${LESAVKA_OUTPUT_DELAY_APPLY_MODE:-absolute}
LESAVKA_OUTPUT_DELAY_SAVE=${LESAVKA_OUTPUT_DELAY_SAVE:-0}
LESAVKA_OUTPUT_DELAY_TARGET=${LESAVKA_OUTPUT_DELAY_TARGET:-video}
LESAVKA_OUTPUT_DELAY_MIN_PAIRS=${LESAVKA_OUTPUT_DELAY_MIN_PAIRS:-8}
@ -273,6 +274,7 @@ write_output_delay_calibration() {
"${LESAVKA_OUTPUT_DELAY_GAIN}" \
"${LESAVKA_OUTPUT_DELAY_MAX_STEP_US}" \
"${LESAVKA_OUTPUT_DELAY_APPLY}" \
"${LESAVKA_OUTPUT_DELAY_APPLY_MODE}" \
"${LESAVKA_OUTPUT_DELAY_SAVE}"
import json
import math
@ -292,6 +294,7 @@ import sys
gain_raw,
max_step_raw,
apply_raw,
apply_mode_raw,
save_raw,
) = sys.argv[1:]
@ -323,6 +326,7 @@ report = json.loads(pathlib.Path(report_path).read_text())
verdict = report.get("verdict") or {}
target = target.strip().lower()
apply_mode = apply_mode_raw.strip().lower()
min_pairs = max(1, as_int(min_pairs_raw, 8))
max_abs_skew_ms = max(1.0, as_float(max_abs_skew_raw, 5000.0))
max_drift_ms = max(0.0, as_float(max_drift_raw, 80.0))
@ -353,6 +357,11 @@ elif target == "audio":
else:
refusal_reasons.append(f"unsupported target {target!r}; use video or audio")
if apply_mode not in {"absolute", "relative"}:
refusal_reasons.append(
f"unsupported apply mode {apply_mode!r}; use absolute or relative"
)
if paired < min_pairs:
refusal_reasons.append(f"paired_event_count {paired} < {min_pairs}")
if max_abs_observed_ms > max_abs_skew_ms:
@ -387,6 +396,7 @@ artifact = {
"ready": ready,
"decision": decision,
"apply_enabled": as_bool(apply_raw),
"apply_mode": apply_mode,
"save_enabled": as_bool(save_raw),
"paired_event_count": paired,
"min_pairs": min_pairs,
@ -402,6 +412,8 @@ artifact = {
"bounded_device_delta_us": bounded_delta_us,
"audio_offset_adjust_us": audio_delta_us,
"video_offset_adjust_us": video_delta_us,
"audio_target_offset_us": audio_delta_us,
"video_target_offset_us": video_delta_us,
"refusal_reasons": refusal_reasons,
"note": note,
}
@ -413,9 +425,12 @@ env_values = {
"output_delay_target": target,
"output_delay_audio_delta_us": audio_delta_us,
"output_delay_video_delta_us": video_delta_us,
"output_delay_audio_target_offset_us": audio_delta_us,
"output_delay_video_target_offset_us": video_delta_us,
"output_delay_measured_skew_ms": f"{median_skew_ms:.3f}",
"output_delay_paired_event_count": paired,
"output_delay_drift_ms": f"{drift_ms:.3f}",
"output_delay_apply_mode": apply_mode,
"output_delay_note": note,
}
with pathlib.Path(output_env_path).open("w") as handle:
@ -668,6 +683,9 @@ maybe_apply_output_delay_calibration() {
echo " ↪ output_delay_drift_ms=${output_delay_drift_ms:-0.0}"
echo " ↪ output_delay_audio_delta_us=${output_delay_audio_delta_us:-0}"
echo " ↪ output_delay_video_delta_us=${output_delay_video_delta_us:-0}"
echo " ↪ output_delay_audio_target_offset_us=${output_delay_audio_target_offset_us:-0}"
echo " ↪ output_delay_video_target_offset_us=${output_delay_video_target_offset_us:-0}"
echo " ↪ output_delay_apply_mode=${output_delay_apply_mode:-${LESAVKA_OUTPUT_DELAY_APPLY_MODE}}"
echo " ↪ output_delay_note=${output_delay_note:-}"
if [[ "${output_delay_ready:-false}" != "true" ]]; then
@ -680,13 +698,49 @@ maybe_apply_output_delay_calibration() {
return 0
fi
local apply_mode="${output_delay_apply_mode:-${LESAVKA_OUTPUT_DELAY_APPLY_MODE}}"
local apply_audio_delta="${output_delay_audio_delta_us:-0}"
local apply_video_delta="${output_delay_video_delta_us:-0}"
if [[ "${apply_mode}" == "absolute" ]]; then
local calibration_output current_audio_offset_us current_video_offset_us
if ! calibration_output="$(
LESAVKA_TLS_DOMAIN="${LESAVKA_TLS_DOMAIN}" \
"${REPO_ROOT}/target/debug/lesavka-relayctl" \
--server "${RESOLVED_LESAVKA_SERVER_ADDR}" \
calibration 2>&1
)"; then
echo " ↪ output delay calibration apply refused: current calibration query failed"
echo "${calibration_output}" >&2
return 0
fi
current_audio_offset_us="$(awk -F= '/^calibration_active_audio_offset_us=/{print $2; exit}' <<<"${calibration_output}")"
current_video_offset_us="$(awk -F= '/^calibration_active_video_offset_us=/{print $2; exit}' <<<"${calibration_output}")"
if [[ ! "${current_audio_offset_us}" =~ ^-?[0-9]+$ || ! "${current_video_offset_us}" =~ ^-?[0-9]+$ ]]; then
echo " ↪ output delay calibration apply refused: could not parse active calibration offsets"
echo "${calibration_output}" >&2
return 0
fi
apply_audio_delta=$(( ${output_delay_audio_target_offset_us:-0} - current_audio_offset_us ))
apply_video_delta=$(( ${output_delay_video_target_offset_us:-0} - current_video_offset_us ))
echo " ↪ current_active_audio_offset_us=${current_audio_offset_us}"
echo " ↪ current_active_video_offset_us=${current_video_offset_us}"
echo " ↪ absolute_target_audio_offset_us=${output_delay_audio_target_offset_us:-0}"
echo " ↪ absolute_target_video_offset_us=${output_delay_video_target_offset_us:-0}"
elif [[ "${apply_mode}" != "relative" ]]; then
echo " ↪ output delay calibration apply refused: unsupported apply mode ${apply_mode}"
return 0
fi
echo " ↪ calibration_apply_audio_delta_us=${apply_audio_delta}"
echo " ↪ calibration_apply_video_delta_us=${apply_video_delta}"
echo "==> applying measured UVC/UAC output-delay calibration"
LESAVKA_TLS_DOMAIN="${LESAVKA_TLS_DOMAIN}" \
"${REPO_ROOT}/target/debug/lesavka-relayctl" \
--server "${RESOLVED_LESAVKA_SERVER_ADDR}" \
calibrate \
"${output_delay_audio_delta_us:-0}" \
"${output_delay_video_delta_us:-0}" \
"${apply_audio_delta}" \
"${apply_video_delta}" \
"${output_delay_note:-direct UVC/UAC output-delay calibration}"
if [[ "${LESAVKA_OUTPUT_DELAY_SAVE}" != "0" ]]; then

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.19.4"
version = "0.19.5"
edition = "2024"
autobins = false

View File

@ -6,7 +6,7 @@ use std::fs::{File, OpenOptions};
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::{AsRawFd, RawFd};
use std::thread;
use std::time::Duration;
use std::time::{Duration, SystemTime};
const STREAM_CTRL_SIZE_11: usize = 26;
const STREAM_CTRL_SIZE_15: usize = 34;
@ -47,6 +47,9 @@ const V4L2_FIELD_NONE: u32 = 1;
const V4L2_PIX_FMT_MJPEG: u32 = u32::from_le_bytes(*b"MJPG");
const MAX_MJPEG_FRAME_BYTES: usize = 1024 * 1024;
const EMPTY_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9];
const DEFAULT_UVC_BUFFER_COUNT: u32 = 2;
const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2;
const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000;
#[repr(C)]
struct V4l2EventSubscription {
@ -253,7 +256,7 @@ impl UvcVideoStream {
fn start(&mut self, cfg: UvcConfig) -> Result<()> {
self.stop();
self.set_format(cfg)?;
self.request_buffers(4)?;
self.request_buffers(uvc_buffer_count())?;
for index in 0..self.buffers.len() {
self.queue_buffer(index as u32)?;
}
@ -421,6 +424,10 @@ impl UvcVideoStream {
}
fn refresh_latest_frame(&mut self) {
if frame_spool_is_stale(&self.frame_path, frame_spool_max_age()) {
self.latest_frame = EMPTY_MJPEG_FRAME.to_vec();
return;
}
if let Ok(frame) = std::fs::read(&self.frame_path)
&& !frame.is_empty()
&& frame.len() <= MAX_MJPEG_FRAME_BYTES
@ -471,6 +478,43 @@ fn frame_spool_path() -> std::path::PathBuf {
.unwrap_or_else(|_| std::path::PathBuf::from("/run/lesavka-uvc-frame.mjpg"))
}
fn uvc_buffer_count() -> u32 {
env_u32("LESAVKA_UVC_BUFFER_COUNT", DEFAULT_UVC_BUFFER_COUNT).clamp(1, 8)
}
fn uvc_idle_pump_sleep() -> Duration {
Duration::from_millis(env_u64(
"LESAVKA_UVC_IDLE_PUMP_MS",
DEFAULT_UVC_IDLE_PUMP_MS,
))
}
fn frame_spool_max_age() -> Option<Duration> {
match env_u64(
"LESAVKA_UVC_FRAME_MAX_AGE_MS",
DEFAULT_UVC_FRAME_MAX_AGE_MS,
) {
0 => None,
value => Some(Duration::from_millis(value)),
}
}
fn frame_spool_is_stale(path: &std::path::Path, max_age: Option<Duration>) -> bool {
let Some(max_age) = max_age else {
return false;
};
let Ok(metadata) = std::fs::metadata(path) else {
return true;
};
let Ok(modified) = metadata.modified() else {
return false;
};
SystemTime::now()
.duration_since(modified)
.map(|age| age > max_age)
.unwrap_or(false)
}
fn main() -> Result<()> {
let (dev, cfg) = parse_args()?;
let _singleton = acquire_singleton_lock()?;
@ -482,7 +526,16 @@ fn main() -> Result<()> {
);
let debug = env::var("LESAVKA_UVC_DEBUG").is_ok();
let idle_sleep = uvc_idle_pump_sleep();
eprintln!("[lesavka-uvc] nonblock=1");
eprintln!(
"[lesavka-uvc] freshness buffers={} idle_pump_ms={} frame_max_age_ms={}",
uvc_buffer_count(),
idle_sleep.as_millis(),
frame_spool_max_age()
.map(|duration| duration.as_millis().to_string())
.unwrap_or_else(|| "disabled".to_string())
);
let mut setup_seen: u64 = 0;
let mut data_seen: u64 = 0;
let mut dq_err_seen: u64 = 0;
@ -548,7 +601,7 @@ fn main() -> Result<()> {
dq_err_last = code;
}
}
thread::sleep(Duration::from_millis(10));
thread::sleep(idle_sleep);
continue;
}
Some(libc::ENODEV) | Some(libc::EBADF) | Some(libc::EIO) => {
@ -1258,6 +1311,13 @@ fn env_u32(name: &str, default: u32) -> u32 {
.unwrap_or(default)
}
fn env_u64(name: &str, default: u64) -> u64 {
env::var(name)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(default)
}
fn env_u8(name: &str) -> Option<u8> {
env::var(name).ok().and_then(|v| v.parse::<u8>().ok())
}

View File

@ -41,6 +41,7 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
"LOCAL_OUTPUT_DELAY_ENV=\"${LOCAL_REPORT_DIR}/output-delay-calibration.env\"",
"LESAVKA_OUTPUT_DELAY_CALIBRATION=${LESAVKA_OUTPUT_DELAY_CALIBRATION:-1}",
"LESAVKA_OUTPUT_DELAY_APPLY=${LESAVKA_OUTPUT_DELAY_APPLY:-0}",
"LESAVKA_OUTPUT_DELAY_APPLY_MODE=${LESAVKA_OUTPUT_DELAY_APPLY_MODE:-absolute}",
"LESAVKA_OUTPUT_DELAY_SAVE=${LESAVKA_OUTPUT_DELAY_SAVE:-0}",
"LESAVKA_OUTPUT_DELAY_TARGET=${LESAVKA_OUTPUT_DELAY_TARGET:-video}",
"LESAVKA_OUTPUT_DELAY_MIN_PAIRS=${LESAVKA_OUTPUT_DELAY_MIN_PAIRS:-8}",
@ -65,6 +66,13 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
"probe_media_origin\": \"server-generated\"",
"probe_media_path\": \"server generated signatures -> UVC/UAC sinks -> lab host capture\"",
"audio_after_video_positive",
"audio_target_offset_us",
"video_target_offset_us",
"output_delay_audio_target_offset_us",
"output_delay_video_target_offset_us",
"calibration_active_video_offset_us",
"absolute_target_video_offset_us",
"calibration_apply_video_delta_us",
"PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,1,3",
"\"${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US}\"",
"\"${LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US}\"",

View File

@ -351,6 +351,55 @@ mod uvc_binary_extra {
assert_eq!(read_fifo_min(missing.to_str().expect("missing")), None);
}
#[test]
#[serial]
fn uvc_freshness_defaults_bound_live_video_backlog() {
with_var("LESAVKA_UVC_BUFFER_COUNT", None::<&str>, || {
with_var("LESAVKA_UVC_IDLE_PUMP_MS", None::<&str>, || {
with_var("LESAVKA_UVC_FRAME_MAX_AGE_MS", None::<&str>, || {
assert_eq!(uvc_buffer_count(), 2);
assert_eq!(uvc_idle_pump_sleep(), std::time::Duration::from_millis(2));
assert_eq!(
frame_spool_max_age(),
Some(std::time::Duration::from_millis(1_000))
);
});
});
});
}
#[test]
#[serial]
fn uvc_freshness_env_clamps_buffers_and_allows_disabling_frame_ttl() {
with_var("LESAVKA_UVC_BUFFER_COUNT", Some("99"), || {
with_var("LESAVKA_UVC_IDLE_PUMP_MS", Some("11"), || {
with_var("LESAVKA_UVC_FRAME_MAX_AGE_MS", Some("0"), || {
assert_eq!(uvc_buffer_count(), 8);
assert_eq!(uvc_idle_pump_sleep(), std::time::Duration::from_millis(11));
assert_eq!(frame_spool_max_age(), None);
});
});
});
}
#[test]
fn frame_spool_staleness_uses_mtime_and_respects_disabled_ttl() {
let frame = NamedTempFile::new().expect("tmp frame");
fs::write(frame.path(), [0xff, 0xd8, 0xff, 0xd9]).expect("write frame");
assert!(!frame_spool_is_stale(frame.path(), None));
let missing = PathBuf::from("/tmp/lesavka-definitely-missing-frame.mjpg");
assert!(frame_spool_is_stale(
missing.as_path(),
Some(std::time::Duration::from_millis(1))
));
std::thread::sleep(std::time::Duration::from_millis(2));
assert!(frame_spool_is_stale(
frame.path(),
Some(std::time::Duration::from_millis(1))
));
}
#[test]
fn compute_payload_cap_clamps_limit_pct_bounds() {
with_var("LESAVKA_UVC_MAXPAYLOAD_LIMIT", None::<&str>, || {