Skip to main content

cdp_server/
session.rs

1// @trace REQ-CDS-003 [entity:CdpSessionGeneric] [sm:SM-CDP-SESSION]
2// CDP Session lifecycle management.
3
4use std::collections::{HashSet, VecDeque};
5use std::io::{Cursor, Read, Write};
6use std::net::TcpStream;
7use std::sync::{Arc, Mutex};
8
9use tungstenite::protocol::WebSocket;
10
11use crate::protocol::{self, CdpMessage, CdpResponse, SessionError};
12use crate::registry::SharedRegistry;
13use crate::{EventSender, RegistryDispatch};
14
15/// Session lifecycle states (SM-CDP-SESSION).
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SessionState {
18    Created,
19    Active,
20    Closing,
21    Closed,
22}
23
24/// ReplayStream replays pre-read bytes to tungstenite on first reads.
25pub struct ReplayStream {
26    stream: TcpStream,
27    replay: Cursor<Vec<u8>>,
28}
29
30impl ReplayStream {
31    pub fn new(stream: TcpStream, peeked: Vec<u8>) -> Self {
32        ReplayStream {
33            stream,
34            replay: Cursor::new(peeked),
35        }
36    }
37}
38
39impl Read for ReplayStream {
40    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
41        if self.replay.position() < self.replay.get_ref().len() as u64 {
42            return self.replay.read(buf);
43        }
44        self.stream.read(buf)
45    }
46}
47
48impl Write for ReplayStream {
49    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
50        self.stream.write(buf)
51    }
52    fn flush(&mut self) -> std::io::Result<()> {
53        self.stream.flush()
54    }
55}
56
57/// A queued outbound event. Domain gating and the browser-only flag are
58/// applied at drain time (the server loop holds the session then).
59#[derive(Debug, Clone)]
60pub struct OutboxEvent {
61    /// Pre-serialized event JSON (may carry a `sessionId` routing tag).
62    pub json: String,
63    /// Domain the event belongs to ("Page" for Page.loadEventFired).
64    pub domain: String,
65    /// Deliver to browser-endpoint sessions only (flat-session events).
66    pub browser_only: bool,
67}
68
69/// Shared session handle: the session itself plus its outbound event queue.
70///
71/// The outbox exists so the EventBroadcaster can queue events for a session
72/// whose mutex is currently held by the server loop (a command dispatch
73/// emitting events for its own session) without self-deadlocking.
74pub struct SessionHandle {
75    pub session: Mutex<CdpSession>,
76    pub outbox: Mutex<VecDeque<OutboxEvent>>,
77}
78
79impl SessionHandle {
80    pub fn new(session: CdpSession) -> Arc<Self> {
81        Arc::new(SessionHandle {
82            session: Mutex::new(session),
83            outbox: Mutex::new(VecDeque::new()),
84        })
85    }
86}
87
88/// CDP client session. Holds a WebSocket connection and tracks enabled domains.
89pub struct CdpSession {
90    session_id: String,
91    target_id: String,
92    ws: WebSocket<ReplayStream>,
93    enabled_domains: HashSet<String>,
94    state: SessionState,
95    is_browser_session: bool,
96    first_domain_enabled: HashSet<String>,
97}
98
99impl CdpSession {
100    pub fn new(
101        session_id: String,
102        target_id: String,
103        ws: WebSocket<ReplayStream>,
104        is_browser_session: bool,
105    ) -> Self {
106        CdpSession {
107            session_id,
108            target_id,
109            ws,
110            enabled_domains: HashSet::new(),
111            state: SessionState::Created,
112            is_browser_session,
113            first_domain_enabled: HashSet::new(),
114        }
115    }
116
117    pub fn session_id(&self) -> &str {
118        &self.session_id
119    }
120
121    pub fn target_id(&self) -> &str {
122        &self.target_id
123    }
124
125    pub fn state(&self) -> SessionState {
126        self.state
127    }
128
129    pub fn is_browser_session(&self) -> bool {
130        self.is_browser_session
131    }
132
133    pub fn has_domain_enabled(&self, domain: &str) -> bool {
134        self.enabled_domains.contains(domain)
135    }
136
137    /// Process one incoming WebSocket message. Returns Err on disconnect.
138    pub fn process(
139        &mut self,
140        registry: &SharedRegistry,
141        event_sender: &dyn EventSender,
142    ) -> Result<(), SessionError> {
143        let msg = match read_ws_message(&mut self.ws) {
144            Ok(Some(msg)) => msg,
145            Ok(None) => return Ok(()),
146            Err(e) => {
147                self.state = SessionState::Closing;
148                return Err(e);
149            }
150        };
151
152        let cdp_msg: CdpMessage = match protocol::parse_message(&msg) {
153            Some(m) => m,
154            None => {
155                let resp =
156                    protocol::error_response(None, protocol::ERR_INVALID_REQUEST, "Invalid JSON");
157                let _ = self.send_text(&protocol::serialize_response(&resp));
158                return Ok(());
159            }
160        };
161        let flattened_session_id = cdp_msg.session_id.clone();
162
163        let response = self.route_command(cdp_msg, registry, event_sender);
164        let mut text = protocol::serialize_response(&response);
165        // Flattened-session routing: responses to messages carrying a
166        // sessionId must echo it (clients like Playwright route by the tag;
167        // an untagged response lands on the root session and is dropped).
168        if let Some(sid) = &flattened_session_id {
169            if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&text) {
170                if v.is_object() {
171                    v["sessionId"] = serde_json::Value::String(sid.clone());
172                    text = v.to_string();
173                }
174            }
175        }
176        let _ = self.send_text(&text);
177        Ok(())
178    }
179
180    /// Route a CDP command to the registry. `Domain.enable`/`Domain.disable`
181    /// are dispatched like any other command — a domain the registry cannot
182    /// serve (unregistered, or an explicit not-supported like Fetch.enable
183    /// without an interception facility) must not fabricate an ok response.
184    /// Domain tracking and the first-enable notification happen only after a
185    /// successful enable dispatch.
186    fn route_command(
187        &mut self,
188        msg: CdpMessage,
189        registry: &SharedRegistry,
190        event_sender: &dyn EventSender,
191    ) -> CdpResponse {
192        let parts: Vec<&str> = msg.method.splitn(2, '.').collect();
193        let domain = parts.first().copied().unwrap_or("");
194        let command = parts.get(1).copied().unwrap_or("");
195
196        // Dispatch to DomainHandler with full routing context (message
197        // sessionId + the WS session's target id).
198        match registry.dispatch_message(&msg, &self.target_id, event_sender) {
199            Some(Ok(result)) => {
200                match command {
201                    "enable" => {
202                        self.enabled_domains.insert(domain.to_string());
203                        if self.state == SessionState::Created {
204                            self.state = SessionState::Active;
205                        }
206                        // Notify handler on first enable for this domain in
207                        // this session.
208                        if !self.first_domain_enabled.contains(domain) {
209                            self.first_domain_enabled.insert(domain.to_string());
210                            registry.notify_session_created(domain, &self.session_id);
211                        }
212                    }
213                    "disable" => {
214                        self.enabled_domains.remove(domain);
215                    }
216                    _ => {}
217                }
218                protocol::ok_response(msg.id, result)
219            }
220            Some(Err(err)) => CdpResponse {
221                id: msg.id,
222                result: None,
223                error: Some(err),
224            },
225            None => protocol::error_response(
226                msg.id,
227                protocol::ERR_METHOD_NOT_FOUND,
228                format!("'{}' wasn't found", msg.method),
229            ),
230        }
231    }
232
233    /// Send raw text over WebSocket.
234    pub fn send_text(&mut self, data: &str) -> Result<(), SessionError> {
235        use tungstenite::Message;
236        self.ws
237            .send(Message::Text(data.into()))
238            .map_err(|_| SessionError::Io)
239    }
240
241    /// Get all enabled domain names (for on_session_destroyed notification).
242    pub fn enabled_domains(&self) -> Vec<String> {
243        self.enabled_domains.iter().cloned().collect()
244    }
245
246    /// Transition to Closing state.
247    pub fn begin_close(&mut self) {
248        self.state = SessionState::Closing;
249    }
250
251    /// Transition to Closed state.
252    pub fn finalize(&mut self) {
253        self.state = SessionState::Closed;
254    }
255}
256
257fn read_ws_message(ws: &mut WebSocket<ReplayStream>) -> Result<Option<String>, SessionError> {
258    use tungstenite::Message;
259    match ws.read() {
260        Ok(Message::Text(text)) => Ok(Some(text.to_string())),
261        Ok(Message::Binary(data)) => Ok(Some(String::from_utf8_lossy(&data).into_owned())),
262        Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => Ok(None),
263        Ok(Message::Close(_)) => Err(SessionError::Closed),
264        Ok(Message::Frame(_)) => Ok(None),
265        Err(_) => Err(SessionError::Io),
266    }
267}
268
269// @trace REQ-CDS-003 [req:REQ-CDS-003] [level:unit]
270#[cfg(test)]
271mod tests {
272    use super::SessionState;
273
274    #[test]
275    fn session_state_equality_same_variant() {
276        assert_eq!(SessionState::Created, SessionState::Created);
277    }
278
279    #[test]
280    fn session_state_equality_different_variants() {
281        assert_ne!(SessionState::Created, SessionState::Closed);
282    }
283
284    #[test]
285    fn session_state_clone() {
286        let original = SessionState::Active;
287        let cloned = original.clone();
288        assert_eq!(original, cloned);
289    }
290
291    #[test]
292    fn session_state_copy() {
293        let original = SessionState::Closing;
294        let copied = original; // Copy, not move
295        assert_eq!(original, copied);
296    }
297
298    #[test]
299    fn session_state_debug_format() {
300        assert!(format!("{:?}", SessionState::Created).contains("Created"));
301        assert!(format!("{:?}", SessionState::Active).contains("Active"));
302        assert!(format!("{:?}", SessionState::Closing).contains("Closing"));
303        assert!(format!("{:?}", SessionState::Closed).contains("Closed"));
304    }
305
306    #[test]
307    fn session_state_all_variants_distinct() {
308        let variants = [
309            SessionState::Created,
310            SessionState::Active,
311            SessionState::Closing,
312            SessionState::Closed,
313        ];
314        for i in 0..variants.len() {
315            for j in (i + 1)..variants.len() {
316                assert_ne!(variants[i], variants[j]);
317            }
318        }
319    }
320
321    #[test]
322    fn session_state_send_sync() {
323        fn assert_send<T: Send>() {}
324        fn assert_sync<T: Sync>() {}
325        assert_send::<SessionState>();
326        assert_sync::<SessionState>();
327    }
328}