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
6use 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/// Configuration for a loopback debug-control server.
24#[derive(Debug, Clone)]
25pub struct ServerConfig {
26    /// Loopback address to bind. Port zero asks the OS to choose a free port.
27    pub bind_address: SocketAddr,
28    /// Atomically written once the server is accepting connections.
29    pub descriptor_path: PathBuf,
30    /// Git revision or build identifier for the renderer.
31    pub renderer_revision: String,
32}
33
34/// Wakes whichever thread services requests, so it does not have to poll for
35/// them.
36///
37/// The server cannot know how to wake its embedder, and the embedder's event
38/// loop does not exist yet when the server starts, so the callback is installed
39/// later. A `OnceLock` rather than a lock because it is written exactly once
40/// and read on the path of every request.
41///
42/// Without one installed the embedder must poll, which is what the Blitz shell
43/// used to do on a 10ms timer: 100 wakeups a second while idle, up to 10ms of
44/// latency on every command, and enough of both to show up in any measurement
45/// taken with the driver attached.
46#[derive(Clone, Default)]
47pub struct ServiceWaker(Arc<OnceLock<Box<dyn Fn() + Send + Sync>>>);
48
49impl ServiceWaker {
50    /// Install the wake callback. Later calls are ignored.
51    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/// A command forwarded from the HTTP server to the renderer thread.
72#[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    /// Complete this request. Failure means the client already disconnected.
82    pub fn respond(self, response: ControlResponse) -> Result<(), ControlResponse> {
83        self.reply.send(response).map_err(|error| error.0)
84    }
85}
86
87/// A renderer response represented using W3C WebDriver success/error values.
88#[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
119/// Running server. Dropping it shuts down the listener and removes discovery.
120pub 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    /// Bind a loopback port, write the descriptor, and start the server thread.
131    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        // Unbounded, where this was `sync_channel(1)` serviced by a poll. A
142        // request that arrives before the embedder can service it (no document
143        // yet, say) used to occupy the only slot, and the next one was refused
144        // outright with "renderer command queue is full".
145        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    /// Handle for telling the server how to wake the thread that services
185    /// requests. Nothing wakes until a callback is installed.
186    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    // Queue first, then wake: the embedder must find the request already there
430    // when it comes round, or the wake is spent on an empty queue.
431    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    /*
600     * The wake has to arrive after the request is queued, never before: a wake
601     * delivered to an empty queue is spent, and the embedder has no timer to
602     * fall back on any more. Draining inside the callback is exactly what the
603     * event loop does when it comes round, so servicing here proves the
604     * ordering rather than asserting it indirectly.
605     */
606    #[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    /*
632     * `sync_channel(1)` refused this outright with "renderer command queue is
633     * full". One unserviced request is normal, not a fault: it happens whenever
634     * a command arrives before the embedder has a document to run it against.
635     */
636    #[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    /// Nothing installs a waker until the embedder is up, and requests that
674    /// arrive in that window must still be queued rather than dropped.
675    #[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}