1use std::fs::{self, OpenOptions};
7use std::io::{self, Read, Write};
8use std::net::{SocketAddr, TcpListener, TcpStream};
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, SyncSender};
12use std::sync::{Arc, OnceLock};
13use std::thread::{self, JoinHandle};
14use std::time::Duration;
15
16use serde::Serialize;
17use serde_json::{Value, json};
18
19const PROTOCOL_VERSION: u32 = 1;
20const MAX_REQUEST_BYTES: usize = 1024 * 1024;
21const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
22
23#[derive(Debug, Clone)]
25pub struct ServerConfig {
26 pub bind_address: SocketAddr,
28 pub descriptor_path: PathBuf,
30 pub renderer_revision: String,
32}
33
34#[derive(Clone, Default)]
47pub struct ServiceWaker(Arc<OnceLock<Box<dyn Fn() + Send + Sync>>>);
48
49impl ServiceWaker {
50 pub fn set(&self, wake: impl Fn() + Send + Sync + 'static) {
52 let _ = self.0.set(Box::new(wake));
53 }
54
55 fn wake(&self) {
56 if let Some(wake) = self.0.get() {
57 wake();
58 }
59 }
60}
61
62impl std::fmt::Debug for ServiceWaker {
63 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 formatter
65 .debug_struct("ServiceWaker")
66 .field("installed", &self.0.get().is_some())
67 .finish()
68 }
69}
70
71#[derive(Debug)]
73pub struct ControlRequest {
74 pub method: String,
75 pub path: String,
76 pub body: Value,
77 reply: SyncSender<ControlResponse>,
78}
79
80impl ControlRequest {
81 pub fn respond(self, response: ControlResponse) -> Result<(), ControlResponse> {
83 self.reply.send(response).map_err(|error| error.0)
84 }
85}
86
87#[derive(Debug)]
89pub enum ControlResponse {
90 Success(Value),
91 Error {
92 error: String,
93 message: String,
94 stacktrace: String,
95 },
96}
97
98impl ControlResponse {
99 pub fn unsupported(message: impl Into<String>) -> Self {
100 Self::Error {
101 error: "unsupported operation".into(),
102 message: message.into(),
103 stacktrace: String::new(),
104 }
105 }
106}
107
108#[derive(Debug, Serialize)]
109#[serde(rename_all = "camelCase")]
110struct Descriptor<'a> {
111 pid: u32,
112 address: String,
113 token: &'a str,
114 protocol_version: u32,
115 renderer: &'static str,
116 renderer_revision: &'a str,
117}
118
119pub struct DebugServer {
121 address: SocketAddr,
122 token: String,
123 descriptor_path: PathBuf,
124 shutdown: Arc<AtomicBool>,
125 waker: ServiceWaker,
126 thread: Option<JoinHandle<()>>,
127}
128
129impl DebugServer {
130 pub fn start(config: ServerConfig) -> io::Result<(Self, Receiver<ControlRequest>)> {
132 if !config.bind_address.ip().is_loopback() {
133 return Err(io::Error::new(
134 io::ErrorKind::InvalidInput,
135 "debug control must bind to a loopback address",
136 ));
137 }
138 let listener = TcpListener::bind(config.bind_address)?;
139 let address = listener.local_addr()?;
140 let token = random_hex(32)?;
141 let (command_tx, command_rx) = mpsc::channel();
146 let shutdown = Arc::new(AtomicBool::new(false));
147 let waker = ServiceWaker::default();
148
149 let thread_shutdown = Arc::clone(&shutdown);
150 let thread_token = token.clone();
151 let thread_waker = waker.clone();
152 let thread = thread::Builder::new()
153 .name("blitz-debug-control".into())
154 .spawn(move || {
155 server_loop(
156 listener,
157 &thread_token,
158 command_tx,
159 &thread_waker,
160 thread_shutdown,
161 )
162 })?;
163
164 if let Err(error) = write_descriptor(&config, address, &token) {
165 shutdown.store(true, Ordering::Release);
166 let _ = TcpStream::connect(address);
167 let _ = thread.join();
168 return Err(error);
169 }
170
171 Ok((
172 Self {
173 address,
174 token,
175 descriptor_path: config.descriptor_path,
176 shutdown,
177 waker,
178 thread: Some(thread),
179 },
180 command_rx,
181 ))
182 }
183
184 pub fn waker(&self) -> ServiceWaker {
187 self.waker.clone()
188 }
189
190 pub fn address(&self) -> SocketAddr {
191 self.address
192 }
193
194 pub fn token(&self) -> &str {
195 &self.token
196 }
197
198 pub fn shutdown(mut self) {
199 self.stop();
200 }
201
202 fn stop(&mut self) {
203 self.shutdown.store(true, Ordering::Release);
204 let _ = TcpStream::connect(self.address);
205 if let Some(thread) = self.thread.take() {
206 let _ = thread.join();
207 }
208 let _ = fs::remove_file(&self.descriptor_path);
209 }
210}
211
212impl Drop for DebugServer {
213 fn drop(&mut self) {
214 self.stop();
215 }
216}
217
218fn random_hex(byte_len: usize) -> io::Result<String> {
219 let mut bytes = vec![0; byte_len];
220 getrandom::fill(&mut bytes).map_err(io::Error::other)?;
221 let mut output = String::with_capacity(byte_len * 2);
222 for byte in bytes {
223 use std::fmt::Write as _;
224 write!(output, "{byte:02x}").unwrap();
225 }
226 Ok(output)
227}
228
229fn write_descriptor(config: &ServerConfig, address: SocketAddr, token: &str) -> io::Result<()> {
230 let descriptor = Descriptor {
231 pid: std::process::id(),
232 address: address.to_string(),
233 token,
234 protocol_version: PROTOCOL_VERSION,
235 renderer: "blitz",
236 renderer_revision: &config.renderer_revision,
237 };
238 let bytes = serde_json::to_vec_pretty(&descriptor).map_err(io::Error::other)?;
239 if let Some(parent) = config.descriptor_path.parent() {
240 fs::create_dir_all(parent)?;
241 }
242 let temporary = config
243 .descriptor_path
244 .with_extension(format!("tmp-{}", random_hex(8)?));
245 let result = (|| {
246 let mut options = OpenOptions::new();
247 options.write(true).create_new(true);
248 #[cfg(unix)]
249 {
250 use std::os::unix::fs::OpenOptionsExt;
251 options.mode(0o600);
252 }
253 let mut file = options.open(&temporary)?;
254 file.write_all(&bytes)?;
255 file.sync_all()?;
256 fs::rename(&temporary, &config.descriptor_path)
257 })();
258 if result.is_err() {
259 let _ = fs::remove_file(&temporary);
260 }
261 result
262}
263
264fn server_loop(
265 listener: TcpListener,
266 token: &str,
267 command_tx: Sender<ControlRequest>,
268 waker: &ServiceWaker,
269 shutdown: Arc<AtomicBool>,
270) {
271 let mut active_session: Option<String> = None;
272 for connection in listener.incoming() {
273 if shutdown.load(Ordering::Acquire) {
274 break;
275 }
276 match connection {
277 Ok(mut stream) => {
278 let _ = stream.set_read_timeout(Some(COMMAND_TIMEOUT));
279 let _ = stream.set_write_timeout(Some(COMMAND_TIMEOUT));
280 let response = match read_request(&mut stream) {
281 Ok(request) => route(request, token, &mut active_session, &command_tx, waker),
282 Err(error) => webdriver_error("invalid argument", error.to_string()),
283 };
284 let _ = write_response(&mut stream, response);
285 }
286 Err(_) if shutdown.load(Ordering::Acquire) => break,
287 Err(_) => continue,
288 }
289 }
290}
291
292#[derive(Debug)]
293struct HttpRequest {
294 method: String,
295 path: String,
296 body: Value,
297}
298
299fn read_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
300 let mut bytes = Vec::with_capacity(4096);
301 let header_end = loop {
302 if bytes.len() >= MAX_REQUEST_BYTES {
303 return Err(io::Error::new(
304 io::ErrorKind::InvalidData,
305 "request is too large",
306 ));
307 }
308 let mut chunk = [0; 4096];
309 let count = stream.read(&mut chunk)?;
310 if count == 0 {
311 return Err(io::Error::new(
312 io::ErrorKind::UnexpectedEof,
313 "connection closed before headers",
314 ));
315 }
316 bytes.extend_from_slice(&chunk[..count]);
317 if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
318 break index + 4;
319 }
320 };
321
322 let headers = std::str::from_utf8(&bytes[..header_end])
323 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
324 let mut lines = headers.split("\r\n");
325 let mut request_line = lines.next().unwrap_or_default().split_whitespace();
326 let method = request_line.next().unwrap_or_default().to_string();
327 let path = request_line.next().unwrap_or_default().to_string();
328 if method.is_empty() || path.is_empty() {
329 return Err(io::Error::new(
330 io::ErrorKind::InvalidData,
331 "invalid request line",
332 ));
333 }
334 let content_length = lines
335 .filter_map(|line| line.split_once(':'))
336 .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
337 .map(|(_, value)| value.trim().parse::<usize>())
338 .transpose()
339 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
340 .unwrap_or(0);
341 if header_end + content_length > MAX_REQUEST_BYTES {
342 return Err(io::Error::new(
343 io::ErrorKind::InvalidData,
344 "request is too large",
345 ));
346 }
347 while bytes.len() < header_end + content_length {
348 let remaining = header_end + content_length - bytes.len();
349 let mut chunk = vec![0; remaining.min(4096)];
350 let count = stream.read(&mut chunk)?;
351 if count == 0 {
352 return Err(io::Error::new(
353 io::ErrorKind::UnexpectedEof,
354 "connection closed before body",
355 ));
356 }
357 bytes.extend_from_slice(&chunk[..count]);
358 }
359 let body = if content_length == 0 {
360 Value::Null
361 } else {
362 serde_json::from_slice(&bytes[header_end..header_end + content_length])
363 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
364 };
365 Ok(HttpRequest { method, path, body })
366}
367
368fn route(
369 request: HttpRequest,
370 token: &str,
371 active_session: &mut Option<String>,
372 command_tx: &Sender<ControlRequest>,
373 waker: &ServiceWaker,
374) -> Value {
375 if request.method == "GET" && request.path == "/status" {
376 return json!({"value": {
377 "ready": true,
378 "message": "Blitz debug control is ready",
379 "protocolVersion": PROTOCOL_VERSION,
380 }});
381 }
382
383 if request.method == "POST" && request.path == "/session" {
384 if active_session.is_some() {
385 return webdriver_error("session not created", "only one session is supported");
386 }
387 let supplied_token = request
388 .body
389 .pointer("/capabilities/alwaysMatch/blitz:token")
390 .and_then(Value::as_str);
391 if supplied_token != Some(token) {
392 return webdriver_error("invalid argument", "invalid blitz:token capability");
393 }
394 let session_id = match random_hex(16) {
395 Ok(value) => value,
396 Err(error) => return webdriver_error("unknown error", error.to_string()),
397 };
398 *active_session = Some(session_id.clone());
399 return json!({"value": {
400 "sessionId": session_id,
401 "capabilities": {
402 "browserName": "blitz",
403 "blitz:protocolVersion": PROTOCOL_VERSION,
404 }
405 }});
406 }
407
408 let Some((session_id, command_path)) = session_path(&request.path) else {
409 return webdriver_error("unknown command", "unknown debug-control route");
410 };
411 if active_session.as_deref() != Some(session_id) {
412 return webdriver_error("invalid session id", "session is not active");
413 }
414 if request.method == "DELETE" && command_path.is_empty() {
415 *active_session = None;
416 return json!({"value": null});
417 }
418
419 let (reply_tx, reply_rx) = mpsc::sync_channel(1);
420 let control_request = ControlRequest {
421 method: request.method,
422 path: command_path.to_string(),
423 body: request.body,
424 reply: reply_tx,
425 };
426 if command_tx.send(control_request).is_err() {
427 return webdriver_error("unknown error", "renderer command channel is closed");
428 }
429 waker.wake();
432 match reply_rx.recv_timeout(COMMAND_TIMEOUT) {
433 Ok(ControlResponse::Success(value)) => json!({"value": value}),
434 Ok(ControlResponse::Error {
435 error,
436 message,
437 stacktrace,
438 }) => json!({"value": {
439 "error": error,
440 "message": message,
441 "stacktrace": stacktrace,
442 }}),
443 Err(RecvTimeoutError::Timeout) => webdriver_error("timeout", "renderer command timed out"),
444 Err(RecvTimeoutError::Disconnected) => {
445 webdriver_error("unknown error", "renderer response channel is closed")
446 }
447 }
448}
449
450fn session_path(path: &str) -> Option<(&str, &str)> {
451 let remainder = path.strip_prefix("/session/")?;
452 let (session_id, command) = remainder.split_once('/').unwrap_or((remainder, ""));
453 Some((session_id, command))
454}
455
456fn webdriver_error(error: &str, message: impl Into<String>) -> Value {
457 json!({"value": {
458 "error": error,
459 "message": message.into(),
460 "stacktrace": "",
461 }})
462}
463
464fn write_response(stream: &mut TcpStream, body: Value) -> io::Result<()> {
465 let bytes = serde_json::to_vec(&body).map_err(io::Error::other)?;
466 write!(
467 stream,
468 "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
469 bytes.len()
470 )?;
471 stream.write_all(&bytes)
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use std::sync::Mutex;
478 use std::time::{SystemTime, UNIX_EPOCH};
479
480 fn descriptor_path() -> PathBuf {
481 let nonce = SystemTime::now()
482 .duration_since(UNIX_EPOCH)
483 .unwrap()
484 .as_nanos();
485 std::env::temp_dir().join(format!("blitz-debug-{nonce}.json"))
486 }
487
488 fn request(address: SocketAddr, method: &str, path: &str, body: Value) -> Value {
489 let body = if body.is_null() {
490 Vec::new()
491 } else {
492 serde_json::to_vec(&body).unwrap()
493 };
494 let mut stream = TcpStream::connect(address).unwrap();
495 write!(
496 stream,
497 "{method} {path} HTTP/1.1\r\nHost: {address}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
498 body.len()
499 )
500 .unwrap();
501 stream.write_all(&body).unwrap();
502 let mut response = Vec::new();
503 stream.read_to_end(&mut response).unwrap();
504 let body_start = response
505 .windows(4)
506 .position(|window| window == b"\r\n\r\n")
507 .unwrap()
508 + 4;
509 serde_json::from_slice(&response[body_start..]).unwrap()
510 }
511
512 fn create_session(address: SocketAddr, token: &str) -> String {
513 request(
514 address,
515 "POST",
516 "/session",
517 json!({"capabilities": {"alwaysMatch": {"blitz:token": token}}}),
518 )["value"]["sessionId"]
519 .as_str()
520 .unwrap()
521 .to_string()
522 }
523
524 #[test]
525 fn status_auth_session_command_and_reconnect() {
526 let descriptor = descriptor_path();
527 let (server, commands) = DebugServer::start(ServerConfig {
528 bind_address: (std::net::Ipv4Addr::LOCALHOST, 0).into(),
529 descriptor_path: descriptor.clone(),
530 renderer_revision: "test-revision".into(),
531 })
532 .unwrap();
533
534 let status = request(server.address(), "GET", "/status", Value::Null);
535 assert_eq!(status["value"]["ready"], true);
536 assert!(descriptor.exists());
537 #[cfg(unix)]
538 {
539 use std::os::unix::fs::PermissionsExt;
540 assert_eq!(
541 fs::metadata(&descriptor).unwrap().permissions().mode() & 0o777,
542 0o600
543 );
544 }
545
546 let rejected = request(
547 server.address(),
548 "POST",
549 "/session",
550 json!({"capabilities": {"alwaysMatch": {"blitz:token": "wrong"}}}),
551 );
552 assert_eq!(rejected["value"]["error"], "invalid argument");
553
554 let session = create_session(server.address(), server.token());
555 let address = server.address();
556 let command_path = format!("/session/{session}/blitz/getDomSnapshot");
557 let client = thread::spawn(move || request(address, "GET", &command_path, Value::Null));
558 let command = commands.recv_timeout(COMMAND_TIMEOUT).unwrap();
559 assert_eq!(command.method, "GET");
560 assert_eq!(command.path, "blitz/getDomSnapshot");
561 command
562 .respond(ControlResponse::Success(json!({"documentRevision": 7})))
563 .unwrap();
564 let response = client.join().unwrap();
565 assert_eq!(response["value"]["documentRevision"], 7);
566
567 let deleted = request(
568 server.address(),
569 "DELETE",
570 &format!("/session/{session}"),
571 Value::Null,
572 );
573 assert!(deleted["value"].is_null());
574 let second_session = create_session(server.address(), server.token());
575 assert_ne!(second_session, session);
576
577 server.shutdown();
578 assert!(!descriptor.exists());
579 }
580
581 #[test]
582 fn rejects_non_loopback_bind_address() {
583 let result = DebugServer::start(ServerConfig {
584 bind_address: ([0, 0, 0, 0], 0).into(),
585 descriptor_path: descriptor_path(),
586 renderer_revision: "test-revision".into(),
587 });
588 assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::InvalidInput));
589 }
590
591 fn snapshot_request() -> HttpRequest {
592 HttpRequest {
593 method: "GET".into(),
594 path: "/session/test-session/blitz/getDomSnapshot".into(),
595 body: Value::Null,
596 }
597 }
598
599 #[test]
607 fn wakes_the_servicer_with_the_request_already_queued() {
608 let (command_tx, command_rx) = mpsc::channel::<ControlRequest>();
609 let waker = ServiceWaker::default();
610 let queue = Mutex::new(command_rx);
611 waker.set(move || {
612 let queued = queue.lock().unwrap().try_recv().unwrap();
613 assert_eq!(queued.path, "blitz/getDomSnapshot");
614 queued
615 .respond(ControlResponse::Success(json!({"serviced": true})))
616 .unwrap();
617 });
618
619 let mut active_session = Some("test-session".to_string());
620 let response = route(
621 snapshot_request(),
622 "token",
623 &mut active_session,
624 &command_tx,
625 &waker,
626 );
627
628 assert_eq!(response["value"]["serviced"], true);
629 }
630
631 #[test]
637 fn a_second_request_queues_behind_an_unserviced_one() {
638 let (command_tx, command_rx) = mpsc::channel::<ControlRequest>();
639 let (reply_tx, _reply_rx) = mpsc::sync_channel(1);
640 command_tx
641 .send(ControlRequest {
642 method: "GET".into(),
643 path: "occupied".into(),
644 body: Value::Null,
645 reply: reply_tx,
646 })
647 .unwrap();
648
649 let waker = ServiceWaker::default();
650 let queue = Mutex::new(command_rx);
651 waker.set(move || {
652 let queue = queue.lock().unwrap();
653 assert_eq!(queue.try_recv().unwrap().path, "occupied");
654 queue
655 .try_recv()
656 .unwrap()
657 .respond(ControlResponse::Success(json!({"serviced": true})))
658 .unwrap();
659 });
660
661 let mut active_session = Some("test-session".to_string());
662 let response = route(
663 snapshot_request(),
664 "token",
665 &mut active_session,
666 &command_tx,
667 &waker,
668 );
669
670 assert_eq!(response["value"]["serviced"], true);
671 }
672
673 #[test]
676 fn queues_requests_while_no_waker_is_installed() {
677 let (command_tx, command_rx) = mpsc::channel::<ControlRequest>();
678 let waker = ServiceWaker::default();
679 let mut active_session = Some("test-session".to_string());
680
681 let sender = command_tx.clone();
682 let servicer = thread::spawn(move || {
683 let queued = command_rx.recv_timeout(COMMAND_TIMEOUT).unwrap();
684 drop(sender);
685 queued
686 .respond(ControlResponse::Success(json!({"serviced": true})))
687 .unwrap();
688 });
689
690 let response = route(
691 snapshot_request(),
692 "token",
693 &mut active_session,
694 &command_tx,
695 &waker,
696 );
697
698 servicer.join().unwrap();
699 assert_eq!(response["value"]["serviced"], true);
700 }
701}