diff --git a/demos/README.md b/demos/README.md index d124ba0..5426f5f 100644 --- a/demos/README.md +++ b/demos/README.md @@ -7,14 +7,20 @@ `Network I/O: RX KB TX KB (last interval)`; it contains no `KB/s`. - `tui_capture_2026-09-11.txt` — readable 110x50 text render of the same bytes (pyte 0.8.2). - `http_capture_2026-09-11.txt` — real `GET /health`, `GET /metrics`, agent log, and local `export` - output from the built binary on a free loopback port. It also preserves the known remote-export - failure at this commit (see below). + output from the built binary on a free loopback port. It also preserves the historical + remote-export failure at commit `5dde108` (see below). - `verification_2026-09-11.md` — build identity (commit, toolchain, binary SHA-256), exact capture commands, artifact hashes, privacy review, and limitations. +- `verification_2026-09-11-remote-export.md` — CLI-boundary verification of the remote-export fix + on base `3dfa107`: success against a live `grainx agent` and controlled errors for unreachable, + malformed, and `http://127.0.0.1:0` endpoints, with no panic text in any capture. Verified build: commit `5dde1082e5f8989793e79bd5130ec728dd0bde83`, binary SHA-256 `9dfd9cccdd5d4f2352d6aca527b0c3f8f75bda35f95aa1134d0bfaee98fdce00`, `rustc 1.94.1`. +Remote export is fixed on `main` at `3dfa107`; the current CLI evidence is in +`verification_2026-09-11-remote-export.md`. + ### Reproduce the TUI capture ~~~bash @@ -53,11 +59,16 @@ mkdir -p /tmp/grainx-export && cd /tmp/grainx-export `network_rx_bytes` and `network_tx_bytes` in the JSON are per-interval deltas, not rates. -### Known failure at this commit +### Remote export status -`export --remote ` panics with `Cannot drop a runtime in a context where blocking is not allowed`. -The failure log is preserved in `http_capture_2026-09-11.txt`; local `export` works. This is a -pre-existing defect, documented rather than fixed so the demo stays bound to the captured commit. +- **Historical:** at `5dde108` (the 2026-09-11 capture commit), `export --remote ` panicked + with `Cannot drop a runtime in a context where blocking is not allowed`. The failure log is + preserved in `http_capture_2026-09-11.txt` as a defect record, not a current result. +- **Fixed:** `main` at `3dfa107` runs the blocking request on a dedicated OS thread. + `verification_2026-09-11-remote-export.md` shows exit 0 against a live `grainx agent`, exit 4 + with controlled errors for unreachable, malformed, and `http://127.0.0.1:0` endpoints, and no + panic text. Automated CLI coverage is in `tests/cli_remote_export.rs`. +- Local `export` works at both commits. ## Historical captures (2026-09-06) diff --git a/demos/verification_2026-09-11-remote-export.md b/demos/verification_2026-09-11-remote-export.md new file mode 100644 index 0000000..e0d6c2e --- /dev/null +++ b/demos/verification_2026-09-11-remote-export.md @@ -0,0 +1,173 @@ +# grainx remote-export CLI verification — 2026-09-11 + +This record proves the `grainx export --remote ` fix at the real CLI boundary on top of +`main` commit `3dfa107` (the commit that moved the blocking `reqwest` client to a dedicated OS +thread). It covers the success path, two controlled-failure paths, and the exact +`http://127.0.0.1:0` case that panicked before the fix. It is evidence for the listed build and +host only, not a compatibility claim. + +## Build identity + +| Field | Value | +| --- | --- | +| Source commit | `3dfa107f78b94812c080eae3b577c7cd6035cfdd` (`fix: build blocking remote metrics client outside the async runtime`) | +| Branch | `test/remote-export-cli` (branched from `origin/main` at the commit above) | +| Worktree at `cargo build` | clean (`git status --porcelain` = 0 paths) | +| Worktree at CLI capture | dirty count 1: untracked `tests/cli_remote_export.rs`; no changes under `src/` | +| Build command | `cargo build --locked` | +| Binary | `target/debug/grainx` (frozen copy used for captures: `grainx.used`) | +| Binary SHA-256 | `5e485df1469c1163db9b253794a30f7124b763969ea8abf39ae908584c7492b6` | +| Toolchain | `rustc 1.94.1 (e408947bf 2026-03-25)` / `cargo 1.94.1 (29ea6fb6a 2026-03-24)` | +| Host | macOS Apple Silicon (darwin arm64), UTC session date 2026-09-11 | + +The demo captures below ran against a frozen copy (`grainx.used`) of the `cargo build --locked` +artifact, and both files hash to the value above. `cargo test` builds its own variant of the bin +target, so freezing the binary removes ambiguity about which bytes produced the captures. + +## Historical failure (not a current result) + +On 2026-09-11, at commit `5dde108`, the same CLI shape exited 101 with: + +~~~text +thread 'main' (157332) panicked at .../tokio-1.53.1/src/runtime/blocking/shutdown.rs:51:21: +Cannot drop a runtime in a context where blocking is not allowed. ... +~~~ + +The full log is preserved in `demos/http_capture_2026-09-11.txt` and +`demos/verification_2026-09-11.md`. That capture is historical evidence of the defect; it is not +evidence of the fix. + +## Captured CLI runs + +All runs used the frozen binary on loopback only; no internet access is required. The success run +used a real `grainx agent` process as the server; the malformed run used +`python3 -m http.server` serving a syntactically invalid `/metrics` body. + +Result summary: + +| Case | Exit code | Output files | Panic text | +| --- | --- | --- | --- | +| Success against live `grainx agent` | 0 | `r.json` (2045 bytes), `r.csv` (987 bytes) | none | +| Unreachable closed loopback port | 4 | none | none | +| Malformed `/metrics` JSON (HTTP 200) | 4 | none | none | +| `http://127.0.0.1:0` (historical panic repro) | 4 | none | none | + +### Success + +~~~bash +port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') +target/debug/grainx agent --bind 127.0.0.1 --port "$port" & +target/debug/grainx export --remote "http://127.0.0.1:$port" --json r.json --csv r.csv +~~~ + +Observed stdout: empty. Observed stderr: + +~~~text +Exported stats to r.json and r.csv +~~~ + +Both files parse. The JSON keeps byte counts (per-interval deltas) and does not rename them to +rates: + +~~~text +json network_rx_bytes=3072 network_tx_bytes=2048 +json keys containing 'rate': [] +system,network_rx_bytes,3072 +system,network_tx_bytes,2048 +~~~ + +(Values are per-run deltas from the agent and vary between runs; the field names and integer-byte +semantics do not.) + +### Unreachable endpoint + +The port number comes from a bind/drop probe and is not listening: + +~~~bash +target/debug/grainx export --remote "http://127.0.0.1:$closed_port" --json r.json --csv r.csv +~~~ + +Observed exit code: `4`. stderr: + +~~~text +Error: remote metrics: remote metrics: failed to fetch remote metrics from http://127.0.0.1:50320/metrics: error sending request for url (http://127.0.0.1:50320/metrics) +~~~ + +No `r.json` was written. + +### Malformed response + +The server returned HTTP 200 with body `{"cpu_usage_percent": "not a snapshot"}` at `/metrics`: + +~~~bash +printf '%s' '{"cpu_usage_percent": "not a snapshot"}' > badserver/metrics +python3 -m http.server --bind 127.0.0.1 "$bad_port" --directory badserver & +target/debug/grainx export --remote "http://127.0.0.1:$bad_port" --json r.json --csv r.csv +~~~ + +Observed exit code: `4`. stderr: + +~~~text +Error: remote metrics: remote metrics: invalid metrics JSON: error decoding response body +~~~ + +No `r.json` or `r.csv` was written. + +### Port zero (exact historical repro) + +~~~bash +target/debug/grainx export --remote http://127.0.0.1:0 --json r.json --csv r.csv +~~~ + +Observed exit code: `4`. stderr: + +~~~text +Error: remote metrics: remote metrics: failed to fetch remote metrics from http://127.0.0.1:0/metrics: error sending request for url (http://127.0.0.1:0/metrics) +~~~ + +No `r.json` was written, and no `panicked` or `Cannot drop a runtime` text appears in any capture. + +## Automated CLI coverage + +`tests/cli_remote_export.rs` spawns the compiled binary via `env!("CARGO_BIN_EXE_grainx")` against +a minimal `std::net::TcpListener` server (no extra dependencies, no internet). Four tests: + +1. `remote_export_succeeds_against_a_live_http_server` — exit 0; JSON deserializes as + `StatsSnapshot`; `network_rx_bytes`/`network_tx_bytes` equal the served byte counts; no + `network_*_rate` keys; CSV contains the same byte-count rows and process section. +2. `remote_export_reports_controlled_error_when_endpoint_unreachable` — exit 4, controlled + message, no panic text, no output files. +3. `remote_export_reports_controlled_error_on_malformed_response` — exit 4, `invalid metrics + JSON`, no panic text, no output files. +4. `remote_export_to_port_zero_reports_controlled_error` — the exact historical repro. + +## Repo checks + +Run on the same commit with the committed lockfile: + +~~~text +cargo fmt --check PASS +cargo check --locked --all-targets PASS +cargo clippy --locked --all-targets -- -D warnings PASS +cargo test --locked PASS +~~~ + +`cargo test --locked` result: 45 library tests, 2 `src/main.rs` tests, 4 `cli_remote_export` +tests, 13 `integration_tests` tests — 64 passed, 0 failed, 0 ignored; 0 doctests. + +## Remaining limits + +- The automated tests bind `127.0.0.1` only. No TLS, proxy, redirect, auth, non-200 status, slow + endpoint, or large-body coverage is claimed here. +- The test server answers one request with `Connection: close`; it is a fixture, not a + conformance suite. +- The malformed case covers schema-invalid JSON in an HTTP 200 body. Non-200 handling + (`remote metrics request failed with status ...`) is implemented but not asserted at the CLI + boundary in this record. +- The success capture is a real host snapshot at one instant; values are samples, not benchmarks. + Process names, disk labels, OS/kernel versions, and uptime appear in the generated files and + were not committed. +- `cargo test` builds a slightly different bin artifact than `cargo build`, so the frozen binary + hash above identifies the captured artifact, not necessarily every local `cargo test` run. +- Windows behavior is not tested; CI runs the test suite on ubuntu-latest and an MSRV + `cargo check` on Rust 1.88. diff --git a/tests/cli_remote_export.rs b/tests/cli_remote_export.rs new file mode 100644 index 0000000..f5821ba --- /dev/null +++ b/tests/cli_remote_export.rs @@ -0,0 +1,198 @@ +//! End-to-end tests for `grainx export --remote ` at the real CLI boundary. +//! +//! The binary is executed with `CARGO_BIN_EXE_grainx` against a minimal HTTP +//! server built on `std::net::TcpListener`. No network access is required. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::thread; +use std::time::{SystemTime, UNIX_EPOCH}; + +use grainx::export::StatsSnapshot; + +const BIN: &str = env!("CARGO_BIN_EXE_grainx"); + +const VALID_METRICS: &str = r#"{ + "timestamp": "2026-09-11T00:00:00Z", + "cpu_usage_percent": 12.5, + "memory_used_bytes": 536870912, + "memory_total_bytes": 1073741824, + "memory_usage_percent": 50.0, + "network_rx_bytes": 4096, + "network_tx_bytes": 2048, + "cpu_cores": [10.0, 20.0], + "disks": [{"name": "test-disk", "total_bytes": 1024, "available_bytes": 512, "used_percent": 50.0}], + "os_name": "TestOS", + "kernel_version": "0.0.1-test", + "uptime_seconds": 42, + "processes": [{"pid": 7, "name": "grainx-test", "cpu_usage": 1.5, "memory_bytes": 1048576}] +}"#; + +const MALFORMED_METRICS: &str = r#"{"cpu_usage_percent": "not a snapshot"}"#; + +fn unique_temp_dir(tag: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "grainx-cli-remote-export-{tag}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +/// Serves exactly one `GET` with `body` as the response payload. +fn spawn_metrics_server(body: &'static str) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let address = listener.local_addr().expect("read test server address"); + let _handle = thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut request = [0u8; 1024]; + let _ = stream.read(&mut request); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + address +} + +fn unused_loopback_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("bind probe port") + .local_addr() + .expect("read probe port") + .port() +} + +fn run_export(remote: &str, json: &Path, csv: &Path) -> Output { + Command::new(BIN) + .arg("export") + .arg("--remote") + .arg(remote) + .arg("--json") + .arg(json) + .arg("--csv") + .arg(csv) + .output() + .expect("run grainx export") +} + +fn assert_controlled_remote_failure(output: &Output, expected_error: &str) { + assert_eq!( + output.status.code(), + Some(4), + "expected controlled remote-metrics exit code 4, got {:?}\nstdout: {}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains(expected_error), + "expected controlled error containing {expected_error:?}, got: {combined}" + ); + assert!( + !combined.contains("panicked"), + "panic text in CLI output: {combined}" + ); + assert!( + !combined.contains("Cannot drop a runtime"), + "tokio runtime-drop panic text in CLI output: {combined}" + ); +} + +#[test] +fn remote_export_succeeds_against_a_live_http_server() { + let dir = unique_temp_dir("success"); + let json_path = dir.join("r.json"); + let csv_path = dir.join("r.csv"); + let address = spawn_metrics_server(VALID_METRICS); + + let output = run_export(&format!("http://{address}"), &json_path, &csv_path); + assert!( + output.status.success(), + "expected exit 0, got {:?}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + + let json_text = std::fs::read_to_string(&json_path).expect("json output written"); + let snapshot: StatsSnapshot = serde_json::from_str(&json_text).expect("json output parses"); + assert_eq!(snapshot.os_name, "TestOS"); + assert_eq!(snapshot.network_rx_bytes, 4096); + assert_eq!(snapshot.network_tx_bytes, 2048); + assert_eq!(snapshot.processes[0].name, "grainx-test"); + + let raw: serde_json::Value = serde_json::from_str(&json_text).expect("json output parses"); + assert!( + raw.get("network_rx_rate").is_none() && raw.get("network_tx_rate").is_none(), + "network fields must stay byte counts, not rates: {json_text}" + ); + + let csv_text = std::fs::read_to_string(&csv_path).expect("csv output written"); + assert!(csv_text.starts_with("section,field,value")); + assert!(csv_text.contains("system,network_rx_bytes,4096")); + assert!(csv_text.contains("system,network_tx_bytes,2048")); + assert!(csv_text.contains("pid,name,cpu_usage_percent,memory_bytes")); + assert!(csv_text.contains("7,grainx-test,1.50,1048576")); + + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn remote_export_reports_controlled_error_when_endpoint_unreachable() { + let dir = unique_temp_dir("unreachable"); + let json_path = dir.join("r.json"); + let csv_path = dir.join("r.csv"); + let port = unused_loopback_port(); + + let output = run_export(&format!("http://127.0.0.1:{port}"), &json_path, &csv_path); + assert_controlled_remote_failure(&output, "failed to fetch remote metrics"); + assert!( + !json_path.exists(), + "no json output on unreachable endpoint" + ); + + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn remote_export_reports_controlled_error_on_malformed_response() { + let dir = unique_temp_dir("malformed"); + let json_path = dir.join("r.json"); + let csv_path = dir.join("r.csv"); + let address = spawn_metrics_server(MALFORMED_METRICS); + + let output = run_export(&format!("http://{address}"), &json_path, &csv_path); + assert_controlled_remote_failure(&output, "invalid metrics JSON"); + assert!(!json_path.exists(), "no json output on malformed response"); + assert!(!csv_path.exists(), "no csv output on malformed response"); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// Port 0 is the exact historical repro of the tokio runtime-drop panic. +#[test] +fn remote_export_to_port_zero_reports_controlled_error() { + let dir = unique_temp_dir("port-zero"); + let json_path = dir.join("r.json"); + let csv_path = dir.join("r.csv"); + + let output = run_export("http://127.0.0.1:0", &json_path, &csv_path); + assert_controlled_remote_failure(&output, "failed to fetch remote metrics"); + assert!(!json_path.exists(), "no json output on port zero"); + + std::fs::remove_dir_all(&dir).ok(); +}