Skip to main content

concinnity_dev/debug/wire/
client.rs

1//! The client half of the runtime debug protocol: a thin localhost WebSocket
2//! client the `concinnity debug <subcommand>` commands drive to talk to a
3//! running `cn debug` server (see `super::server` for the other end).
4//!
5//! One TCP connection per request: the server answers each request on its own
6//! thread and the requests are tiny, so a fresh connection per command is
7//! simpler and more robust than holding a persistent socket. Every connect and
8//! read is bounded by a timeout, so a gone or wedged server surfaces a clear
9//! error instead of hanging. The socket-free helpers these commands use
10//! (payload validation, reply inspection, the watch-target enum) live in
11//! `super::super::protocol`, where they are unit-tested directly.
12//!
13//! Subcommands:
14//!   send `<json>`      send one raw JSON command (with its own "cmd" field)
15//!                      and print the reply; the escape hatch for any command
16//!                      the typed helpers below do not cover
17//!   screenshot `<path>`  capture the last presented frame to a PNG
18//!   watch `<target>`     poll a read-only snapshot and print it until Ctrl-C
19
20use std::net::{Ipv4Addr, SocketAddr, TcpStream};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::time::Duration;
24
25use serde_json::{Value, json};
26use tokio_tungstenite::tungstenite::{self, Message};
27
28use crate::debug::protocol::{WatchTarget, reply_ok, validate_payload};
29
30// Bound every connect so a missing server fails fast instead of hanging.
31const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
32// Bound every read/write so a wedged server surfaces a timeout, not a hang.
33const IO_TIMEOUT: Duration = Duration::from_secs(5);
34
35// Exit code for a transport- or protocol-level failure (cannot reach the
36// server, connection dropped, malformed reply). Distinct from a logical
37// failure (a well-formed `{"ok":false}` reply) so callers can tell "the
38// server was unreachable" apart from "the command was rejected".
39const EXIT_TRANSPORT: i32 = 3;
40
41type WsStream = tungstenite::WebSocket<TcpStream>;
42
43// Open a WebSocket connection to the localhost debug server on `port`.
44//
45// Uses an explicit connect timeout and read/write timeouts on the underlying
46// socket so a dead or unresponsive server produces a clear error rather than
47// blocking forever. A handshake that cannot complete within the read timeout
48// is reported as a timeout (a live server answers the handshake immediately).
49fn connect(port: u16) -> Result<WsStream, String> {
50    let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
51    let stream = TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).map_err(|e| {
52        format!("cannot connect to ws://127.0.0.1:{port}: {e} (is `cn debug` running?)")
53    })?;
54    stream
55        .set_read_timeout(Some(IO_TIMEOUT))
56        .and_then(|()| stream.set_write_timeout(Some(IO_TIMEOUT)))
57        .map_err(|e| format!("cannot configure socket timeouts: {e}"))?;
58
59    let url = format!("ws://127.0.0.1:{port}/");
60    match tungstenite::client::client(url.as_str(), stream) {
61        Ok((ws, _resp)) => Ok(ws),
62        Err(tungstenite::HandshakeError::Failure(e)) => {
63            Err(format!("websocket handshake failed on port {port}: {e}"))
64        }
65        // A blocking-with-timeout socket reports a stalled handshake as
66        // WouldBlock, which tungstenite surfaces as `Interrupted`. Treat it as
67        // a timeout rather than retrying (a retry would just block again).
68        Err(tungstenite::HandshakeError::Interrupted(_)) => Err(format!(
69            "websocket handshake timed out on port {port} (>{}s)",
70            IO_TIMEOUT.as_secs()
71        )),
72    }
73}
74
75// Read WebSocket messages until a text frame arrives, returning its payload.
76fn read_text(ws: &mut WsStream) -> Result<String, String> {
77    loop {
78        match ws.read() {
79            Ok(Message::Text(text)) => return Ok(text.to_string()),
80            // The server never pings, but answer one anyway to be well-behaved.
81            Ok(Message::Ping(payload)) => {
82                let _ = ws.send(Message::Pong(payload));
83            }
84            Ok(Message::Close(_)) => return Err("server closed the connection".to_string()),
85            // Binary / Pong / continuation frames are never sent by the server.
86            Ok(_) => {}
87            Err(e) => return Err(map_read_error(e)),
88        }
89    }
90}
91
92// Turn a read error into a message that names a timeout as such.
93fn map_read_error(e: tungstenite::Error) -> String {
94    if let tungstenite::Error::Io(io) = &e
95        && matches!(
96            io.kind(),
97            std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
98        )
99    {
100        return format!("timed out waiting for reply (>{}s)", IO_TIMEOUT.as_secs());
101    }
102    format!("read failed: {e}")
103}
104
105// Send one JSON request and return the parsed reply.
106fn request(port: u16, payload: &str) -> Result<Value, String> {
107    let mut ws = connect(port)?;
108    ws.send(Message::text(payload))
109        .map_err(|e| format!("failed to send request: {e}"))?;
110    let reply = read_text(&mut ws)?;
111    // Best-effort clean close so the server logs a tidy disconnect.
112    let _ = ws.close(None);
113    let _ = ws.flush();
114    serde_json::from_str(&reply).map_err(|e| format!("malformed reply from server: {e}"))
115}
116
117// Send a bare `{"cmd": <name>}` request and return the parsed reply.
118fn request_cmd(port: u16, cmd: &str) -> Result<Value, String> {
119    request(port, &json!({ "cmd": cmd }).to_string())
120}
121
122// Print a JSON value to stdout, pretty-printed when possible.
123fn print_reply(reply: &Value) {
124    println!(
125        "{}",
126        serde_json::to_string_pretty(reply).unwrap_or_else(|_| reply.to_string())
127    );
128}
129
130// Print a transport error to stderr and exit with the transport code.
131fn fail_transport(msg: &str) -> ! {
132    eprintln!("cn debug: {msg}");
133    std::process::exit(EXIT_TRANSPORT);
134}
135
136/// `concinnity debug send <json>`: send one raw JSON command and print the
137/// reply. Exits 0 only when the server answered `"ok": true`, so it composes in
138/// shell `&&` chains; a rejected command exits 1 and a transport failure exits 3.
139pub fn send(port: u16, json: &str) -> std::io::Result<()> {
140    let payload = match validate_payload(json) {
141        Ok(p) => p,
142        Err(msg) => {
143            eprintln!("cn debug send: {msg}");
144            std::process::exit(EXIT_TRANSPORT);
145        }
146    };
147    let reply = request(port, &payload).unwrap_or_else(|msg| fail_transport(&msg));
148    print_reply(&reply);
149    if reply_ok(&reply) {
150        Ok(())
151    } else {
152        std::process::exit(1);
153    }
154}
155
156/// `concinnity debug screenshot <path>`: capture the last presented frame to a
157/// PNG. Resolves `path` to an absolute path so the file lands where the caller
158/// expects regardless of the engine's working directory. Exits 0 only on success.
159pub fn screenshot(port: u16, path: &str) -> std::io::Result<()> {
160    let abs = std::path::absolute(path).unwrap_or_else(|_| std::path::PathBuf::from(path));
161    let abs = abs.to_string_lossy().to_string();
162    let reply = request(
163        port,
164        &json!({ "cmd": "screenshot", "path": abs }).to_string(),
165    )
166    .unwrap_or_else(|msg| fail_transport(&msg));
167    if reply_ok(&reply) {
168        let saved = reply.get("path").and_then(Value::as_str).unwrap_or(&abs);
169        println!("[screenshot] saved: {saved}");
170        Ok(())
171    } else {
172        let err = reply
173            .get("error")
174            .and_then(Value::as_str)
175            .unwrap_or("unknown error");
176        eprintln!("[screenshot] failed: {err}");
177        std::process::exit(1);
178    }
179}
180
181// Sleep for `total_ms`, but in short chunks so a Ctrl-C flag flip is noticed
182// promptly instead of after a full interval.
183fn sleep_interruptible(total_ms: u64, running: &AtomicBool) {
184    let mut left = total_ms;
185    while left > 0 && running.load(Ordering::SeqCst) {
186        let chunk = left.min(100);
187        std::thread::sleep(Duration::from_millis(chunk));
188        left -= chunk;
189    }
190}
191
192/// `concinnity debug watch <target>`: poll a read-only snapshot every
193/// `interval_ms` and print each reply until Ctrl-C. A connection failure on the
194/// very first poll is fatal (exit 3) -- there is nothing to watch; later
195/// failures are printed and retried, so a server restart mid-session recovers.
196pub fn watch(port: u16, target: WatchTarget, interval_ms: u64) -> std::io::Result<()> {
197    let running = Arc::new(AtomicBool::new(true));
198    let flag = Arc::clone(&running);
199    // If a handler is already installed the loop still exits on process signal,
200    // so an error here is not fatal.
201    let _ = ctrlc::set_handler(move || flag.store(false, Ordering::SeqCst));
202
203    let cmd = target.cmd();
204    println!(
205        "[watch] polling {} on ws://127.0.0.1:{port} every {interval_ms}ms (Ctrl-C to stop)",
206        target.label()
207    );
208
209    let mut ever_ok = false;
210    while running.load(Ordering::SeqCst) {
211        match request_cmd(port, cmd) {
212            Ok(reply) => {
213                ever_ok = true;
214                print_reply(&reply);
215            }
216            Err(msg) => {
217                if !ever_ok {
218                    fail_transport(&msg);
219                }
220                eprintln!("[watch] {msg}");
221            }
222        }
223        sleep_interruptible(interval_ms, &running);
224    }
225    println!();
226    Ok(())
227}
228
229#[cfg(test)]
230mod tests {
231    use std::time::Instant;
232
233    use super::*;
234
235    // Candidate localhost ports for the "server is not running" tests. They sit
236    // below every platform's ephemeral range so `bind(:0)` never hands one out,
237    // avoiding the race where a dropped ephemeral port is reassigned to a
238    // parallel test that then listens on it. Same rationale as the CLI's
239    // cli.rs.
240    const DEAD_PORT_CANDIDATES: [u16; 4] = [28474, 28475, 28476, 28477];
241
242    // The first candidate that promptly refuses a connection (nothing
243    // listening), or `None` when the host refuses none. Callers skip when
244    // `None`: the paths they assert are platform-independent and still run on
245    // hosts where the connection is refused.
246    fn find_dead_port() -> Option<u16> {
247        DEAD_PORT_CANDIDATES
248            .into_iter()
249            .find(|&port| connect_refused(port))
250    }
251
252    // True when connecting to `127.0.0.1:port` is refused, i.e. nothing is
253    // listening. A connect that instead times out (a dropped SYN with no RST, as
254    // some firewalled loopback stacks do) returns false: the port is unused but
255    // not promptly refused, so it is not a usable dead port and the caller skips
256    // rather than wait out the multi-second connect timeout.
257    fn connect_refused(port: u16) -> bool {
258        let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
259        matches!(
260            TcpStream::connect_timeout(&addr, Duration::from_millis(250)),
261            Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused
262        )
263    }
264
265    // Stable Rust has no runtime "skipped" test state, so a test whose host
266    // precondition is unmet prints this and returns (counting as passed).
267    // Visible under `cargo test -- --nocapture`.
268    fn skip_no_dead_port(test: &str) {
269        eprintln!("[skip] {test}: no localhost port refuses connections on this host");
270    }
271
272    #[test]
273    fn connect_to_dead_port_errors_fast() {
274        let Some(port) = find_dead_port() else {
275            return skip_no_dead_port("connect_to_dead_port_errors_fast");
276        };
277        // Connecting to a port nothing listens on must fail promptly with a
278        // clear message rather than hanging.
279        let start = Instant::now();
280        let err = connect(port).expect_err("connect to a dead port must fail");
281        assert!(
282            start.elapsed() < CONNECT_TIMEOUT,
283            "a refused connection should fail well before the connect timeout"
284        );
285        assert!(err.contains("cannot connect"), "unexpected error: {err}");
286    }
287
288    #[test]
289    fn request_to_dead_port_errors() {
290        let Some(port) = find_dead_port() else {
291            return skip_no_dead_port("request_to_dead_port_errors");
292        };
293        assert!(request_cmd(port, "state").is_err());
294    }
295}