Skip to main content

ps_blitz_debug_control/
lib.rs

1//! Debug-only, loopback WebDriver transport for Blitz renderers.
2//!
3//! The server owns networking and session authentication. Renderer commands
4//! cross a serialized channel and must be executed by the UI/runtime thread.
5//!
6//! # Security boundary
7//!
8//! Enabling this server grants the holder of its per-process discovery token
9//! debugger-level control, including arbitrary JavaScript execution in the
10//! document. It deliberately has the same trust posture as a browser remote
11//! debugging port: loopback-only transport, an unpredictable token, and a
12//! descriptor created with owner-only permissions on Unix. It is not a sandbox
13//! or an authorization boundary against another process running as the same
14//! OS user and able to read that user's files. Production builds should leave
15//! the feature and its environment variables disabled.
16
17use std::fs::{self, OpenOptions};
18use std::io::{self, Read, Write};
19use std::net::{SocketAddr, TcpListener, TcpStream};
20use std::path::PathBuf;
21use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
22use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError};
23use std::sync::{Arc, OnceLock};
24use std::thread::{self, JoinHandle};
25use std::time::{Duration, Instant};
26
27use serde::Serialize;
28use serde_json::{Value, json};
29
30const PROTOCOL_VERSION: u32 = 1;
31const MAX_REQUEST_BYTES: usize = 1024 * 1024;
32const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
33const MAX_PENDING_COMMANDS: usize = 64;
34const MAX_CONNECTIONS: usize = 64;
35
36#[derive(Default)]
37struct ActiveSession(AtomicU64);
38
39impl ActiveSession {
40    fn create(&self) -> io::Result<Option<String>> {
41        let value = loop {
42            let mut bytes = [0_u8; 8];
43            getrandom::fill(&mut bytes).map_err(io::Error::other)?;
44            let value = u64::from_ne_bytes(bytes);
45            if value != 0 {
46                break value;
47            }
48        };
49        match self
50            .0
51            .compare_exchange(0, value, Ordering::AcqRel, Ordering::Acquire)
52        {
53            Ok(_) => Ok(Some(format!("{value:016x}"))),
54            Err(_) => Ok(None),
55        }
56    }
57
58    fn matches(&self, supplied: &str) -> bool {
59        u64::from_str_radix(supplied, 16)
60            .ok()
61            .is_some_and(|value| value != 0 && self.0.load(Ordering::Acquire) == value)
62    }
63
64    fn clear(&self) {
65        self.0.store(0, Ordering::Release);
66    }
67}
68
69/// Configuration for a loopback debug-control server.
70#[derive(Debug, Clone)]
71pub struct ServerConfig {
72    /// Loopback address to bind. Port zero asks the OS to choose a free port.
73    pub bind_address: SocketAddr,
74    /// Atomically written once the server is accepting connections.
75    pub descriptor_path: PathBuf,
76    /// Git revision or build identifier for the renderer.
77    pub renderer_revision: String,
78}
79
80/// Wakes whichever thread services requests, so it does not have to poll for
81/// them.
82///
83/// The server cannot know how to wake its embedder, and the embedder's event
84/// loop does not exist yet when the server starts, so the callback is installed
85/// later. A `OnceLock` rather than a lock because it is written exactly once
86/// and read on the path of every request.
87///
88/// Without one installed the embedder must poll, which is what the Blitz shell
89/// used to do on a 10ms timer: 100 wakeups a second while idle, up to 10ms of
90/// latency on every command, and enough of both to show up in any measurement
91/// taken with the driver attached.
92#[derive(Clone, Default)]
93pub struct ServiceWaker(Arc<OnceLock<Box<dyn Fn() + Send + Sync>>>);
94
95impl ServiceWaker {
96    /// Install the wake callback. Later calls are ignored.
97    pub fn set(&self, wake: impl Fn() + Send + Sync + 'static) {
98        let _ = self.0.set(Box::new(wake));
99    }
100
101    fn wake(&self) {
102        if let Some(wake) = self.0.get() {
103            wake();
104        }
105    }
106}
107
108impl std::fmt::Debug for ServiceWaker {
109    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        formatter
111            .debug_struct("ServiceWaker")
112            .field("installed", &self.0.get().is_some())
113            .finish()
114    }
115}
116
117/// A command forwarded from the HTTP server to the renderer thread.
118#[derive(Debug)]
119pub struct ControlRequest {
120    pub method: String,
121    pub path: String,
122    pub body: Value,
123    reply: SyncSender<ControlResponse>,
124}
125
126impl ControlRequest {
127    /// Complete this request. Failure means the client already disconnected.
128    pub fn respond(self, response: ControlResponse) -> Result<(), ControlResponse> {
129        self.reply.send(response).map_err(|error| error.0)
130    }
131}
132
133/// A renderer response represented using W3C WebDriver success/error values.
134#[derive(Debug)]
135pub enum ControlResponse {
136    Success(Value),
137    Error {
138        error: String,
139        message: String,
140        stacktrace: String,
141    },
142}
143
144impl ControlResponse {
145    pub fn unsupported(message: impl Into<String>) -> Self {
146        Self::Error {
147            error: "unsupported operation".into(),
148            message: message.into(),
149            stacktrace: String::new(),
150        }
151    }
152}
153
154#[derive(Debug, Serialize)]
155#[serde(rename_all = "camelCase")]
156struct Descriptor<'a> {
157    pid: u32,
158    address: String,
159    token: &'a str,
160    protocol_version: u32,
161    renderer: &'static str,
162    renderer_revision: &'a str,
163}
164
165/// Running server. Dropping it shuts down the listener and removes discovery.
166pub struct DebugServer {
167    address: SocketAddr,
168    token: String,
169    descriptor_path: PathBuf,
170    shutdown: Arc<AtomicBool>,
171    waker: ServiceWaker,
172    thread: Option<JoinHandle<()>>,
173}
174
175impl DebugServer {
176    /// Bind a loopback port, write the descriptor, and start the server thread.
177    pub fn start(config: ServerConfig) -> io::Result<(Self, Receiver<ControlRequest>)> {
178        if !config.bind_address.ip().is_loopback() {
179            return Err(io::Error::new(
180                io::ErrorKind::InvalidInput,
181                "debug control must bind to a loopback address",
182            ));
183        }
184        let listener = TcpListener::bind(config.bind_address)?;
185        let address = listener.local_addr()?;
186        let token = random_hex(32)?;
187        // Enough room for a driver burst, but never an unbounded owner of parsed
188        // request bodies while the renderer is unavailable.
189        let (command_tx, command_rx) = mpsc::sync_channel(MAX_PENDING_COMMANDS);
190        let shutdown = Arc::new(AtomicBool::new(false));
191        let waker = ServiceWaker::default();
192
193        let thread_shutdown = Arc::clone(&shutdown);
194        let thread_token = token.clone();
195        let thread_waker = waker.clone();
196        let thread = thread::Builder::new()
197            .name("blitz-debug-control".into())
198            .spawn(move || {
199                server_loop(
200                    listener,
201                    &thread_token,
202                    command_tx,
203                    &thread_waker,
204                    thread_shutdown,
205                )
206            })?;
207
208        if let Err(error) = write_descriptor(&config, address, &token) {
209            shutdown.store(true, Ordering::Release);
210            let _ = TcpStream::connect(address);
211            let _ = thread.join();
212            return Err(error);
213        }
214
215        Ok((
216            Self {
217                address,
218                token,
219                descriptor_path: config.descriptor_path,
220                shutdown,
221                waker,
222                thread: Some(thread),
223            },
224            command_rx,
225        ))
226    }
227
228    /// Handle for telling the server how to wake the thread that services
229    /// requests. Nothing wakes until a callback is installed.
230    pub fn waker(&self) -> ServiceWaker {
231        self.waker.clone()
232    }
233
234    pub fn address(&self) -> SocketAddr {
235        self.address
236    }
237
238    pub fn token(&self) -> &str {
239        &self.token
240    }
241
242    pub fn shutdown(mut self) {
243        self.stop();
244    }
245
246    fn stop(&mut self) {
247        self.shutdown.store(true, Ordering::Release);
248        let _ = TcpStream::connect(self.address);
249        if let Some(thread) = self.thread.take() {
250            let _ = thread.join();
251        }
252        let _ = fs::remove_file(&self.descriptor_path);
253    }
254}
255
256impl Drop for DebugServer {
257    fn drop(&mut self) {
258        self.stop();
259    }
260}
261
262fn random_hex(byte_len: usize) -> io::Result<String> {
263    let mut bytes = vec![0; byte_len];
264    getrandom::fill(&mut bytes).map_err(io::Error::other)?;
265    let mut output = String::with_capacity(byte_len * 2);
266    for byte in bytes {
267        use std::fmt::Write as _;
268        write!(output, "{byte:02x}").unwrap();
269    }
270    Ok(output)
271}
272
273fn write_descriptor(config: &ServerConfig, address: SocketAddr, token: &str) -> io::Result<()> {
274    let descriptor = Descriptor {
275        pid: std::process::id(),
276        address: address.to_string(),
277        token,
278        protocol_version: PROTOCOL_VERSION,
279        renderer: "blitz",
280        renderer_revision: &config.renderer_revision,
281    };
282    let bytes = serde_json::to_vec_pretty(&descriptor).map_err(io::Error::other)?;
283    if let Some(parent) = config.descriptor_path.parent() {
284        fs::create_dir_all(parent)?;
285    }
286    let temporary = config
287        .descriptor_path
288        .with_extension(format!("tmp-{}", random_hex(8)?));
289    let result = (|| {
290        let mut options = OpenOptions::new();
291        options.write(true).create_new(true);
292        #[cfg(unix)]
293        {
294            use std::os::unix::fs::OpenOptionsExt;
295            options.mode(0o600);
296        }
297        let mut file = options.open(&temporary)?;
298        file.write_all(&bytes)?;
299        file.sync_all()?;
300        fs::rename(&temporary, &config.descriptor_path)
301    })();
302    if result.is_err() {
303        let _ = fs::remove_file(&temporary);
304    }
305    result
306}
307
308fn server_loop(
309    listener: TcpListener,
310    token: &str,
311    command_tx: SyncSender<ControlRequest>,
312    waker: &ServiceWaker,
313    shutdown: Arc<AtomicBool>,
314) {
315    let active_session = Arc::new(ActiveSession::default());
316    let active_connections = Arc::new(AtomicUsize::new(0));
317    let token: Arc<str> = Arc::from(token);
318    for connection in listener.incoming() {
319        if shutdown.load(Ordering::Acquire) {
320            break;
321        }
322        match connection {
323            Ok(mut stream) => {
324                if active_connections.fetch_add(1, Ordering::AcqRel) >= MAX_CONNECTIONS {
325                    active_connections.fetch_sub(1, Ordering::AcqRel);
326                    let _ = write_response(
327                        &mut stream,
328                        webdriver_error("unknown error", "debug-control connection limit reached"),
329                    );
330                    continue;
331                }
332                let token = Arc::clone(&token);
333                let active_session = Arc::clone(&active_session);
334                let thread_connections = Arc::clone(&active_connections);
335                let command_tx = command_tx.clone();
336                let waker = waker.clone();
337                let spawned = thread::Builder::new()
338                    .name("blitz-debug-connection".into())
339                    .spawn(move || {
340                        let _permit = ConnectionPermit(thread_connections);
341                        let _ = stream.set_write_timeout(Some(COMMAND_TIMEOUT));
342                        let response = match read_request(&mut stream) {
343                            Ok(request) => {
344                                route(request, &token, &active_session, &command_tx, &waker)
345                            }
346                            Err(error) => webdriver_error("invalid argument", error.to_string()),
347                        };
348                        let _ = write_response(&mut stream, response);
349                    });
350                if spawned.is_err() {
351                    active_connections.fetch_sub(1, Ordering::AcqRel);
352                }
353            }
354            Err(_) if shutdown.load(Ordering::Acquire) => break,
355            Err(_) => continue,
356        }
357    }
358}
359
360struct ConnectionPermit(Arc<AtomicUsize>);
361
362impl Drop for ConnectionPermit {
363    fn drop(&mut self) {
364        self.0.fetch_sub(1, Ordering::AcqRel);
365    }
366}
367
368#[derive(Debug)]
369struct HttpRequest {
370    method: String,
371    path: String,
372    body: Value,
373}
374
375fn read_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
376    read_request_within(stream, COMMAND_TIMEOUT)
377}
378
379fn read_request_within(stream: &mut TcpStream, within: Duration) -> io::Result<HttpRequest> {
380    let deadline = Instant::now() + within;
381    let mut bytes = Vec::with_capacity(4096);
382    let mut scan_from = 0;
383    let header_end = loop {
384        if bytes.len() >= MAX_REQUEST_BYTES {
385            return Err(io::Error::new(
386                io::ErrorKind::InvalidData,
387                "request is too large",
388            ));
389        }
390        let remaining = deadline.saturating_duration_since(Instant::now());
391        if remaining.is_zero() {
392            return Err(io::Error::new(io::ErrorKind::TimedOut, "request timed out"));
393        }
394        stream.set_read_timeout(Some(remaining))?;
395        let mut chunk = [0; 4096];
396        let allowed = (MAX_REQUEST_BYTES - bytes.len()).min(chunk.len());
397        let count = stream.read(&mut chunk[..allowed])?;
398        if count == 0 {
399            return Err(io::Error::new(
400                io::ErrorKind::UnexpectedEof,
401                "connection closed before headers",
402            ));
403        }
404        let prior_len = bytes.len();
405        bytes.extend_from_slice(&chunk[..count]);
406        scan_from = scan_from.min(prior_len.saturating_sub(3));
407        if let Some(index) = bytes[scan_from..]
408            .windows(4)
409            .position(|window| window == b"\r\n\r\n")
410        {
411            break scan_from + index + 4;
412        }
413        scan_from = bytes.len().saturating_sub(3);
414    };
415
416    let headers = std::str::from_utf8(&bytes[..header_end])
417        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
418    let mut lines = headers.split("\r\n");
419    let mut request_line = lines.next().unwrap_or_default().split_whitespace();
420    let method = request_line.next().unwrap_or_default().to_string();
421    let path = request_line.next().unwrap_or_default().to_string();
422    if method.is_empty() || path.is_empty() {
423        return Err(io::Error::new(
424            io::ErrorKind::InvalidData,
425            "invalid request line",
426        ));
427    }
428    let header_fields: Vec<_> = lines.filter_map(|line| line.split_once(':')).collect();
429    let content_length = header_fields
430        .iter()
431        .copied()
432        .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
433        .map(|(_, value)| value.trim().parse::<usize>())
434        .transpose()
435        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
436        .unwrap_or(0);
437    if header_fields.iter().any(|(name, value)| {
438        name.eq_ignore_ascii_case("transfer-encoding")
439            && !value.trim().eq_ignore_ascii_case("identity")
440    }) {
441        return Err(io::Error::new(
442            io::ErrorKind::InvalidData,
443            "transfer-encoding is unsupported; send Content-Length",
444        ));
445    }
446    let body_end = header_end
447        .checked_add(content_length)
448        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "request size overflow"))?;
449    if body_end > MAX_REQUEST_BYTES {
450        return Err(io::Error::new(
451            io::ErrorKind::InvalidData,
452            "request is too large",
453        ));
454    }
455    while bytes.len() < body_end {
456        let deadline_remaining = deadline.saturating_duration_since(Instant::now());
457        if deadline_remaining.is_zero() {
458            return Err(io::Error::new(io::ErrorKind::TimedOut, "request timed out"));
459        }
460        stream.set_read_timeout(Some(deadline_remaining))?;
461        let remaining = body_end - bytes.len();
462        let mut chunk = vec![0; remaining.min(4096)];
463        let count = stream.read(&mut chunk)?;
464        if count == 0 {
465            return Err(io::Error::new(
466                io::ErrorKind::UnexpectedEof,
467                "connection closed before body",
468            ));
469        }
470        bytes.extend_from_slice(&chunk[..count]);
471    }
472    let body = if content_length == 0 {
473        Value::Null
474    } else {
475        serde_json::from_slice(&bytes[header_end..body_end])
476            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
477    };
478    Ok(HttpRequest { method, path, body })
479}
480
481fn route(
482    request: HttpRequest,
483    token: &str,
484    active_session: &ActiveSession,
485    command_tx: &SyncSender<ControlRequest>,
486    waker: &ServiceWaker,
487) -> Value {
488    if request.method == "GET" && request.path == "/status" {
489        return json!({"value": {
490            "ready": true,
491            "message": "Blitz debug control is ready",
492            "protocolVersion": PROTOCOL_VERSION,
493        }});
494    }
495
496    if request.method == "POST" && request.path == "/session" {
497        let supplied_token = request
498            .body
499            .pointer("/capabilities/alwaysMatch/blitz:token")
500            .and_then(Value::as_str);
501        if !token_matches(supplied_token, token) {
502            return webdriver_error("invalid argument", "invalid blitz:token capability");
503        }
504        let session_id = match active_session.create() {
505            Ok(Some(value)) => value,
506            Ok(None) => {
507                return webdriver_error("session not created", "only one session is supported");
508            }
509            Err(error) => return webdriver_error("unknown error", error.to_string()),
510        };
511        return json!({"value": {
512            "sessionId": session_id,
513            "capabilities": {
514                "browserName": "blitz",
515                "blitz:protocolVersion": PROTOCOL_VERSION,
516            }
517        }});
518    }
519
520    let Some((session_id, command_path)) = session_path(&request.path) else {
521        return webdriver_error("unknown command", "unknown debug-control route");
522    };
523    if !active_session.matches(session_id) {
524        return webdriver_error("invalid session id", "session is not active");
525    }
526    if request.method == "DELETE" && command_path.is_empty() {
527        active_session.clear();
528        return json!({"value": null});
529    }
530
531    let (reply_tx, reply_rx) = mpsc::sync_channel(1);
532    let control_request = ControlRequest {
533        method: request.method,
534        path: command_path.to_string(),
535        body: request.body,
536        reply: reply_tx,
537    };
538    match command_tx.try_send(control_request) {
539        Ok(()) => {}
540        Err(TrySendError::Full(_)) => {
541            return webdriver_error("unknown error", "renderer command queue is full");
542        }
543        Err(TrySendError::Disconnected(_)) => {
544            return webdriver_error("unknown error", "renderer command channel is closed");
545        }
546    }
547    // Queue first, then wake: the embedder must find the request already there
548    // when it comes round, or the wake is spent on an empty queue.
549    waker.wake();
550    match reply_rx.recv_timeout(COMMAND_TIMEOUT) {
551        Ok(ControlResponse::Success(value)) => json!({"value": value}),
552        Ok(ControlResponse::Error {
553            error,
554            message,
555            stacktrace,
556        }) => json!({"value": {
557            "error": error,
558            "message": message,
559            "stacktrace": stacktrace,
560        }}),
561        Err(RecvTimeoutError::Timeout) => webdriver_error("timeout", "renderer command timed out"),
562        Err(RecvTimeoutError::Disconnected) => {
563            webdriver_error("unknown error", "renderer response channel is closed")
564        }
565    }
566}
567
568/// Compare an attacker-controlled capability with the fixed-size discovery
569/// token without leaking how many prefix bytes matched.
570fn token_matches(supplied: Option<&str>, expected: &str) -> bool {
571    let supplied = supplied.unwrap_or_default().as_bytes();
572    let expected = expected.as_bytes();
573    let mut difference = supplied.len() ^ expected.len();
574    for (index, expected_byte) in expected.iter().enumerate() {
575        difference |= usize::from(*expected_byte ^ supplied.get(index).copied().unwrap_or(0));
576    }
577    difference == 0
578}
579
580fn session_path(path: &str) -> Option<(&str, &str)> {
581    let remainder = path.strip_prefix("/session/")?;
582    let (session_id, command) = remainder.split_once('/').unwrap_or((remainder, ""));
583    Some((session_id, command))
584}
585
586fn webdriver_error(error: &str, message: impl Into<String>) -> Value {
587    json!({"value": {
588        "error": error,
589        "message": message.into(),
590        "stacktrace": "",
591    }})
592}
593
594fn write_response(stream: &mut TcpStream, body: Value) -> io::Result<()> {
595    let bytes = serde_json::to_vec(&body).map_err(io::Error::other)?;
596    let status = match body.pointer("/value/error").and_then(Value::as_str) {
597        None => "200 OK",
598        Some("unknown command" | "invalid session id") => "404 Not Found",
599        Some("timeout" | "unknown error") => "500 Internal Server Error",
600        Some(_) => "400 Bad Request",
601    };
602    write!(
603        stream,
604        "HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
605        bytes.len()
606    )?;
607    stream.write_all(&bytes)
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use std::sync::Mutex;
614    use std::time::{SystemTime, UNIX_EPOCH};
615
616    #[test]
617    fn token_comparison_rejects_prefixes_suffixes_and_missing_values() {
618        assert!(token_matches(Some("012345"), "012345"));
619        assert!(!token_matches(Some("01234x"), "012345"));
620        assert!(!token_matches(Some("0123456"), "012345"));
621        assert!(!token_matches(Some("01234"), "012345"));
622        assert!(!token_matches(None, "012345"));
623    }
624
625    fn descriptor_path() -> PathBuf {
626        let nonce = SystemTime::now()
627            .duration_since(UNIX_EPOCH)
628            .unwrap()
629            .as_nanos();
630        std::env::temp_dir().join(format!("blitz-debug-{nonce}.json"))
631    }
632
633    fn request(address: SocketAddr, method: &str, path: &str, body: Value) -> Value {
634        let body = if body.is_null() {
635            Vec::new()
636        } else {
637            serde_json::to_vec(&body).unwrap()
638        };
639        let mut stream = TcpStream::connect(address).unwrap();
640        write!(
641            stream,
642            "{method} {path} HTTP/1.1\r\nHost: {address}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
643            body.len()
644        )
645        .unwrap();
646        stream.write_all(&body).unwrap();
647        let mut response = Vec::new();
648        stream.read_to_end(&mut response).unwrap();
649        let body_start = response
650            .windows(4)
651            .position(|window| window == b"\r\n\r\n")
652            .unwrap()
653            + 4;
654        serde_json::from_slice(&response[body_start..]).unwrap()
655    }
656
657    fn raw_request(address: SocketAddr, request: &str) -> Value {
658        let mut stream = TcpStream::connect(address).unwrap();
659        stream.write_all(request.as_bytes()).unwrap();
660        let mut response = Vec::new();
661        stream.read_to_end(&mut response).unwrap();
662        let body_start = response
663            .windows(4)
664            .position(|window| window == b"\r\n\r\n")
665            .unwrap()
666            + 4;
667        serde_json::from_slice(&response[body_start..]).unwrap()
668    }
669
670    fn create_session(address: SocketAddr, token: &str) -> String {
671        request(
672            address,
673            "POST",
674            "/session",
675            json!({"capabilities": {"alwaysMatch": {"blitz:token": token}}}),
676        )["value"]["sessionId"]
677            .as_str()
678            .unwrap()
679            .to_string()
680    }
681
682    #[test]
683    fn status_auth_session_command_and_reconnect() {
684        let descriptor = descriptor_path();
685        let (server, commands) = DebugServer::start(ServerConfig {
686            bind_address: (std::net::Ipv4Addr::LOCALHOST, 0).into(),
687            descriptor_path: descriptor.clone(),
688            renderer_revision: "test-revision".into(),
689        })
690        .unwrap();
691
692        let status = request(server.address(), "GET", "/status", Value::Null);
693        assert_eq!(status["value"]["ready"], true);
694        assert!(descriptor.exists());
695        #[cfg(unix)]
696        {
697            use std::os::unix::fs::PermissionsExt;
698            assert_eq!(
699                fs::metadata(&descriptor).unwrap().permissions().mode() & 0o777,
700                0o600
701            );
702        }
703
704        let rejected = request(
705            server.address(),
706            "POST",
707            "/session",
708            json!({"capabilities": {"alwaysMatch": {"blitz:token": "wrong"}}}),
709        );
710        assert_eq!(rejected["value"]["error"], "invalid argument");
711
712        let session = create_session(server.address(), server.token());
713        let duplicate = request(
714            server.address(),
715            "POST",
716            "/session",
717            json!({"capabilities": {"alwaysMatch": {"blitz:token": server.token()}}}),
718        );
719        assert_eq!(duplicate["value"]["error"], "session not created");
720        assert_eq!(
721            request(
722                server.address(),
723                "GET",
724                "/session/not-the-session/blitz/getDomSnapshot",
725                Value::Null,
726            )["value"]["error"],
727            "invalid session id"
728        );
729        assert_eq!(
730            request(server.address(), "GET", "/not-a-route", Value::Null)["value"]["error"],
731            "unknown command"
732        );
733        let address = server.address();
734        let command_path = format!("/session/{session}/blitz/getDomSnapshot");
735        let client = thread::spawn(move || request(address, "GET", &command_path, Value::Null));
736        let command = commands.recv_timeout(COMMAND_TIMEOUT).unwrap();
737        assert_eq!(command.method, "GET");
738        assert_eq!(command.path, "blitz/getDomSnapshot");
739        command
740            .respond(ControlResponse::Success(json!({"documentRevision": 7})))
741            .unwrap();
742        let response = client.join().unwrap();
743        assert_eq!(response["value"]["documentRevision"], 7);
744
745        let deleted = request(
746            server.address(),
747            "DELETE",
748            &format!("/session/{session}"),
749            Value::Null,
750        );
751        assert!(deleted["value"].is_null());
752        let second_session = create_session(server.address(), server.token());
753        assert_ne!(second_session, session);
754
755        server.shutdown();
756        assert!(!descriptor.exists());
757    }
758
759    #[test]
760    fn rejects_non_loopback_bind_address() {
761        let result = DebugServer::start(ServerConfig {
762            bind_address: ([0, 0, 0, 0], 0).into(),
763            descriptor_path: descriptor_path(),
764            renderer_revision: "test-revision".into(),
765        });
766        assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::InvalidInput));
767    }
768
769    #[test]
770    fn overflowing_content_length_is_rejected_without_killing_the_server() {
771        let (server, _commands) = DebugServer::start(ServerConfig {
772            bind_address: (std::net::Ipv4Addr::LOCALHOST, 0).into(),
773            descriptor_path: descriptor_path(),
774            renderer_revision: "test-revision".into(),
775        })
776        .unwrap();
777
778        let rejected = raw_request(
779            server.address(),
780            &format!(
781                "POST /session HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
782                usize::MAX
783            ),
784        );
785        assert_eq!(rejected["value"]["error"], "invalid argument");
786        let malformed_length = raw_request(
787            server.address(),
788            "POST /session HTTP/1.1\r\nHost: localhost\r\nContent-Length: nope\r\nConnection: close\r\n\r\n",
789        );
790        assert_eq!(malformed_length["value"]["error"], "invalid argument");
791        let malformed_json = raw_request(
792            server.address(),
793            "POST /session HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1\r\nConnection: close\r\n\r\n{",
794        );
795        assert_eq!(malformed_json["value"]["error"], "invalid argument");
796        assert_eq!(
797            request(server.address(), "GET", "/status", Value::Null)["value"]["ready"],
798            true
799        );
800        server.shutdown();
801    }
802
803    #[test]
804    fn a_slow_client_does_not_block_other_connections_and_has_an_absolute_deadline() {
805        let (server, _commands) = DebugServer::start(ServerConfig {
806            bind_address: (std::net::Ipv4Addr::LOCALHOST, 0).into(),
807            descriptor_path: descriptor_path(),
808            renderer_revision: "test-revision".into(),
809        })
810        .unwrap();
811        let mut slow = TcpStream::connect(server.address()).unwrap();
812        slow.write_all(b"G").unwrap();
813        let started = Instant::now();
814        assert_eq!(
815            request(server.address(), "GET", "/status", Value::Null)["value"]["ready"],
816            true
817        );
818        assert!(started.elapsed() < Duration::from_millis(500));
819
820        let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap();
821        let address = listener.local_addr().unwrap();
822        let reader = thread::spawn(move || {
823            let (mut stream, _) = listener.accept().unwrap();
824            read_request_within(&mut stream, Duration::from_millis(40)).unwrap_err()
825        });
826        let mut drip = TcpStream::connect(address).unwrap();
827        drip.write_all(b"G").unwrap();
828        let error = reader.join().unwrap();
829        assert!(matches!(
830            error.kind(),
831            io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
832        ));
833        server.shutdown();
834    }
835
836    fn snapshot_request() -> HttpRequest {
837        HttpRequest {
838            method: "GET".into(),
839            path: "/session/0000000000000001/blitz/getDomSnapshot".into(),
840            body: Value::Null,
841        }
842    }
843
844    /*
845     * The wake has to arrive after the request is queued, never before: a wake
846     * delivered to an empty queue is spent, and the embedder has no timer to
847     * fall back on any more. Draining inside the callback is exactly what the
848     * event loop does when it comes round, so servicing here proves the
849     * ordering rather than asserting it indirectly.
850     */
851    #[test]
852    fn wakes_the_servicer_with_the_request_already_queued() {
853        let (command_tx, command_rx) = mpsc::sync_channel::<ControlRequest>(4);
854        let waker = ServiceWaker::default();
855        let queue = Mutex::new(command_rx);
856        waker.set(move || {
857            let queued = queue.lock().unwrap().try_recv().unwrap();
858            assert_eq!(queued.path, "blitz/getDomSnapshot");
859            queued
860                .respond(ControlResponse::Success(json!({"serviced": true})))
861                .unwrap();
862        });
863
864        let active_session = ActiveSession(AtomicU64::new(1));
865        let response = route(
866            snapshot_request(),
867            "token",
868            &active_session,
869            &command_tx,
870            &waker,
871        );
872
873        assert_eq!(response["value"]["serviced"], true);
874    }
875
876    /*
877     * `sync_channel(1)` refused this outright with "renderer command queue is
878     * full". One unserviced request is normal, not a fault: it happens whenever
879     * a command arrives before the embedder has a document to run it against.
880     */
881    #[test]
882    fn a_second_request_queues_behind_an_unserviced_one() {
883        let (command_tx, command_rx) = mpsc::sync_channel::<ControlRequest>(4);
884        let (reply_tx, _reply_rx) = mpsc::sync_channel(1);
885        command_tx
886            .send(ControlRequest {
887                method: "GET".into(),
888                path: "occupied".into(),
889                body: Value::Null,
890                reply: reply_tx,
891            })
892            .unwrap();
893
894        let waker = ServiceWaker::default();
895        let queue = Mutex::new(command_rx);
896        waker.set(move || {
897            let queue = queue.lock().unwrap();
898            assert_eq!(queue.try_recv().unwrap().path, "occupied");
899            queue
900                .try_recv()
901                .unwrap()
902                .respond(ControlResponse::Success(json!({"serviced": true})))
903                .unwrap();
904        });
905
906        let active_session = ActiveSession(AtomicU64::new(1));
907        let response = route(
908            snapshot_request(),
909            "token",
910            &active_session,
911            &command_tx,
912            &waker,
913        );
914
915        assert_eq!(response["value"]["serviced"], true);
916    }
917
918    #[test]
919    fn a_saturated_renderer_queue_fails_without_growing() {
920        let (command_tx, _command_rx) = mpsc::sync_channel::<ControlRequest>(1);
921        let (reply_tx, _reply_rx) = mpsc::sync_channel(1);
922        command_tx
923            .send(ControlRequest {
924                method: "GET".into(),
925                path: "occupied".into(),
926                body: Value::Null,
927                reply: reply_tx,
928            })
929            .unwrap();
930
931        let response = route(
932            snapshot_request(),
933            "token",
934            &ActiveSession(AtomicU64::new(1)),
935            &command_tx,
936            &ServiceWaker::default(),
937        );
938
939        assert_eq!(response["value"]["error"], "unknown error");
940        assert_eq!(
941            response["value"]["message"],
942            "renderer command queue is full"
943        );
944    }
945
946    /// Nothing installs a waker until the embedder is up, and requests that
947    /// arrive in that window must still be queued rather than dropped.
948    #[test]
949    fn queues_requests_while_no_waker_is_installed() {
950        let (command_tx, command_rx) = mpsc::sync_channel::<ControlRequest>(4);
951        let waker = ServiceWaker::default();
952        let active_session = ActiveSession(AtomicU64::new(1));
953
954        let sender = command_tx.clone();
955        let servicer = thread::spawn(move || {
956            let queued = command_rx.recv_timeout(COMMAND_TIMEOUT).unwrap();
957            drop(sender);
958            queued
959                .respond(ControlResponse::Success(json!({"serviced": true})))
960                .unwrap();
961        });
962
963        let response = route(
964            snapshot_request(),
965            "token",
966            &active_session,
967            &command_tx,
968            &waker,
969        );
970
971        servicer.join().unwrap();
972        assert_eq!(response["value"]["serviced"], true);
973    }
974}