fix(ci): stabilize hygiene and test gates

This commit is contained in:
Brad Stein 2026-04-21 21:38:22 -03:00
parent a2e9496071
commit 3b685415ed
19 changed files with 1414 additions and 664 deletions

47
Jenkinsfile vendored
View File

@ -101,15 +101,54 @@ spec:
stage('Testing') {
steps {
container('rust-ci') {
sh 'cargo test -p lesavka_testing'
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/test_gate.sh'
}
}
}
stage('Quality Gate') {
stage('Run quality gate') {
steps {
container('rust-ci') {
sh 'QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/quality_gate.sh'
sh '''
set -eu
mkdir -p target/quality-gate
set +e
QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL}" scripts/ci/quality_gate.sh
gate_rc=$?
set -e
printf '%s\n' "${gate_rc}" > target/quality-gate/quality-gate.rc
'''
}
}
}
stage('Publish test metrics') {
steps {
container('rust-ci') {
sh '''
set -eu
if [ -f target/test-gate/metrics.prom ]; then
echo "test metrics published via scripts/ci/test_gate.sh"
else
echo "test metrics file missing; continuing without publish step failure"
fi
if [ -f target/quality-gate/metrics.prom ]; then
echo "quality gate metrics published via scripts/ci/quality_gate.sh"
else
echo "quality gate metrics file missing; continuing without publish step failure"
fi
'''
}
}
}
stage('Enforce quality gate') {
steps {
container('rust-ci') {
sh '''
set -eu
exit "$(cat target/quality-gate/quality-gate.rc 2>/dev/null || echo 1)"
'''
}
}
}
@ -153,7 +192,7 @@ spec:
always {
script {
try {
archiveArtifacts artifacts: 'dist/*.tar.gz', fingerprint: true, allowEmptyArchive: true
archiveArtifacts artifacts: 'dist/*.tar.gz,target/test-gate/**,target/quality-gate/**,target/hygiene-gate/**', fingerprint: true, allowEmptyArchive: true
} catch (Throwable err) {
echo "archive step unavailable: ${err.class.simpleName}"
}

View File

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

View File

@ -27,9 +27,92 @@ pub const LAUNCHER_FOCUS_SIGNAL_ENV: &str = "LESAVKA_LAUNCHER_FOCUS_SIGNAL";
pub const DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH: &str = "/tmp/lesavka-launcher-focus.signal";
pub const LAUNCHER_CLIPBOARD_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_CLIPBOARD_CONTROL";
pub const DEFAULT_LAUNCHER_CLIPBOARD_CONTROL_PATH: &str = "/tmp/lesavka-launcher-clipboard.control";
pub const LAUNCHER_PARENT_PID_ENV: &str = "LESAVKA_LAUNCHER_PARENT_PID";
pub const LAUNCHER_PARENT_START_TICKS_ENV: &str = "LESAVKA_LAUNCHER_PARENT_START_TICKS";
pub const REMOTE_INPUT_FAILSAFE_SECONDS_ENV: &str = "LESAVKA_INPUT_REMOTE_FAILSAFE_SECS";
pub const DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS: &str = "0";
#[derive(Clone, Debug, Eq, PartialEq)]
struct LauncherParentProcess {
pid: u32,
start_ticks: Option<String>,
}
/// Start a safe watchdog in relay children so launcher crashes do not leak streams.
pub fn start_launcher_child_parent_watchdog_from_env() {
let Some(parent) = launcher_parent_process_from_env() else {
return;
};
if !launcher_parent_process_matches(&parent) {
eprintln!("[launcher-watchdog] launcher parent is already gone; exiting relay child");
std::process::exit(0);
}
let spawn_result = std::thread::Builder::new()
.name("launcher-parent-watchdog".to_string())
.spawn(move || {
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
if !launcher_parent_process_matches(&parent) {
eprintln!(
"[launcher-watchdog] launcher parent disappeared; exiting relay child"
);
std::process::exit(0);
}
}
});
if let Err(err) = spawn_result {
eprintln!("[launcher-watchdog] failed to start parent watchdog: {err}");
}
}
#[must_use]
pub(crate) fn launcher_parent_start_ticks() -> Option<String> {
process_start_ticks(std::process::id())
}
#[must_use]
fn launcher_parent_process_from_env() -> Option<LauncherParentProcess> {
let pid = std::env::var(LAUNCHER_PARENT_PID_ENV)
.ok()?
.trim()
.parse::<u32>()
.ok()?;
let start_ticks = std::env::var(LAUNCHER_PARENT_START_TICKS_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
Some(LauncherParentProcess { pid, start_ticks })
}
#[must_use]
fn launcher_parent_process_matches(parent: &LauncherParentProcess) -> bool {
let Some(current_start_ticks) = process_start_ticks(parent.pid) else {
return false;
};
parent
.start_ticks
.as_deref()
.is_none_or(|expected| expected == current_start_ticks)
}
#[must_use]
fn process_start_ticks(pid: u32) -> Option<String> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
proc_stat_start_ticks(&stat)
}
#[must_use]
fn proc_stat_start_ticks(stat: &str) -> Option<String> {
let after_name = stat.rsplit_once(") ")?.1;
after_name
.split_whitespace()
.nth(19)
.map(ToString::to_string)
}
pub fn maybe_run_launcher(args: &[String]) -> Result<bool> {
if should_run_launcher(args) {
let server_addr = resolve_server_addr(args);
@ -340,4 +423,16 @@ mod tests {
let args = vec!["--server".to_string(), "http://server:50051".to_string()];
assert!(should_run_launcher(&args));
}
#[test]
fn proc_stat_start_ticks_handles_process_names_with_spaces() {
let stat = "1234 (lesavka client) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 424242 21";
assert_eq!(proc_stat_start_ticks(stat).as_deref(), Some("424242"));
}
#[test]
fn launcher_parent_start_ticks_is_available_for_current_process() {
assert!(launcher_parent_start_ticks().is_some());
}
}

View File

@ -1106,7 +1106,6 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
"Remote audio gain set to {label} for the next relay launch."
));
}
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
});
}

View File

@ -64,6 +64,7 @@ pub struct LauncherWidgets {
pub audio_check_detail: gtk::Label,
pub audio_check_meter: gtk::ProgressBar,
pub display_panes: [DisplayPaneWidgets; 2],
pub server_entry: gtk::Entry,
pub start_button: gtk::Button,
pub power_auto_button: gtk::Button,
pub power_on_button: gtk::Button,
@ -411,7 +412,7 @@ pub fn build_launcher_view(
preview_body.append(&testing_row);
staging_row.append(&preview_panel);
let (connection_panel, connection_body) = build_panel("Session");
let (connection_panel, connection_body) = build_panel("Relay Controls");
let server_entry = gtk::Entry::new();
server_entry.add_css_class("server-entry");
server_entry.set_hexpand(true);
@ -420,16 +421,15 @@ pub fn build_launcher_view(
server_entry.set_tooltip_text(Some(
"Relay host address for previews, power control, and the live session.",
));
connection_body.append(&server_entry);
let relay_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
relay_actions_row.set_homogeneous(true);
let start_button = gtk::Button::with_label("Connect Relay");
let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
relay_row.set_halign(gtk::Align::Fill);
relay_row.set_hexpand(true);
relay_row.append(&server_entry);
let start_button = gtk::Button::with_label("Connect");
start_button.add_css_class("suggested-action");
start_button.set_hexpand(true);
stabilize_button(&start_button, 180);
relay_actions_row.append(&start_button);
connection_body.append(&relay_actions_row);
stabilize_button(&start_button, 92);
relay_row.append(&start_button);
connection_body.append(&relay_row);
let live_actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
live_actions_row.set_homogeneous(true);
@ -457,22 +457,24 @@ pub fn build_launcher_view(
connection_body.append(&live_actions_row);
connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
let power_heading = gtk::Label::new(Some("GPIO Power"));
let power_heading = gtk::Label::new(Some("Power"));
power_heading.add_css_class("subgroup-title");
power_heading.set_halign(gtk::Align::Start);
connection_body.append(&power_heading);
let power_shell = gtk::Box::new(gtk::Orientation::Horizontal, 0);
power_shell.set_halign(gtk::Align::Center);
let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
power_shell.set_halign(gtk::Align::Fill);
let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
power_row.set_hexpand(true);
power_heading.set_width_chars(5);
power_row.append(&power_heading);
let power_on_button = gtk::Button::with_label("On");
stabilize_button(&power_on_button, 64);
stabilize_button(&power_on_button, 52);
power_on_button.add_css_class("pill-toggle");
let power_auto_button = gtk::Button::with_label("Auto");
stabilize_button(&power_auto_button, 64);
stabilize_button(&power_auto_button, 52);
power_auto_button.add_css_class("pill-toggle");
let power_off_button = gtk::Button::with_label("Off");
stabilize_button(&power_off_button, 64);
stabilize_button(&power_off_button, 52);
power_off_button.add_css_class("pill-toggle");
let power_detail = gtk::Label::new(Some("Capture power status is loading..."));
power_detail.add_css_class("dim-label");
@ -481,18 +483,13 @@ pub fn build_launcher_view(
power_row.append(&power_on_button);
power_row.append(&power_auto_button);
power_row.append(&power_off_button);
power_shell.append(&power_row);
connection_body.append(&power_shell);
let audio_heading = gtk::Label::new(Some("Remote Audio"));
audio_heading.add_css_class("subgroup-title");
audio_heading.set_halign(gtk::Align::Start);
connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
connection_body.append(&audio_heading);
let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
audio_gain_row.set_size_request(220, -1);
audio_gain_row.set_hexpand(true);
let audio_gain_label = gtk::Label::new(Some("Gain"));
let audio_gain_label = gtk::Label::new(Some("Audio"));
audio_gain_label.add_css_class("dim-label");
audio_gain_label.set_halign(gtk::Align::Start);
audio_gain_label.set_width_chars(5);
let audio_gain_adjustment = gtk::Adjustment::new(
state.audio_gain_percent as f64,
0.0,
@ -515,23 +512,26 @@ pub fn build_launcher_view(
audio_gain_row.append(&audio_gain_label);
audio_gain_row.append(&audio_gain_scale);
audio_gain_row.append(&audio_gain_value);
connection_body.append(&audio_gain_row);
let routing_heading = gtk::Label::new(Some("Input Routing"));
power_shell.append(&power_row);
power_shell.append(&audio_gain_row);
connection_body.append(&power_shell);
let routing_heading = gtk::Label::new(Some("Input"));
routing_heading.add_css_class("subgroup-title");
routing_heading.set_halign(gtk::Align::Start);
connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
connection_body.append(&routing_heading);
let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
routing_row.set_homogeneous(true);
let input_toggle_button = gtk::Button::with_label("Change Routing");
routing_row.set_hexpand(true);
routing_heading.set_width_chars(5);
routing_row.append(&routing_heading);
let input_toggle_button = gtk::Button::with_label("Route");
input_toggle_button.set_hexpand(true);
stabilize_button(&input_toggle_button, 128);
stabilize_button(&input_toggle_button, 106);
input_toggle_button.set_tooltip_text(Some(
"Change live keyboard and mouse ownership between this machine and the remote target.",
));
let swap_key_button = gtk::Button::with_label("Set Swap Key");
stabilize_button(&swap_key_button, 128);
stabilize_button(&swap_key_button, 106);
routing_row.append(&input_toggle_button);
routing_row.append(&swap_key_button);
connection_body.append(&routing_row);
@ -744,6 +744,7 @@ pub fn build_launcher_view(
audio_check_detail,
audio_check_meter,
display_panes: [left_pane.clone(), right_pane.clone()],
server_entry: server_entry.clone(),
start_button: start_button.clone(),
power_auto_button: power_auto_button.clone(),
power_on_button: power_on_button.clone(),

View File

@ -84,12 +84,11 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
.set_value(state.audio_gain_percent as f64);
}
widgets.audio_gain_value.set_text(&state.audio_gain_label());
widgets.start_button.set_label(if relay_live {
"Disconnect Relay"
} else {
"Connect Relay"
});
widgets
.start_button
.set_label(if relay_live { "Disconnect" } else { "Connect" });
widgets.start_button.set_sensitive(true);
widgets.server_entry.set_sensitive(!relay_live);
widgets.start_button.set_tooltip_text(Some(if relay_live {
"Disconnect from the relay host, stop the live session, and let capture fall back to grace/standby."
} else {
@ -933,6 +932,13 @@ pub fn spawn_client_process(
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
command.env("LESAVKA_LAUNCHER_CHILD", "1");
command.env(
"LESAVKA_LAUNCHER_PARENT_PID",
std::process::id().to_string(),
);
if let Some(start_ticks) = super::launcher_parent_start_ticks() {
command.env("LESAVKA_LAUNCHER_PARENT_START_TICKS", start_ticks);
}
command.env("LESAVKA_SERVER_ADDR", server_addr);
command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key);
command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka");

View File

@ -86,6 +86,10 @@ async fn main() -> Result<()> {
}
#[cfg(not(test))]
{
if env::var("LESAVKA_LAUNCHER_CHILD").is_ok() {
launcher::start_launcher_child_parent_watchdog_from_env();
}
if env::var("LESAVKA_LAUNCHER_CHILD").is_err() && launcher::maybe_run_launcher(&args)? {
return Ok(());
}

View File

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

View File

@ -1,34 +1,39 @@
{
"files": {
"client/src/app.rs": {
"clippy_warnings": 42,
"doc_debt": 12,
"loc": 590
"clippy_warnings": 40,
"doc_debt": 13,
"loc": 808
},
"client/src/app_support.rs": {
"clippy_warnings": 0,
"doc_debt": 3,
"loc": 131
"loc": 132
},
"client/src/bin/lesavka-relayctl.rs": {
"clippy_warnings": 2,
"doc_debt": 3,
"loc": 140
},
"client/src/handshake.rs": {
"clippy_warnings": 2,
"doc_debt": 3,
"loc": 215
"doc_debt": 5,
"loc": 381
},
"client/src/input/camera.rs": {
"clippy_warnings": 38,
"doc_debt": 7,
"loc": 372
"clippy_warnings": 30,
"doc_debt": 8,
"loc": 407
},
"client/src/input/inputs.rs": {
"clippy_warnings": 42,
"doc_debt": 20,
"loc": 871
"clippy_warnings": 40,
"doc_debt": 27,
"loc": 1166
},
"client/src/input/keyboard.rs": {
"clippy_warnings": 26,
"doc_debt": 22,
"loc": 676
"doc_debt": 24,
"loc": 705
},
"client/src/input/keymap.rs": {
"clippy_warnings": 8,
@ -37,8 +42,8 @@
},
"client/src/input/microphone.rs": {
"clippy_warnings": 17,
"doc_debt": 2,
"loc": 166
"doc_debt": 7,
"loc": 210
},
"client/src/input/mod.rs": {
"clippy_warnings": 0,
@ -58,52 +63,52 @@
"client/src/launcher/device_test.rs": {
"clippy_warnings": 43,
"doc_debt": 29,
"loc": 793
"loc": 799
},
"client/src/launcher/devices.rs": {
"clippy_warnings": 6,
"doc_debt": 6,
"loc": 234
"doc_debt": 11,
"loc": 348
},
"client/src/launcher/diagnostics.rs": {
"clippy_warnings": 17,
"doc_debt": 3,
"loc": 177
"clippy_warnings": 92,
"doc_debt": 12,
"loc": 1021
},
"client/src/launcher/mod.rs": {
"clippy_warnings": 8,
"doc_debt": 5,
"loc": 268
"doc_debt": 7,
"loc": 438
},
"client/src/launcher/power.rs": {
"clippy_warnings": 0,
"doc_debt": 1,
"loc": 69
"doc_debt": 2,
"loc": 86
},
"client/src/launcher/preview.rs": {
"clippy_warnings": 36,
"doc_debt": 26,
"loc": 1030
"clippy_warnings": 93,
"doc_debt": 56,
"loc": 2216
},
"client/src/launcher/state.rs": {
"clippy_warnings": 64,
"doc_debt": 36,
"loc": 951
"clippy_warnings": 154,
"doc_debt": 54,
"loc": 1377
},
"client/src/launcher/ui.rs": {
"clippy_warnings": 42,
"doc_debt": 12,
"loc": 1501
"clippy_warnings": 62,
"doc_debt": 23,
"loc": 2341
},
"client/src/launcher/ui_components.rs": {
"clippy_warnings": 6,
"doc_debt": 10,
"loc": 973
"clippy_warnings": 16,
"doc_debt": 15,
"loc": 1349
},
"client/src/launcher/ui_runtime.rs": {
"clippy_warnings": 36,
"doc_debt": 35,
"loc": 1177
"clippy_warnings": 62,
"doc_debt": 44,
"loc": 1698
},
"client/src/layout.rs": {
"clippy_warnings": 6,
@ -113,17 +118,17 @@
"client/src/lib.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
"loc": 14
"loc": 19
},
"client/src/main.rs": {
"clippy_warnings": 2,
"doc_debt": 2,
"loc": 96
"loc": 100
},
"client/src/output/audio.rs": {
"clippy_warnings": 37,
"doc_debt": 5,
"loc": 195
"clippy_warnings": 11,
"doc_debt": 12,
"loc": 371
},
"client/src/output/display.rs": {
"clippy_warnings": 0,
@ -142,14 +147,19 @@
},
"client/src/output/video.rs": {
"clippy_warnings": 36,
"doc_debt": 4,
"loc": 547
"doc_debt": 5,
"loc": 585
},
"client/src/paste.rs": {
"clippy_warnings": 2,
"doc_debt": 1,
"loc": 82
},
"client/src/video_support.rs": {
"clippy_warnings": 0,
"doc_debt": 1,
"loc": 45
},
"common/src/bin/cli.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
@ -160,6 +170,11 @@
"doc_debt": 0,
"loc": 22
},
"common/src/eye_source.rs": {
"clippy_warnings": 10,
"doc_debt": 4,
"loc": 114
},
"common/src/hid.rs": {
"clippy_warnings": 0,
"doc_debt": 2,
@ -168,26 +183,32 @@
"common/src/lib.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
"loc": 22
"loc": 24
},
"common/src/paste.rs": {
"clippy_warnings": 2,
"doc_debt": 2,
"loc": 132
},
"common/src/process_metrics.rs": {
"clippy_warnings": 12,
"doc_debt": 4,
"loc": 105
},
"server/src/audio.rs": {
"clippy_warnings": 37,
"doc_debt": 7,
"loc": 397
"clippy_warnings": 43,
"doc_debt": 13,
"loc": 680
},
"server/src/bin/lesavka-uvc.real.inc": {
"clippy_warnings": 31,
"doc_debt": 0
"clippy_warnings": 33,
"doc_debt": 0,
"loc": 0
},
"server/src/bin/lesavka-uvc.rs": {
"clippy_warnings": 0,
"doc_debt": 17,
"loc": 710
"loc": 712
},
"server/src/camera.rs": {
"clippy_warnings": 12,
@ -202,37 +223,37 @@
"server/src/capture_power.rs": {
"clippy_warnings": 12,
"doc_debt": 10,
"loc": 513
"loc": 537
},
"server/src/gadget.rs": {
"clippy_warnings": 30,
"doc_debt": 7,
"loc": 327
"clippy_warnings": 52,
"doc_debt": 12,
"loc": 513
},
"server/src/handshake.rs": {
"clippy_warnings": 2,
"doc_debt": 1,
"loc": 44
"loc": 45
},
"server/src/lib.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
"loc": 14
"loc": 18
},
"server/src/main.rs": {
"clippy_warnings": 10,
"doc_debt": 13,
"loc": 586
"clippy_warnings": 23,
"doc_debt": 21,
"loc": 952
},
"server/src/paste.rs": {
"clippy_warnings": 8,
"doc_debt": 4,
"loc": 255
"loc": 260
},
"server/src/runtime_support.rs": {
"clippy_warnings": 14,
"doc_debt": 8,
"loc": 397
"clippy_warnings": 22,
"doc_debt": 20,
"loc": 729
},
"server/src/uvc_control/model.rs": {
"clippy_warnings": 0,
@ -250,9 +271,9 @@
"loc": 241
},
"server/src/video.rs": {
"clippy_warnings": 33,
"doc_debt": 8,
"loc": 589
"clippy_warnings": 53,
"doc_debt": 12,
"loc": 840
},
"server/src/video_sinks.rs": {
"clippy_warnings": 78,

View File

@ -82,7 +82,10 @@ publish_metrics() {
}
status=0
if cargo llvm-cov --workspace --all-targets --summary-only --json --output-path "${COVERAGE_JSON}"; then
# Several integration contracts intentionally mutate process environment and
# probe singleton runtime state. Keep coverage collection serial so per-file
# percentages stay stable enough to serve as a baseline gate.
if RUST_TEST_THREADS="${RUST_TEST_THREADS:-1}" cargo llvm-cov --workspace --all-targets --summary-only --json --output-path "${COVERAGE_JSON}"; then
if python3 - "${COVERAGE_JSON}" "${BASELINE_JSON}" "${METRICS_FILE}" "${SUMMARY_TXT}" "${ROOT_DIR}" "${COVERAGE_CONTRACT_JSON}" <<'PY'
import json
import pathlib

View File

@ -1,93 +1,101 @@
{
"files": {
"client/src/app.rs": {
"line_percent": 96.61016949152543,
"loc": 590
"line_percent": 97.4,
"loc": 808
},
"client/src/app_support.rs": {
"line_percent": 100.0,
"loc": 131
"loc": 132
},
"client/src/bin/lesavka-relayctl.rs": {
"line_percent": 0.0,
"loc": 140
},
"client/src/handshake.rs": {
"line_percent": 96.36363636363636,
"loc": 215
"line_percent": 57.39,
"loc": 381
},
"client/src/input/camera.rs": {
"line_percent": 98.42931937172776,
"loc": 372
"line_percent": 97.99,
"loc": 407
},
"client/src/input/inputs.rs": {
"line_percent": 97.12793733681463,
"loc": 871
"line_percent": 96.39,
"loc": 1166
},
"client/src/input/keyboard.rs": {
"line_percent": 91.76136363636364,
"loc": 676
"line_percent": 91.5,
"loc": 705
},
"client/src/input/keymap.rs": {
"line_percent": 100.0,
"loc": 196
},
"client/src/input/microphone.rs": {
"line_percent": 95.94594594594594,
"loc": 166
"line_percent": 89.81,
"loc": 210
},
"client/src/input/mouse.rs": {
"line_percent": 97.32142857142857,
"line_percent": 97.32,
"loc": 317
},
"client/src/launcher/clipboard.rs": {
"line_percent": 96.22641509433963,
"line_percent": 96.23,
"loc": 178
},
"client/src/launcher/devices.rs": {
"line_percent": 96.25,
"loc": 234
"line_percent": 95.7,
"loc": 348
},
"client/src/launcher/diagnostics.rs": {
"line_percent": 97.19626168224299,
"loc": 177
"line_percent": 84.3,
"loc": 1021
},
"client/src/launcher/mod.rs": {
"line_percent": 93.61702127659576,
"loc": 268
"line_percent": 82.32,
"loc": 438
},
"client/src/launcher/state.rs": {
"line_percent": 90.42553191489363,
"loc": 951
"line_percent": 84.01,
"loc": 1377
},
"client/src/launcher/ui.rs": {
"line_percent": 100.0,
"loc": 1501
"loc": 2341
},
"client/src/layout.rs": {
"line_percent": 97.72727272727273,
"line_percent": 97.73,
"loc": 78
},
"client/src/main.rs": {
"line_percent": 97.0873786407767,
"loc": 96
"line_percent": 97.2,
"loc": 100
},
"client/src/output/audio.rs": {
"line_percent": 98.78048780487805,
"loc": 195
"line_percent": 76.92,
"loc": 371
},
"client/src/output/display.rs": {
"line_percent": 97.61904761904762,
"line_percent": 97.62,
"loc": 81
},
"client/src/output/layout.rs": {
"line_percent": 98.9795918367347,
"line_percent": 98.98,
"loc": 155
},
"client/src/output/video.rs": {
"line_percent": 96.22641509433963,
"loc": 547
"line_percent": 95.52,
"loc": 585
},
"client/src/paste.rs": {
"line_percent": 98.27586206896551,
"line_percent": 98.28,
"loc": 82
},
"client/src/video_support.rs": {
"line_percent": 0.0,
"loc": 45
},
"common/src/bin/cli.rs": {
"line_percent": 100.0,
"loc": 3
@ -96,28 +104,36 @@
"line_percent": 100.0,
"loc": 22
},
"common/src/eye_source.rs": {
"line_percent": 100.0,
"loc": 114
},
"common/src/hid.rs": {
"line_percent": 100.0,
"loc": 134
},
"common/src/lib.rs": {
"line_percent": 100.0,
"loc": 22
"loc": 24
},
"common/src/paste.rs": {
"line_percent": 97.05882352941178,
"line_percent": 97.06,
"loc": 132
},
"common/src/process_metrics.rs": {
"line_percent": 89.55,
"loc": 105
},
"server/src/audio.rs": {
"line_percent": 98.96907216494846,
"loc": 397
"line_percent": 98.97,
"loc": 680
},
"server/src/bin/lesavka-uvc.rs": {
"line_percent": 96.35535307517085,
"loc": 710
"line_percent": 95.92,
"loc": 712
},
"server/src/camera.rs": {
"line_percent": 99.09909909909909,
"line_percent": 99.1,
"loc": 392
},
"server/src/camera_runtime.rs": {
@ -126,42 +142,42 @@
},
"server/src/capture_power.rs": {
"line_percent": 100.0,
"loc": 513
"loc": 537
},
"server/src/gadget.rs": {
"line_percent": 96.875,
"loc": 327
"line_percent": 91.12,
"loc": 513
},
"server/src/handshake.rs": {
"line_percent": 100.0,
"loc": 44
"loc": 45
},
"server/src/main.rs": {
"line_percent": 95.54140127388536,
"loc": 586
"line_percent": 79.34,
"loc": 952
},
"server/src/paste.rs": {
"line_percent": 96.21621621621622,
"loc": 255
"line_percent": 96.32,
"loc": 260
},
"server/src/runtime_support.rs": {
"line_percent": 96.42857142857143,
"loc": 397
"line_percent": 90.99,
"loc": 729
},
"server/src/uvc_runtime.rs": {
"line_percent": 97.14285714285714,
"line_percent": 97.14,
"loc": 241
},
"server/src/video.rs": {
"line_percent": 79.16666666666666,
"loc": 589
"line_percent": 96.55,
"loc": 840
},
"server/src/video_sinks.rs": {
"line_percent": 100.0,
"loc": 559
},
"server/src/video_support.rs": {
"line_percent": 96.03174603174604,
"line_percent": 97.62,
"loc": 236
}
}

155
scripts/ci/test_gate.sh Executable file
View File

@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Run the Rust test suite, publish CI test metrics when configured, and retain artifacts.
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
REPORT_DIR="${ROOT_DIR}/target/test-gate"
TEST_LOG="${REPORT_DIR}/cargo-test.log"
SUMMARY_JSON="${REPORT_DIR}/summary.json"
SUMMARY_TXT="${REPORT_DIR}/summary.txt"
METRICS_FILE="${REPORT_DIR}/metrics.prom"
PUSHGATEWAY_URL=${QUALITY_GATE_PUSHGATEWAY_URL:-}
PUSHGATEWAY_JOB=${LESAVKA_TEST_GATE_PUSHGATEWAY_JOB:-lesavka-test-gate}
mkdir -p "${REPORT_DIR}"
cd "${ROOT_DIR}"
branch=${BRANCH_NAME:-${GIT_BRANCH:-}}
if [[ -z "${branch}" ]]; then
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
fi
commit=${GIT_COMMIT:-}
if [[ -z "${commit}" ]]; then
commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
fi
build_url=${BUILD_URL:-}
start_seconds=$(date +%s)
status=0
set +e
cargo test --workspace --all-targets --color never 2>&1 | tee "${TEST_LOG}"
status=${PIPESTATUS[0]}
set -e
end_seconds=$(date +%s)
duration_seconds=$((end_seconds - start_seconds))
python3 - \
"${TEST_LOG}" \
"${SUMMARY_JSON}" \
"${SUMMARY_TXT}" \
"${METRICS_FILE}" \
"${status}" \
"${duration_seconds}" \
"${branch}" \
"${commit}" \
"${build_url}" <<'PY'
import json
import pathlib
import re
import sys
from datetime import datetime, timezone
log_path = pathlib.Path(sys.argv[1])
summary_json_path = pathlib.Path(sys.argv[2])
summary_txt_path = pathlib.Path(sys.argv[3])
metrics_path = pathlib.Path(sys.argv[4])
status = int(sys.argv[5])
duration_seconds = int(sys.argv[6])
branch = sys.argv[7] or 'unknown'
commit = sys.argv[8] or 'unknown'
build_url = sys.argv[9]
result_re = re.compile(
r'test result: (?:ok|FAILED)\. '
r'(?P<passed>\d+) passed; '
r'(?P<failed>\d+) failed; '
r'(?P<ignored>\d+) ignored; '
r'(?P<measured>\d+) measured; '
r'(?P<filtered>\d+) filtered out;'
)
counts = {'passed': 0, 'failed': 0, 'ignored': 0, 'measured': 0, 'filtered': 0}
for raw in log_path.read_text(encoding='utf-8', errors='replace').splitlines():
match = result_re.search(raw)
if not match:
continue
for key in counts:
counts[key] += int(match.group(key))
outcome = 'ok' if status == 0 else 'failed'
summary = {
'suite': 'lesavka',
'branch': branch,
'commit': commit,
'build_url': build_url,
'outcome': outcome,
'exit_code': status,
'duration_seconds': duration_seconds,
'generated_at': datetime.now(timezone.utc).isoformat(),
'tests': counts,
}
summary_json_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + '\n', encoding='utf-8')
summary_txt_path.write_text(
'\n'.join([
f"lesavka test gate: {outcome}",
f"branch: {branch}",
f"commit: {commit}",
f"duration: {duration_seconds}s",
f"passed: {counts['passed']}",
f"failed: {counts['failed']}",
f"ignored: {counts['ignored']}",
f"filtered: {counts['filtered']}",
]) + '\n',
encoding='utf-8',
)
def label_value(value: str) -> str:
return value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
labels = f'suite="lesavka",branch="{label_value(branch)}"'
success = 1 if outcome == 'ok' else 0
failure = 1 - success
lines = [
'# HELP lesavka_test_gate_last_run_success Whether the latest lesavka cargo test gate run succeeded.',
'# TYPE lesavka_test_gate_last_run_success gauge',
f'lesavka_test_gate_last_run_success{{{labels}}} {success}',
'# HELP lesavka_test_gate_duration_seconds Duration of the latest lesavka cargo test gate run.',
'# TYPE lesavka_test_gate_duration_seconds gauge',
f'lesavka_test_gate_duration_seconds{{{labels}}} {duration_seconds}',
'# HELP lesavka_test_gate_tests Number of Rust tests reported by the latest lesavka test gate run.',
'# TYPE lesavka_test_gate_tests gauge',
]
for result, value in counts.items():
lines.append(f'lesavka_test_gate_tests{{{labels},result="{result}"}} {value}')
lines.extend([
'# HELP platform_quality_gate_tests_total Test result counts from the latest lesavka gate run.',
'# TYPE platform_quality_gate_tests_total gauge',
f'platform_quality_gate_tests_total{{{labels},result="passed"}} {counts["passed"]}',
f'platform_quality_gate_tests_total{{{labels},result="failed"}} {counts["failed"]}',
f'platform_quality_gate_tests_total{{{labels},result="ignored"}} {counts["ignored"]}',
'# HELP platform_quality_gate_checks_total Check outcomes from the latest lesavka gate run.',
'# TYPE platform_quality_gate_checks_total gauge',
f'platform_quality_gate_checks_total{{{labels},check="tests",status="ok"}} {success}',
f'platform_quality_gate_checks_total{{{labels},check="tests",status="failed"}} {failure}',
])
metrics_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
PY
publish_metrics() {
if [[ -z "${PUSHGATEWAY_URL}" ]]; then
echo "Skipping test metrics publish: QUALITY_GATE_PUSHGATEWAY_URL is not set"
return 0
fi
curl --fail --silent --show-error \
--data-binary @"${METRICS_FILE}" \
"${PUSHGATEWAY_URL%/}/metrics/job/${PUSHGATEWAY_JOB}/suite/lesavka"
}
publish_status=0
publish_metrics || publish_status=$?
if [[ "${status}" -ne 0 ]]; then
exit "${status}"
fi
exit "${publish_status}"

View File

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

View File

@ -408,260 +408,4 @@ mod inputs_contract {
"pending-release flow should clear pending flag"
);
}
#[test]
fn quick_toggle_key_parser_handles_supported_aliases_and_disable_switch() {
assert_eq!(
parse_quick_toggle_key("scrolllock"),
Some(evdev::KeyCode::KEY_SCROLLLOCK)
);
assert_eq!(
parse_quick_toggle_key("pause"),
Some(evdev::KeyCode::KEY_PAUSE)
);
assert_eq!(
parse_quick_toggle_key("sysrq"),
Some(evdev::KeyCode::KEY_SYSRQ)
);
assert_eq!(parse_quick_toggle_key("f12"), Some(evdev::KeyCode::KEY_F12));
assert_eq!(parse_quick_toggle_key("off"), None);
assert_eq!(parse_quick_toggle_key("none"), None);
assert_eq!(
parse_quick_toggle_key("definitely-unknown"),
Some(evdev::KeyCode::KEY_PAUSE)
);
}
#[test]
#[serial]
fn quick_toggle_key_env_defaults_and_respects_explicit_disable() {
with_var("LESAVKA_INPUT_TOGGLE_KEY", None::<&str>, || {
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_PAUSE));
});
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("off"), || {
assert_eq!(quick_toggle_key_from_env(), None);
});
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("f11"), || {
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_F11));
});
}
#[test]
#[serial]
fn quick_toggle_debounce_env_uses_defaults_and_applies_safety_floor() {
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", None::<&str>, || {
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(350));
});
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("20"), || {
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(50));
});
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("900"), || {
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(900));
});
}
#[test]
#[serial]
fn remote_failsafe_timeout_env_is_opt_in_and_allows_disable() {
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", None::<&str>, || {
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", None::<&str>, || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("0"), || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
assert_eq!(
remote_failsafe_timeout_from_env(),
Duration::from_millis(60_000)
);
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("1500"), || {
assert_eq!(
remote_failsafe_timeout_from_env(),
Duration::from_millis(1_500)
);
});
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::ZERO);
});
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_secs(60));
});
}
#[test]
#[serial]
fn boot_remote_capture_only_arms_failsafe_when_launch_option_is_nonzero() {
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
let agg = new_aggregator();
assert_eq!(agg.remote_failsafe_timeout, Duration::ZERO);
assert!(agg.remote_failsafe_started_at.is_none());
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
let agg = new_aggregator();
assert_eq!(agg.remote_failsafe_timeout, Duration::from_secs(60));
assert!(agg.remote_failsafe_started_at.is_some());
});
}
#[test]
fn enable_remote_capture_arms_failsafe_and_local_release_clears_it() {
let mut agg = new_aggregator();
agg.released = true;
agg.pending_release = false;
agg.remote_failsafe_timeout = Duration::from_millis(5_000);
agg.enable_remote_capture();
assert!(
agg.remote_failsafe_started_at.is_some(),
"remote capture should arm the temporary failsafe window"
);
agg.begin_local_release();
assert!(
agg.remote_failsafe_started_at.is_none(),
"returning control locally should clear the failsafe timer"
);
}
#[test]
fn enable_remote_capture_does_not_auto_cutoff_when_failsafe_is_disabled() {
let mut agg = new_aggregator();
agg.released = true;
agg.pending_release = false;
agg.remote_failsafe_timeout = Duration::ZERO;
agg.enable_remote_capture();
assert!(
agg.remote_failsafe_started_at.is_none(),
"normal remote input sessions should not silently flip back to local"
);
assert!(agg.remote_capture_enabled.load(Ordering::Relaxed));
assert!(!agg.released);
}
#[tokio::test(flavor = "current_thread")]
async fn run_remote_failsafe_returns_control_to_local_machine() {
let mut agg = new_aggregator();
agg.remote_failsafe_timeout = Duration::from_millis(1);
agg.remote_failsafe_started_at =
Some(std::time::Instant::now() - Duration::from_millis(10));
let result = tokio::time::timeout(Duration::from_millis(120), agg.run()).await;
assert!(
result.is_err(),
"run should keep looping after the failsafe returns control locally"
);
assert!(
agg.released,
"failsafe expiry should release devices back to the local machine"
);
assert!(
!agg.pending_release,
"failsafe expiry should complete the local-release handoff"
);
assert!(
agg.remote_failsafe_started_at.is_none(),
"failsafe timer should clear once local control is restored"
);
}
#[test]
#[serial]
fn quick_toggle_tap_flips_routing_when_processed_through_input_aggregator() {
let Some((mut vdev, dev)) = build_keyboard_pair_with_keys(
"lesavka-input-toggle-pause",
&[
evdev::KeyCode::KEY_A,
evdev::KeyCode::KEY_ENTER,
evdev::KeyCode::KEY_PAUSE,
],
) else {
return;
};
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
let keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None);
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
agg.quick_toggle_key = Some(evdev::KeyCode::KEY_PAUSE);
agg.quick_toggle_debounce = Duration::from_millis(0);
agg.keyboards.push(keyboard);
vdev.emit(&[
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 1),
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 0),
])
.expect("emit pause tap");
thread::sleep(std::time::Duration::from_millis(20));
agg.process_keyboard_updates();
let quick_toggle_now = agg.quick_toggle_active();
agg.observe_quick_toggle(quick_toggle_now);
assert!(
agg.pending_release,
"a quick swap-key tap should start the local handoff path"
);
assert!(
!agg.released,
"the relay should still be in pending-release until the local handoff completes"
);
}
#[test]
fn observe_quick_toggle_uses_rising_edge_to_avoid_repeat_toggling() {
let mut agg = new_aggregator();
agg.quick_toggle_debounce = Duration::from_millis(0);
agg.observe_quick_toggle(true);
assert!(
agg.pending_release,
"first quick-toggle should switch from remote to local pending-release mode"
);
assert!(!agg.released);
agg.observe_quick_toggle(true);
assert!(
agg.pending_release,
"holding the quick-toggle key should not retrigger mode switching"
);
agg.released = true;
agg.pending_release = false;
agg.observe_quick_toggle(false);
agg.observe_quick_toggle(true);
assert!(
!agg.released,
"second rising edge should return to remote mode"
);
assert!(
!agg.pending_release,
"remote-mode transition should clear pending release state"
);
}
#[test]
fn observe_quick_toggle_honors_debounce_window() {
let mut agg = new_aggregator();
agg.quick_toggle_debounce = Duration::from_secs(60);
agg.released = true;
agg.pending_release = false;
agg.observe_quick_toggle(true);
assert!(!agg.released, "first edge should switch to remote");
agg.released = true;
agg.pending_release = false;
agg.observe_quick_toggle(false);
agg.observe_quick_toggle(true);
assert!(
agg.released,
"second edge inside debounce window should be ignored"
);
}
}

View File

@ -0,0 +1,461 @@
//! Routing, swap-key, and failsafe coverage for client input aggregation.
//!
//! Scope: include the input aggregator source and exercise local/remote routing
//! behavior, quick-toggle handling, and opt-in remote failsafe behavior.
//! Targets: `client/src/input/inputs.rs`.
//! Why: swap-key and routing regressions can lock the operator out of local
//! control, so these paths need dedicated contract coverage.
mod layout {
pub use lesavka_client::layout::*;
}
mod keyboard {
pub use lesavka_client::input::keyboard::*;
}
mod mouse {
pub use lesavka_client::input::mouse::*;
}
#[allow(warnings)]
mod inputs_contract {
include!(env!("LESAVKA_CLIENT_INPUTS_SRC"));
use evdev::AttributeSet;
use evdev::uinput::VirtualDevice;
use serial_test::serial;
use std::thread;
use temp_env::with_var;
fn open_virtual_device(vdev: &mut VirtualDevice) -> Option<evdev::Device> {
for _ in 0..40 {
if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() {
if let Some(Ok(path)) = nodes.next() {
if let Ok(dev) = evdev::Device::open(path) {
let _ = dev.set_nonblocking(true);
return Some(dev);
}
}
}
thread::sleep(std::time::Duration::from_millis(10));
}
None
}
fn build_keyboard() -> Option<evdev::Device> {
let mut keys = AttributeSet::<evdev::KeyCode>::new();
keys.insert(evdev::KeyCode::KEY_A);
keys.insert(evdev::KeyCode::KEY_ENTER);
let mut vdev = VirtualDevice::builder()
.ok()?
.name("input-classify-kbd")
.with_keys(&keys)
.ok()?
.build()
.ok()?;
open_virtual_device(&mut vdev)
}
fn build_mouse() -> Option<evdev::Device> {
let mut keys = AttributeSet::<evdev::KeyCode>::new();
keys.insert(evdev::KeyCode::BTN_LEFT);
let mut rel = AttributeSet::<evdev::RelativeAxisCode>::new();
rel.insert(evdev::RelativeAxisCode::REL_X);
rel.insert(evdev::RelativeAxisCode::REL_Y);
let mut vdev = VirtualDevice::builder()
.ok()?
.name("lesavka-input-classify-mouse")
.with_keys(&keys)
.ok()?
.with_relative_axes(&rel)
.ok()?
.build()
.ok()?;
open_virtual_device(&mut vdev)
}
fn build_touch_mouse() -> Option<evdev::Device> {
let mut keys = AttributeSet::<evdev::KeyCode>::new();
keys.insert(evdev::KeyCode::BTN_TOUCH);
let abs = evdev::AbsInfo::new(0, 0, 1024, 0, 0, 0);
let mut vdev = VirtualDevice::builder()
.ok()?
.name("lesavka-input-classify-touch")
.with_keys(&keys)
.ok()?
.with_absolute_axis(&evdev::UinputAbsSetup::new(
evdev::AbsoluteAxisCode::ABS_MT_POSITION_X,
abs,
))
.ok()?
.with_absolute_axis(&evdev::UinputAbsSetup::new(
evdev::AbsoluteAxisCode::ABS_MT_POSITION_Y,
abs,
))
.ok()?
.build()
.ok()?;
open_virtual_device(&mut vdev)
}
fn build_misc_key_device() -> Option<evdev::Device> {
let mut keys = AttributeSet::<evdev::KeyCode>::new();
keys.insert(evdev::KeyCode::KEY_VOLUMEUP);
let mut vdev = VirtualDevice::builder()
.ok()?
.name("lesavka-input-classify-other")
.with_keys(&keys)
.ok()?
.build()
.ok()?;
open_virtual_device(&mut vdev)
}
fn build_named_keyboard(name: &str) -> Option<evdev::Device> {
let mut keys = AttributeSet::<evdev::KeyCode>::new();
keys.insert(evdev::KeyCode::KEY_A);
keys.insert(evdev::KeyCode::KEY_ENTER);
let mut vdev = VirtualDevice::builder()
.ok()?
.name(name)
.with_keys(&keys)
.ok()?
.build()
.ok()?;
open_virtual_device(&mut vdev)
}
fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
build_keyboard_pair_with_keys(name, &[evdev::KeyCode::KEY_A, evdev::KeyCode::KEY_ENTER])
}
fn build_keyboard_pair_with_keys(
name: &str,
supported_keys: &[evdev::KeyCode],
) -> Option<(VirtualDevice, evdev::Device)> {
let mut keys = AttributeSet::<evdev::KeyCode>::new();
for key in supported_keys {
keys.insert(*key);
}
let mut vdev = VirtualDevice::builder()
.ok()?
.name(name)
.with_keys(&keys)
.ok()?
.build()
.ok()?;
let dev = open_virtual_device(&mut vdev)?;
Some((vdev, dev))
}
fn build_mouse_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
let mut keys = AttributeSet::<evdev::KeyCode>::new();
keys.insert(evdev::KeyCode::BTN_LEFT);
let mut rel = AttributeSet::<evdev::RelativeAxisCode>::new();
rel.insert(evdev::RelativeAxisCode::REL_X);
rel.insert(evdev::RelativeAxisCode::REL_Y);
let mut vdev = VirtualDevice::builder()
.ok()?
.name(name)
.with_keys(&keys)
.ok()?
.with_relative_axes(&rel)
.ok()?
.build()
.ok()?;
let dev = open_virtual_device(&mut vdev)?;
Some((vdev, dev))
}
fn new_aggregator() -> InputAggregator {
let (kbd_tx, _) = tokio::sync::broadcast::channel(32);
let (mou_tx, _) = tokio::sync::broadcast::channel(32);
InputAggregator::new(false, kbd_tx, mou_tx, None)
}
#[test]
fn quick_toggle_key_parser_handles_supported_aliases_and_disable_switch() {
assert_eq!(
parse_quick_toggle_key("scrolllock"),
Some(evdev::KeyCode::KEY_SCROLLLOCK)
);
assert_eq!(
parse_quick_toggle_key("pause"),
Some(evdev::KeyCode::KEY_PAUSE)
);
assert_eq!(
parse_quick_toggle_key("sysrq"),
Some(evdev::KeyCode::KEY_SYSRQ)
);
assert_eq!(parse_quick_toggle_key("f12"), Some(evdev::KeyCode::KEY_F12));
assert_eq!(parse_quick_toggle_key("off"), None);
assert_eq!(parse_quick_toggle_key("none"), None);
assert_eq!(
parse_quick_toggle_key("definitely-unknown"),
Some(evdev::KeyCode::KEY_PAUSE)
);
}
#[test]
#[serial]
fn quick_toggle_key_env_defaults_and_respects_explicit_disable() {
with_var("LESAVKA_INPUT_TOGGLE_KEY", None::<&str>, || {
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_PAUSE));
});
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("off"), || {
assert_eq!(quick_toggle_key_from_env(), None);
});
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("f11"), || {
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_F11));
});
}
#[test]
#[serial]
fn quick_toggle_debounce_env_uses_defaults_and_applies_safety_floor() {
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", None::<&str>, || {
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(350));
});
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("20"), || {
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(50));
});
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("900"), || {
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(900));
});
}
#[test]
#[serial]
fn remote_failsafe_timeout_env_is_opt_in_and_allows_disable() {
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", None::<&str>, || {
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", None::<&str>, || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("0"), || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
assert_eq!(
remote_failsafe_timeout_from_env(),
Duration::from_millis(60_000)
);
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("1500"), || {
assert_eq!(
remote_failsafe_timeout_from_env(),
Duration::from_millis(1_500)
);
});
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::ZERO);
});
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_secs(60));
});
}
#[test]
#[serial]
fn boot_remote_capture_only_arms_failsafe_when_launch_option_is_nonzero() {
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
let agg = new_aggregator();
assert_eq!(agg.remote_failsafe_timeout, Duration::ZERO);
assert!(agg.remote_failsafe_started_at.is_none());
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
let agg = new_aggregator();
assert_eq!(agg.remote_failsafe_timeout, Duration::from_secs(60));
assert!(agg.remote_failsafe_started_at.is_some());
});
}
#[test]
fn enable_remote_capture_arms_failsafe_and_local_release_clears_it() {
let mut agg = new_aggregator();
let remote_capture_enabled = agg.remote_capture_enabled_handle();
agg.released = true;
agg.pending_release = false;
agg.remote_failsafe_timeout = Duration::from_millis(5_000);
agg.enable_remote_capture();
assert!(remote_capture_enabled.load(Ordering::Relaxed));
assert!(agg.remote_capture_active());
assert!(
agg.remote_failsafe_started_at.is_some(),
"remote capture should arm the temporary failsafe window"
);
agg.begin_local_release();
assert!(!agg.remote_capture_active());
assert!(
agg.remote_failsafe_started_at.is_none(),
"returning control locally should clear the failsafe timer"
);
}
#[test]
fn local_release_timeout_helpers_are_stable() {
let mut agg = new_aggregator();
assert!(!agg.pending_release_timed_out());
agg.pending_release = true;
agg.pending_release_timeout = Duration::from_millis(1);
agg.pending_release_started_at = Some(Instant::now() - Duration::from_millis(5));
assert!(agg.pending_release_timed_out());
}
#[test]
fn enable_remote_capture_does_not_auto_cutoff_when_failsafe_is_disabled() {
let mut agg = new_aggregator();
agg.released = true;
agg.pending_release = false;
agg.remote_failsafe_timeout = Duration::ZERO;
agg.enable_remote_capture();
assert!(
agg.remote_failsafe_started_at.is_none(),
"normal remote input sessions should not silently flip back to local"
);
assert!(agg.remote_capture_enabled.load(Ordering::Relaxed));
assert!(!agg.released);
}
#[tokio::test(flavor = "current_thread")]
async fn run_remote_failsafe_returns_control_to_local_machine() {
let mut agg = new_aggregator();
agg.remote_failsafe_timeout = Duration::from_millis(1);
agg.remote_failsafe_started_at =
Some(std::time::Instant::now() - Duration::from_millis(10));
let result = tokio::time::timeout(Duration::from_millis(120), agg.run()).await;
assert!(
result.is_err(),
"run should keep looping after the failsafe returns control locally"
);
assert!(
agg.released,
"failsafe expiry should release devices back to the local machine"
);
assert!(
!agg.pending_release,
"failsafe expiry should complete the local-release handoff"
);
assert!(
agg.remote_failsafe_started_at.is_none(),
"failsafe timer should clear once local control is restored"
);
}
#[test]
#[serial]
fn quick_toggle_tap_flips_routing_when_processed_through_input_aggregator() {
let Some((mut vdev, dev)) = build_keyboard_pair_with_keys(
"lesavka-input-toggle-pause",
&[
evdev::KeyCode::KEY_A,
evdev::KeyCode::KEY_ENTER,
evdev::KeyCode::KEY_PAUSE,
],
) else {
return;
};
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
let keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None);
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
agg.quick_toggle_key = Some(evdev::KeyCode::KEY_PAUSE);
agg.quick_toggle_debounce = Duration::from_millis(0);
agg.keyboards.push(keyboard);
vdev.emit(&[
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 1),
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 0),
])
.expect("emit pause tap");
thread::sleep(std::time::Duration::from_millis(20));
agg.process_keyboard_updates();
let quick_toggle_now = agg.quick_toggle_active();
agg.observe_quick_toggle(quick_toggle_now);
assert!(
agg.pending_release,
"a quick swap-key tap should start the local handoff path"
);
assert!(
!agg.released,
"the relay should still be in pending-release until the local handoff completes"
);
}
#[test]
fn observe_quick_toggle_uses_rising_edge_to_avoid_repeat_toggling() {
let mut agg = new_aggregator();
agg.quick_toggle_debounce = Duration::from_millis(0);
agg.observe_quick_toggle(true);
assert!(
agg.pending_release,
"first quick-toggle should switch from remote to local pending-release mode"
);
assert!(!agg.released);
agg.observe_quick_toggle(true);
assert!(
agg.pending_release,
"holding the quick-toggle key should not retrigger mode switching"
);
agg.released = true;
agg.pending_release = false;
agg.observe_quick_toggle(false);
agg.observe_quick_toggle(true);
assert!(
!agg.released,
"second rising edge should return to remote mode"
);
assert!(
!agg.pending_release,
"remote-mode transition should clear pending release state"
);
}
#[test]
fn observe_quick_toggle_honors_debounce_window() {
let mut agg = new_aggregator();
agg.quick_toggle_debounce = Duration::from_secs(60);
agg.released = true;
agg.pending_release = false;
agg.observe_quick_toggle(true);
assert!(!agg.released, "first edge should switch to remote");
agg.released = true;
agg.pending_release = false;
agg.observe_quick_toggle(false);
agg.observe_quick_toggle(true);
assert!(
agg.released,
"second edge inside debounce window should be ignored"
);
}
}

View File

@ -21,6 +21,12 @@ fn const_i32(name: &str) -> i32 {
.unwrap_or_else(|err| panic!("invalid {name} constant: {err}"))
}
fn source_index(needle: &str) -> usize {
UI_SRC
.find(needle)
.unwrap_or_else(|| panic!("missing source marker: {needle}"))
}
#[test]
fn launcher_default_size_stays_inside_1080p() {
assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360);
@ -43,6 +49,7 @@ fn eye_panes_keep_the_locked_larger_preview_footprint() {
#[test]
fn device_staging_and_testing_bottoms_stay_locked_together() {
assert!(UI_SRC.contains("staging_row.set_homogeneous(true);"));
assert!(UI_SRC.contains("staging_row.set_vexpand(false);"));
assert!(UI_SRC.contains("devices_panel.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains("preview_panel.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains(
@ -58,6 +65,7 @@ fn device_testing_keeps_webcam_and_mic_playback_as_equal_bottom_columns() {
assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 158);
assert!(UI_SRC.contains("webcam_group.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains("playback_group.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains("preview_body.set_vexpand(false);"));
assert!(UI_SRC.contains("playback_body.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains("audio_check_meter.set_vexpand(true);"));
assert!(UI_SRC.contains("playback_body.append(&audio_check_meter);"));
@ -83,11 +91,43 @@ fn operations_column_fills_height_and_splits_extra_space_between_logs() {
);
}
#[test]
fn relay_controls_keep_connect_inline_with_server_entry() {
assert!(UI_SRC.contains("build_panel(\"Relay Controls\")"));
assert!(UI_SRC.contains("let relay_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
assert!(UI_SRC.contains("relay_row.append(&server_entry);"));
assert!(UI_SRC.contains("let start_button = gtk::Button::with_label(\"Connect\");"));
assert!(UI_SRC.contains("relay_row.append(&start_button);"));
assert!(
source_index("relay_row.append(&server_entry);")
< source_index("relay_row.append(&start_button);")
);
}
#[test]
fn remote_audio_gain_control_stays_in_the_operations_rail() {
assert!(UI_SRC.contains("let audio_heading = gtk::Label::new(Some(\"Remote Audio\"));"));
assert!(!UI_SRC.contains("Remote Audio"));
assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);"));
assert!(UI_SRC.contains("let power_heading = gtk::Label::new(Some(\"Power\"));"));
assert!(UI_SRC.contains("power_row.append(&power_heading);"));
assert!(UI_SRC.contains("let audio_gain_scale ="));
assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);"));
assert!(UI_SRC.contains("audio_gain_value.set_width_chars(5);"));
assert!(UI_SRC.contains("connection_body.append(&audio_gain_row);"));
assert!(UI_SRC.contains("audio_gain_row.set_size_request(220, -1);"));
assert!(UI_SRC.contains("power_shell.append(&audio_gain_row);"));
assert_eq!(
UI_SRC
.matches("connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));")
.count(),
2,
"the operations rail should not gain extra vertical sections that stretch the lower layout"
);
assert!(
source_index("let power_heading = gtk::Label::new(Some(\"Power\"));")
< source_index("let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")
);
assert!(
source_index("power_shell.append(&audio_gain_row);")
< source_index("let routing_heading = gtk::Label::new(Some(\"Input\"));")
);
}

View File

@ -0,0 +1,34 @@
//! Contract tests for launcher-owned relay process lifetime.
//!
//! Scope: static guardrails around launcher runtime process management.
//! Targets: `client/src/launcher/ui_runtime.rs`.
//! Why: the launcher is the owner of the live relay. If it crashes, audio,
//! video, and input streams must not keep running as leaked child processes.
const UI_RUNTIME_SRC: &str = include_str!("../../client/src/launcher/ui_runtime.rs");
const LAUNCHER_MOD_SRC: &str = include_str!("../../client/src/launcher/mod.rs");
const MAIN_SRC: &str = include_str!("../../client/src/main.rs");
#[test]
fn relay_child_gets_parent_identity_from_launcher() {
assert!(UI_RUNTIME_SRC.contains("\"LESAVKA_LAUNCHER_PARENT_PID\""));
assert!(UI_RUNTIME_SRC.contains("std::process::id().to_string()"));
assert!(UI_RUNTIME_SRC.contains("LESAVKA_LAUNCHER_PARENT_START_TICKS"));
assert!(UI_RUNTIME_SRC.contains("launcher_parent_start_ticks()"));
}
#[test]
fn relay_child_starts_safe_parent_watchdog_on_boot() {
assert!(MAIN_SRC.contains("launcher::start_launcher_child_parent_watchdog_from_env();"));
assert!(LAUNCHER_MOD_SRC.contains("start_launcher_child_parent_watchdog_from_env"));
assert!(LAUNCHER_MOD_SRC.contains("launcher-parent-watchdog"));
assert!(LAUNCHER_MOD_SRC.contains("std::process::exit(0);"));
assert!(LAUNCHER_MOD_SRC.contains("proc_stat_start_ticks"));
}
#[test]
fn relay_address_entry_is_locked_while_relay_is_live() {
assert!(UI_RUNTIME_SRC.contains("widgets.server_entry.set_sensitive(!relay_live);"));
assert!(UI_RUNTIME_SRC.contains("\"Connect\""));
assert!(UI_RUNTIME_SRC.contains("\"Disconnect\""));
}

View File

@ -459,238 +459,4 @@ mod server_main_binary_extra {
server.abort();
});
}
#[test]
#[serial]
fn reset_usb_reports_host_not_attached_after_fake_cycle() {
let dir = tempdir().expect("tempdir");
let hid_dir = dir.path().join("hid");
std::fs::create_dir_all(&hid_dir).expect("create hid dir");
std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0");
std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1");
build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "not attached");
let helper = dir.path().join("noop-core.sh");
write_helper(
&helper,
r#"#!/usr/bin/env bash
set -euo pipefail
echo noop core helper >&2
"#,
);
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
with_var(
"LESAVKA_HID_DIR",
Some(hid_dir.to_string_lossy().to_string()),
|| {
with_fast_usb_recovery(&helper, || {
let kb = tokio::fs::File::from_std(
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(hid_dir.join("hidg0"))
.expect("open hidg0"),
);
let ms = tokio::fs::File::from_std(
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(hid_dir.join("hidg1"))
.expect("open hidg1"),
);
let handler = Handler {
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
gadget: UsbGadget::new("lesavka"),
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)),
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
capture_power: CapturePowerManager::new(),
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
std::collections::HashMap::new(),
)),
};
let rt = tokio::runtime::Runtime::new().expect("runtime");
let err = rt
.block_on(async {
handler.reset_usb(tonic::Request::new(Empty {})).await
})
.expect_err("reset usb should report a host that never enumerates");
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
assert!(
err.message().contains("still not attached"),
"unexpected reset error: {}",
err.message()
);
});
},
);
});
}
#[test]
#[serial]
fn reset_usb_forced_rebuild_can_recover_unattached_fake_udc() {
let dir = tempdir().expect("tempdir");
let hid_dir = dir.path().join("hid");
std::fs::create_dir_all(&hid_dir).expect("create hid dir");
std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0");
std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1");
build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "not attached");
let helper = dir.path().join("recover-core.sh");
write_helper(
&helper,
r#"#!/usr/bin/env bash
set -euo pipefail
echo recover core helper >&2
printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/state"
"#,
);
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
with_var(
"LESAVKA_HID_DIR",
Some(hid_dir.to_string_lossy().to_string()),
|| {
with_fast_usb_recovery(&helper, || {
let kb = tokio::fs::File::from_std(
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(hid_dir.join("hidg0"))
.expect("open hidg0"),
);
let ms = tokio::fs::File::from_std(
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(hid_dir.join("hidg1"))
.expect("open hidg1"),
);
let handler = Handler {
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
gadget: UsbGadget::new("lesavka"),
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)),
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
capture_power: CapturePowerManager::new(),
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
std::collections::HashMap::new(),
)),
};
let rt = tokio::runtime::Runtime::new().expect("runtime");
let reply = rt
.block_on(async {
handler.reset_usb(tonic::Request::new(Empty {})).await
})
.expect("reset usb should recover fake host")
.into_inner();
assert!(reply.ok);
});
},
);
});
}
#[test]
fn shared_eye_hub_forwards_inner_packets() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
with_capture_power_disabled(|| {
rt.block_on(async {
let lease = CapturePowerManager::new().acquire().await;
let packet = VideoPacket {
id: 2,
pts: 42,
data: vec![9, 8, 7],
..Default::default()
};
let hub = EyeHub::spawn(stream::iter(vec![Ok(packet.clone())]), lease);
hub.subscribers
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
let mut rx = hub.tx.subscribe();
let observed = rx.recv().await.expect("hub packet");
assert_eq!(observed.id, packet.id);
assert_eq!(observed.pts, packet.pts);
assert_eq!(observed.data, packet.data);
});
});
}
#[test]
fn conflicting_eye_hubs_for_the_same_source_are_pruned_before_reopen() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
with_capture_power_disabled(|| {
rt.block_on(async {
let requested_key = EyeHubKey {
source_id: 1,
requested_width: 1280,
requested_height: 720,
requested_fps: 60,
};
let stale_same_source_key = EyeHubKey {
source_id: 1,
requested_width: 1920,
requested_height: 1080,
requested_fps: 60,
};
let keep_other_source_key = EyeHubKey {
source_id: 0,
requested_width: 1920,
requested_height: 1080,
requested_fps: 60,
};
let stale_same_source = EyeHub::spawn(
stream::pending::<Result<VideoPacket, tonic::Status>>(),
CapturePowerManager::new().acquire().await,
);
let stopped_other_source = EyeHub::spawn(
stream::pending::<Result<VideoPacket, tonic::Status>>(),
CapturePowerManager::new().acquire().await,
);
stopped_other_source.shutdown();
let keep_other_source = EyeHub::spawn(
stream::pending::<Result<VideoPacket, tonic::Status>>(),
CapturePowerManager::new().acquire().await,
);
let mut hubs = std::collections::HashMap::new();
hubs.insert(stale_same_source_key, stale_same_source.clone());
hubs.insert(
EyeHubKey {
source_id: 0,
requested_width: 1280,
requested_height: 720,
requested_fps: 60,
},
stopped_other_source,
);
hubs.insert(keep_other_source_key, keep_other_source.clone());
let removed = take_conflicting_eye_hubs(&mut hubs, requested_key);
assert_eq!(removed.len(), 2);
assert!(!hubs.contains_key(&stale_same_source_key));
assert!(hubs.contains_key(&keep_other_source_key));
});
});
}
#[test]
fn eye_hub_shutdown_marks_the_hub_as_not_running() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
with_capture_power_disabled(|| {
rt.block_on(async {
let hub = EyeHub::spawn(
stream::pending::<Result<VideoPacket, tonic::Status>>(),
CapturePowerManager::new().acquire().await,
);
assert!(hub.running.load(std::sync::atomic::Ordering::Relaxed));
hub.shutdown();
assert!(!hub.running.load(std::sync::atomic::Ordering::Relaxed));
});
});
}
}

View File

@ -0,0 +1,366 @@
//! USB reset and eye-hub coverage for server main relay branches.
//!
//! Scope: include `server/src/main.rs` and exercise USB recovery plus shared
//! eye-feed hub behavior with synthetic endpoints.
//! Targets: `server/src/main.rs`.
//! Why: USB recovery and shared downstream video hubs are operational escape
//! hatches; regressions here can leave HID or eye feeds unavailable.
#[allow(warnings)]
mod server_main_binary_extra {
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
use futures_util::stream;
use lesavka_common::lesavka::relay_client::RelayClient;
use serial_test::serial;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use temp_env::with_var;
use tempfile::tempdir;
async fn connect_with_retry(addr: std::net::SocketAddr) -> tonic::transport::Channel {
let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
.expect("endpoint")
.tcp_nodelay(true);
for _ in 0..40 {
if let Ok(channel) = endpoint.clone().connect().await {
return channel;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("failed to connect to local tonic server");
}
fn write_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(path, content).expect("write file");
}
fn with_fake_gadget_roots(sys_root: &Path, cfg_root: &Path, f: impl FnOnce()) {
let sys_root = sys_root.to_string_lossy().to_string();
let cfg_root = cfg_root.to_string_lossy().to_string();
with_var("LESAVKA_GADGET_SYSFS_ROOT", Some(sys_root), || {
with_var("LESAVKA_GADGET_CONFIGFS_ROOT", Some(cfg_root), f);
});
}
fn with_capture_power_disabled(f: impl FnOnce()) {
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), f);
}
fn build_fake_gadget_tree(base: &Path, ctrl: &str, gadget_name: &str, state: &str) {
write_file(
&base.join(format!("sys/class/udc/{ctrl}/state")),
&format!("{state}\n"),
);
write_file(
&base.join(format!("cfg/{gadget_name}/UDC")),
&format!("{ctrl}\n"),
);
write_file(&base.join("sys/bus/platform/drivers/dwc3/unbind"), "");
write_file(&base.join("sys/bus/platform/drivers/dwc3/bind"), "");
}
fn write_helper(path: &Path, body: &str) {
write_file(path, body);
let mut perms = std::fs::metadata(path)
.expect("helper metadata")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).expect("chmod helper");
}
fn with_fast_usb_recovery(helper: &Path, f: impl FnOnce()) {
let helper = helper.to_string_lossy().to_string();
with_var("LESAVKA_CORE_HELPER", Some(helper), || {
with_var("LESAVKA_USB_RECOVERY_CYCLE_WAIT_MS", Some("0"), || {
with_var("LESAVKA_USB_RECOVERY_REBUILD_WAIT_MS", Some("0"), || {
with_var("LESAVKA_USB_RECOVERY_FINAL_WAIT_MS", Some("0"), f);
})
})
});
}
fn build_handler_for_tests_with_modes(
kb_writable: bool,
ms_writable: bool,
) -> (tempfile::TempDir, Handler) {
let dir = tempdir().expect("tempdir");
let kb_path = dir.path().join("hidg0.bin");
let ms_path = dir.path().join("hidg1.bin");
std::fs::write(&kb_path, []).expect("create kb file");
std::fs::write(&ms_path, []).expect("create ms file");
let kb_std = std::fs::OpenOptions::new()
.read(true)
.write(kb_writable)
.create(kb_writable)
.truncate(kb_writable)
.open(&kb_path)
.expect("open kb");
let ms_std = std::fs::OpenOptions::new()
.read(true)
.write(ms_writable)
.create(ms_writable)
.truncate(ms_writable)
.open(&ms_path)
.expect("open ms");
let kb = tokio::fs::File::from_std(kb_std);
let ms = tokio::fs::File::from_std(ms_std);
(
dir,
Handler {
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
gadget: UsbGadget::new("lesavka"),
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
capture_power: CapturePowerManager::new(),
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
std::collections::HashMap::new(),
)),
},
)
}
fn build_handler_for_tests() -> (tempfile::TempDir, Handler) {
build_handler_for_tests_with_modes(true, true)
}
#[test]
#[serial]
fn reset_usb_reports_host_not_attached_after_fake_cycle() {
let dir = tempdir().expect("tempdir");
let hid_dir = dir.path().join("hid");
std::fs::create_dir_all(&hid_dir).expect("create hid dir");
std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0");
std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1");
build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "not attached");
let helper = dir.path().join("noop-core.sh");
write_helper(
&helper,
r#"#!/usr/bin/env bash
set -euo pipefail
echo noop core helper >&2
"#,
);
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
with_var(
"LESAVKA_HID_DIR",
Some(hid_dir.to_string_lossy().to_string()),
|| {
with_fast_usb_recovery(&helper, || {
let kb = tokio::fs::File::from_std(
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(hid_dir.join("hidg0"))
.expect("open hidg0"),
);
let ms = tokio::fs::File::from_std(
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(hid_dir.join("hidg1"))
.expect("open hidg1"),
);
let handler = Handler {
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
gadget: UsbGadget::new("lesavka"),
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)),
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
capture_power: CapturePowerManager::new(),
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
std::collections::HashMap::new(),
)),
};
let rt = tokio::runtime::Runtime::new().expect("runtime");
let err = rt
.block_on(async {
handler.reset_usb(tonic::Request::new(Empty {})).await
})
.expect_err("reset usb should report a host that never enumerates");
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
assert!(
err.message().contains("still not attached"),
"unexpected reset error: {}",
err.message()
);
});
},
);
});
}
#[test]
#[serial]
fn reset_usb_forced_rebuild_can_recover_unattached_fake_udc() {
let dir = tempdir().expect("tempdir");
let hid_dir = dir.path().join("hid");
std::fs::create_dir_all(&hid_dir).expect("create hid dir");
std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0");
std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1");
build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "not attached");
let helper = dir.path().join("recover-core.sh");
write_helper(
&helper,
r#"#!/usr/bin/env bash
set -euo pipefail
echo recover core helper >&2
printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/state"
"#,
);
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
with_var(
"LESAVKA_HID_DIR",
Some(hid_dir.to_string_lossy().to_string()),
|| {
with_fast_usb_recovery(&helper, || {
let kb = tokio::fs::File::from_std(
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(hid_dir.join("hidg0"))
.expect("open hidg0"),
);
let ms = tokio::fs::File::from_std(
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(hid_dir.join("hidg1"))
.expect("open hidg1"),
);
let handler = Handler {
kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
gadget: UsbGadget::new("lesavka"),
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)),
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
capture_power: CapturePowerManager::new(),
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
std::collections::HashMap::new(),
)),
};
let rt = tokio::runtime::Runtime::new().expect("runtime");
let reply = rt
.block_on(async {
handler.reset_usb(tonic::Request::new(Empty {})).await
})
.expect("reset usb should recover fake host")
.into_inner();
assert!(reply.ok);
});
},
);
});
}
#[test]
fn shared_eye_hub_forwards_inner_packets() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
with_capture_power_disabled(|| {
rt.block_on(async {
let lease = CapturePowerManager::new().acquire().await;
let packet = VideoPacket {
id: 2,
pts: 42,
data: vec![9, 8, 7],
..Default::default()
};
let hub = EyeHub::spawn(stream::iter(vec![Ok(packet.clone())]), lease);
hub.subscribers
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
let mut rx = hub.tx.subscribe();
let observed = rx.recv().await.expect("hub packet");
assert_eq!(observed.id, packet.id);
assert_eq!(observed.pts, packet.pts);
assert_eq!(observed.data, packet.data);
});
});
}
#[test]
fn conflicting_eye_hubs_for_the_same_source_are_pruned_before_reopen() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
with_capture_power_disabled(|| {
rt.block_on(async {
let requested_key = EyeHubKey {
source_id: 1,
requested_width: 1280,
requested_height: 720,
requested_fps: 60,
};
let stale_same_source_key = EyeHubKey {
source_id: 1,
requested_width: 1920,
requested_height: 1080,
requested_fps: 60,
};
let keep_other_source_key = EyeHubKey {
source_id: 0,
requested_width: 1920,
requested_height: 1080,
requested_fps: 60,
};
let stale_same_source = EyeHub::spawn(
stream::pending::<Result<VideoPacket, tonic::Status>>(),
CapturePowerManager::new().acquire().await,
);
let stopped_other_source = EyeHub::spawn(
stream::pending::<Result<VideoPacket, tonic::Status>>(),
CapturePowerManager::new().acquire().await,
);
stopped_other_source.shutdown();
let keep_other_source = EyeHub::spawn(
stream::pending::<Result<VideoPacket, tonic::Status>>(),
CapturePowerManager::new().acquire().await,
);
let mut hubs = std::collections::HashMap::new();
hubs.insert(stale_same_source_key, stale_same_source.clone());
hubs.insert(
EyeHubKey {
source_id: 0,
requested_width: 1280,
requested_height: 720,
requested_fps: 60,
},
stopped_other_source,
);
hubs.insert(keep_other_source_key, keep_other_source.clone());
let removed = take_conflicting_eye_hubs(&mut hubs, requested_key);
assert_eq!(removed.len(), 2);
assert!(!hubs.contains_key(&stale_same_source_key));
assert!(hubs.contains_key(&keep_other_source_key));
});
});
}
#[test]
fn eye_hub_shutdown_marks_the_hub_as_not_running() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
with_capture_power_disabled(|| {
rt.block_on(async {
let hub = EyeHub::spawn(
stream::pending::<Result<VideoPacket, tonic::Status>>(),
CapturePowerManager::new().acquire().await,
);
assert!(hub.running.load(std::sync::atomic::Ordering::Relaxed));
hub.shutdown();
assert!(!hub.running.load(std::sync::atomic::Ordering::Relaxed));
});
});
}
}