concinnity_dev/debug/wire/
client.rs1use 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
30const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
32const IO_TIMEOUT: Duration = Duration::from_secs(5);
34
35const EXIT_TRANSPORT: i32 = 3;
40
41type WsStream = tungstenite::WebSocket<TcpStream>;
42
43fn 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 Err(tungstenite::HandshakeError::Interrupted(_)) => Err(format!(
69 "websocket handshake timed out on port {port} (>{}s)",
70 IO_TIMEOUT.as_secs()
71 )),
72 }
73}
74
75fn 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 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 Ok(_) => {}
87 Err(e) => return Err(map_read_error(e)),
88 }
89 }
90}
91
92fn 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
105fn 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 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
117fn request_cmd(port: u16, cmd: &str) -> Result<Value, String> {
119 request(port, &json!({ "cmd": cmd }).to_string())
120}
121
122fn print_reply(reply: &Value) {
124 println!(
125 "{}",
126 serde_json::to_string_pretty(reply).unwrap_or_else(|_| reply.to_string())
127 );
128}
129
130fn fail_transport(msg: &str) -> ! {
132 eprintln!("cn debug: {msg}");
133 std::process::exit(EXIT_TRANSPORT);
134}
135
136pub 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
156pub 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
181fn 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
192pub 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 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 const DEAD_PORT_CANDIDATES: [u16; 4] = [28474, 28475, 28476, 28477];
241
242 fn find_dead_port() -> Option<u16> {
247 DEAD_PORT_CANDIDATES
248 .into_iter()
249 .find(|&port| connect_refused(port))
250 }
251
252 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 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 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}