/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
libs/core/inspector.rs
2 356 строк
80 KB
Nathan Whitaker
fix(inspector): support Chrome worker debugging (#35629)
01 июл 2026, 03:27
Не верифицирован
01 июл 2026, 03:27
1afa1a9
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. //! The documentation for the inspector API is sparse, but these are helpful: //! <https://chromedevtools.github.io/devtools-protocol/> //! <https://web.archive.org/web/20210918052901/https://hyperandroid.com/2020/02/12/v8-inspector-from-an-embedder-standpoint/> use std::cell::Cell; use std::cell::RefCell; use std::collections::HashMap; use std::ffi::c_void; use std::mem::take; use std::pin::Pin; use std::ptr::NonNull; use std::rc::Rc; use std::sync::Arc; use std::task::Context; use std::task::Poll; use std::thread; use parking_lot::Mutex; use crate::futures::channel::mpsc; use crate::futures::channel::mpsc::UnboundedReceiver; use crate::futures::channel::mpsc::UnboundedSender; use crate::futures::channel::oneshot; use crate::futures::prelude::*; use crate::futures::stream::FuturesUnordered; use crate::futures::stream::StreamExt; use crate::futures::task; use crate::serde_json::json; #[derive(Debug)] pub enum InspectorMsgKind { Notification, Message(i32), } #[derive(Debug)] pub struct InspectorMsg { pub kind: InspectorMsgKind, pub content: String, } impl InspectorMsg { /// Create a notification message from a JSON value pub fn notification(content: serde_json::Value) -> Self { Self { kind: InspectorMsgKind::Notification, content: content.to_string(), } } } /// Maximum number of in-flight + completed requests kept for /// Network.getResponseBody/streamResourceContent. Older entries are evicted. const NETWORK_BUFFER_MAX_REQUESTS: usize = 32; /// Per-request body cap. Beyond this, additional chunks are silently dropped /// (matching Node's behavior in `RequestEntry::push_*_data_blob`). const NETWORK_BUFFER_MAX_BYTES_PER_REQUEST: usize = 10 * 1024 * 1024; #[derive(Debug, Default)] struct NetworkDataEntry { /// Top-level `charset` from `Network.requestWillBeSent`, if any. /// Used by `Network.getRequestPostData` to decide whether the captured /// request body can be returned as a utf-8 string. request_charset: Option<String>, /// `response.charset` from `Network.responseReceived`, if any. /// Used by `Network.getResponseBody` to decide between utf-8 string /// and base64 binary output. response_charset: Option<String>, /// Accumulated request body bytes. Populated from `request.postData` on /// `requestWillBeSent`, then any subsequent `Network.dataSent` chunks. post_data: Vec<u8>, /// Decoded response body bytes, concatenated across `Network.dataReceived` /// events. data: Vec<u8>, /// `true` once the request body is fully sent: either no `hasPostData` was /// declared, or `Network.dataSent` with `finished: true` arrived. is_request_finished: bool, /// `true` once `Network.loadingFinished` arrived for this request. is_response_finished: bool, /// `true` once `Network.streamResourceContent` was called for this request: /// further `Network.dataReceived` events bypass the buffer and flow over the /// wire instead, matching Node's `is_streaming` switch. is_streaming: bool, } /// Bounded per-process buffer of request/response bodies, keyed by /// CDP `requestId`. Sized to keep peak memory usage predictable so that /// long-running processes attached to a debugger don't grow unbounded. #[derive(Debug, Default)] struct NetworkDataBuffer { entries: HashMap<String, NetworkDataEntry>, /// FIFO of request IDs in insertion order; oldest evicted first. order: std::collections::VecDeque<String>, } impl NetworkDataBuffer { fn ensure_capacity(&mut self) { while self.order.len() >= NETWORK_BUFFER_MAX_REQUESTS { if let Some(oldest) = self.order.pop_front() { self.entries.remove(&oldest); } else { break; } } } fn insert(&mut self, request_id: &str, entry: NetworkDataEntry) { if self.entries.contains_key(request_id) { return; } self.ensure_capacity(); self.order.push_back(request_id.to_string()); self.entries.insert(request_id.to_string(), entry); } fn get(&self, request_id: &str) -> Option<&NetworkDataEntry> { self.entries.get(request_id) } fn get_mut(&mut self, request_id: &str) -> Option<&mut NetworkDataEntry> { self.entries.get_mut(request_id) } fn erase(&mut self, request_id: &str) { if self.entries.remove(request_id).is_some() { self.order.retain(|id| id != request_id); } } } fn extend_capped(target: &mut Vec<u8>, bytes: &[u8]) { let remaining = NETWORK_BUFFER_MAX_BYTES_PER_REQUEST.saturating_sub(target.len()); let take = bytes.len().min(remaining); target.extend_from_slice(&bytes[..take]); } /// Charset string matching Node's `request_charset == "utf-8"` check /// in `network_agent.cc`. Anything else is treated as binary. fn is_utf8_charset(charset: Option<&str>) -> bool { charset.is_some_and(|c| c.eq_ignore_ascii_case("utf-8")) } fn encode_b64(bytes: &[u8]) -> String { base64::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes) } /// CDP error returned by the custom-domain handlers. The error code mirrors /// Node's `protocol::DispatchResponse` constants used in `network_agent.cc`: /// `InvalidParams` for caller-state issues (request not found, not finished, /// streaming), `ServerError` for the binary-request-body case that the /// protocol doesn't model. enum CdpError { InvalidParams(&'static str), ServerError(&'static str), } impl CdpError { fn code(&self) -> i32 { match self { // Per JSON-RPC spec and Node's DispatchResponse::InvalidParams. Self::InvalidParams(_) => -32602, // Per Node's DispatchResponse::ServerError. Self::ServerError(_) => -32000, } } fn message(&self) -> &'static str { match self { Self::InvalidParams(m) | Self::ServerError(m) => m, } } } /// Build the CDP response for `Network.getResponseBody`. Matches Node's /// `NetworkAgent::getResponseBody`: rejects if the response hasn't finished, /// returns utf-8 as string or otherwise as base64, then erases the entry. fn build_get_response_body( buf: &mut NetworkDataBuffer, request_id: &str, ) -> Result<serde_json::Value, CdpError> { let entry = buf .get(request_id) .ok_or(CdpError::InvalidParams("Request not found"))?; if entry.is_streaming { return Err(CdpError::InvalidParams( "Response body of the request is been streamed", )); } if !entry.is_response_finished { return Err(CdpError::InvalidParams("Response data is not finished yet")); } let result = if is_utf8_charset(entry.response_charset.as_deref()) && let Ok(s) = std::str::from_utf8(&entry.data) { json!({ "body": s, "base64Encoded": false }) } else { json!({ "body": encode_b64(&entry.data), "base64Encoded": true, }) }; buf.erase(request_id); Ok(result) } /// Build the CDP response for `Network.streamResourceContent`. Matches Node's /// `NetworkAgent::streamResourceContent`: returns currently-buffered data, /// flips the entry to streaming mode (so subsequent `Network.dataReceived` /// events flow over the wire), drains the buffer, and erases the entry if the /// response already finished. fn build_stream_resource_content( buf: &mut NetworkDataBuffer, request_id: &str, ) -> Result<serde_json::Value, CdpError> { let entry = buf .get_mut(request_id) .ok_or(CdpError::InvalidParams("Request not found"))?; entry.is_streaming = true; let buffered = std::mem::take(&mut entry.data); let is_response_finished = entry.is_response_finished; if is_response_finished { buf.erase(request_id); } Ok(json!({ "bufferedData": encode_b64(&buffered) })) } /// Build the CDP response for `Network.getRequestPostData`. Matches Node's /// `NetworkAgent::getRequestPostData`: rejects if not yet finished, rejects /// binary bodies, otherwise returns the body as a utf-8 string. fn build_get_request_post_data( buf: &NetworkDataBuffer, request_id: &str, ) -> Result<serde_json::Value, CdpError> { let entry = buf .get(request_id) .ok_or(CdpError::InvalidParams("Request not found"))?; if !entry.is_request_finished { return Err(CdpError::InvalidParams("Request data is not finished yet")); } if !is_utf8_charset(entry.request_charset.as_deref()) { // Node returns ServerError here, not InvalidParams: the protocol simply // doesn't model binary post bodies, so this is a server-side limitation // rather than a caller mistake. return Err(CdpError::ServerError( "Unable to serialize binary request body", )); } let s = std::str::from_utf8(&entry.post_data) .map_err(|_| CdpError::ServerError("Request body is not valid UTF-8"))?; Ok(json!({ "postData": s })) } fn make_cdp_error_response(id: &serde_json::Value, error: &CdpError) -> String { json!({ "id": id, "error": { "code": error.code(), "message": error.message(), } }) .to_string() } fn make_cdp_ok_response( id: &serde_json::Value, result: serde_json::Value, ) -> String { json!({ "id": id, "result": result, }) .to_string() } enum CustomDomainOutcome { Empty, Result(serde_json::Value), Error(CdpError), } /// Dispatch a CDP command to one of the custom domains we own /// (`Network.*`, `DOMStorage.enable/disable`). Returns `None` if the method /// isn't ours and should be forwarded to V8. fn dispatch_custom_domain( method: &str, parsed: &serde_json::Value, session: &InspectorSession, ) -> Option<CustomDomainOutcome> { match method { "Network.enable" => { session.state.network_enabled.set(true); Some(CustomDomainOutcome::Empty) } "Network.disable" => { session.state.network_enabled.set(false); Some(CustomDomainOutcome::Empty) } "DOMStorage.enable" => { session.state.dom_storage_enabled.set(true); Some(CustomDomainOutcome::Empty) } "DOMStorage.disable" => { session.state.dom_storage_enabled.set(false); Some(CustomDomainOutcome::Empty) } "Network.getResponseBody" | "Network.streamResourceContent" | "Network.getRequestPostData" => { let request_id = parsed .pointer("/params/requestId") .and_then(|v| v.as_str()) .unwrap_or(""); let mut buf = session.state.network_data.borrow_mut(); let result = match method { "Network.getResponseBody" => { build_get_response_body(&mut buf, request_id) } "Network.streamResourceContent" => { build_stream_resource_content(&mut buf, request_id) } _ => build_get_request_post_data(&buf, request_id), }; Some(match result { Ok(v) => CustomDomainOutcome::Result(v), Err(err) => CustomDomainOutcome::Error(err), }) } _ => None, } } // TODO(bartlomieju): remove this pub type SessionProxySender = UnboundedSender<InspectorMsg>; // TODO(bartlomieju): remove this pub type SessionProxyReceiver = UnboundedReceiver<String>; /// Channels for an inspector session pub enum InspectorSessionChannels { /// Regular inspector session with bidirectional communication Regular { tx: SessionProxySender, rx: SessionProxyReceiver, }, /// Worker inspector session with separate channels for main<->worker communication Worker { /// Main thread sends commands TO worker main_to_worker_tx: UnboundedSender<String>, /// Worker sends responses/events TO main thread worker_to_main_rx: UnboundedReceiver<InspectorMsg>, /// Worker URL for identification worker_url: String, }, } /// Encapsulates channels and metadata for creating an inspector session pub struct InspectorSessionProxy { pub channels: InspectorSessionChannels, pub kind: InspectorSessionKind, } /// Creates a pair of inspector session proxies for worker-main communication. pub fn create_worker_inspector_session_pair( worker_url: String, ) -> (InspectorSessionProxy, InspectorSessionProxy) { let (worker_to_main_tx, worker_to_main_rx) = mpsc::unbounded::<InspectorMsg>(); let (main_to_worker_tx, main_to_worker_rx) = mpsc::unbounded::<String>(); let main_side = InspectorSessionProxy { channels: InspectorSessionChannels::Worker { main_to_worker_tx, worker_to_main_rx, worker_url, }, kind: InspectorSessionKind::NonBlocking { wait_for_disconnect: false, }, }; let worker_side = InspectorSessionProxy { channels: InspectorSessionChannels::Regular { tx: worker_to_main_tx, rx: main_to_worker_rx, }, kind: InspectorSessionKind::NonBlocking { wait_for_disconnect: false, }, }; (main_side, worker_side) } pub type InspectorSessionSend = Box<dyn Fn(InspectorMsg)>; #[derive(Clone, Copy, Debug)] enum PollState { Idle, Woken, Polling, Parked, Dropped, } /// This structure is used responsible for providing inspector interface /// to the `JsRuntime`. /// /// It stores an instance of `v8::inspector::V8Inspector` and additionally /// implements `v8::inspector::V8InspectorClientImpl`. /// /// After creating this structure it's possible to connect multiple sessions /// to the inspector, in case of Deno it's either: a "websocket session" that /// provides integration with Chrome Devtools, or an "in-memory session" that /// is used for REPL or coverage collection. pub struct JsRuntimeInspector { v8_inspector: Rc<v8::inspector::V8Inspector>, new_session_tx: UnboundedSender<InspectorSessionProxy>, deregister_tx: RefCell<Option<oneshot::Sender<()>>>, state: Rc<JsRuntimeInspectorState>, } impl Drop for JsRuntimeInspector { fn drop(&mut self) { // Since the waker is cloneable, it might outlive the inspector itself. // Set the poll state to 'dropped' so it doesn't attempt to request an // interrupt from the isolate. self .state .waker .update(|w| w.poll_state = PollState::Dropped); // V8 automatically deletes all sessions when an `V8Inspector` instance is // deleted, however InspectorSession also has a drop handler that cleans // up after itself. To avoid a double free, make sure the inspector is // dropped last. self.state.sessions.borrow_mut().drop_sessions(); // Notify counterparty that this instance is being destroyed. Ignoring // result because counterparty waiting for the signal might have already // dropped the other end of channel. if let Some(deregister_tx) = self.deregister_tx.borrow_mut().take() { let _ = deregister_tx.send(()); } } } #[derive(Clone)] struct JsRuntimeInspectorState { isolate_ptr: v8::UnsafeRawIsolatePtr, context: v8::Global<v8::Context>, flags: Rc<RefCell<InspectorFlags>>, waker: Arc<InspectorWaker>, sessions: Rc<RefCell<SessionContainer>>, active_session_count: Rc<Cell<usize>>, is_dispatching_message: Rc<RefCell<bool>>, pending_worker_messages: Arc<Mutex<Vec<(String, String)>>>, nodeworker_enabled: Rc<Cell<bool>>, nodeworker_wait_for_debugger_on_start: Rc<Cell<bool>>, auto_attach_enabled: Rc<Cell<bool>>, auto_attach_wait_for_debugger_on_start: Rc<Cell<bool>>, discover_targets_enabled: Rc<Cell<bool>>, /// Shared buffer of captured Network.* request/response bodies, populated /// from `op_inspector_emit_protocol_event` and read by the dispatcher /// when handling `Network.getResponseBody`/`getRequestPostData`/ /// `streamResourceContent`. network_data: Rc<RefCell<NetworkDataBuffer>>, } struct JsRuntimeInspectorClient(Rc<JsRuntimeInspectorState>); impl v8::inspector::V8InspectorClientImpl for JsRuntimeInspectorClient { fn run_message_loop_on_pause(&self, context_group_id: i32) { assert_eq!(context_group_id, JsRuntimeInspector::CONTEXT_GROUP_ID); self.0.flags.borrow_mut().on_pause = true; let _ = self.0.poll_sessions(None); } fn quit_message_loop_on_pause(&self) { self.0.flags.borrow_mut().on_pause = false; } fn run_if_waiting_for_debugger(&self, context_group_id: i32) { assert_eq!(context_group_id, JsRuntimeInspector::CONTEXT_GROUP_ID); let mut flags = self.0.flags.borrow_mut(); flags.waiting_for_session = false; flags.paused_on_start = false; } fn ensure_default_context_in_group( &self, context_group_id: i32, ) -> Option<v8::Local<'_, v8::Context>> { assert_eq!(context_group_id, JsRuntimeInspector::CONTEXT_GROUP_ID); let context = self.0.context.clone(); let mut isolate = unsafe { v8::Isolate::from_raw_isolate_ptr(self.0.isolate_ptr) }; let isolate = &mut isolate; v8::callback_scope!(unsafe scope, isolate); let local = v8::Local::new(scope, context); Some(unsafe { local.extend_lifetime_unchecked() }) } fn resource_name_to_url( &self, resource_name: &v8::inspector::StringView, ) -> Option<v8::UniquePtr<v8::inspector::StringBuffer>> { let resource_name = resource_name.to_string(); #[allow( clippy::disallowed_methods, reason = "Url::from_file_path is more efficient when not using the error and this code doesn't need Wasm support" )] let url = url::Url::from_file_path(resource_name).ok()?; let src_view = v8::inspector::StringView::from(url.as_str().as_bytes()); Some(v8::inspector::StringBuffer::create(src_view)) } } impl JsRuntimeInspectorState { #[allow(clippy::result_unit_err, reason = "error details not needed")] pub fn poll_sessions( &self, mut invoker_cx: Option<&mut Context>, ) -> Result<Poll<()>, ()> { // The futures this function uses do not have re-entrant poll() functions. // However it is can happen that poll_sessions() gets re-entered, e.g. // when an interrupt request is honored while the inspector future is polled // by the task executor. We let the caller know by returning some error. let Ok(mut sessions) = self.sessions.try_borrow_mut() else { return Err(()); }; self.waker.update(|w| { match w.poll_state { PollState::Idle | PollState::Woken => w.poll_state = PollState::Polling, _ => unreachable!(), }; }); // Create a new Context object that will make downstream futures // use the InspectorWaker when they are ready to be polled again. let waker_ref = task::waker_ref(&self.waker); let cx = &mut Context::from_waker(&waker_ref); loop { loop { // Do one "handshake" with a newly connected session at a time. if let Some(session) = sessions.handshake.take() { let id = sessions.next_local_id; sessions.next_local_id += 1; let mut fut = pump_inspector_session_messages(session.clone(), id).boxed_local(); // Only add to established if the future is still pending. // If the channel is already closed (e.g., worker terminated quickly), // the future may complete immediately on first poll. Pushing a // completed future to FuturesUnordered would cause a panic when // polled again. Also skip inserting into local — the session is // already dead and would never be cleaned up (no established future // to yield back its ID). if fut.poll_unpin(cx).is_pending() { sessions.established.push(fut); sessions.insert_local_session(id, session); // Track the first session as the main session for Target events if sessions.main_session_id.is_none() { sessions.main_session_id = Some(id); } } continue; } // Accept new connections. if let Poll::Ready(Some(session_proxy)) = sessions.session_rx.poll_next_unpin(cx) { match session_proxy.channels { InspectorSessionChannels::Worker { main_to_worker_tx, worker_to_main_rx, worker_url, } => { // Get the next local ID for this worker let worker_id = sessions.next_local_id; sessions.next_local_id += 1; sessions.register_worker_session(worker_id, worker_url.clone()); // Register the worker channels sessions.register_worker_channels( worker_id, main_to_worker_tx, worker_to_main_rx, ); // Notify the main session about worker target creation/attachment. // // discover_targets_enabled: Set to true when the debugger calls // Target.setDiscoverTargets. When enabled, the inspector sends // Target.targetCreated events for new workers. // // auto_attach_enabled: Set to true when the debugger calls // Target.setAutoAttach. When enabled, the inspector automatically // attaches to new workers and sends Target.attachedToTarget events. if let Some(main_id) = sessions.main_session_id && let Some(main_session) = sessions.local.get(&main_id) && let Some(ts) = sessions.target_sessions.get(&format!("{}", worker_id)) { if self.discover_targets_enabled.get() { (main_session.state.send)(InspectorMsg::notification( json!({ "method": "Target.targetCreated", "params": { "targetInfo": ts.target_info(false) } }), )); } if self.auto_attach_enabled.get() { ts.attached.set(true); let waiting_for_debugger = self.auto_attach_wait_for_debugger_on_start.get(); (main_session.state.send)(InspectorMsg::notification( json!({ "method": "Target.attachedToTarget", "params": { "sessionId": ts.session_id, "targetInfo": ts.target_info(true), "waitingForDebugger": waiting_for_debugger } }), )); } if self.nodeworker_enabled.get() { let waiting_for_debugger = self.nodeworker_wait_for_debugger_on_start.get(); (main_session.state.send)(InspectorMsg::notification( json!({ "method": "NodeWorker.attachedToWorker", "params": { "sessionId": ts.session_id, "workerInfo": ts.worker_info(), "waitingForDebugger": waiting_for_debugger } }), )); } } continue; } InspectorSessionChannels::Regular { tx, rx } => { // Normal session (not a worker) let session = InspectorSession::new( sessions.v8_inspector.as_ref().unwrap().clone(), self.is_dispatching_message.clone(), Box::new(move |msg| { let _ = tx.unbounded_send(msg); }), Some(rx), session_proxy.kind, self.sessions.clone(), self.pending_worker_messages.clone(), self.nodeworker_enabled.clone(), self.nodeworker_wait_for_debugger_on_start.clone(), self.auto_attach_enabled.clone(), self.auto_attach_wait_for_debugger_on_start.clone(), self.discover_targets_enabled.clone(), self.flags.clone(), self.network_data.clone(), ); let prev = sessions.handshake.replace(session); assert!(prev.is_none()); continue; } } } // Poll worker message channels - forward messages from workers to main session if let Some(main_id) = sessions.main_session_id { // Get main session send function before mutably iterating over target_sessions let main_session_send = sessions.local.get(&main_id).map(|s| s.state.send.clone()); if let Some(send) = main_session_send { let mut has_worker_message = false; let mut terminated_workers = Vec::new(); for target_session in sessions.target_sessions.values() { // Skip sessions that haven't had their channels registered if !target_session.has_channels() { continue; } match target_session.poll_from_worker(cx) { Poll::Ready(Some(msg)) => { // CDP Flattened Session Mode: Add sessionId at top level // Used by Chrome DevTools (when NodeWorker.enable has NOT been called) // VSCode uses NodeWorker.receivedMessageFromWorker instead if !self.nodeworker_enabled.get() { if let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(&msg.content) { if let Some(obj) = parsed.as_object_mut() { obj.insert( "sessionId".to_string(), json!(target_session.session_id), ); let flattened_msg = parsed.to_string(); // Send the flattened response with sessionId at top level send(InspectorMsg { kind: msg.kind, content: flattened_msg, }); } } else { // Fallback: send as-is if not valid JSON send(msg); } } else { let wrapped_nodeworker = json!({ "method": "NodeWorker.receivedMessageFromWorker", "params": { "sessionId": target_session.session_id, "message": msg.content, "workerId": target_session.target_id } }); send(InspectorMsg { kind: InspectorMsgKind::Notification, content: wrapped_nodeworker.to_string(), }); } has_worker_message = true; } Poll::Ready(None) => { // Worker channel closed - worker has terminated // Notify debugger based on which protocol is in use if self.nodeworker_enabled.get() { // VSCode / Node.js style send(InspectorMsg::notification(json!({ "method": "NodeWorker.detachedFromWorker", "params": { "sessionId": target_session.session_id } }))); } else if self.auto_attach_enabled.get() || self.discover_targets_enabled.get() { // Chrome DevTools style send(InspectorMsg::notification(json!({ "method": "Target.targetDestroyed", "params": { "targetId": target_session.target_id } }))); } terminated_workers.push(target_session.session_id.clone()); } Poll::Pending => {} } } // Clean up terminated worker sessions for session_id in terminated_workers { sessions.target_sessions.remove(&session_id); has_worker_message = true; // Trigger re-poll } if has_worker_message { continue; } } } // Poll established sessions. match sessions.established.poll_next_unpin(cx) { Poll::Ready(Some(completed_id)) => { // A session's pump future completed (WS disconnected). // Remove it from local so sessions_state() no longer // reports it as active. sessions.remove_local_session(completed_id); // If this was the main session, promote another session // or clear, so new connections can become main and // Target/worker notifications continue to work. if sessions.main_session_id == Some(completed_id) { // Promote an arbitrary remaining session. HashMap iteration // order is non-deterministic, but in practice there is // typically only one debugger client connected at a time. sessions.main_session_id = sessions.local.keys().next().copied(); } continue; } Poll::Ready(None) => { break; } Poll::Pending => { break; } }; } let should_block = { let flags = self.flags.borrow(); flags.on_pause || flags.waiting_for_session }; // Process any queued NodeWorker messages after polling completes // Drain from the thread-safe Mutex queue (doesn't require borrowing sessions) let pending_messages: Vec<(String, String)> = { let mut queue = self.pending_worker_messages.lock(); queue.drain(..).collect() }; for (session_id, message) in pending_messages { if let Some(target_session) = sessions.target_sessions.get(&session_id) { target_session.send_to_worker(message); } } let new_state = self.waker.update(|w| { match w.poll_state { PollState::Woken => { // The inspector was woken while the session handler was being // polled, so we poll it another time. w.poll_state = PollState::Polling; } PollState::Polling if !should_block => { // The session handler doesn't need to be polled any longer, and // there's no reason to block (execution is not paused), so this // function is about to return. w.poll_state = PollState::Idle; // Register the task waker that can be used to wake the parent // task that will poll the inspector future. if let Some(cx) = invoker_cx.take() { w.task_waker.replace(cx.waker().clone()); } // Register the address of the inspector, which allows the waker // to request an interrupt from the isolate. w.inspector_state_ptr = NonNull::new(self as *const _ as *mut Self); } PollState::Polling if should_block => { // Isolate execution has been paused but there are no more // events to process, so this thread will be parked. Therefore, // store the current thread handle in the waker so it knows // which thread to unpark when new events arrive. w.poll_state = PollState::Parked; w.parked_thread.replace(thread::current()); } _ => unreachable!(), }; w.poll_state }); match new_state { PollState::Idle => break, // Yield to task. PollState::Polling => continue, // Poll the session handler again. PollState::Parked => thread::park(), // Park the thread. _ => unreachable!(), }; } Ok(Poll::Pending) } } impl JsRuntimeInspector { /// Currently Deno supports only a single context in `JsRuntime` /// and thus it's id is provided as an associated constant. const CONTEXT_GROUP_ID: i32 = 1; pub fn new( isolate_ptr: v8::UnsafeRawIsolatePtr, scope: &mut v8::PinScope, context: v8::Local<v8::Context>, is_main_runtime: bool, worker_id: Option<u32>, ) -> Rc<Self> { let (new_session_tx, new_session_rx) = mpsc::unbounded::<InspectorSessionProxy>(); let waker = InspectorWaker::new(scope.thread_safe_handle()); let active_session_count = Rc::new(Cell::new(0)); let state = Rc::new(JsRuntimeInspectorState { waker, flags: Default::default(), isolate_ptr, context: v8::Global::new(scope, context), sessions: Rc::new(RefCell::new(SessionContainer::temporary_placeholder( active_session_count.clone(), ))), active_session_count, is_dispatching_message: Default::default(), pending_worker_messages: Arc::new(Mutex::new(Vec::new())), nodeworker_enabled: Rc::new(Cell::new(false)), nodeworker_wait_for_debugger_on_start: Rc::new(Cell::new(false)), auto_attach_enabled: Rc::new(Cell::new(false)), auto_attach_wait_for_debugger_on_start: Rc::new(Cell::new(false)), discover_targets_enabled: Rc::new(Cell::new(false)), network_data: Rc::new(RefCell::new(NetworkDataBuffer::default())), }); let client = Box::new(JsRuntimeInspectorClient(state.clone())); let v8_inspector_client = v8::inspector::V8InspectorClient::new(client); let v8_inspector = Rc::new(v8::inspector::V8Inspector::create( scope, v8_inspector_client, )); *state.sessions.borrow_mut() = SessionContainer::new( v8_inspector.clone(), new_session_rx, state.active_session_count.clone(), ); // Tell the inspector about the main realm. let context_name_bytes = if is_main_runtime { &b"main realm"[..] } else { &format!("worker [{}]", worker_id.unwrap_or(1)).into_bytes() }; let context_name = v8::inspector::StringView::from(context_name_bytes); // NOTE(bartlomieju): this is what Node.js does and it turns out some // debuggers (like VSCode) rely on this information to disconnect after // program completes. // The auxData structure should match // {isDefault: boolean, type: 'default'|'isolated'|'worker'}. // `isDefault` means this is the default context for its target, not // whether the target is the main runtime. Chrome DevTools treats // non-default contexts as content scripts and may blackbox them. let aux_data = if is_main_runtime { r#"{"isDefault": true, "type": "default"}"# } else { r#"{"isDefault": true, "type": "worker"}"# }; let aux_data_view = v8::inspector::StringView::from(aux_data.as_bytes()); v8_inspector.context_created( context, Self::CONTEXT_GROUP_ID, context_name, aux_data_view, ); // Poll the session handler so we will get notified whenever there is // new incoming debugger activity. let _ = state.poll_sessions(None).unwrap(); Rc::new(Self { v8_inspector, state, new_session_tx, deregister_tx: RefCell::new(None), }) } pub fn is_dispatching_message(&self) -> bool { *self.state.is_dispatching_message.borrow() } pub fn context_destroyed( &self, scope: &mut v8::PinScope<'_, '_>, context: v8::Global<v8::Context>, ) { let context = v8::Local::new(scope, context); self.v8_inspector.context_destroyed(context); } pub fn exception_thrown( &self, scope: &mut v8::PinScope<'_, '_>, exception: v8::Local<'_, v8::Value>, in_promise: bool, ) { let context = scope.get_current_context(); let message = v8::Exception::create_message(scope, exception); let stack_trace = message.get_stack_trace(scope); let stack_trace = self.v8_inspector.create_stack_trace(stack_trace); self.v8_inspector.exception_thrown( context, if in_promise { v8::inspector::StringView::from("Uncaught (in promise)".as_bytes()) } else { v8::inspector::StringView::from("Uncaught".as_bytes()) }, exception, v8::inspector::StringView::from("".as_bytes()), v8::inspector::StringView::from("".as_bytes()), 0, 0, stack_trace, 0, ); } /// Broadcast `Runtime.executionContextDestroyed` to all connected sessions /// without requiring a V8 scope. This is used during `process.exit()` to /// notify debuggers that the execution context is being torn down before /// calling `std::process::exit()`, which would skip the normal event-loop /// shutdown path where V8's `context_destroyed` is called. /// /// Sessions that opted into `NodeRuntime.notifyWhenWaitingForDisconnect` /// are skipped here — they receive `NodeRuntime.waitingForDisconnect` /// instead, via `broadcast_waiting_for_disconnect`. pub fn broadcast_context_destroyed(&self) { let sessions = self.state.sessions.borrow(); for session in sessions.local.values() { if session.state.notify_waiting_for_disconnect.get() { continue; } (session.state.send)(InspectorMsg::notification(json!({ "method": "Runtime.executionContextDestroyed", "params": { "executionContextId": Self::CONTEXT_GROUP_ID } }))); } } /// Emit `NodeRuntime.waitingForDisconnect` to every session that opted in /// via `NodeRuntime.notifyWhenWaitingForDisconnect(enabled: true)`. Called /// during the process-exit handler before blocking for disconnect, so /// debugger clients can close cleanly instead of waiting for a TCP RST. pub fn broadcast_waiting_for_disconnect(&self) { let sessions = self.state.sessions.borrow(); for session in sessions.local.values() { if !session.state.notify_waiting_for_disconnect.get() { continue; } (session.state.send)(InspectorMsg::notification(json!({ "method": "NodeRuntime.waitingForDisconnect" }))); } } /// Block the current thread until all inspector sessions have disconnected. /// Used after `broadcast_context_destroyed` to give debuggers a chance to /// process the notification before the process exits. /// /// Note: this intentionally does not set a blocking flag on `poll_sessions`. /// Unlike `wait_for_session` (which blocks waiting for a *new* WS /// connection handled by a separate server thread), disconnect detection /// requires the async I/O reactor to process WebSocket close frames. /// Parking the thread would prevent those events from being delivered. /// The loop spins with `poll_sessions(None)` which returns quickly when /// idle, yielding a brief busy-wait that is acceptable given this only /// runs during process exit. pub fn wait_for_sessions_disconnect(&self) { loop { { let sessions = self.state.sessions.borrow(); if sessions.local.is_empty() && sessions.established.is_empty() { break; } } let _ = self.state.poll_sessions(None); } } pub fn sessions_state(&self) -> SessionsState { self.state.sessions.borrow().sessions_state() } pub fn poll_sessions_from_event_loop(&self, cx: &mut Context) { let _ = self.state.poll_sessions(Some(cx)).unwrap(); } /// This function blocks the thread until at least one inspector client has /// established a websocket connection. pub fn wait_for_session(&self) { loop { if let Some(_session) = self.state.sessions.borrow_mut().local.values().next() { self.state.flags.borrow_mut().waiting_for_session = false; break; } else { self.state.flags.borrow_mut().waiting_for_session = true; let _ = self.state.poll_sessions(None).unwrap(); } } } /// This function blocks the thread until at least one inspector client has /// established a websocket connection. /// /// After that, it instructs V8 to pause at the next statement. /// Frontend must send "Runtime.runIfWaitingForDebugger" message to resume /// execution. pub fn wait_for_session_and_break_on_next_statement(&self) { self.state.flags.borrow_mut().paused_on_start = true; // Block the main thread until Runtime.runIfWaitingForDebugger is // received (which clears paused_on_start). This matches Node.js // --inspect-brk behavior where execution is blocked until the // frontend explicitly resumes. // // poll_sessions will block when waiting_for_session is true and // process incoming messages (including from WS clients). The pump // handler intercepts Runtime.runIfWaitingForDebugger and clears // paused_on_start. Since poll_sessions processes all pending // messages before returning, paused_on_start will be false when // poll_sessions returns (if the message was received). loop { if !self.state.flags.borrow().paused_on_start { break; } self.state.flags.borrow_mut().waiting_for_session = true; let _ = self.state.poll_sessions(None).unwrap(); } // Schedule a V8 debugger break on the next statement. By this point // the frontend has sent Debugger.enable (enabling the debugger agent) // and Runtime.runIfWaitingForDebugger (which unblocked us above). // The break causes a Debugger.paused notification, matching the // --inspect-brk behavior where execution pauses at the first statement. // Use the session that sent runIfWaitingForDebugger (it has Debugger // enabled), falling back to any available session. let sessions = self.state.sessions.borrow(); let resumed_by = self.state.flags.borrow().resumed_by_session_id; let session = resumed_by .and_then(|id| sessions.local.get(&id)) .or_else(|| sessions.local.values().next()); if let Some(session) = session { let reason = v8::inspector::StringView::from(&b"debugCommand"[..]); let detail = v8::inspector::StringView::empty(); session .v8_session .schedule_pause_on_next_statement(reason, detail); } // Clear so it doesn't stale-reference a session in future cycles. self.state.flags.borrow_mut().resumed_by_session_id = None; } pub fn wait_for_runtime_run_if_waiting_for_debugger(&self) { self.state.flags.borrow_mut().paused_on_start = true; loop { if !self.state.flags.borrow().paused_on_start { break; } self.state.flags.borrow_mut().waiting_for_session = true; let _ = self.state.poll_sessions(None).unwrap(); } self.state.flags.borrow_mut().resumed_by_session_id = None; } pub fn wait_for_page_wait_for_debugger(&self) { const PAGE_WAIT_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(500); { let mut flags = self.state.flags.borrow_mut(); flags.paused_on_start = true; flags.waiting_for_session = false; flags.page_wait_for_debugger_received = false; } let deadline = std::time::Instant::now() + PAGE_WAIT_GRACE_PERIOD; loop { { let flags = self.state.flags.borrow(); if !flags.paused_on_start || flags.page_wait_for_debugger_received { break; } } if std::time::Instant::now() >= deadline { let mut flags = self.state.flags.borrow_mut(); if !flags.page_wait_for_debugger_received { flags.paused_on_start = false; flags.waiting_for_session = false; flags.resumed_by_session_id = None; break; } } let _ = self.state.poll_sessions(None).unwrap(); std::thread::sleep(std::time::Duration::from_millis(1)); } if self.state.flags.borrow().paused_on_start { loop { if !self.state.flags.borrow().paused_on_start { break; } self.state.flags.borrow_mut().waiting_for_session = true; let _ = self.state.poll_sessions(None).unwrap(); } let mut flags = self.state.flags.borrow_mut(); flags.page_wait_for_debugger_received = false; flags.resumed_by_session_id = None; } } pub fn should_wait_for_debugger_on_worker_start(&self) -> bool { self.state.auto_attach_enabled.get() && self.state.auto_attach_wait_for_debugger_on_start.get() || self.state.nodeworker_enabled.get() && self.state.nodeworker_wait_for_debugger_on_start.get() } pub fn should_wait_for_page_wait_for_debugger_on_worker_start(&self) -> bool { self.state.active_session_count.get() > 0 && !self.should_wait_for_debugger_on_worker_start() } pub fn wait_for_debugger_enabled_for_worker_message(&self) { const WORKER_MESSAGE_DEBUGGER_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(500); let deadline = std::time::Instant::now() + WORKER_MESSAGE_DEBUGGER_GRACE_PERIOD; loop { if self.state.flags.borrow().debugger_enabled_session_count > 0 { break; } if std::time::Instant::now() >= deadline { break; } let _ = self.state.poll_sessions(None).unwrap(); std::thread::sleep(std::time::Duration::from_millis(1)); } } /// Obtain a sender for proxy channels. pub fn get_session_sender(&self) -> UnboundedSender<InspectorSessionProxy> { self.new_session_tx.clone() } /// Create a channel that notifies the frontend when inspector is dropped. /// /// NOTE: Only a single handler is currently available. pub fn add_deregister_handler(&self) -> oneshot::Receiver<()> { let maybe_deregister_tx = self.deregister_tx.borrow_mut().take(); if let Some(deregister_tx) = maybe_deregister_tx && !deregister_tx.is_canceled() { panic!("Inspector deregister handler already exists and is alive."); } let (tx, rx) = oneshot::channel::<()>(); self.deregister_tx.borrow_mut().replace(tx); rx } /// Capture an incoming `Network.*` event payload into the shared body buffer /// before it is broadcast to sessions. Called from /// `op_inspector_emit_protocol_event` so subsequent /// `Network.getResponseBody`/`streamResourceContent`/`getRequestPostData` /// CDP commands have something to return. /// /// Returns `true` if the event should still be broadcast to sessions, or /// `false` if the event was fully consumed by the buffer. Matches Node's /// `NetworkAgent`: `dataSent` is purely a capture event, and `dataReceived` /// is only forwarded once `streamResourceContent` has flipped the entry /// into streaming mode. pub fn capture_network_event( &self, event_name: &str, params: &serde_json::Value, ) -> bool { let Some(request_id) = params.get("requestId").and_then(|v| v.as_str()) else { return true; }; let mut buf = self.state.network_data.borrow_mut(); match event_name { "Network.requestWillBeSent" => { let charset = params .get("charset") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let has_post_data = params .pointer("/request/hasPostData") .and_then(|v| v.as_bool()) .unwrap_or(false); let mut entry = NetworkDataEntry { request_charset: charset, is_request_finished: !has_post_data, ..NetworkDataEntry::default() }; if let Some(post_data) = params.pointer("/request/postData").and_then(|v| v.as_str()) { extend_capped(&mut entry.post_data, post_data.as_bytes()); } buf.insert(request_id, entry); true } "Network.responseReceived" => { if let Some(entry) = buf.get_mut(request_id) { entry.response_charset = params .pointer("/response/charset") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } true } "Network.loadingFinished" => { if let Some(entry) = buf.get_mut(request_id) { entry.is_response_finished = true; if entry.is_streaming { buf.erase(request_id); } } true } "Network.loadingFailed" => { buf.erase(request_id); true } "Network.dataReceived" => { let Some(entry) = buf.get_mut(request_id) else { // No tracked request — pass through to the wire. return true; }; if entry.is_streaming { // Streaming mode: bypass the buffer and forward to sessions. return true; } if let Some(data_b64) = params.get("data").and_then(|v| v.as_str()) && let Ok(bytes) = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, data_b64, ) { extend_capped(&mut entry.data, &bytes); } false } "Network.dataSent" => { let Some(entry) = buf.get_mut(request_id) else { return false; }; if params .get("finished") .and_then(|v| v.as_bool()) .unwrap_or(false) { entry.is_request_finished = true; return false; } if let Some(data_b64) = params.get("data").and_then(|v| v.as_str()) && let Ok(bytes) = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, data_b64, ) { extend_capped(&mut entry.post_data, &bytes); } false } _ => true, } } /// Broadcast a protocol event notification to all sessions that have the /// given domain enabled. Used for custom domains like Network. pub fn broadcast_to_sessions(&self, event_name: &str, params_json: &str) { let domain = event_name.split('.').next().unwrap_or(""); let notification = json!({ "method": event_name, "params": serde_json::from_str::<serde_json::Value>(params_json) .unwrap_or_else(|_| json!({})) }) .to_string(); let sessions = self.state.sessions.borrow(); // `sessions.local` contains all active sessions — both programmatic // (created via create_local_session) and WebSocket-based (added during // handshake). The `established` field only holds their pump futures. for session in sessions.local.values() { let enabled = match domain { "Network" => session.state.network_enabled.get(), "DOMStorage" => session.state.dom_storage_enabled.get(), _ => false, }; if enabled { (session.state.send)(InspectorMsg { kind: InspectorMsgKind::Notification, content: notification.clone(), }); } } } pub fn create_local_session( inspector: Rc<JsRuntimeInspector>, callback: InspectorSessionSend, kind: InspectorSessionKind, ) -> LocalInspectorSession { let (session_id, sessions) = { let sessions = inspector.state.sessions.clone(); let inspector_session = InspectorSession::new( inspector.v8_inspector.clone(), inspector.state.is_dispatching_message.clone(), callback, None, kind, sessions.clone(), inspector.state.pending_worker_messages.clone(), inspector.state.nodeworker_enabled.clone(), inspector .state .nodeworker_wait_for_debugger_on_start .clone(), inspector.state.auto_attach_enabled.clone(), inspector .state .auto_attach_wait_for_debugger_on_start .clone(), inspector.state.discover_targets_enabled.clone(), inspector.state.flags.clone(), inspector.state.network_data.clone(), ); let session_id = { let mut s = sessions.borrow_mut(); let id = s.next_local_id; s.next_local_id += 1; assert!(s.insert_local_session(id, inspector_session).is_none()); id }; take(&mut inspector.state.flags.borrow_mut().waiting_for_session); (session_id, sessions) }; LocalInspectorSession::new(session_id, sessions) } } #[derive(Default)] struct InspectorFlags { waiting_for_session: bool, on_pause: bool, /// Set when --inspect-brk is used. Remains true until /// Runtime.runIfWaitingForDebugger is received, allowing /// NodeRuntime.waitingForDebugger to be emitted. paused_on_start: bool, /// Set when a frontend explicitly sends Page.waitForDebugger. page_wait_for_debugger_received: bool, /// Tracks which session sent Runtime.runIfWaitingForDebugger, /// so schedule_pause_on_next_statement targets the correct session. resumed_by_session_id: Option<i32>, debugger_enabled_session_count: usize, } #[derive(Debug)] pub struct SessionsState { pub has_active: bool, pub has_blocking: bool, pub has_nonblocking: bool, pub has_nonblocking_wait_for_disconnect: bool, } /// A helper structure that helps coordinate sessions during different /// parts of their lifecycle. pub struct SessionContainer { v8_inspector: Option<Rc<v8::inspector::V8Inspector>>, session_rx: UnboundedReceiver<InspectorSessionProxy>, handshake: Option<Rc<InspectorSession>>, established: FuturesUnordered<InspectorSessionPumpMessages>, next_local_id: i32, local: HashMap<i32, Rc<InspectorSession>>, active_session_count: Rc<Cell<usize>>, target_sessions: HashMap<String, Rc<TargetSession>>, // sessionId -> TargetSession main_session_id: Option<i32>, // The first session that should receive Target events next_worker_id: u32, // Sequential ID for worker display naming (1, 2, 3, ...) } struct MainWorkerChannels { main_to_worker_tx: UnboundedSender<String>, worker_to_main_rx: UnboundedReceiver<InspectorMsg>, } /// Represents a CDP Target session (e.g., a worker) struct TargetSession { target_id: String, session_id: String, local_session_id: i32, /// Sequential worker ID for display (1, 2, 3, ...) - independent from session IDs worker_id: u32, main_worker_channels: RefCell<Option<MainWorkerChannels>>, url: String, /// Track if we've already sent attachedToTarget for this session attached: Cell<bool>, } impl TargetSession { /// Get a display title for the worker using Node.js style naming /// e.g., "worker [1]", "worker [2]" fn title(&self) -> String { format!("worker [{}]", self.worker_id) } /// Send a message to the worker (main → worker direction) fn send_to_worker(&self, message: String) { if let Some(channels) = self.main_worker_channels.borrow().as_ref() { let _ = channels.main_to_worker_tx.unbounded_send(message); } } /// Returns true if worker channels have been registered fn has_channels(&self) -> bool { self.main_worker_channels.borrow().is_some() } /// Poll for messages from the worker (worker → main direction). /// Panics if channels have not been registered yet - caller should /// check has_channels() first. fn poll_from_worker(&self, cx: &mut Context) -> Poll<Option<InspectorMsg>> { self .main_worker_channels .borrow_mut() .as_mut() .expect("poll_from_worker called before channels were registered") .worker_to_main_rx .poll_next_unpin(cx) } } impl SessionContainer { fn new( v8_inspector: Rc<v8::inspector::V8Inspector>, new_session_rx: UnboundedReceiver<InspectorSessionProxy>, active_session_count: Rc<Cell<usize>>, ) -> Self { Self { v8_inspector: Some(v8_inspector), session_rx: new_session_rx, handshake: None, established: FuturesUnordered::new(), next_local_id: 1, local: HashMap::new(), active_session_count, target_sessions: HashMap::new(), main_session_id: None, next_worker_id: 1, // Workers are numbered starting from 1 } } /// V8 automatically deletes all sessions when an `V8Inspector` instance is /// deleted, however InspectorSession also has a drop handler that cleans /// up after itself. To avoid a double free, we need to manually drop /// all sessions before dropping the inspector instance. fn drop_sessions(&mut self) { self.v8_inspector = Default::default(); self.handshake.take(); self.established.clear(); self.local.clear(); self.active_session_count.set(0); } fn insert_local_session( &mut self, id: i32, session: Rc<InspectorSession>, ) -> Option<Rc<InspectorSession>> { let previous = self.local.insert(id, session); if previous.is_none() { self .active_session_count .set(self.active_session_count.get() + 1); } previous } fn remove_local_session(&mut self, id: i32) -> Option<Rc<InspectorSession>> { let removed = self.local.remove(&id); if removed.is_some() { self .active_session_count .set(self.active_session_count.get().saturating_sub(1)); } removed } fn sessions_state(&self) -> SessionsState { SessionsState { has_active: !self.established.is_empty() || self.handshake.is_some() || !self.local.is_empty(), has_blocking: self .local .values() .any(|s| matches!(s.state.kind, InspectorSessionKind::Blocking)), has_nonblocking: self.local.values().any(|s| { matches!(s.state.kind, InspectorSessionKind::NonBlocking { .. }) }), has_nonblocking_wait_for_disconnect: self.local.values().any(|s| { matches!( s.state.kind, InspectorSessionKind::NonBlocking { wait_for_disconnect: true } ) }), } } /// A temporary placeholder that should be used before actual /// instance of V8Inspector is created. It's used in favor /// of `Default` implementation to signal that it's not meant /// for actual use. fn temporary_placeholder(active_session_count: Rc<Cell<usize>>) -> Self { let (_tx, rx) = mpsc::unbounded::<InspectorSessionProxy>(); Self { v8_inspector: Default::default(), session_rx: rx, handshake: None, established: FuturesUnordered::new(), next_local_id: 1, local: HashMap::new(), active_session_count, target_sessions: HashMap::new(), main_session_id: None, next_worker_id: 1, } } pub fn dispatch_message_from_frontend( &mut self, session_id: i32, message: String, ) { let session = self.local.get(&session_id).unwrap(); // Try custom-domain dispatch before sending to V8. For methods we own // (Network.*, DOMStorage.enable/disable), generate the response here // and skip V8 entirely. if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&message) && let Some(method) = parsed.get("method").and_then(|m| m.as_str()) { match method { "Debugger.enable" => session.set_debugger_enabled(true), "Debugger.disable" => session.set_debugger_enabled(false), _ => {} } if method == "Page.waitForDebugger" { session.wait_for_debugger(); if let Some(id) = parsed.get("id") { let call_id = id.as_i64().unwrap_or(0) as i32; (session.state.send)(InspectorMsg { kind: InspectorMsgKind::Message(call_id), content: make_cdp_ok_response(id, json!({})), }); } return; } if let Some(outcome) = dispatch_custom_domain(method, &parsed, session) { if let Some(id) = parsed.get("id") { let call_id = id.as_i64().unwrap_or(0) as i32; let content = match outcome { CustomDomainOutcome::Empty => make_cdp_ok_response(id, json!({})), CustomDomainOutcome::Result(v) => make_cdp_ok_response(id, v), CustomDomainOutcome::Error(err) => { make_cdp_error_response(id, &err) } }; (session.state.send)(InspectorMsg { kind: InspectorMsgKind::Message(call_id), content, }); } return; } } session.dispatch_message(message); } /// Register a worker session and return the assigned worker ID fn register_worker_session( &mut self, local_session_id: i32, worker_url: String, ) -> u32 { // Assign a sequential worker ID for display purposes let worker_id = self.next_worker_id; self.next_worker_id += 1; // Use the local_session_id for internal session routing let target_id = format!("{}", local_session_id); let session_id = format!("{}", local_session_id); let target_session = Rc::new(TargetSession { target_id: target_id.clone(), session_id: session_id.clone(), local_session_id, worker_id, main_worker_channels: RefCell::new(None), url: worker_url.clone(), attached: Cell::new(false), }); self .target_sessions .insert(session_id.clone(), target_session.clone()); worker_id } /// Register the communication channels for a worker's V8 inspector /// This is called from the worker side to establish bidirectional communication pub fn register_worker_channels( &mut self, local_session_id: i32, main_to_worker_tx: UnboundedSender<String>, worker_to_main_rx: UnboundedReceiver<InspectorMsg>, ) -> bool { // Find the target session for this local session ID for target_session in self.target_sessions.values() { if target_session.local_session_id == local_session_id { *target_session.main_worker_channels.borrow_mut() = Some(MainWorkerChannels { main_to_worker_tx, worker_to_main_rx, }); return true; } } false } fn target_session_by_target_id( &self, target_id: &str, ) -> Option<Rc<TargetSession>> { self .target_sessions .values() .find(|ts| ts.target_id == target_id) .cloned() } } struct InspectorWakerInner { poll_state: PollState, task_waker: Option<task::Waker>, parked_thread: Option<thread::Thread>, inspector_state_ptr: Option<NonNull<JsRuntimeInspectorState>>, isolate_handle: v8::IsolateHandle, } // SAFETY: unsafe trait must have unsafe implementation unsafe impl Send for InspectorWakerInner {} struct InspectorWaker(Mutex<InspectorWakerInner>); impl InspectorWaker { fn new(isolate_handle: v8::IsolateHandle) -> Arc<Self> { let inner = InspectorWakerInner { poll_state: PollState::Idle, task_waker: None, parked_thread: None, inspector_state_ptr: None, isolate_handle, }; Arc::new(Self(Mutex::new(inner))) } fn update<F, R>(&self, update_fn: F) -> R where F: FnOnce(&mut InspectorWakerInner) -> R, { let mut g = self.0.lock(); update_fn(&mut g) } } impl task::ArcWake for InspectorWaker { fn wake_by_ref(arc_self: &Arc<Self>) { arc_self.update(|w| { match w.poll_state { PollState::Idle => { // Wake the task, if any, that has polled the Inspector future last. if let Some(waker) = w.task_waker.take() { waker.wake() } // Request an interrupt from the isolate if it's running and there's // not unhandled interrupt request in flight. if let Some(arg) = w .inspector_state_ptr .take() .map(|ptr| ptr.as_ptr() as *mut c_void) { w.isolate_handle.request_interrupt(handle_interrupt, arg); } unsafe extern "C" fn handle_interrupt( _isolate: v8::UnsafeRawIsolatePtr, arg: *mut c_void, ) { // SAFETY: `InspectorWaker` is owned by `JsRuntimeInspector`, so the // pointer to the latter is valid as long as waker is alive. let inspector_state = unsafe { &*(arg as *mut JsRuntimeInspectorState) }; let _ = inspector_state.poll_sessions(None); } } PollState::Parked => { // Unpark the isolate thread. let parked_thread = w.parked_thread.take().unwrap(); assert_ne!(parked_thread.id(), thread::current().id()); parked_thread.unpark(); } _ => {} }; w.poll_state = PollState::Woken; }); } } #[derive(Clone, Copy, Debug)] pub enum InspectorSessionKind { Blocking, NonBlocking { wait_for_disconnect: bool }, } #[derive(Clone)] struct InspectorSessionState { is_dispatching_message: Rc<RefCell<bool>>, send: Rc<InspectorSessionSend>, rx: Rc<RefCell<Option<SessionProxyReceiver>>>, // Describes if session should keep event loop alive, eg. a local REPL // session should keep event loop alive, but a Websocket session shouldn't. kind: InspectorSessionKind, sessions: Rc<RefCell<SessionContainer>>, // Thread-safe queue for NodeWorker messages that need to be sent to workers pending_worker_messages: Arc<Mutex<Vec<(String, String)>>>, // Track whether NodeWorker.enable has been called (enables VSCode-style worker debugging) nodeworker_enabled: Rc<Cell<bool>>, nodeworker_wait_for_debugger_on_start: Rc<Cell<bool>>, // Track whether Target.setAutoAttach has been called (enables worker auto-attach) auto_attach_enabled: Rc<Cell<bool>>, auto_attach_wait_for_debugger_on_start: Rc<Cell<bool>>, // Track whether Target.setDiscoverTargets has been called (enables target discovery) discover_targets_enabled: Rc<Cell<bool>>, // Track whether Network.enable has been called (per-session, not shared) network_enabled: Cell<bool>, // Track whether DOMStorage.enable has been called (per-session, not shared) dom_storage_enabled: Cell<bool>, // Track whether NodeRuntime.enable has been called (per-session, not shared, // because one client disabling it should not affect another client's state) noderuntime_enabled: Cell<bool>, // Track whether NodeRuntime.notifyWhenWaitingForDisconnect has been called // with `enabled: true`. When set, the runtime emits // NodeRuntime.waitingForDisconnect (instead of // Runtime.executionContextDestroyed) to this session at exit, and waits for // the session to disconnect before terminating the process. notify_waiting_for_disconnect: Cell<bool>, debugger_enabled: Cell<bool>, // Inspector flags (shared with JsRuntimeInspectorState) for checking waiting_for_session flags: Rc<RefCell<InspectorFlags>>, // Shared body buffer used by the Network.* command handlers. network_data: Rc<RefCell<NetworkDataBuffer>>, } /// An inspector session that proxies messages to concrete "transport layer", /// eg. Websocket or another set of channels. struct InspectorSession { v8_session: v8::inspector::V8InspectorSession, state: InspectorSessionState, } impl InspectorSession { const CONTEXT_GROUP_ID: i32 = 1; #[allow(clippy::too_many_arguments, reason = "construction")] pub fn new( v8_inspector: Rc<v8::inspector::V8Inspector>, is_dispatching_message: Rc<RefCell<bool>>, send: InspectorSessionSend, rx: Option<SessionProxyReceiver>, kind: InspectorSessionKind, sessions: Rc<RefCell<SessionContainer>>, pending_worker_messages: Arc<Mutex<Vec<(String, String)>>>, nodeworker_enabled: Rc<Cell<bool>>, nodeworker_wait_for_debugger_on_start: Rc<Cell<bool>>, auto_attach_enabled: Rc<Cell<bool>>, auto_attach_wait_for_debugger_on_start: Rc<Cell<bool>>, discover_targets_enabled: Rc<Cell<bool>>, flags: Rc<RefCell<InspectorFlags>>, network_data: Rc<RefCell<NetworkDataBuffer>>, ) -> Rc<Self> { let state = InspectorSessionState { is_dispatching_message, send: Rc::new(send), rx: Rc::new(RefCell::new(rx)), kind, sessions, pending_worker_messages, nodeworker_enabled, nodeworker_wait_for_debugger_on_start, auto_attach_enabled, auto_attach_wait_for_debugger_on_start, discover_targets_enabled, network_enabled: Cell::new(false), dom_storage_enabled: Cell::new(false), noderuntime_enabled: Cell::new(false), notify_waiting_for_disconnect: Cell::new(false), debugger_enabled: Cell::new(false), flags, network_data, }; let v8_session = v8_inspector.connect( Self::CONTEXT_GROUP_ID, v8::inspector::Channel::new(Box::new(state.clone())), v8::inspector::StringView::empty(), v8::inspector::V8InspectorClientTrustLevel::FullyTrusted, ); Rc::new(Self { v8_session, state }) } // Dispatch message to V8 session fn dispatch_message(&self, msg: String) { *self.state.is_dispatching_message.borrow_mut() = true; let msg = v8::inspector::StringView::from(msg.as_bytes()); self.v8_session.dispatch_protocol_message(msg); *self.state.is_dispatching_message.borrow_mut() = false; } fn wait_for_debugger(&self) { let mut flags = self.state.flags.borrow_mut(); flags.paused_on_start = true; flags.waiting_for_session = true; flags.page_wait_for_debugger_received = true; } fn set_debugger_enabled(&self, enabled: bool) { if self.state.debugger_enabled.replace(enabled) == enabled { return; } let mut flags = self.state.flags.borrow_mut(); if enabled { flags.debugger_enabled_session_count += 1; } else { flags.debugger_enabled_session_count = flags.debugger_enabled_session_count.saturating_sub(1); } } /// Queue a message to be sent to a worker fn queue_worker_message(&self, session_id: &str, message: String) { self .state .pending_worker_messages .lock() .push((session_id.to_string(), message)); } /// Notify all registered workers via a callback fn notify_workers<F>(&self, mut f: F) where F: FnMut(&TargetSession, &dyn Fn(InspectorMsg)) + 'static, { let sessions = self.state.sessions.clone(); let send = self.state.send.clone(); deno_core::unsync::spawn(async move { let sessions = sessions.borrow(); for ts in sessions.target_sessions.values() { f(ts, &|msg| send(msg)); } }); } } impl InspectorSessionState { fn send_message( &self, msg_kind: InspectorMsgKind, msg: v8::UniquePtr<v8::inspector::StringBuffer>, ) { let msg = msg.unwrap().string().to_string(); (self.send)(InspectorMsg { kind: msg_kind, content: msg, }); } } impl v8::inspector::ChannelImpl for InspectorSessionState { fn send_response( &self, call_id: i32, message: v8::UniquePtr<v8::inspector::StringBuffer>, ) { self.send_message(InspectorMsgKind::Message(call_id), message); } fn send_notification( &self, message: v8::UniquePtr<v8::inspector::StringBuffer>, ) { self.send_message(InspectorMsgKind::Notification, message); } fn flush_protocol_notifications(&self) {} } type InspectorSessionPumpMessages = Pin<Box<dyn Future<Output = i32>>>; /// Helper to extract a string param from CDP params fn get_str_param(params: &Option<serde_json::Value>, key: &str) -> String { params .as_ref() .and_then(|p| p.get(key)) .and_then(|v| v.as_str()) .unwrap_or_default() .to_owned() } /// Helper to extract a bool param from CDP params fn get_bool_param(params: &Option<serde_json::Value>, key: &str) -> bool { params .as_ref() .and_then(|p| p.get(key)) .and_then(|v| v.as_bool()) .unwrap_or(false) } impl TargetSession { /// Build target info JSON for CDP events fn target_info(&self, attached: bool) -> serde_json::Value { json!({ "targetId": self.target_id, "type": "node_worker", "title": self.title(), "url": self.url, "attached": attached, "canAccessOpener": true }) } /// Build worker info JSON for NodeWorker events fn worker_info(&self) -> serde_json::Value { json!({ "workerId": self.target_id, "type": "node_worker", "title": self.title(), "url": self.url }) } } async fn pump_inspector_session_messages( session: Rc<InspectorSession>, session_id: i32, ) -> i32 { let mut rx = session.state.rx.borrow_mut().take().unwrap(); while let Some(msg) = rx.next().await { let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&msg) else { session.dispatch_message(msg); continue; }; // CDP Flattened Session Mode: route messages with top-level sessionId to workers if let Some(session_id) = parsed.get("sessionId").and_then(|s| s.as_str()) { let mut worker_msg = parsed.clone(); if let Some(obj) = worker_msg.as_object_mut() { obj.remove("sessionId"); session.queue_worker_message(session_id, worker_msg.to_string()); } continue; } let Some(method) = parsed.get("method").and_then(|m| m.as_str()) else { session.dispatch_message(msg); continue; }; let params = parsed.get("params").cloned(); let msg_id = parsed.get("id").cloned(); if let Some(outcome) = dispatch_custom_domain(method, &parsed, &session) { if let Some(id) = msg_id { let call_id = id.as_i64().unwrap_or(0) as i32; let content = match outcome { CustomDomainOutcome::Empty => make_cdp_ok_response(&id, json!({})), CustomDomainOutcome::Result(v) => make_cdp_ok_response(&id, v), CustomDomainOutcome::Error(err) => make_cdp_error_response(&id, &err), }; (session.state.send)(InspectorMsg { kind: InspectorMsgKind::Message(call_id), content, }); } continue; } match method { "Debugger.enable" => { session.set_debugger_enabled(true); session.dispatch_message(msg); continue; } "Debugger.disable" => { session.set_debugger_enabled(false); session.dispatch_message(msg); continue; } "NodeRuntime.enable" => { session.state.noderuntime_enabled.set(true); // If the runtime is paused on start (--inspect-brk), emit // NodeRuntime.waitingForDebugger. We check paused_on_start // (not waiting_for_session) because the session has already // connected by the time this message is received. let flags = session.state.flags.borrow(); if flags.waiting_for_session || flags.paused_on_start { drop(flags); (session.state.send)(InspectorMsg::notification(json!({ "method": "NodeRuntime.waitingForDebugger" }))); } } "NodeRuntime.disable" => { session.state.noderuntime_enabled.set(false); } "NodeRuntime.notifyWhenWaitingForDisconnect" => { let enabled = get_bool_param(¶ms, "enabled"); session.state.notify_waiting_for_disconnect.set(enabled); } "Runtime.runIfWaitingForDebugger" => { // Clear paused_on_start so wait_for_session_and_break_on_next_statement // unblocks. Also clear waiting_for_session so poll_sessions stops // blocking. Track which session resumed us so we schedule the pause // on the correct session (the one that has Debugger.enable active). { let mut flags = session.state.flags.borrow_mut(); flags.paused_on_start = false; flags.waiting_for_session = false; flags.resumed_by_session_id = Some(session_id); } // Forward to V8 for actual processing session.dispatch_message(msg); continue; } "Page.waitForDebugger" => { session.wait_for_debugger(); } "NodeWorker.enable" => { session.state.nodeworker_enabled.set(true); session .state .nodeworker_wait_for_debugger_on_start .set(get_bool_param(¶ms, "waitForDebuggerOnStart")); let waiting_for_debugger = session.state.nodeworker_wait_for_debugger_on_start.get(); session.notify_workers(move |ts, send| { send(InspectorMsg::notification(json!({ "method": "NodeWorker.attachedToWorker", "params": { "sessionId": ts.session_id, "workerInfo": ts.worker_info(), "waitingForDebugger": waiting_for_debugger } }))); }); } "NodeWorker.sendMessageToWorker" | "Target.sendMessageToTarget" => { session.queue_worker_message( &get_str_param(¶ms, "sessionId"), get_str_param(¶ms, "message"), ); } "Target.setDiscoverTargets" => { let discover = get_bool_param(¶ms, "discover"); session.state.discover_targets_enabled.set(discover); if discover { session.notify_workers(|ts, send| { send(InspectorMsg::notification(json!({ "method": "Target.targetCreated", "params": { "targetInfo": ts.target_info(false) } }))); }); } } "Target.getTargets" => { if let Some(id) = msg_id { let call_id = id.as_i64().unwrap_or(0) as i32; let send = session.state.send.clone(); let sessions = session.state.sessions.clone(); deno_core::unsync::spawn(async move { let sessions = sessions.borrow(); let target_infos = sessions .target_sessions .values() .map(|ts| ts.target_info(ts.attached.get())) .collect::<Vec<_>>(); send(InspectorMsg { kind: InspectorMsgKind::Message(call_id), content: json!({ "id": id, "result": { "targetInfos": target_infos } }) .to_string(), }); }); } continue; } "Target.attachToTarget" => { let target_id = get_str_param(¶ms, "targetId"); if let Some(id) = msg_id { let call_id = id.as_i64().unwrap_or(0) as i32; let send = session.state.send.clone(); let sessions = session.state.sessions.clone(); deno_core::unsync::spawn(async move { let target_session = sessions.borrow().target_session_by_target_id(&target_id); let content = if let Some(ts) = target_session { ts.attached.set(true); json!({ "id": id, "result": { "sessionId": ts.session_id } }) } else { json!({ "id": id, "error": { "code": -32602, "message": "No target with given id found" } }) }; send(InspectorMsg { kind: InspectorMsgKind::Message(call_id), content: content.to_string(), }); }); } continue; } "Target.detachFromTarget" => { let session_id = get_str_param(¶ms, "sessionId"); let sessions = session.state.sessions.clone(); deno_core::unsync::spawn(async move { if let Some(ts) = sessions.borrow().target_sessions.get(&session_id) { ts.attached.set(false); } }); } "Target.setAutoAttach" => { let auto_attach = get_bool_param(¶ms, "autoAttach"); let wait_for_debugger_on_start = get_bool_param(¶ms, "waitForDebuggerOnStart"); let send = session.state.send.clone(); let sessions = session.state.sessions.clone(); session.state.auto_attach_enabled.set(auto_attach); session .state .auto_attach_wait_for_debugger_on_start .set(wait_for_debugger_on_start); if auto_attach { deno_core::unsync::spawn(async move { let sessions = sessions.borrow(); for ts in sessions.target_sessions.values() { if ts.attached.replace(true) { continue; // Skip if already attached } send(InspectorMsg::notification(json!({ "method": "Target.attachedToTarget", "params": { "sessionId": ts.session_id, "targetInfo": ts.target_info(true), "waitingForDebugger": wait_for_debugger_on_start } }))); } }); } } _ => { session.dispatch_message(msg); continue; } } // Send default `{result: {}}` ack for commands handled above // that don't produce their own response payload. if let Some(id) = msg_id { let call_id = id.as_i64().unwrap_or(0) as i32; (session.state.send)(InspectorMsg { kind: InspectorMsgKind::Message(call_id), content: json!({ "id": id, "result": {} }) .to_string(), }); } } session_id } /// A local inspector session that can be used to send and receive protocol messages directly on /// the same thread as an isolate. /// /// Does not provide any abstraction over CDP messages. pub struct LocalInspectorSession { sessions: Rc<RefCell<SessionContainer>>, session_id: i32, } impl LocalInspectorSession { pub fn new(session_id: i32, sessions: Rc<RefCell<SessionContainer>>) -> Self { Self { sessions, session_id, } } pub fn dispatch(&mut self, msg: String) { self .sessions .borrow_mut() .dispatch_message_from_frontend(self.session_id, msg); } pub fn post_message<T: serde::Serialize>( &mut self, id: i32, method: &str, params: Option<T>, ) { let message = json!({ "id": id, "method": method, "params": params, }); let stringified_msg = serde_json::to_string(&message).unwrap(); self.dispatch(stringified_msg); } } impl Drop for LocalInspectorSession { fn drop(&mut self) { let mut sessions = self.sessions.borrow_mut(); sessions.remove_local_session(self.session_id); if sessions.main_session_id == Some(self.session_id) { sessions.main_session_id = sessions.local.keys().next().copied(); } } }