From c2ba5a2a6c33b3599e6815569e1f61341a974afe Mon Sep 17 00:00:00 2001 From: Ember Date: Tue, 12 May 2026 17:05:19 -0700 Subject: [PATCH] Add daemon startup smoke test and let it run on a PC in debug_mode v0.11.0 shipped a daemon binary that built fine but didn't actually serve a working frontend. CI was green. Nothing in the pipeline asserted that the built binary comes up and serves something. Add daemon/tests/smoke.rs as an integration test that spawns the built binary against a tempdir-backed config with debug_mode = true, picks an ephemeral port, and asserts: - GET /index.html is 2xx and the decompressed body contains "Rayhunter" - GET /api/qmdl-manifest is 2xx - the daemon exits cleanly on SIGINT Captures the daemon's stderr into a buffer so startup/shutdown failures print actionable context instead of just "did not start listening". Runs as part of the regular cargo test invocation, no new CI job. For the smoke test (and #826) to work, the daemon needs to come up on a PC without /dev/diag, a screen, or wpa_supplicant. The DIAG read thread, display driver, and key input were already gated on debug_mode. Gate the two remaining device-dependent workers the same way: - run_battery_notification_worker (polls battery sysfs paths) - wifi_station::run_wifi_client (talks to wpa_supplicant) doc/installing-from-source.md gains a "Running the daemon on your PC" section. doc/porting.md drops its duplicate debug_mode line and links to the new section. Closes #826. --- Cargo.lock | 31 ++++++ daemon/Cargo.toml | 4 + daemon/src/main.rs | 28 ++--- daemon/tests/smoke.rs | 185 ++++++++++++++++++++++++++++++++++ doc/installing-from-source.md | 19 ++++ doc/porting.md | 2 +- 6 files changed, 256 insertions(+), 13 deletions(-) create mode 100644 daemon/tests/smoke.rs diff --git a/Cargo.lock b/Cargo.lock index c1b3e84..e1a68ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,6 +274,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.13.3" @@ -992,6 +1005,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -4988,6 +5018,7 @@ version = "0.12.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" dependencies = [ + "async-compression", "base64 0.22.1", "bytes", "futures-core", diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 37eac20..53fa2e3 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -47,3 +47,7 @@ rustls-post-quantum = { version = "0.2.4", optional = true } async-trait = "0.1.88" utoipa = { version = "5.4.0", optional = true } url = "2.5.4" + +[dev-dependencies] +reqwest = { version = "0.12.20", default-features = false, features = ["gzip"] } +tempfile = "3.10.2" diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 17dc87d..46d8144 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -277,12 +277,14 @@ async fn run_with_config( analysis_tx.clone(), ); - run_battery_notification_worker( - &task_tracker, - config.device.clone(), - notification_service.new_handler(), - shutdown_token.clone(), - ); + if !config.debug_mode { + run_battery_notification_worker( + &task_tracker, + config.device.clone(), + notification_service.new_handler(), + shutdown_token.clone(), + ); + } run_notification_worker( &task_tracker, @@ -291,12 +293,14 @@ async fn run_with_config( ); let wifi_status = Arc::new(RwLock::new(WifiStatus::default())); - wifi_station::run_wifi_client( - &task_tracker, - &config.wifi_config(), - shutdown_token.clone(), - wifi_status.clone(), - ); + if !config.debug_mode { + wifi_station::run_wifi_client( + &task_tracker, + &config.wifi_config(), + shutdown_token.clone(), + wifi_status.clone(), + ); + } if !config.webdav.url.trim().is_empty() { run_webdav_upload_worker( diff --git a/daemon/tests/smoke.rs b/daemon/tests/smoke.rs new file mode 100644 index 0000000..c86c783 --- /dev/null +++ b/daemon/tests/smoke.rs @@ -0,0 +1,185 @@ +use std::io::Read; +use std::net::{TcpListener, TcpStream}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use tempfile::TempDir; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(5); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + +struct DaemonGuard { + child: Option, + stderr: Arc>>, +} + +impl DaemonGuard { + fn stderr_dump(&self) -> String { + let buf = self.stderr.lock().unwrap(); + String::from_utf8_lossy(&buf).into_owned() + } + + fn shutdown(&mut self, timeout: Duration) -> std::io::Result { + let mut child = self + .child + .take() + .expect("daemon already shut down or never started"); + + #[cfg(unix)] + { + let pid = child.id() as libc::pid_t; + // SAFETY: child.id() returns the OS pid of a process we own. + unsafe { libc::kill(pid, libc::SIGINT) }; + } + #[cfg(not(unix))] + { + let _ = child.kill(); + } + + let start = Instant::now(); + loop { + match child.try_wait()? { + Some(status) => return Ok(status), + None => { + if start.elapsed() >= timeout { + let _ = child.kill(); + return child.wait(); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + } + } +} + +impl Drop for DaemonGuard { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn pick_free_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); + listener.local_addr().expect("local_addr").port() +} + +fn wait_for_port(port: u16, timeout: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return true; + } + std::thread::sleep(Duration::from_millis(100)); + } + false +} + +#[tokio::test(flavor = "current_thread")] +async fn daemon_serves_index_and_api() { + let port = pick_free_port(); + + let tmp = TempDir::new().unwrap(); + let qmdl_dir = tmp.path().join("qmdl"); + std::fs::create_dir(&qmdl_dir).unwrap(); + // The daemon refuses to create a store in debug_mode, so seed an empty + // manifest. See init_qmdl_store in daemon/src/main.rs. + std::fs::write(qmdl_dir.join("manifest.toml"), "entries = []\n").unwrap(); + + let config_path = tmp.path().join("config.toml"); + std::fs::write( + &config_path, + format!( + "qmdl_store_path = \"{}\"\nport = {}\ndebug_mode = true\n", + qmdl_dir.display(), + port, + ), + ) + .unwrap(); + + let daemon_bin = env!("CARGO_BIN_EXE_rayhunter-daemon"); + let mut child = Command::new(daemon_bin) + .arg(&config_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn daemon"); + + let stderr_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + if let Some(mut pipe) = child.stderr.take() { + let sink = stderr_buf.clone(); + std::thread::spawn(move || { + let mut chunk = [0u8; 4096]; + loop { + match pipe.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => sink.lock().unwrap().extend_from_slice(&chunk[..n]), + } + } + }); + } + + let mut guard = DaemonGuard { + child: Some(child), + stderr: stderr_buf, + }; + + if !wait_for_port(port, STARTUP_TIMEOUT) { + panic!( + "daemon did not start listening on {port} within {STARTUP_TIMEOUT:?}\n--- daemon stderr ---\n{}", + guard.stderr_dump(), + ); + } + + // reqwest's rustls backend gets pulled in via feature unification with the + // daemon's production deps. The test process needs its own crypto provider. + rayhunter_daemon::crypto_provider::install_default(); + + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .gzip(true) + .build() + .unwrap(); + let base = format!("http://127.0.0.1:{port}"); + + let resp = client + .get(format!("{base}/index.html")) + .send() + .await + .expect("GET /index.html failed"); + assert!( + resp.status().is_success(), + "GET /index.html returned {}", + resp.status(), + ); + let body = resp.text().await.expect("could not read index.html body"); + assert!( + body.contains("Rayhunter"), + "decompressed index.html body did not contain 'Rayhunter' marker (len={})", + body.len(), + ); + + let resp = client + .get(format!("{base}/api/qmdl-manifest")) + .send() + .await + .expect("GET /api/qmdl-manifest failed"); + assert!( + resp.status().is_success(), + "GET /api/qmdl-manifest returned {}", + resp.status(), + ); + + let status = guard + .shutdown(SHUTDOWN_TIMEOUT) + .expect("waiting for daemon exit failed"); + assert!( + status.success(), + "daemon did not exit cleanly after SIGINT: {status}\n--- daemon stderr ---\n{}", + guard.stderr_dump(), + ); +} diff --git a/doc/installing-from-source.md b/doc/installing-from-source.md index 4a2f95c..b1f9d61 100644 --- a/doc/installing-from-source.md +++ b/doc/installing-from-source.md @@ -27,6 +27,25 @@ Then you can build everything with: ./scripts/install-dev.sh orbic # replace 'orbic' with your device type ``` +## Running the daemon on your PC + +If you don't have a target device handy, you can run `rayhunter-daemon` on your +PC with `debug_mode = true`. This skips DIAG, the device display, key input, +the battery worker, and the WiFi client, so recording-related endpoints will +not work, but the frontend and read-only APIs do. + +```sh +mkdir -p ./qmdl && printf 'entries = []\n' > ./qmdl/manifest.toml +cat > config.toml <<'EOF' +qmdl_store_path = "./qmdl" +port = 8080 +debug_mode = true +EOF +cargo run -p rayhunter-daemon -- ./config.toml +``` + +Open `http://127.0.0.1:8080`. + ## Hot-reloading the frontend If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change. diff --git a/doc/porting.md b/doc/porting.md index 9a0fda0..c5939bd 100644 --- a/doc/porting.md +++ b/doc/porting.md @@ -55,7 +55,7 @@ You can copy the daemon and config files to the device using `netcat` or `adb pu The `device` setting in `config.toml` must match one of the lowercase variant names from the `Device` enum (e.g. `"orbic"`, `"tplink"`). This controls which display driver is used. -Setting `debug_mode = true` in `config.toml` runs the daemon without `/dev/diag`, so you can test the display and web UI without the hardware. +To bring the daemon up without `/dev/diag` (for instance, to test the display and web UI before the hardware path works), see [Running the daemon on your PC](./installing-from-source.md#running-the-daemon-on-your-pc). ### Autostart