ci(lesavka): repair hygiene gate regressions
This commit is contained in:
parent
3d32ee0922
commit
92b9aeecbd
@ -187,6 +187,10 @@ path = "tests/component/client/uplink/client_uplink_component_contract.rs"
|
||||
name = "client_app_include_contract"
|
||||
path = "tests/contract/client/app/client_app_include_contract.rs"
|
||||
|
||||
[[test]]
|
||||
name = "client_app_support_contract"
|
||||
path = "tests/contract/client/app/client_app_support_contract.rs"
|
||||
|
||||
[[test]]
|
||||
name = "client_app_process_contract"
|
||||
path = "tests/contract/client/app/client_app_process_contract.rs"
|
||||
|
||||
@ -2,8 +2,7 @@ impl LesavkaClientApp {
|
||||
/*──────────────── bundled webcam + mic stream ─────────────────*/
|
||||
#[cfg(not(coverage))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Keeps `webcam_media_loop` explicit because it sits on the live uplink path, where stale media must be dropped instead of queued into latency.
|
||||
/// Inputs are the typed parameters; output is the return value or side effect.
|
||||
/// Run the live bundled uplink loop, dropping stale media instead of queueing latency.
|
||||
async fn webcam_media_loop(
|
||||
ep: Channel,
|
||||
initial_camera_source: Option<String>,
|
||||
@ -27,7 +26,8 @@ impl LesavkaClientApp {
|
||||
continue;
|
||||
}
|
||||
|
||||
let active_camera_source = state.camera_source.resolve(initial_camera_source.as_deref());
|
||||
let active_camera_source =
|
||||
state.camera_source.resolve(initial_camera_source.as_deref());
|
||||
let active_camera_profile =
|
||||
state.camera_profile.resolve(initial_camera_profile.as_deref());
|
||||
let active_camera_cfg = camera_config_with_live_codec(camera_cfg, &state.camera_codec);
|
||||
@ -279,9 +279,9 @@ impl LesavkaClientApp {
|
||||
let desired_source = state
|
||||
.microphone_source
|
||||
.resolve(initial_microphone_source.as_deref());
|
||||
let desired_audio_codec = state
|
||||
.audio_codec
|
||||
.resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus);
|
||||
let desired_audio_codec = state.audio_codec.resolve(
|
||||
lesavka_common::audio_transport::UpstreamAudioCodec::Opus,
|
||||
);
|
||||
let desired_noise_suppression =
|
||||
state.noise_suppression.resolve(false);
|
||||
if state.camera != active_camera_requested
|
||||
|
||||
@ -74,174 +74,3 @@ fn env_flag_enabled(name: &str) -> bool {
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
DEFAULT_SERVER_ADDR, camera_config_from_caps, next_delay, resolve_server_addr,
|
||||
sanitize_video_queue,
|
||||
};
|
||||
use crate::handshake::PeerCaps;
|
||||
use crate::input::camera::CameraCodec;
|
||||
use serial_test::serial;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn resolve_server_addr_prefers_cli_then_env_then_default() {
|
||||
assert_eq!(
|
||||
resolve_server_addr(&[String::from("http://cli:1")], Some("http://env:2")),
|
||||
"http://cli:1"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_server_addr(
|
||||
&[
|
||||
String::from("--no-launcher"),
|
||||
String::from("--server"),
|
||||
String::from("http://cli-flag:3"),
|
||||
],
|
||||
Some("http://env:2"),
|
||||
),
|
||||
"http://cli-flag:3"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_server_addr(&[String::from("--launcher")], Some("http://env:2")),
|
||||
"http://env:2"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_server_addr(&[], Some("http://env:2")),
|
||||
"http://env:2"
|
||||
);
|
||||
assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn camera_config_from_caps_requires_complete_profile() {
|
||||
temp_env::with_var("LESAVKA_CAM_CODEC", None::<&str>, || {
|
||||
let mut caps = PeerCaps {
|
||||
camera: true,
|
||||
microphone: false,
|
||||
bundled_webcam_media: false,
|
||||
server_version: None,
|
||||
camera_output: Some(String::from("uvc")),
|
||||
camera_codec: Some(String::from("mjpeg")),
|
||||
camera_width: Some(1280),
|
||||
camera_height: Some(720),
|
||||
camera_fps: Some(25),
|
||||
eye_width: None,
|
||||
eye_height: None,
|
||||
eye_fps: None,
|
||||
};
|
||||
|
||||
let config = camera_config_from_caps(&caps).expect("complete caps should map");
|
||||
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
||||
assert_eq!(config.width, 1280);
|
||||
|
||||
caps.camera_codec = Some(String::from("h265"));
|
||||
let config = camera_config_from_caps(&caps).expect("h265 alias should map");
|
||||
assert!(matches!(config.codec, CameraCodec::Hevc));
|
||||
|
||||
caps.camera_codec = Some(String::from("vp9"));
|
||||
assert!(camera_config_from_caps(&caps).is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn camera_config_from_caps_uses_negotiated_codec_over_launcher_default() {
|
||||
let caps = PeerCaps {
|
||||
camera: true,
|
||||
microphone: false,
|
||||
bundled_webcam_media: false,
|
||||
server_version: None,
|
||||
camera_output: Some(String::from("uvc")),
|
||||
camera_codec: Some(String::from("hevc")),
|
||||
camera_width: Some(1280),
|
||||
camera_height: Some(720),
|
||||
camera_fps: Some(25),
|
||||
eye_width: None,
|
||||
eye_height: None,
|
||||
eye_fps: None,
|
||||
};
|
||||
|
||||
temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || {
|
||||
let config = camera_config_from_caps(&caps).expect("caps codec should map");
|
||||
assert!(matches!(config.codec, CameraCodec::Hevc));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn camera_config_from_caps_allows_explicit_forced_codec_override() {
|
||||
let caps = PeerCaps {
|
||||
camera: true,
|
||||
microphone: false,
|
||||
bundled_webcam_media: false,
|
||||
server_version: None,
|
||||
camera_output: Some(String::from("uvc")),
|
||||
camera_codec: Some(String::from("hevc")),
|
||||
camera_width: Some(1280),
|
||||
camera_height: Some(720),
|
||||
camera_fps: Some(25),
|
||||
eye_width: None,
|
||||
eye_height: None,
|
||||
eye_fps: None,
|
||||
};
|
||||
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("LESAVKA_CAM_CODEC", Some("mjpeg")),
|
||||
("LESAVKA_CAM_CODEC_FORCE", Some("1")),
|
||||
],
|
||||
|| {
|
||||
let config = camera_config_from_caps(&caps).expect("forced override should map");
|
||||
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn camera_config_force_flag_ignores_unknown_override() {
|
||||
let caps = PeerCaps {
|
||||
camera: true,
|
||||
microphone: false,
|
||||
bundled_webcam_media: false,
|
||||
server_version: None,
|
||||
camera_output: Some(String::from("uvc")),
|
||||
camera_codec: Some(String::from("hevc")),
|
||||
camera_width: Some(1280),
|
||||
camera_height: Some(720),
|
||||
camera_fps: Some(25),
|
||||
eye_width: None,
|
||||
eye_height: None,
|
||||
eye_fps: None,
|
||||
};
|
||||
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("LESAVKA_CAM_CODEC", Some("vp9")),
|
||||
("LESAVKA_CAM_CODEC_FORCE", Some("1")),
|
||||
],
|
||||
|| {
|
||||
let config =
|
||||
camera_config_from_caps(&caps).expect("bad forced override should fall back");
|
||||
assert!(matches!(config.codec, CameraCodec::Hevc));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_video_queue_enforces_floor() {
|
||||
assert_eq!(sanitize_video_queue(None), 8);
|
||||
assert_eq!(sanitize_video_queue(Some(1)), 4);
|
||||
assert_eq!(sanitize_video_queue(Some(32)), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_delay_doubles_until_capped() {
|
||||
assert_eq!(next_delay(Duration::from_secs(1)), Duration::from_secs(2));
|
||||
assert_eq!(next_delay(Duration::from_secs(15)), Duration::from_secs(30));
|
||||
assert_eq!(next_delay(Duration::from_secs(31)), Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,14 +12,7 @@ impl LauncherState {
|
||||
}
|
||||
|
||||
pub fn set_server_version(&mut self, version: Option<String>) {
|
||||
self.server_version = version.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
});
|
||||
self.server_version = normalize_optional_text(version);
|
||||
}
|
||||
|
||||
pub fn set_server_media_caps(
|
||||
@ -31,22 +24,8 @@ impl LauncherState {
|
||||
) {
|
||||
self.server_camera = camera;
|
||||
self.server_microphone = microphone;
|
||||
self.server_camera_output = camera_output.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
});
|
||||
self.server_camera_codec = camera_codec.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
});
|
||||
self.server_camera_output = normalize_optional_text(camera_output);
|
||||
self.server_camera_codec = normalize_optional_text(camera_codec);
|
||||
}
|
||||
|
||||
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
|
||||
@ -481,3 +460,10 @@ impl LauncherState {
|
||||
self.upstream_sync = upstream_sync;
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
@ -340,6 +340,7 @@ 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_AUDIT_CONTROL_PATH` | UVC helper boundary-audit runtime control path; defaults to `/tmp/lesavka-uvc-frame-audit.control`, where a path value enables exact-frame audit capture and `off`/`0` disables it without restarting the server |
|
||||
| `LESAVKA_UVC_FRAME_AUDIT_DIR` | UVC helper boundary-audit directory; when set, the server saves exact MJPEG frames published to the UVC helper plus a JSONL index so recordings can be compared against pre-UVC payloads |
|
||||
| `LESAVKA_UVC_FRAME_AUDIT_EVERY` | UVC helper boundary-audit sampling interval; saves every Nth spooled frame, defaults to `1` for short repros |
|
||||
| `LESAVKA_UVC_FRAME_AUDIT_LOG_PATH` | UVC helper boundary-audit JSONL path override; defaults to `spool-audit.jsonl` inside `LESAVKA_UVC_FRAME_AUDIT_DIR` |
|
||||
|
||||
@ -68,12 +68,12 @@
|
||||
"client/src/app/uplink_media/webcam_media_loop.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 3,
|
||||
"loc": 476
|
||||
"loc": 492
|
||||
},
|
||||
"client/src/app_support.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 3,
|
||||
"loc": 174
|
||||
"doc_debt": 0,
|
||||
"loc": 76
|
||||
},
|
||||
"client/src/bin/lesavka-relayctl.rs": {
|
||||
"clippy_warnings": 0,
|
||||
@ -362,8 +362,8 @@
|
||||
},
|
||||
"client/src/launcher/state/launcher_state_impl.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 17,
|
||||
"loc": 475
|
||||
"doc_debt": 16,
|
||||
"loc": 469
|
||||
},
|
||||
"client/src/launcher/state/launcher_status_line.rs": {
|
||||
"clippy_warnings": 0,
|
||||
@ -1328,32 +1328,32 @@
|
||||
"server/src/video_sinks/mjpeg_spool.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 4,
|
||||
"loc": 443
|
||||
"loc": 468
|
||||
},
|
||||
"server/src/video_sinks/mjpeg_spool/audit.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0,
|
||||
"loc": 287
|
||||
"loc": 338
|
||||
},
|
||||
"server/src/video_sinks/mjpeg_spool/tests.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 2,
|
||||
"loc": 439
|
||||
"loc": 489
|
||||
},
|
||||
"server/src/video_sinks/webcam_sink.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 7,
|
||||
"loc": 479
|
||||
"loc": 480
|
||||
},
|
||||
"server/src/video_sinks/webcam_sink/constructor.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 1,
|
||||
"loc": 382
|
||||
"loc": 384
|
||||
},
|
||||
"server/src/video_sinks/webcam_sink/frame_handoff.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 4,
|
||||
"loc": 357
|
||||
"loc": 364
|
||||
},
|
||||
"server/src/video_sinks/webcam_sink/tests.rs": {
|
||||
"clippy_warnings": 0,
|
||||
|
||||
@ -22,6 +22,7 @@ HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC = 8000
|
||||
DEFAULT_ISOCHRONOUS_LIMIT_PCT = 85
|
||||
DEFAULT_UVC_MAX_PACKET = 1024
|
||||
DEFAULT_MEDIA_CONTROL_PATH = "/tmp/lesavka-media.control"
|
||||
DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH = "/tmp/lesavka-uvc-frame-audit.control"
|
||||
MARKER_BITS = 32
|
||||
MARKER_COLUMNS = 16
|
||||
CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"}
|
||||
@ -235,6 +236,32 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--max-reference-artifacts", type=int, default=12)
|
||||
parser.add_argument("--reference-every", type=int, default=900)
|
||||
parser.add_argument("--progress-every", type=int, default=150)
|
||||
parser.add_argument(
|
||||
"--server-uvc-audit",
|
||||
action="store_true",
|
||||
help="enable exact server-side UVC-bound MJPEG audit evidence for this run",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--server-uvc-audit-host",
|
||||
default="",
|
||||
help="SSH host running the Lesavka server; defaults to --inject-host when set",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--server-uvc-audit-control-path",
|
||||
default=DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH,
|
||||
help="runtime control file read by the server to enable UVC-bound frame auditing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--server-uvc-audit-dir",
|
||||
default="",
|
||||
help="remote audit directory; default uses a unique /tmp path on the server host",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--server-uvc-audit-sample-frames",
|
||||
type=int,
|
||||
default=30,
|
||||
help="number of audited MJPEG frames to copy/decode for boundary classification",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stream-analyze",
|
||||
action="store_true",
|
||||
@ -417,6 +444,263 @@ def restore_remote_live_upstream(host: str, state: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def resolve_server_uvc_audit_host(args: argparse.Namespace) -> str:
|
||||
if args.server_uvc_audit_host:
|
||||
return args.server_uvc_audit_host
|
||||
if args.inject_host:
|
||||
return args.inject_host
|
||||
return ""
|
||||
|
||||
|
||||
def setup_server_uvc_audit(args: argparse.Namespace, artifact_stamp: str) -> tuple[str, str] | None:
|
||||
if not args.server_uvc_audit:
|
||||
return None
|
||||
host = resolve_server_uvc_audit_host(args)
|
||||
if not host:
|
||||
raise SystemExit("--server-uvc-audit requires --server-uvc-audit-host when --local-inject is used")
|
||||
remote_dir = args.server_uvc_audit_dir or f"/tmp/lesavka-synthetic-rct-uvc-audit-{artifact_stamp}"
|
||||
command = (
|
||||
f"rm -rf {shlex.quote(remote_dir)} && "
|
||||
f"mkdir -p {shlex.quote(remote_dir)} && "
|
||||
f"printf '%s\\n' {shlex.quote(remote_dir)} > {shlex.quote(args.server_uvc_audit_control_path)}"
|
||||
)
|
||||
subprocess.run(["ssh", host, command], check=True)
|
||||
print(
|
||||
f"enabled server UVC-bound frame audit on {host}: {remote_dir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return host, remote_dir
|
||||
|
||||
|
||||
def cleanup_server_uvc_audit(args: argparse.Namespace, state: tuple[str, str] | None) -> None:
|
||||
if state is None:
|
||||
return
|
||||
host, _remote_dir = state
|
||||
command = f"rm -f {shlex.quote(args.server_uvc_audit_control_path)}"
|
||||
subprocess.run(["ssh", host, command], check=False)
|
||||
print(
|
||||
f"disabled server UVC-bound frame audit on {host}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def read_jsonl(path: pathlib.Path) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
if not path.exists():
|
||||
return records
|
||||
for line in path.read_text(errors="replace").splitlines():
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
records.append(value)
|
||||
return records
|
||||
|
||||
|
||||
def sample_records(records: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
|
||||
if limit <= 0 or len(records) <= limit:
|
||||
return records
|
||||
if limit == 1:
|
||||
return [records[-1]]
|
||||
indexes = {
|
||||
round(idx * (len(records) - 1) / (limit - 1))
|
||||
for idx in range(limit)
|
||||
}
|
||||
return [records[idx] for idx in sorted(indexes)]
|
||||
|
||||
|
||||
def copy_server_uvc_audit(
|
||||
args: argparse.Namespace,
|
||||
state: tuple[str, str] | None,
|
||||
local_dir: pathlib.Path,
|
||||
) -> pathlib.Path | None:
|
||||
if state is None:
|
||||
return None
|
||||
host, remote_dir = state
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
remote_log = f"{remote_dir.rstrip('/')}/spool-audit.jsonl"
|
||||
local_log = local_dir / "spool-audit.jsonl"
|
||||
subprocess.run(["scp", f"{host}:{remote_log}", str(local_log)], check=False)
|
||||
records = read_jsonl(local_log)
|
||||
for record in sample_records(records, args.server_uvc_audit_sample_frames):
|
||||
frame_file = str(record.get("file") or "")
|
||||
if not frame_file or "/" in frame_file:
|
||||
continue
|
||||
subprocess.run(
|
||||
[
|
||||
"scp",
|
||||
f"{host}:{remote_dir.rstrip('/')}/{frame_file}",
|
||||
str(local_dir / frame_file),
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return local_dir
|
||||
|
||||
|
||||
def decode_mjpeg_to_gray(path: pathlib.Path, width: int, height: int) -> bytes | None:
|
||||
if width <= 0 or height <= 0 or not path.exists():
|
||||
return None
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(path),
|
||||
"-an",
|
||||
"-pix_fmt",
|
||||
"gray",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
expected = width * height
|
||||
if proc.returncode != 0 or len(proc.stdout) < expected:
|
||||
return None
|
||||
return proc.stdout[:expected]
|
||||
|
||||
|
||||
def summarize_server_uvc_audit(
|
||||
local_dir: pathlib.Path | None,
|
||||
mode_width: int,
|
||||
mode_height: int,
|
||||
mode_fps: int,
|
||||
capture_data: dict[str, Any] | None,
|
||||
args: argparse.Namespace,
|
||||
) -> dict[str, Any] | None:
|
||||
if local_dir is None:
|
||||
return None
|
||||
log_path = local_dir / "spool-audit.jsonl"
|
||||
records = read_jsonl(log_path)
|
||||
frame_size_counts: collections.Counter[str] = collections.Counter()
|
||||
uvc_mode_counts: collections.Counter[str] = collections.Counter()
|
||||
complete_count = 0
|
||||
rejected_count = 0
|
||||
decoded_sample_count = 0
|
||||
marker_sample_count = 0
|
||||
visual_sample_count = 0
|
||||
sample_reason_counts: collections.Counter[str] = collections.Counter()
|
||||
previous_seq: int | None = None
|
||||
|
||||
for record in records:
|
||||
width = record.get("frame_width")
|
||||
height = record.get("frame_height")
|
||||
frame_size_counts[f"{width}x{height}"] += 1
|
||||
uvc_width = record.get("uvc_width")
|
||||
uvc_height = record.get("uvc_height")
|
||||
uvc_fps = record.get("uvc_fps")
|
||||
uvc_mode_counts[f"{uvc_width}x{uvc_height}@{uvc_fps}"] += 1
|
||||
complete_count += int(bool(record.get("jpeg_complete")))
|
||||
rejected_count += int(bool(record.get("rejected")))
|
||||
|
||||
for record in sample_records(records, args.server_uvc_audit_sample_frames):
|
||||
frame_file = str(record.get("file") or "")
|
||||
width = int(record.get("frame_width") or 0)
|
||||
height = int(record.get("frame_height") or 0)
|
||||
if not frame_file or width <= 0 or height <= 0:
|
||||
continue
|
||||
raw = decode_mjpeg_to_gray(local_dir / frame_file, width, height)
|
||||
if raw is None:
|
||||
continue
|
||||
decoded_sample_count += 1
|
||||
result = analyze_frame(raw, width, height, args, previous_seq)
|
||||
comparison_seq = result.get("comparison_sequence")
|
||||
if comparison_seq is not None:
|
||||
previous_seq = int(comparison_seq)
|
||||
if result.get("decoded_sequence") is not None:
|
||||
marker_sample_count += 1
|
||||
if result.get("visual_suspicious"):
|
||||
visual_sample_count += 1
|
||||
sample_reason_counts.update(result.get("visual_reasons") or [])
|
||||
|
||||
matching_records = [
|
||||
record
|
||||
for record in records
|
||||
if int(record.get("frame_width") or 0) == mode_width
|
||||
and int(record.get("frame_height") or 0) == mode_height
|
||||
]
|
||||
matching_uvc_records = [
|
||||
record
|
||||
for record in records
|
||||
if int(record.get("uvc_width") or 0) == mode_width
|
||||
and int(record.get("uvc_height") or 0) == mode_height
|
||||
and int(record.get("uvc_fps") or 0) == mode_fps
|
||||
]
|
||||
capture_visual_frames = int((capture_data or {}).get("visual_suspicious_frames") or 0)
|
||||
capture_frames = int((capture_data or {}).get("frames") or 0)
|
||||
status = "inconclusive"
|
||||
diagnosis: list[str] = []
|
||||
if not records:
|
||||
status = "server_boundary_missing"
|
||||
diagnosis.append(
|
||||
"server UVC-bound audit recorded no frames; the software output path did not prove it produced fresh webcam frames during the probe"
|
||||
)
|
||||
elif rejected_count and rejected_count == len(records):
|
||||
status = "server_boundary_rejected"
|
||||
diagnosis.append(
|
||||
"every audited UVC-bound frame was rejected before handoff; corruption or profile trouble is before the browser-facing UVC path"
|
||||
)
|
||||
elif complete_count < len(records):
|
||||
status = "server_boundary_incomplete_jpeg"
|
||||
diagnosis.append(
|
||||
"the server UVC-bound audit contains incomplete JPEG payloads, so corruption exists before or at the server handoff"
|
||||
)
|
||||
elif not matching_records:
|
||||
status = "server_boundary_frame_mode_mismatch"
|
||||
diagnosis.append(
|
||||
f"server UVC-bound frames did not match requested {mode_width}x{mode_height}; observed frame sizes {dict(frame_size_counts)}"
|
||||
)
|
||||
elif not matching_uvc_records:
|
||||
status = "server_boundary_uvc_mode_mismatch"
|
||||
diagnosis.append(
|
||||
f"server UVC-bound records did not advertise requested {mode_width}x{mode_height}@{mode_fps}; observed UVC modes {dict(uvc_mode_counts)}"
|
||||
)
|
||||
elif visual_sample_count:
|
||||
status = "server_boundary_visual_corruption"
|
||||
diagnosis.append(
|
||||
"decoded server UVC-bound audit samples were already visually suspicious before reaching the host/browser"
|
||||
)
|
||||
elif capture_visual_frames:
|
||||
status = "downstream_uvc_or_browser_corruption"
|
||||
diagnosis.append(
|
||||
"server UVC-bound samples were clean and mode-matched, but receiver capture showed visual corruption; the software UVC gadget/browser leg is implicated"
|
||||
)
|
||||
elif capture_frames:
|
||||
status = "no_visual_corruption_observed"
|
||||
diagnosis.append(
|
||||
"server UVC-bound samples and receiver capture had no visual corruption in this run"
|
||||
)
|
||||
|
||||
summary = {
|
||||
"schema": "lesavka.server-uvc-boundary-summary.v1",
|
||||
"status": status,
|
||||
"diagnosis": diagnosis,
|
||||
"record_count": len(records),
|
||||
"complete_count": complete_count,
|
||||
"rejected_count": rejected_count,
|
||||
"frame_size_counts": dict(frame_size_counts),
|
||||
"uvc_mode_counts": dict(uvc_mode_counts),
|
||||
"matching_frame_records": len(matching_records),
|
||||
"matching_uvc_mode_records": len(matching_uvc_records),
|
||||
"decoded_sample_count": decoded_sample_count,
|
||||
"marker_sample_count": marker_sample_count,
|
||||
"visual_sample_count": visual_sample_count,
|
||||
"sample_visual_reason_counts": dict(sample_reason_counts),
|
||||
"artifact_dir": str(local_dir),
|
||||
"log_path": str(log_path),
|
||||
}
|
||||
(local_dir / "boundary-summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
||||
return summary
|
||||
|
||||
|
||||
def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
if (not args.inject_host and not args.local_inject) or not args.rct_host:
|
||||
raise SystemExit(
|
||||
@ -425,11 +709,16 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
if not shutil.which("ssh") or not shutil.which("scp"):
|
||||
raise SystemExit("ssh and scp are required for the remote synthetic probe")
|
||||
width, height, fps = mode_dimensions(args)
|
||||
run_stamp = timestamp()
|
||||
inject_max_frame_bytes = args.inject_max_frame_bytes or default_inject_max_frame_bytes(fps)
|
||||
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir(args.mode)
|
||||
artifact_dir = (
|
||||
pathlib.Path(args.artifact_dir)
|
||||
if args.artifact_dir
|
||||
else pathlib.Path("artifacts/synthetic-rct") / f"{args.mode.replace('@', '-').replace('x', 'x')}-{run_stamp}"
|
||||
)
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{timestamp()}"
|
||||
remote_inject_dir = args.remote_inject_dir or f"/tmp/lesavka-synthetic-uplink-{timestamp()}"
|
||||
remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{run_stamp}"
|
||||
remote_inject_dir = args.remote_inject_dir or f"/tmp/lesavka-synthetic-uplink-{run_stamp}"
|
||||
remote_script = f"/tmp/lesavka-synthetic-rct-probe-{os.getpid()}.py"
|
||||
script_text = pathlib.Path(__file__).read_text()
|
||||
subprocess.run(
|
||||
@ -537,6 +826,10 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
"rct_host": args.rct_host,
|
||||
"pause_local_live_upstream": args.pause_local_live_upstream,
|
||||
"media_control_path": args.media_control_path,
|
||||
"server_uvc_audit": args.server_uvc_audit,
|
||||
"server_uvc_audit_host": resolve_server_uvc_audit_host(args),
|
||||
"server_uvc_audit_control_path": args.server_uvc_audit_control_path,
|
||||
"server_uvc_audit_sample_frames": args.server_uvc_audit_sample_frames,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
@ -598,7 +891,9 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
diagnosis: list[str] = []
|
||||
paused_control: tuple[pathlib.Path, bytes | None] | None = None
|
||||
paused_remote_control: tuple[str, dict[str, Any]] | None = None
|
||||
server_audit_state: tuple[str, str] | None = None
|
||||
try:
|
||||
server_audit_state = setup_server_uvc_audit(args, run_stamp)
|
||||
if args.pause_local_live_upstream:
|
||||
if args.local_inject:
|
||||
paused_control = pause_local_live_upstream(args)
|
||||
@ -624,12 +919,14 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
capture = start_capture()
|
||||
capture_rc, inject_rc = wait_capture_or_inject_exit(capture, inject)
|
||||
finally:
|
||||
cleanup_server_uvc_audit(args, server_audit_state)
|
||||
if paused_remote_control is not None:
|
||||
restore_remote_live_upstream(*paused_remote_control)
|
||||
if paused_control is not None:
|
||||
restore_local_live_upstream(*paused_control)
|
||||
local_capture = artifact_dir / "capture"
|
||||
local_inject = artifact_dir / "inject"
|
||||
local_server_audit = artifact_dir / "server-uvc-audit"
|
||||
if capture is not None:
|
||||
subprocess.run(["scp", "-r", f"{args.rct_host}:{remote_rct_dir}", str(local_capture)], check=False)
|
||||
if args.local_inject:
|
||||
@ -639,7 +936,9 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
shutil.copytree(remote_inject_dir, local_inject)
|
||||
else:
|
||||
subprocess.run(["scp", "-r", f"{args.inject_host}:{remote_inject_dir}", str(local_inject)], check=False)
|
||||
copied_server_audit = copy_server_uvc_audit(args, server_audit_state, local_server_audit)
|
||||
capture_summary = local_capture / "summary.json"
|
||||
capture_data: dict[str, Any] | None = None
|
||||
if capture_summary.exists():
|
||||
try:
|
||||
capture_data = json.loads(capture_summary.read_text())
|
||||
@ -695,6 +994,17 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
server_boundary_summary = summarize_server_uvc_audit(
|
||||
copied_server_audit,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
capture_data,
|
||||
args,
|
||||
)
|
||||
if server_boundary_summary:
|
||||
for item in server_boundary_summary.get("diagnosis") or []:
|
||||
diagnosis.append(str(item))
|
||||
summary = {
|
||||
"schema": "lesavka.synthetic-rct-probe.orchestrator.v1",
|
||||
"mode": args.mode,
|
||||
@ -704,6 +1014,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
"artifact_dir": str(artifact_dir),
|
||||
"capture_artifacts": str(local_capture),
|
||||
"inject_artifacts": str(local_inject),
|
||||
"server_uvc_boundary": server_boundary_summary,
|
||||
"server_uvc_audit_artifacts": str(local_server_audit) if copied_server_audit else None,
|
||||
}
|
||||
(artifact_dir / "run-summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
|
||||
@ -23,6 +23,9 @@ pub(super) struct MjpegSpoolTiming {
|
||||
pub profile: &'static str,
|
||||
pub source_pts_us: Option<u64>,
|
||||
pub decoded_pts_us: Option<u64>,
|
||||
pub uvc_width: Option<u16>,
|
||||
pub uvc_height: Option<u16>,
|
||||
pub uvc_fps: Option<u32>,
|
||||
}
|
||||
|
||||
impl MjpegSpoolTiming {
|
||||
@ -36,6 +39,9 @@ impl MjpegSpoolTiming {
|
||||
profile: "mjpeg-passthrough",
|
||||
source_pts_us: Some(source_pts_us),
|
||||
decoded_pts_us: None,
|
||||
uvc_width: None,
|
||||
uvc_height: None,
|
||||
uvc_fps: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,6 +56,9 @@ impl MjpegSpoolTiming {
|
||||
profile: "mjpeg-normalized",
|
||||
source_pts_us: Some(source_pts_us),
|
||||
decoded_pts_us: None,
|
||||
uvc_width: None,
|
||||
uvc_height: None,
|
||||
uvc_fps: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,6 +73,9 @@ impl MjpegSpoolTiming {
|
||||
profile: "hevc-decoded-mjpeg",
|
||||
source_pts_us: Some(source_pts_us),
|
||||
decoded_pts_us,
|
||||
uvc_width: None,
|
||||
uvc_height: None,
|
||||
uvc_fps: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,8 +93,18 @@ impl MjpegSpoolTiming {
|
||||
profile: "hevc-decoded-mjpeg-rejected",
|
||||
source_pts_us: Some(source_pts_us),
|
||||
decoded_pts_us,
|
||||
uvc_width: None,
|
||||
uvc_height: None,
|
||||
uvc_fps: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn with_uvc_mode(mut self, width: u16, height: u16, fps: u32) -> Self {
|
||||
self.uvc_width = Some(width);
|
||||
self.uvc_height = Some(height);
|
||||
self.uvc_fps = Some(fps);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether the UVC helper file-spool path should own MJPEG emission.
|
||||
@ -345,12 +367,15 @@ pub(super) fn format_mjpeg_spool_metadata(
|
||||
timing: MjpegSpoolTiming,
|
||||
) -> String {
|
||||
format!(
|
||||
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-meta.v1\",\"sequence\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{}}}\n",
|
||||
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-meta.v1\",\"sequence\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{}}}\n",
|
||||
sequence,
|
||||
timing.profile,
|
||||
bytes,
|
||||
json_number_or_null(timing.source_pts_us),
|
||||
json_number_or_null(timing.decoded_pts_us),
|
||||
json_number_or_null(timing.uvc_width.map(u64::from)),
|
||||
json_number_or_null(timing.uvc_height.map(u64::from)),
|
||||
json_number_or_null(timing.uvc_fps.map(u64::from)),
|
||||
unix_now_ns()
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::super::hevc_mjpeg_guard;
|
||||
use super::MjpegSpoolTiming;
|
||||
|
||||
static AUDIT_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||
@ -11,6 +12,7 @@ static AUDIT_SAVED_FRAMES: AtomicU64 = AtomicU64::new(0);
|
||||
const DEFAULT_UVC_FRAME_AUDIT_EVERY: u32 = 1;
|
||||
const DEFAULT_UVC_FRAME_AUDIT_MAX_FRAMES: u32 = 1800;
|
||||
const DEFAULT_UVC_FRAME_AUDIT_ROLLING: bool = true;
|
||||
const DEFAULT_UVC_FRAME_AUDIT_CONTROL_PATH: &str = "/tmp/lesavka-uvc-frame-audit.control";
|
||||
const FNV1A64_OFFSET: u64 = 0xcbf29ce484222325;
|
||||
const FNV1A64_PRIME: u64 = 0x100000001b3;
|
||||
|
||||
@ -71,6 +73,30 @@ pub(super) fn mjpeg_spool_audit_dir() -> Option<PathBuf> {
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(mjpeg_spool_runtime_audit_dir)
|
||||
}
|
||||
|
||||
/// Resolve the runtime control-file audit directory.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH` or the default control path.
|
||||
/// Output: an audit directory, unless the file is absent or contains an off
|
||||
/// value. Why: lab probes need to enable exact UVC-bound captures around one
|
||||
/// run without restarting or making all installed calls write MJPEG dumps.
|
||||
fn mjpeg_spool_runtime_audit_dir() -> Option<PathBuf> {
|
||||
let control_path = std::env::var("LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from(DEFAULT_UVC_FRAME_AUDIT_CONTROL_PATH));
|
||||
let value = fs::read_to_string(control_path).ok()?;
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.eq_ignore_ascii_case("0")
|
||||
|| trimmed.eq_ignore_ascii_case("false")
|
||||
|| trimmed.eq_ignore_ascii_case("no")
|
||||
|| trimmed.eq_ignore_ascii_case("off")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(trimmed))
|
||||
}
|
||||
|
||||
/// Resolve the UVC-bound audit sampling interval.
|
||||
@ -189,14 +215,28 @@ fn format_audit_record(
|
||||
let profile = timing.map(|value| value.profile).unwrap_or("unknown");
|
||||
let source_pts_us = timing.and_then(|value| value.source_pts_us);
|
||||
let decoded_pts_us = timing.and_then(|value| value.decoded_pts_us);
|
||||
let uvc_width = timing.and_then(|value| value.uvc_width.map(u64::from));
|
||||
let uvc_height = timing.and_then(|value| value.uvc_height.map(u64::from));
|
||||
let uvc_fps = timing.and_then(|value| value.uvc_fps.map(u64::from));
|
||||
let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(data);
|
||||
format!(
|
||||
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
|
||||
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"bytes\":{},\"jpeg_complete\":{},\"frame_width\":{},\"frame_height\":{},\"entropy_bytes\":{},\"entropy_distinct_bytes\":{},\"entropy_dominant_pct\":{},\"entropy_max_run\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
|
||||
sequence,
|
||||
slot,
|
||||
json_escape(profile),
|
||||
data.len(),
|
||||
inspection.complete,
|
||||
json_number_or_null(inspection.width.map(u64::from)),
|
||||
json_number_or_null(inspection.height.map(u64::from)),
|
||||
inspection.entropy_bytes,
|
||||
inspection.entropy_distinct_bytes,
|
||||
inspection.entropy_dominant_pct,
|
||||
inspection.entropy_max_run,
|
||||
json_number_or_null(source_pts_us),
|
||||
json_number_or_null(decoded_pts_us),
|
||||
json_number_or_null(uvc_width),
|
||||
json_number_or_null(uvc_height),
|
||||
json_number_or_null(uvc_fps),
|
||||
unix_now_ns(),
|
||||
fnv1a64_hex(data),
|
||||
json_escape(frame_file)
|
||||
@ -218,15 +258,26 @@ fn format_rejected_audit_record(
|
||||
) -> String {
|
||||
let source_pts_us = timing.source_pts_us;
|
||||
let decoded_pts_us = timing.decoded_pts_us;
|
||||
let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(data);
|
||||
format!(
|
||||
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"rejected\":true,\"reason\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
|
||||
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"rejected\":true,\"reason\":\"{}\",\"bytes\":{},\"jpeg_complete\":{},\"frame_width\":{},\"frame_height\":{},\"entropy_bytes\":{},\"entropy_distinct_bytes\":{},\"entropy_dominant_pct\":{},\"entropy_max_run\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
|
||||
sequence,
|
||||
slot,
|
||||
json_escape(timing.profile),
|
||||
json_escape(reason),
|
||||
data.len(),
|
||||
inspection.complete,
|
||||
json_number_or_null(inspection.width.map(u64::from)),
|
||||
json_number_or_null(inspection.height.map(u64::from)),
|
||||
inspection.entropy_bytes,
|
||||
inspection.entropy_distinct_bytes,
|
||||
inspection.entropy_dominant_pct,
|
||||
inspection.entropy_max_run,
|
||||
json_number_or_null(source_pts_us),
|
||||
json_number_or_null(decoded_pts_us),
|
||||
json_number_or_null(timing.uvc_width.map(u64::from)),
|
||||
json_number_or_null(timing.uvc_height.map(u64::from)),
|
||||
json_number_or_null(timing.uvc_fps.map(u64::from)),
|
||||
unix_now_ns(),
|
||||
fnv1a64_hex(data),
|
||||
json_escape(frame_file)
|
||||
|
||||
@ -184,6 +184,48 @@ fn mjpeg_spool_audit_knobs_are_opt_in_and_bounded() {
|
||||
assert!(super::audit::should_sample_spool_audit_frame(4, 3));
|
||||
}
|
||||
|
||||
/// Verifies runtime audit control can enable boundary evidence without a
|
||||
/// server restart.
|
||||
///
|
||||
/// Input: a temporary control file containing an audit directory. Output:
|
||||
/// audit dir resolution follows the control file when the env dir is unset.
|
||||
/// Why: hardware/lab probes need to turn on exact UVC-bound frame evidence
|
||||
/// around one run without making every installed call write MJPEG dumps.
|
||||
#[test]
|
||||
fn mjpeg_spool_audit_can_be_enabled_by_runtime_control_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let control = dir.path().join("uvc-audit.control");
|
||||
let audit = dir.path().join("audit");
|
||||
std::fs::write(&control, format!("{}\n", audit.display())).expect("write control");
|
||||
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("LESAVKA_UVC_FRAME_AUDIT_DIR", None::<&str>),
|
||||
(
|
||||
"LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH",
|
||||
Some(control.to_str().expect("utf8 path")),
|
||||
),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(super::audit::mjpeg_spool_audit_dir(), Some(audit));
|
||||
},
|
||||
);
|
||||
|
||||
std::fs::write(&control, "off\n").expect("disable control");
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("LESAVKA_UVC_FRAME_AUDIT_DIR", None::<&str>),
|
||||
(
|
||||
"LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH",
|
||||
Some(control.to_str().expect("utf8 path")),
|
||||
),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(super::audit::mjpeg_spool_audit_dir(), None);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies metadata records carry enough timing evidence for RCT analysis.
|
||||
///
|
||||
/// Input: HEVC-decoded spool timing. Output: JSON fields for source and
|
||||
@ -350,7 +392,10 @@ fn spool_mjpeg_frame_writes_enabled_boundary_audit() {
|
||||
super::spool_mjpeg_frame_with_timing(
|
||||
&frame,
|
||||
b"audit-jpeg",
|
||||
Some(super::MjpegSpoolTiming::mjpeg_passthrough(222)),
|
||||
Some(
|
||||
super::MjpegSpoolTiming::mjpeg_passthrough(222)
|
||||
.with_uvc_mode(1280, 720, 30),
|
||||
),
|
||||
)
|
||||
.expect("spool audited frame");
|
||||
super::spool_mjpeg_frame_with_timing(
|
||||
@ -377,6 +422,11 @@ fn spool_mjpeg_frame_writes_enabled_boundary_audit() {
|
||||
assert!(index.contains("\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\""));
|
||||
assert!(index.contains("\"profile\":\"mjpeg-passthrough\""));
|
||||
assert!(index.contains("\"source_pts_us\":222"));
|
||||
assert!(index.contains("\"jpeg_complete\":false"));
|
||||
assert!(index.contains("\"frame_width\":null"));
|
||||
assert!(index.contains("\"uvc_width\":1280"));
|
||||
assert!(index.contains("\"uvc_height\":720"));
|
||||
assert!(index.contains("\"uvc_fps\":30"));
|
||||
assert!(index.contains("\"file\":\"frame-"));
|
||||
}
|
||||
|
||||
|
||||
@ -46,6 +46,7 @@ pub struct WebcamSink {
|
||||
direct_mjpeg_max_bytes: usize,
|
||||
uvc_width: u16,
|
||||
uvc_height: u16,
|
||||
uvc_fps: u32,
|
||||
direct_mjpeg_profile_mismatch_seen: AtomicBool,
|
||||
unexpected_mjpeg_in_hevc_seen: AtomicBool,
|
||||
last_decoded_mjpeg_bytes: AtomicU64,
|
||||
|
||||
@ -61,6 +61,7 @@ impl WebcamSink {
|
||||
direct_mjpeg_max_bytes: mjpeg_spool_frame_max_bytes(cfg.fps),
|
||||
uvc_width: cfg.width.min(u32::from(u16::MAX)) as u16,
|
||||
uvc_height: cfg.height.min(u32::from(u16::MAX)) as u16,
|
||||
uvc_fps: cfg.fps.max(1),
|
||||
direct_mjpeg_profile_mismatch_seen: AtomicBool::new(false),
|
||||
unexpected_mjpeg_in_hevc_seen: AtomicBool::new(false),
|
||||
last_decoded_mjpeg_bytes: AtomicU64::new(0),
|
||||
@ -368,6 +369,7 @@ impl WebcamSink {
|
||||
direct_mjpeg_max_bytes: mjpeg_spool_frame_max_bytes(cfg.fps),
|
||||
uvc_width: cfg.width.min(u32::from(u16::MAX)) as u16,
|
||||
uvc_height: cfg.height.min(u32::from(u16::MAX)) as u16,
|
||||
uvc_fps: cfg.fps.max(1),
|
||||
direct_mjpeg_profile_mismatch_seen: AtomicBool::new(false),
|
||||
unexpected_mjpeg_in_hevc_seen: AtomicBool::new(false),
|
||||
last_decoded_mjpeg_bytes: AtomicU64::new(0),
|
||||
|
||||
@ -107,14 +107,16 @@ impl WebcamSink {
|
||||
{
|
||||
self.decoded_mjpeg_miss_count.store(0, Ordering::Relaxed);
|
||||
let decoded_pts_us = buffer.pts().map(|pts| pts.nseconds() / 1_000);
|
||||
let timing = MjpegSpoolTiming::hevc_decoded_mjpeg(pkt.pts, decoded_pts_us);
|
||||
let timing = MjpegSpoolTiming::hevc_decoded_mjpeg(pkt.pts, decoded_pts_us)
|
||||
.with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps);
|
||||
let previous_bytes = self.last_decoded_mjpeg_bytes.load(Ordering::Relaxed);
|
||||
let decoded_bytes = map.as_slice().len();
|
||||
if let Some(reason) =
|
||||
hevc_mjpeg_guard::decoded_mjpeg_reject_reason(previous_bytes, map.as_slice())
|
||||
{
|
||||
let rejected_timing =
|
||||
MjpegSpoolTiming::rejected_hevc_decoded_mjpeg(pkt.pts, decoded_pts_us);
|
||||
MjpegSpoolTiming::rejected_hevc_decoded_mjpeg(pkt.pts, decoded_pts_us)
|
||||
.with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps);
|
||||
let reason_text = format!("{reason:?}");
|
||||
super::mjpeg_spool::audit_rejected_mjpeg_frame(
|
||||
map.as_slice(),
|
||||
@ -218,7 +220,8 @@ impl WebcamSink {
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
fn spool_passthrough_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) {
|
||||
let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts);
|
||||
let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts)
|
||||
.with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps);
|
||||
if let Err(err) = spool_mjpeg_frame_with_timing(path, &pkt.data, Some(timing)) {
|
||||
warn!(target:"lesavka_server::video", %err, "failed to spool MJPEG frame for UVC helper");
|
||||
} else {
|
||||
@ -347,7 +350,8 @@ impl WebcamSink {
|
||||
return;
|
||||
}
|
||||
|
||||
let timing = MjpegSpoolTiming::mjpeg_normalized(pkt.pts);
|
||||
let timing = MjpegSpoolTiming::mjpeg_normalized(pkt.pts)
|
||||
.with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps);
|
||||
if let Err(err) = spool_mjpeg_frame_with_timing(path, normalized, Some(timing)) {
|
||||
warn!(target:"lesavka_server::video", %err, "failed to spool normalized direct MJPEG frame for UVC helper");
|
||||
} else {
|
||||
|
||||
173
tests/contract/client/app/client_app_support_contract.rs
Normal file
173
tests/contract/client/app/client_app_support_contract.rs
Normal file
@ -0,0 +1,173 @@
|
||||
// Integration coverage for client app support helpers.
|
||||
//
|
||||
// Scope: exercise server-address, camera-capability, and retry-delay helpers
|
||||
// against deterministic local types.
|
||||
// Targets: `client/src/app_support.rs`.
|
||||
// Why: these startup decisions shape live camera codec negotiation and should
|
||||
// stay covered without adding source-file LOC to the hygiene ratchet.
|
||||
|
||||
mod handshake {
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PeerCaps {
|
||||
pub camera: bool,
|
||||
pub microphone: bool,
|
||||
pub bundled_webcam_media: bool,
|
||||
pub server_version: Option<String>,
|
||||
pub camera_output: Option<String>,
|
||||
pub camera_codec: Option<String>,
|
||||
pub camera_width: Option<u32>,
|
||||
pub camera_height: Option<u32>,
|
||||
pub camera_fps: Option<u32>,
|
||||
pub eye_width: Option<u32>,
|
||||
pub eye_height: Option<u32>,
|
||||
pub eye_fps: Option<u32>,
|
||||
}
|
||||
}
|
||||
|
||||
mod input {
|
||||
pub mod camera {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CameraCodec {
|
||||
Mjpeg,
|
||||
H264,
|
||||
Hevc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct CameraConfig {
|
||||
pub codec: CameraCodec,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "../../../../client/src/app_support.rs"]
|
||||
mod app_support;
|
||||
|
||||
use app_support::{
|
||||
DEFAULT_SERVER_ADDR, camera_config_from_caps, next_delay, resolve_server_addr,
|
||||
sanitize_video_queue,
|
||||
};
|
||||
use handshake::PeerCaps;
|
||||
use input::camera::CameraCodec;
|
||||
use serial_test::serial;
|
||||
use std::time::Duration;
|
||||
|
||||
fn peer_caps(codec: &str) -> PeerCaps {
|
||||
PeerCaps {
|
||||
camera: true,
|
||||
microphone: false,
|
||||
bundled_webcam_media: false,
|
||||
server_version: None,
|
||||
camera_output: Some(String::from("uvc")),
|
||||
camera_codec: Some(codec.to_string()),
|
||||
camera_width: Some(1280),
|
||||
camera_height: Some(720),
|
||||
camera_fps: Some(25),
|
||||
eye_width: None,
|
||||
eye_height: None,
|
||||
eye_fps: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_server_addr_prefers_cli_then_env_then_default() {
|
||||
assert_eq!(
|
||||
resolve_server_addr(&[String::from("http://cli:1")], Some("http://env:2")),
|
||||
"http://cli:1"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_server_addr(
|
||||
&[
|
||||
String::from("--no-launcher"),
|
||||
String::from("--server"),
|
||||
String::from("http://cli-flag:3"),
|
||||
],
|
||||
Some("http://env:2"),
|
||||
),
|
||||
"http://cli-flag:3"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_server_addr(&[String::from("--launcher")], Some("http://env:2")),
|
||||
"http://env:2"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_server_addr(&[], Some("http://env:2")),
|
||||
"http://env:2"
|
||||
);
|
||||
assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn camera_config_from_caps_requires_complete_profile() {
|
||||
temp_env::with_var("LESAVKA_CAM_CODEC", None::<&str>, || {
|
||||
let mut caps = peer_caps("mjpeg");
|
||||
let config = camera_config_from_caps(&caps).expect("complete caps should map");
|
||||
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
||||
assert_eq!(config.width, 1280);
|
||||
|
||||
caps.camera_codec = Some(String::from("h265"));
|
||||
let config = camera_config_from_caps(&caps).expect("h265 alias should map");
|
||||
assert!(matches!(config.codec, CameraCodec::Hevc));
|
||||
|
||||
caps.camera_codec = Some(String::from("vp9"));
|
||||
assert!(camera_config_from_caps(&caps).is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn camera_config_from_caps_uses_negotiated_codec_over_launcher_default() {
|
||||
temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || {
|
||||
let config = camera_config_from_caps(&peer_caps("hevc")).expect("caps codec should map");
|
||||
assert!(matches!(config.codec, CameraCodec::Hevc));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn camera_config_from_caps_allows_explicit_forced_codec_override() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("LESAVKA_CAM_CODEC", Some("mjpeg")),
|
||||
("LESAVKA_CAM_CODEC_FORCE", Some("1")),
|
||||
],
|
||||
|| {
|
||||
let config = camera_config_from_caps(&peer_caps("hevc")).expect("forced override");
|
||||
assert!(matches!(config.codec, CameraCodec::Mjpeg));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn camera_config_force_flag_ignores_unknown_override() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("LESAVKA_CAM_CODEC", Some("vp9")),
|
||||
("LESAVKA_CAM_CODEC_FORCE", Some("1")),
|
||||
],
|
||||
|| {
|
||||
let config = camera_config_from_caps(&peer_caps("hevc")).expect("fallback codec");
|
||||
assert!(matches!(config.codec, CameraCodec::Hevc));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_video_queue_enforces_floor() {
|
||||
assert_eq!(sanitize_video_queue(None), 8);
|
||||
assert_eq!(sanitize_video_queue(Some(1)), 4);
|
||||
assert_eq!(sanitize_video_queue(Some(32)), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_delay_doubles_until_capped() {
|
||||
assert_eq!(next_delay(Duration::from_secs(1)), Duration::from_secs(2));
|
||||
assert_eq!(next_delay(Duration::from_secs(15)), Duration::from_secs(30));
|
||||
assert_eq!(next_delay(Duration::from_secs(31)), Duration::from_secs(30));
|
||||
}
|
||||
@ -10,42 +10,62 @@ use std::fs;
|
||||
|
||||
use serial_test::serial;
|
||||
|
||||
#[allow(dead_code, clippy::items_after_test_module)]
|
||||
mod spool {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/server/src/video_sinks/mjpeg_spool.rs"
|
||||
));
|
||||
|
||||
pub fn write_hevc(
|
||||
path: &std::path::Path,
|
||||
data: &[u8],
|
||||
source_pts_us: u64,
|
||||
decoded_pts_us: Option<u64>,
|
||||
) -> anyhow::Result<()> {
|
||||
spool_mjpeg_frame_with_timing(
|
||||
path,
|
||||
data,
|
||||
Some(MjpegSpoolTiming::hevc_decoded_mjpeg(
|
||||
source_pts_us,
|
||||
decoded_pts_us,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn write_mjpeg(
|
||||
path: &std::path::Path,
|
||||
data: &[u8],
|
||||
source_pts_us: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
spool_mjpeg_frame_with_timing(
|
||||
path,
|
||||
data,
|
||||
Some(MjpegSpoolTiming::mjpeg_passthrough(source_pts_us)),
|
||||
)
|
||||
mod video_support {
|
||||
pub fn env_u32(name: &str, default: u32) -> u32 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
}
|
||||
|
||||
mod video_sinks {
|
||||
#[allow(clippy::items_after_test_module)]
|
||||
pub mod hevc_mjpeg_guard {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/server/src/video_sinks/hevc_mjpeg_guard.rs"
|
||||
));
|
||||
}
|
||||
|
||||
#[allow(dead_code, clippy::items_after_test_module)]
|
||||
pub mod spool {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/server/src/video_sinks/mjpeg_spool.rs"
|
||||
));
|
||||
|
||||
pub fn write_hevc(
|
||||
path: &std::path::Path,
|
||||
data: &[u8],
|
||||
source_pts_us: u64,
|
||||
decoded_pts_us: Option<u64>,
|
||||
) -> anyhow::Result<()> {
|
||||
spool_mjpeg_frame_with_timing(
|
||||
path,
|
||||
data,
|
||||
Some(MjpegSpoolTiming::hevc_decoded_mjpeg(
|
||||
source_pts_us,
|
||||
decoded_pts_us,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn write_mjpeg(
|
||||
path: &std::path::Path,
|
||||
data: &[u8],
|
||||
source_pts_us: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
spool_mjpeg_frame_with_timing(
|
||||
path,
|
||||
data,
|
||||
Some(MjpegSpoolTiming::mjpeg_passthrough(source_pts_us)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
use video_sinks::spool;
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn hevc_decoded_mjpeg_spool_writes_frame_and_timing_metadata() {
|
||||
|
||||
@ -52,6 +52,11 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
||||
"--media-control-path",
|
||||
"--jpeg-quality",
|
||||
"--inject-max-frame-bytes",
|
||||
"--server-uvc-audit",
|
||||
"--server-uvc-audit-host",
|
||||
"--server-uvc-audit-control-path",
|
||||
"--server-uvc-audit-dir",
|
||||
"--server-uvc-audit-sample-frames",
|
||||
"--stream-analyze",
|
||||
"--sequence-window",
|
||||
"--mix-mae-threshold",
|
||||
@ -94,6 +99,20 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
||||
"lower_dominant_sequence",
|
||||
"max_mixed_band_count",
|
||||
"max_sequence_boundary_count",
|
||||
"lesavka.server-uvc-boundary-summary.v1",
|
||||
"server_uvc_boundary",
|
||||
"server_uvc_audit_artifacts",
|
||||
"server_boundary_missing",
|
||||
"server_boundary_frame_mode_mismatch",
|
||||
"server_boundary_uvc_mode_mismatch",
|
||||
"server_boundary_visual_corruption",
|
||||
"downstream_uvc_or_browser_corruption",
|
||||
"no_visual_corruption_observed",
|
||||
"spool-audit.jsonl",
|
||||
"matching_frame_records",
|
||||
"matching_uvc_mode_records",
|
||||
"decoded_sample_count",
|
||||
"marker_sample_count",
|
||||
"diagnosis",
|
||||
"encoded_oversize_frames",
|
||||
"sent_frames",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user