diff --git a/Cargo.lock b/Cargo.lock index 5741221..4711c4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.13" +version = "0.22.14" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.13" +version = "0.22.14" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.13" +version = "0.22.14" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index ef2cfe1..4011f7b 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.13" +version = "0.22.14" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 43cee7e..85711c2 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.13" +version = "0.22.14" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index ab5afb5..c9cabb2 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -605,6 +605,46 @@ list_server_bound_inactive_lines() { sudo ss -H -B -tn "sport = :$port" 2>/dev/null | sed '/^$/d' || true } +list_server_established_client_lines_proc() { + local port=$1 hex_port + hex_port=$(printf '%04X' "$port") + sudo awk -v target="$hex_port" ' + FNR == 1 { next } + $4 == "01" { + split($2, local, ":") + if (toupper(local[2]) == target) print $0 + } + ' /proc/net/tcp /proc/net/tcp6 2>/dev/null | sed '/^$/d' +} + +list_server_established_client_lines() { + local port=$1 + if command -v ss >/dev/null 2>&1; then + sudo ss -Htn state established "sport = :$port" 2>/dev/null | sed '/^$/d' || true + fi + list_server_established_client_lines_proc "$port" +} + +lesavka_server_has_active_clients() { + local port + port=$(server_bind_port) || return 1 + list_server_established_client_lines "$port" | grep -q . +} + +server_active_client_detail() { + local port detail + port=$(server_bind_port) || { + printf 'unable to parse server bind port' + return 0 + } + detail=$(list_server_established_client_lines "$port" | head -n 5 | paste -sd ';' -) + if [[ -z $detail ]]; then + printf 'no established clients on :%s' "$port" + return 0 + fi + printf 'established clients on :%s => %s' "$port" "$detail" +} + list_server_listener_inodes_proc() { local port=$1 hex_port hex_port=$(printf '%04X' "$port") @@ -809,6 +849,51 @@ validate_server_ready() { return 1 } +install_lesavka_server_unit_file() { + cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-server.service >/dev/null +[Unit] +Description=lesavka gRPC relay +After=network.target lesavka-core.service lesavka-uvc.service +StartLimitIntervalSec=30 +StartLimitBurst=10 + +[Service] +ExecStart=/usr/local/bin/lesavka-server +TimeoutStopSec=10 +KillSignal=SIGTERM +KillMode=control-group +Restart=always +Environment=RUST_LOG=lesavka_server=info,lesavka_server::audio=info,lesavka_server::video=info,lesavka_server::gadget=info +Environment=RUST_BACKTRACE=1 +Environment=GST_DEBUG="*:2,alsasink:6,alsasrc:6" +Environment=LESAVKA_UVC_EXTERNAL=1 +Environment=LESAVKA_EYE_ADAPTIVE=1 +Environment=LESAVKA_EYE_MIN_FPS=12 +Environment=LESAVKA_EYE_FPS=20 +Environment=LESAVKA_MIC_INIT_ATTEMPTS=5 +Environment=LESAVKA_MIC_INIT_DELAY_MS=250 +Environment=LESAVKA_SERVER_LOG_PATH=/var/log/lesavka/server.log +EnvironmentFile=-/etc/lesavka/uvc.env +EnvironmentFile=-/etc/lesavka/server.env +Restart=always +RestartSec=5 +StandardError=append:/var/log/lesavka/server.stderr +User=root + +[Install] +WantedBy=multi-user.target +UNIT +} + +restart_lesavka_server_only() { + sudo truncate -s 0 /var/log/lesavka/server.stderr + sudo systemctl stop lesavka-server >/dev/null 2>&1 || true + clear_stale_server_listener + sudo systemctl reset-failed lesavka-server >/dev/null 2>&1 || true + sudo systemctl restart lesavka-server + validate_server_ready +} + normalize_hdmi_connector() { local name="$1" if [[ $name =~ (HDMI-A-[0-9]+)$ ]]; then @@ -1481,17 +1566,44 @@ if [[ "$HOST_GADGET_PROTECTED" == "1" ]] \ fi if [[ "$ATTACHED_UVC_CHANGE_DEFERRED" == "1" ]]; then echo "⚠️ UVC runtime settings or live descriptors differ while the host is attached." >&2 - echo " Refusing to rewrite UVC/server runtime env or restart Lesavka services because that can wedge the Pi USB controller." >&2 - echo " Leaving /etc/lesavka/server.env and /etc/lesavka/uvc.env unchanged; binaries were installed but running services were left untouched." >&2 + echo " The installer will not restart lesavka-core or lesavka-uvc because that can wedge the Pi USB controller." >&2 echo " To apply descriptor-changing UVC settings, use a maintenance window with LESAVKA_ALLOW_GADGET_RESET=1 LESAVKA_FORCE_GADGET_REBUILD=1." >&2 + if lesavka_server_has_active_clients && [[ "${LESAVKA_INSTALL_RESTART_WITH_CLIENTS:-0}" != "1" ]]; then + echo "⚠️ Active gRPC clients are connected; leaving the running server untouched for this install." >&2 + echo " $(server_active_client_detail)" >&2 + echo " Leaving /etc/lesavka/server.env and /etc/lesavka/uvc.env unchanged; binaries were installed but running services were left untouched." >&2 + echo " Rerun the installer after clients disconnect, or set LESAVKA_INSTALL_RESTART_WITH_CLIENTS=1 to force a server-only restart." >&2 + rm -f "$SERVER_ENV_TMP" "$UVC_ENV_TMP" + INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true) + INSTALLED_SHA=$(git -C "$SCRIPT_REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true) + sudo systemctl start lesavka-recovery-ladder.timer >/dev/null 2>&1 || true + echo "✅ lesavka-server binaries installed; live service restart deferred because clients are connected." + echo "➡️ Installed binaries: lesavka-server ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}" + echo "➡️ Recovery ladder: installed, enabled, and started in server-only mode" + echo "➡️ Deferred UVC codec request: ${INSTALL_UVC_CODEC}" + exit 0 + fi + + echo "✅ No active gRPC clients detected; applying runtime env and restarting lesavka-server only." >&2 + echo " Preserving lesavka-core and lesavka-uvc so the attached USB gadget is not cycled." >&2 + sudo install -m 0644 "$SERVER_ENV_TMP" /etc/lesavka/server.env + sudo install -m 0644 "$UVC_ENV_TMP" /etc/lesavka/uvc.env rm -f "$SERVER_ENV_TMP" "$UVC_ENV_TMP" + sudo install -d -m 0755 /var/log/lesavka + sudo truncate -s 0 /var/log/lesavka/server.log + install_lesavka_server_unit_file + sudo systemctl daemon-reload + sudo systemctl enable lesavka-server >/dev/null 2>&1 || true + restart_lesavka_server_only + sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true) INSTALLED_SHA=$(git -C "$SCRIPT_REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true) sudo systemctl start lesavka-recovery-ladder.timer >/dev/null 2>&1 || true - echo "✅ lesavka-server binaries installed; live service restart deferred for attached-gadget safety." - echo "➡️ Installed binaries: lesavka-server ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}" + echo "✅ lesavka-server binaries, runtime env, and server unit installed; server-only restart completed." + echo "➡️ Installed and running: lesavka-server ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}" echo "➡️ Recovery ladder: installed, enabled, and started in server-only mode" - echo "➡️ Deferred UVC codec request: ${INSTALL_UVC_CODEC}" + echo "➡️ UVC codec request persisted for the next safe gadget rebuild: ${INSTALL_UVC_CODEC}" + echo "➡️ Live UVC descriptors may still differ until a maintenance-window gadget rebuild is allowed." exit 0 fi sudo install -m 0644 "$SERVER_ENV_TMP" /etc/lesavka/server.env @@ -1533,39 +1645,7 @@ WantedBy=multi-user.target UNIT echo "==> 6b. Systemd units - lesavka-server" -cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-server.service >/dev/null -[Unit] -Description=lesavka gRPC relay -After=network.target lesavka-core.service lesavka-uvc.service -StartLimitIntervalSec=30 -StartLimitBurst=10 - -[Service] -ExecStart=/usr/local/bin/lesavka-server -TimeoutStopSec=10 -KillSignal=SIGTERM -KillMode=control-group -Restart=always -Environment=RUST_LOG=lesavka_server=info,lesavka_server::audio=info,lesavka_server::video=info,lesavka_server::gadget=info -Environment=RUST_BACKTRACE=1 -Environment=GST_DEBUG="*:2,alsasink:6,alsasrc:6" -Environment=LESAVKA_UVC_EXTERNAL=1 -Environment=LESAVKA_EYE_ADAPTIVE=1 -Environment=LESAVKA_EYE_MIN_FPS=12 -Environment=LESAVKA_EYE_FPS=20 -Environment=LESAVKA_MIC_INIT_ATTEMPTS=5 -Environment=LESAVKA_MIC_INIT_DELAY_MS=250 -Environment=LESAVKA_SERVER_LOG_PATH=/var/log/lesavka/server.log -EnvironmentFile=-/etc/lesavka/uvc.env -EnvironmentFile=-/etc/lesavka/server.env -Restart=always -RestartSec=5 -StandardError=append:/var/log/lesavka/server.stderr -User=root - -[Install] -WantedBy=multi-user.target -UNIT +install_lesavka_server_unit_file echo "==> 6c. Systemd units - initialization" sudo install -d -m 0755 /var/log/lesavka @@ -1687,12 +1767,7 @@ fi validate_uvc_gadget_ready -sudo truncate -s 0 /var/log/lesavka/server.stderr -sudo systemctl stop lesavka-server >/dev/null 2>&1 || true -clear_stale_server_listener -sudo systemctl reset-failed lesavka-server >/dev/null 2>&1 || true -sudo systemctl restart lesavka-server -validate_server_ready +restart_lesavka_server_only sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true sudo systemctl start lesavka-recovery-ladder.timer INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true) diff --git a/server/Cargo.toml b/server/Cargo.toml index b845afd..207f38d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.13" +version = "0.22.14" edition = "2024" autobins = false diff --git a/server/src/main/entrypoint.rs b/server/src/main/entrypoint.rs index ce5d602..c244ee2 100644 --- a/server/src/main/entrypoint.rs +++ b/server/src/main/entrypoint.rs @@ -1,7 +1,22 @@ /*──────────────── main ───────────────────────*/ +fn print_version_and_exit_requested() -> bool { + if std::env::args() + .skip(1) + .any(|arg| arg == "--version" || arg == "-V") + { + println!("{} {}", PKG_NAME, lesavka_server::VERSION); + return true; + } + false +} + #[cfg(not(coverage))] #[tokio::main(worker_threads = 4)] async fn main() -> anyhow::Result<()> { + if print_version_and_exit_requested() { + return Ok(()); + } + let _guard = init_tracing()?; info!("🚀 {} v{} starting up", PKG_NAME, lesavka_server::VERSION); @@ -43,6 +58,10 @@ async fn main() -> anyhow::Result<()> { #[cfg(coverage)] #[tokio::main(worker_threads = 2)] async fn main() -> anyhow::Result<()> { + if print_version_and_exit_requested() { + return Ok(()); + } + let gadget = UsbGadget::new("lesavka"); let _handler = Handler::new(gadget).await?; Err(anyhow::anyhow!("coverage mode skips live gRPC serve loop")) diff --git a/tests/contract/server/main/server_main_process_contract.rs b/tests/contract/server/main/server_main_process_contract.rs index bdc9c9a..d8f4e69 100644 --- a/tests/contract/server/main/server_main_process_contract.rs +++ b/tests/contract/server/main/server_main_process_contract.rs @@ -110,6 +110,30 @@ fn wait_for_log(path: &PathBuf, needle: &str, deadline: Instant) -> String { } } +#[test] +#[serial] +fn server_binary_reports_version_without_starting_relay() { + let Some(bin) = find_binary("lesavka-server") else { + return; + }; + let output = Command::new(bin) + .arg("--version") + .output() + .expect("run lesavka-server --version"); + assert!( + output.status.success(), + "version probe should exit successfully; stderr was:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(version) = server_package_version() { + assert!( + stdout.contains(&format!("lesavka_server {version}")), + "version probe should print the package version; stdout was:\n{stdout}" + ); + } +} + #[test] #[serial] fn server_binary_stays_up_with_missing_hid_nodes_and_current_version() { diff --git a/tests/installer/scripts/install/server_install_script_contract.rs b/tests/installer/scripts/install/server_install_script_contract.rs index 76bf60f..41e37c1 100644 --- a/tests/installer/scripts/install/server_install_script_contract.rs +++ b/tests/installer/scripts/install/server_install_script_contract.rs @@ -256,13 +256,33 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "server installs should stop any stale recovery timer before a long build/install cycle" ); assert!( - SERVER_INSTALL - .contains("Leaving /etc/lesavka/server.env and /etc/lesavka/uvc.env unchanged"), - "deferred attached-gadget installs should preserve the last known live runtime env" + SERVER_INSTALL.contains("lesavka_server_has_active_clients") + && SERVER_INSTALL.contains("list_server_established_client_lines") + && SERVER_INSTALL.contains("LESAVKA_INSTALL_RESTART_WITH_CLIENTS"), + "attached-gadget installs should distinguish idle safe updates from active client sessions" ); assert!( - SERVER_INSTALL.contains("binaries were installed but running services were left untouched"), - "deferred attached-gadget installs must not restart live services" + SERVER_INSTALL + .contains("Active gRPC clients are connected; leaving the running server untouched"), + "active clients should keep the old server process alive instead of forcing a disruptive update" + ); + assert!( + SERVER_INSTALL + .contains("Leaving /etc/lesavka/server.env and /etc/lesavka/uvc.env unchanged"), + "active-client deferral should preserve the last known live runtime env" + ); + assert!( + SERVER_INSTALL + .contains("No active gRPC clients detected; applying runtime env and restarting lesavka-server only") + && SERVER_INSTALL + .contains("Preserving lesavka-core and lesavka-uvc so the attached USB gadget is not cycled") + && SERVER_INSTALL.contains("restart_lesavka_server_only"), + "idle attached-gadget installs should leave the newest server running without cycling USB gadget services" + ); + assert!( + SERVER_INSTALL.contains("server-only restart completed") + && SERVER_INSTALL.contains("Live UVC descriptors may still differ"), + "idle attached-gadget installs should be explicit about server readiness and deferred descriptor rebuilds" ); assert!( SERVER_INSTALL