fix(probe): bound long UVC capture

This commit is contained in:
Brad Stein 2026-08-13 03:07:34 -03:00
parent 2a5f059a0b
commit 9b1bf46cb9
7 changed files with 51 additions and 11 deletions

6
Cargo.lock generated
View File

@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.27.10" version = "0.27.11"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1692,7 +1692,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.27.10" version = "0.27.11"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.27.10" version = "0.27.11"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

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

View File

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

View File

@ -326,6 +326,11 @@ probe terminate with evidence even when malformed packets produce no decoded
timestamps. This is a hardware-testable correction; marked Tethys capture is timestamps. This is a hardware-testable correction; marked Tethys capture is
still required before declaring the tear/smear class fixed. still required before declaring the tear/smear class fixed.
Release 0.27.11 extends the same wall-clock guarantee to the long-running real
webcam artifact detector. A malformed stream now yields a bounded report with
`capture_timed_out`, FFmpeg diagnostics, and extracted reference/suspicious
frames instead of blocking the soak indefinitely.
## 7. The Resolved Downstream Video Failure ## 7. The Resolved Downstream Video Failure
The current blank downstream feeds fail before transport or decoding. The current blank downstream feeds fail before transport or decoding.
@ -402,7 +407,7 @@ The safe completion sequence for this incident is:
6. Run `scripts/install/server.sh` as the trusted deployment path. Preserve the 6. Run `scripts/install/server.sh` as the trusted deployment path. Preserve the
already-attached USB gadget unless a controlled rebuild is explicitly already-attached USB gadget unless a controlled rebuild is explicitly
required. required.
7. Confirm Theia reports server version `0.27.10`, the pushed release revision, 7. Confirm Theia reports server version `0.27.11`, the pushed release revision,
direct MJPEG normalizer timeout `0`, and a coherent UVC contract. direct MJPEG normalizer timeout `0`, and a coherent UVC contract.
8. Open both downstream RPCs and prove that each emits changing, decodable H.264 8. Open both downstream RPCs and prove that each emits changing, decodable H.264
frames. frames.
@ -420,7 +425,7 @@ hardware contract is repeatable. The remaining work falls into five groups.
### A. Install And Version Parity ### A. Install And Version Parity
- Push and deploy `0.27.10` through the client/server install scripts. - Push and deploy `0.27.11` through the client/server install scripts.
- Confirm client/server version and revision in every hardware probe artifact. - Confirm client/server version and revision in every hardware probe artifact.
- Eliminate the current state where a fixed client talks to an unfixed server. - Eliminate the current state where a fixed client talks to an unfixed server.
@ -486,6 +491,6 @@ host repair:
9. disconnect/reconnect and device changes recover without stale media; and 9. disconnect/reconnect and device changes recover without stale media; and
10. diagnostics identify the failed physical stage when any item breaks. 10. diagnostics identify the failed physical stage when any item breaks.
Until that sequence passes on the installed `0.27.10` client/server pair, the Until that sequence passes on the installed `0.27.11` client/server pair, the
current release should be described as a validated code correction awaiting current release should be described as a validated code correction awaiting
hardware deployment and end-to-end acceptance, not as a completed product fix. hardware deployment and end-to-end acceptance, not as a completed product fix.

View File

@ -476,6 +476,7 @@ def run_capture(args: argparse.Namespace) -> int:
analysis_elapsed = 0.0 analysis_elapsed = 0.0
raw_capture_bytes = 0 raw_capture_bytes = 0
ffmpeg_rc: int | None = None ffmpeg_rc: int | None = None
capture_timed_out = False
previous: bytes | None = None previous: bytes | None = None
frame_index = 0 frame_index = 0
suspicious_count = 0 suspicious_count = 0
@ -569,9 +570,24 @@ def run_capture(args: argparse.Namespace) -> int:
(artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in capture_command) + "\n") (artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in capture_command) + "\n")
print(f"capturing raw RCT frames before analysis: {raw_path}", file=sys.stderr) print(f"capturing raw RCT frames before analysis: {raw_path}", file=sys.stderr)
started = time.monotonic() started = time.monotonic()
proc = subprocess.run(capture_command, stdout=subprocess.DEVNULL, stderr=err, check=False) try:
capture_elapsed = time.monotonic() - started proc = subprocess.run(
capture_command,
stdout=subprocess.DEVNULL,
stderr=err,
check=False,
timeout=max(5.0, args.duration + 10.0),
)
ffmpeg_rc = proc.returncode ffmpeg_rc = proc.returncode
except subprocess.TimeoutExpired:
capture_timed_out = True
ffmpeg_rc = 124
err.write(
f"capture exceeded wall timeout after {args.duration + 10.0:.1f}s\n".encode()
)
err.flush()
capture_elapsed = time.monotonic() - started
raw_path.touch(exist_ok=True)
raw_capture_bytes = raw_path.stat().st_size if raw_path.exists() else 0 raw_capture_bytes = raw_path.stat().st_size if raw_path.exists() else 0
print( print(
f"analyzing captured raw RCT frames bytes={raw_capture_bytes} capture_s={capture_elapsed:.3f}", f"analyzing captured raw RCT frames bytes={raw_capture_bytes} capture_s={capture_elapsed:.3f}",
@ -601,6 +617,7 @@ def run_capture(args: argparse.Namespace) -> int:
"duration_observed_s": round(elapsed, 3), "duration_observed_s": round(elapsed, 3),
"analysis_duration_s": round(analysis_elapsed, 3), "analysis_duration_s": round(analysis_elapsed, 3),
"ffmpeg_rc": ffmpeg_rc, "ffmpeg_rc": ffmpeg_rc,
"capture_timed_out": capture_timed_out,
"raw_capture_bytes": raw_capture_bytes, "raw_capture_bytes": raw_capture_bytes,
"frames": frame_index, "frames": frame_index,
"fps_observed": round(frame_index / elapsed, 3), "fps_observed": round(frame_index / elapsed, 3),
@ -633,6 +650,7 @@ def format_summary(summary: dict[str, Any]) -> str:
f"device: {summary['device']}", f"device: {summary['device']}",
f"mode: {summary['width']}x{summary['height']}@{summary['fps_requested']}", f"mode: {summary['width']}x{summary['height']}@{summary['fps_requested']}",
f"frames: {summary['frames']} ({summary['fps_observed']} fps observed)", f"frames: {summary['frames']} ({summary['fps_observed']} fps observed)",
f"capture timed out: {summary.get('capture_timed_out', False)}",
f"suspicious: {summary['suspicious_frames']} ({summary['suspicious_pct']}%)", f"suspicious: {summary['suspicious_frames']} ({summary['suspicious_pct']}%)",
f"static: {summary.get('static_frames', 0)} ({summary.get('static_pct', 0.0)}%)", f"static: {summary.get('static_frames', 0)} ({summary.get('static_pct', 0.0)}%)",
f"max deltas: upper={summary.get('max_upper_delta', 0.0)} lower={summary.get('max_lower_delta', 0.0)}", f"max deltas: upper={summary.get('max_upper_delta', 0.0)} lower={summary.get('max_lower_delta', 0.0)}",

View File

@ -16,7 +16,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.27.10" version = "0.27.11"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -52,6 +52,23 @@ fn rct_uvc_artifact_probe_documents_late_path_lower_half_detection() {
} }
} }
#[test]
fn rct_uvc_artifact_probe_terminates_when_malformed_video_never_decodes() {
for expected in [
"timeout=max(5.0, args.duration + 10.0)",
"except subprocess.TimeoutExpired:",
"capture_timed_out = True",
"ffmpeg_rc = 124",
"raw_path.touch(exist_ok=True)",
"\"capture_timed_out\": capture_timed_out",
] {
assert!(
PROBE_SRC.contains(expected),
"long receiver probe must report instead of hanging when corrupt UVC packets produce no decoded frames: {expected}"
);
}
}
#[test] #[test]
fn rct_uvc_artifact_probe_self_test_flags_synthetic_lower_half_slab() { fn rct_uvc_artifact_probe_self_test_flags_synthetic_lower_half_slab() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");