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, Ordering};
22use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, SyncSender};
23use std::sync::{Arc, OnceLock};
24use std::thread::{self, JoinHandle};
25use std::time::Duration;
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);
33
34/// Configuration for a loopback debug-control server.
35#[derive(Debug, Clone)]
36pub struct ServerConfig {
37    /// Loopback address to bind. Port zero asks the OS to choose a free port.
38    pub bind_address: SocketAddr,
39    /// Atomically written once the server is accepting connections.
40    pub descriptor_path: PathBuf,
41    /// Git revision or build identifier for the renderer.
42    pub renderer_revision: String,
43}
44
45/// Wakes whichever thread services requests, so it does not have to poll for
46/// them.
47///
48/// The server cannot know how to wake its embedder, and the embedder's event
49/// loop does not exist yet when the server starts, so the callback is installed
50/// later. A `OnceLock` rather than a lock because it is written exactly once
51/// and read on the path of every request.
52///
53/// Without one installed the embedder must poll, which is what the Blitz shell
54/// used to do on a 10ms timer: 100 wakeups a second while idle, up to 10ms of
55/// latency on every command, and enough of both to show up in any measurement
56/// taken with the driver attached.
57#[derive(Clone, Default)]
58pub struct ServiceWaker(Arc<OnceLock<Box<dyn Fn() + Send + Sync>>>);
59
60impl ServiceWaker {
61    /// Install the wake callback. Later calls are ignored.
62    pub fn set(&self, wake: impl Fn() + Send + Sync + 'static) {
63        let _ = self.0.set(Box::new(wake));
64    }
65
66    fn wake(&self) {
67        if let Some(wake) = self.0.get() {
68            wake();
69        }
70    }
71}
72
73impl std::fmt::Debug for ServiceWaker {
74    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        formatter
76            .debug_struct("ServiceWaker")
77            .field("installed", &self.0.get().is_some())
78            .finish()
79    }
80}
81
82/// A command forwarded from the HTTP server to the renderer thread.
83#[derive(Debug)]
84pub struct ControlRequest {
85    pub method: String,
86    pub path: String,
87    pub body: Value,
88    reply: SyncSender<ControlResponse>,
89}
90
91impl ControlRequest {
92    /// Complete this request. Failure means the client already disconnected.
93    pub fn respond(self, response: ControlResponse) -> Result<(), ControlResponse> {
94        self.reply.send(response).map_err(|error| error.0)
95    }
96}
97
98/// A renderer response represented using W3C WebDriver success/error values.
99#[derive(Debug)]
100pub enum ControlResponse {
101    Success(Value),
102    Error {
103        error: String,
104        message: String,
105        stacktrace: String,
106    },
107}
108
109impl ControlResponse {
110    pub fn unsupported(message: impl Into<String>) -> Self {
111        Self::Error {
112            error: "unsupported operation".into(),
113            message: message.into(),
114            stacktrace: String::new(),
115        }
116    }
117}
118
119#[derive(Debug, Serialize)]
120#[serde(rename_all = "camelCase")]
121struct Descriptor<'a> {
122    pid: u32,
123    address: String,
124    token: &'a str,
125    protocol_version: u32,
126    renderer: &'static str,
127    renderer_revision: &'a str,
128}
129
130/// Running server. Dropping it shuts down the listener and removes discovery.
131pub struct DebugServer {
132    address: SocketAddr,
133    token: String,
134    descriptor_path: PathBuf,
135    shutdown: Arc<AtomicBool>,
136    waker: ServiceWaker,
137    thread: Option<JoinHandle<()>>,
138}
139
140impl DebugServer {
141    /// Bind a loopback port, write the descriptor, and start the server thread.
142    pub fn start(config: ServerConfig) -> io::Result<(Self, Receiver<ControlRequest>)> {
143        if !config.bind_address.ip().is_loopback() {
144            return Err(io::Error::new(
145                io::ErrorKind::InvalidInput,
146                "debug control must bind to a loopback address",
147            ));
148        }
149        let listener = TcpListener::bind(config.bind_address)?;
150        let address = listener.local_addr()?;
151        let token = random_hex(32)?;
152        // Unbounded, where this was `sync_channel(1)` serviced by a poll. A
153        // request that arrives before the embedder can service it (no document
154        // yet, say) used to occupy the only slot, and the next one was refused
155        // outright with "renderer command queue is full".
156        let (command_tx, command_rx) = mpsc::channel();
157        let shutdown = Arc::new(AtomicBool::new(false));
158        let waker = ServiceWaker::default();
159
160        let thread_shutdown = Arc::clone(&shutdown);
161        let thread_token = token.clone();
162        let thread_waker = waker.clone();
163        let thread = thread::Builder::new()
164            .name("blitz-debug-control".into())
165            .spawn(move || {
166                server_loop(
167                    listener,
168                    &thread_token,
169                    command_tx,
170                    &thread_waker,
171                    thread_shutdown,
172                )
173            })?;
174
175        if let Err(error) = write_descriptor(&config, address, &token) {
176            shutdown.store(true, Ordering::Release);
177            let _ = TcpStream::connect(address);
178            let _ = thread.join();
179            return Err(error);
180        }
181
182        Ok((
183            Self {
184                address,
185                token,
186                descriptor_path: config.descriptor_path,
187                shutdown,
188                waker,
189                thread: Some(thread),
190            },
191            command_rx,
192        ))
193    }
194
195    /// Handle for telling the server how to wake the thread that services
196    /// requests. Nothing wakes until a callback is installed.
197    pub fn waker(&self) -> ServiceWaker {
198        self.waker.clone()
199    }
200
201    pub fn address(&self) -> SocketAddr {
202        self.address
203    }
204
205    pub fn token(&self) -> &str {
206        &self.token
207    }
208
209    pub fn shutdown(mut self) {
210        self.stop();
211    }
212
213    fn stop(&mut self) {
214        self.shutdown.store(true, Ordering::Release);
215        let _ = TcpStream::connect(self.address);
216        if let Some(thread) = self.thread.take() {
217            let _ = thread.join();
218        }
219        let _ = fs::remove_file(&self.descriptor_path);
220    }
221}
222
223impl Drop for DebugServer {
224    fn drop(&mut self) {
225        self.stop();
226    }
227}
228
229fn random_hex(byte_len: usize) -> io::Result<String> {
230    let mut bytes = vec![0; byte_len];
231    getrandom::fill(&mut bytes).map_err(io::Error::other)?;
232    let mut output = String::with_capacity(byte_len * 2);
233    for byte in bytes {
234        use std::fmt::Write as _;
235        write!(output, "{byte:02x}").unwrap();
236    }
237    Ok(output)
238}
239
240fn write_descriptor(config: &ServerConfig, address: SocketAddr, token: &str) -> io::Result<()> {
241    let descriptor = Descriptor {
242        pid: std::process::id(),
243        address: address.to_string(),
244        token,
245        protocol_version: PROTOCOL_VERSION,
246        renderer: "blitz",
247        renderer_revision: &config.renderer_revision,
248    };
249    let bytes = serde_json::to_vec_pretty(&descriptor).map_err(io::Error::other)?;
250    if let Some(parent) = config.descriptor_path.parent() {
251        fs::create_dir_all(parent)?;
252    }
253    let temporary = config
254        .descriptor_path
255        .with_extension(format!("tmp-{}", random_hex(8)?));
256    let result = (|| {
257        let mut options = OpenOptions::new();
258        options.write(true).create_new(true);
259        #[cfg(unix)]
260        {
261            use std::os::unix::fs::OpenOptionsExt;
262            options.mode(0o600);
263        }
264        let mut file = options.open(&temporary)?;
265        file.write_all(&bytes)?;
266        file.sync_all()?;
267        fs::rename(&temporary, &config.descriptor_path)
268    })();
269    if result.is_err() {
270        let _ = fs::remove_file(&temporary);
271    }
272    result
273}
274
275fn server_loop(
276    listener: TcpListener,
277    token: &str,
278    command_tx: Sender<ControlRequest>,
279    waker: &ServiceWaker,
280    shutdown: Arc<AtomicBool>,
281) {
282    let mut active_session: Option<String> = None;
283    for connection in listener.incoming() {
284        if shutdown.load(Ordering::Acquire) {
285            break;
286        }
287        match connection {
288            Ok(mut stream) => {
289                let _ = stream.set_read_timeout(Some(COMMAND_TIMEOUT));
290                let _ = stream.set_write_timeout(Some(COMMAND_TIMEOUT));
291                let response = match read_request(&mut stream) {
292                    Ok(request) => route(request, token, &mut active_session, &command_tx, waker),
293                    Err(error) => webdriver_error("invalid argument", error.to_string()),
294                };
295                let _ = write_response(&mut stream, response);
296            }
297            Err(_) if shutdown.load(Ordering::Acquire) => break,
298            Err(_) => continue,
299        }
300    }
301}
302
303#[derive(Debug)]
304struct HttpRequest {
305    method: String,
306    path: String,
307    body: Value,
308}
309
310fn read_request(stream: &mut TcpStream) -> io::Result<HttpRequest> {
311    let mut bytes = Vec::with_capacity(4096);
312    let header_end = loop {
313        if bytes.len() >= MAX_REQUEST_BYTES {
314            return Err(io::Error::new(
315                io::ErrorKind::InvalidData,
316                "request is too large",
317            ));
318        }
319        let mut chunk = [0; 4096];
320        let count = stream.read(&mut chunk)?;
321        if count == 0 {
322            return Err(io::Error::new(
323                io::ErrorKind::UnexpectedEof,
324                "connection closed before headers",
325            ));
326        }
327        bytes.extend_from_slice(&chunk[..count]);
328        if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
329            break index + 4;
330        }
331    };
332
333    let headers = std::str::from_utf8(&bytes[..header_end])
334        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
335    let mut lines = headers.split("\r\n");
336    let mut request_line = lines.next().unwrap_or_default().split_whitespace();
337    let method = request_line.next().unwrap_or_default().to_string();
338    let path = request_line.next().unwrap_or_default().to_string();
339    if method.is_empty() || path.is_empty() {
340        return Err(io::Error::new(
341            io::ErrorKind::InvalidData,
342            "invalid request line",
343        ));
344    }
345    let content_length = lines
346        .filter_map(|line| line.split_once(':'))
347        .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
348        .map(|(_, value)| value.trim().parse::<usize>())
349        .transpose()
350        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
351        .unwrap_or(0);
352    if header_end + content_length > MAX_REQUEST_BYTES {
353        return Err(io::Error::new(
354            io::ErrorKind::InvalidData,
355            "request is too large",
356        ));
357    }
358    while bytes.len() < header_end + content_length {
359        let remaining = header_end + content_length - bytes.len();
360        let mut chunk = vec![0; remaining.min(4096)];
361        let count = stream.read(&mut chunk)?;
362        if count == 0 {
363            return Err(io::Error::new(
364                io::ErrorKind::UnexpectedEof,
365                "connection closed before body",
366            ));
367        }
368        bytes.extend_from_slice(&chunk[..count]);
369    }
370    let body = if content_length == 0 {
371        Value::Null
372    } else {
373        serde_json::from_slice(&bytes[header_end..header_end + content_length])
374            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
375    };
376    Ok(HttpRequest { method, path, body })
377}
378
379fn route(
380    request: HttpRequest,
381    token: &str,
382    active_session: &mut Option<String>,
383    command_tx: &Sender<ControlRequest>,
384    waker: &ServiceWaker,
385) -> Value {
386    if request.method == "GET" && request.path == "/status" {
387        return json!({"value": {
388            "ready": true,
389            "message": "Blitz debug control is ready",
390            "protocolVersion": PROTOCOL_VERSION,
391        }});
392    }
393
394    if request.method == "POST" && request.path == "/session" {
395        if active_session.is_some() {
396            return webdriver_error("session not created", "only one session is supported");
397        }
398        let supplied_token = request
399            .body
400            .pointer("/capabilities/alwaysMatch/blitz:token")
401            .and_then(Value::as_str);
402        if !token_matches(supplied_token, token) {
403            return webdriver_error("invalid argument", "invalid blitz:token capability");
404        }
405        let session_id = match random_hex(16) {
406            Ok(value) => value,
407            Err(error) => return webdriver_error("unknown error", error.to_string()),
408        };
409        *active_session = Some(session_id.clone());
410        return json!({"value": {
411            "sessionId": session_id,
412            "capabilities": {
413                "browserName": "blitz",
414                "blitz:protocolVersion": PROTOCOL_VERSION,
415            }
416        }});
417    }
418
419    let Some((session_id, command_path)) = session_path(&request.path) else {
420        return webdriver_error("unknown command", "unknown debug-control route");
421    };
422    if active_session.as_deref() != Some(session_id) {
423        return webdriver_error("invalid session id", "session is not active");
424    }
425    if request.method == "DELETE" && command_path.is_empty() {
426        *active_session = None;
427        return json!({"value": null});
428    }
429
430    let (reply_tx, reply_rx) = mpsc::sync_channel(1);
431    let control_request = ControlRequest {
432        method: request.method,
433        path: command_path.to_string(),
434        body: request.body,
435        reply: reply_tx,
436    };
437    if command_tx.send(control_request).is_err() {
438        return webdriver_error("unknown error", "renderer command channel is closed");
439    }
440    // Queue first, then wake: the embedder must find the request already there
441    // when it comes round, or the wake is spent on an empty queue.
442    waker.wake();
443    match reply_rx.recv_timeout(COMMAND_TIMEOUT) {
444        Ok(ControlResponse::Success(value)) => json!({"value": value}),
445        Ok(ControlResponse::Error {
446            error,
447            message,
448            stacktrace,
449        }) => json!({"value": {
450            "error": error,
451            "message": message,
452            "stacktrace": stacktrace,
453        }}),
454        Err(RecvTimeoutError::Timeout) => webdriver_error("timeout", "renderer command timed out"),
455        Err(RecvTimeoutError::Disconnected) => {
456            webdriver_error("unknown error", "renderer response channel is closed")
457        }
458    }
459}
460
461/// Compare an attacker-controlled capability with the fixed-size discovery
462/// token without leaking how many prefix bytes matched.
463fn token_matches(supplied: Option<&str>, expected: &str) -> bool {
464    let supplied = supplied.unwrap_or_default().as_bytes();
465    let expected = expected.as_bytes();
466    let mut difference = supplied.len() ^ expected.len();
467    for (index, expected_byte) in expected.iter().enumerate() {
468        difference |= usize::from(*expected_byte ^ supplied.get(index).copied().unwrap_or(0));
469    }
470    difference == 0
471}
472
473fn session_path(path: &str) -> Option<(&str, &str)> {
474    let remainder = path.strip_prefix("/session/")?;
475    let (session_id, command) = remainder.split_once('/').unwrap_or((remainder, ""));
476    Some((session_id, command))
477}
478
479fn webdriver_error(error: &str, message: impl Into<String>) -> Value {
480    json!({"value": {
481        "error": error,
482        "message": message.into(),
483        "stacktrace": "",
484    }})
485}
486
487fn write_response(stream: &mut TcpStream, body: Value) -> io::Result<()> {
488    let bytes = serde_json::to_vec(&body).map_err(io::Error::other)?;
489    write!(
490        stream,
491        "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
492        bytes.len()
493    )?;
494    stream.write_all(&bytes)
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use std::sync::Mutex;
501    use std::time::{SystemTime, UNIX_EPOCH};
502
503    #[test]
504    fn token_comparison_rejects_prefixes_suffixes_and_missing_values() {
505        assert!(token_matches(Some("012345"), "012345"));
506        assert!(!token_matches(Some("01234x"), "012345"));
507        assert!(!token_matches(Some("0123456"), "012345"));
508        assert!(!token_matches(Some("01234"), "012345"));
509        assert!(!token_matches(None, "012345"));
510    }
511
512    fn descriptor_path() -> PathBuf {
513        let nonce = SystemTime::now()
514            .duration_since(UNIX_EPOCH)
515            .unwrap()
516            .as_nanos();
517        std::env::temp_dir().join(format!("blitz-debug-{nonce}.json"))
518    }
519
520    fn request(address: SocketAddr, method: &str, path: &str, body: Value) -> Value {
521        let body = if body.is_null() {
522            Vec::new()
523        } else {
524            serde_json::to_vec(&body).unwrap()
525        };
526        let mut stream = TcpStream::connect(address).unwrap();
527        write!(
528            stream,
529            "{method} {path} HTTP/1.1\r\nHost: {address}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
530            body.len()
531        )
532        .unwrap();
533        stream.write_all(&body).unwrap();
534        let mut response = Vec::new();
535        stream.read_to_end(&mut response).unwrap();
536        let body_start = response
537            .windows(4)
538            .position(|window| window == b"\r\n\r\n")
539            .unwrap()
540            + 4;
541        serde_json::from_slice(&response[body_start..]).unwrap()
542    }
543
544    fn create_session(address: SocketAddr, token: &str) -> String {
545        request(
546            address,
547            "POST",
548            "/session",
549            json!({"capabilities": {"alwaysMatch": {"blitz:token": token}}}),
550        )["value"]["sessionId"]
551            .as_str()
552            .unwrap()
553            .to_string()
554    }
555
556    #[test]
557    fn status_auth_session_command_and_reconnect() {
558        let descriptor = descriptor_path();
559        let (server, commands) = DebugServer::start(ServerConfig {
560            bind_address: (std::net::Ipv4Addr::LOCALHOST, 0).into(),
561            descriptor_path: descriptor.clone(),
562            renderer_revision: "test-revision".into(),
563        })
564        .unwrap();
565
566        let status = request(server.address(), "GET", "/status", Value::Null);
567        assert_eq!(status["value"]["ready"], true);
568        assert!(descriptor.exists());
569        #[cfg(unix)]
570        {
571            use std::os::unix::fs::PermissionsExt;
572            assert_eq!(
573                fs::metadata(&descriptor).unwrap().permissions().mode() & 0o777,
574                0o600
575            );
576        }
577
578        let rejected = request(
579            server.address(),
580            "POST",
581            "/session",
582            json!({"capabilities": {"alwaysMatch": {"blitz:token": "wrong"}}}),
583        );
584        assert_eq!(rejected["value"]["error"], "invalid argument");
585
586        let session = create_session(server.address(), server.token());
587        let address = server.address();
588        let command_path = format!("/session/{session}/blitz/getDomSnapshot");
589        let client = thread::spawn(move || request(address, "GET", &command_path, Value::Null));
590        let command = commands.recv_timeout(COMMAND_TIMEOUT).unwrap();
591        assert_eq!(command.method, "GET");
592        assert_eq!(command.path, "blitz/getDomSnapshot");
593        command
594            .respond(ControlResponse::Success(json!({"documentRevision": 7})))
595            .unwrap();
596        let response = client.join().unwrap();
597        assert_eq!(response["value"]["documentRevision"], 7);
598
599        let deleted = request(
600            server.address(),
601            "DELETE",
602            &format!("/session/{session}"),
603            Value::Null,
604        );
605        assert!(deleted["value"].is_null());
606        let second_session = create_session(server.address(), server.token());
607        assert_ne!(second_session, session);
608
609        server.shutdown();
610        assert!(!descriptor.exists());
611    }
612
613    #[test]
614    fn rejects_non_loopback_bind_address() {
615        let result = DebugServer::start(ServerConfig {
616            bind_address: ([0, 0, 0, 0], 0).into(),
617            descriptor_path: descriptor_path(),
618            renderer_revision: "test-revision".into(),
619        });
620        assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::InvalidInput));
621    }
622
623    fn snapshot_request() -> HttpRequest {
624        HttpRequest {
625            method: "GET".into(),
626            path: "/session/test-session/blitz/getDomSnapshot".into(),
627            body: Value::Null,
628        }
629    }
630
631    /*
632     * The wake has to arrive after the request is queued, never before: a wake
633     * delivered to an empty queue is spent, and the embedder has no timer to
634     * fall back on any more. Draining inside the callback is exactly what the
635     * event loop does when it comes round, so servicing here proves the
636     * ordering rather than asserting it indirectly.
637     */
638    #[test]
639    fn wakes_the_servicer_with_the_request_already_queued() {
640        let (command_tx, command_rx) = mpsc::channel::<ControlRequest>();
641        let waker = ServiceWaker::default();
642        let queue = Mutex::new(command_rx);
643        waker.set(move || {
644            let queued = queue.lock().unwrap().try_recv().unwrap();
645            assert_eq!(queued.path, "blitz/getDomSnapshot");
646            queued
647                .respond(ControlResponse::Success(json!({"serviced": true})))
648                .unwrap();
649        });
650
651        let mut active_session = Some("test-session".to_string());
652        let response = route(
653            snapshot_request(),
654            "token",
655            &mut active_session,
656            &command_tx,
657            &waker,
658        );
659
660        assert_eq!(response["value"]["serviced"], true);
661    }
662
663    /*
664     * `sync_channel(1)` refused this outright with "renderer command queue is
665     * full". One unserviced request is normal, not a fault: it happens whenever
666     * a command arrives before the embedder has a document to run it against.
667     */
668    #[test]
669    fn a_second_request_queues_behind_an_unserviced_one() {
670        let (command_tx, command_rx) = mpsc::channel::<ControlRequest>();
671        let (reply_tx, _reply_rx) = mpsc::sync_channel(1);
672        command_tx
673            .send(ControlRequest {
674                method: "GET".into(),
675                path: "occupied".into(),
676                body: Value::Null,
677                reply: reply_tx,
678            })
679            .unwrap();
680
681        let waker = ServiceWaker::default();
682        let queue = Mutex::new(command_rx);
683        waker.set(move || {
684            let queue = queue.lock().unwrap();
685            assert_eq!(queue.try_recv().unwrap().path, "occupied");
686            queue
687                .try_recv()
688                .unwrap()
689                .respond(ControlResponse::Success(json!({"serviced": true})))
690                .unwrap();
691        });
692
693        let mut active_session = Some("test-session".to_string());
694        let response = route(
695            snapshot_request(),
696            "token",
697            &mut active_session,
698            &command_tx,
699            &waker,
700        );
701
702        assert_eq!(response["value"]["serviced"], true);
703    }
704
705    /// Nothing installs a waker until the embedder is up, and requests that
706    /// arrive in that window must still be queued rather than dropped.
707    #[test]
708    fn queues_requests_while_no_waker_is_installed() {
709        let (command_tx, command_rx) = mpsc::channel::<ControlRequest>();
710        let waker = ServiceWaker::default();
711        let mut active_session = Some("test-session".to_string());
712
713        let sender = command_tx.clone();
714        let servicer = thread::spawn(move || {
715            let queued = command_rx.recv_timeout(COMMAND_TIMEOUT).unwrap();
716            drop(sender);
717            queued
718                .respond(ControlResponse::Success(json!({"serviced": true})))
719                .unwrap();
720        });
721
722        let response = route(
723            snapshot_request(),
724            "token",
725            &mut active_session,
726            &command_tx,
727            &waker,
728        );
729
730        servicer.join().unwrap();
731        assert_eq!(response["value"]["serviced"], true);
732    }
733}