diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs index a8f7ab4..5853f51 100644 --- a/crates/obscura-cdp/src/dispatch.rs +++ b/crates/obscura-cdp/src/dispatch.rs @@ -121,6 +121,14 @@ impl CdpContext { } pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { + // headless_chrome (and older Puppeteer) wrap every CDP call inside + // Target.sendMessageToTarget. Unwrap and recurse BEFORE acquiring the + // V8 lock — the recursive dispatch will acquire it for the inner call, + // and tokio Mutex is not reentrant. + if req.method == "Target.sendMessageToTarget" { + return dispatch_send_message_to_target(req, ctx).await; + } + // Issue #19: V8 fatal abort under concurrent CDP work. // // Every CDP handler below may end up calling into a per-Page `JsRuntime` @@ -187,6 +195,63 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { } } +async fn dispatch_send_message_to_target(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { + let session_id = req + .params + .get("sessionId") + .and_then(|v| v.as_str()) + .map(str::to_string); + let message = match req.params.get("message").and_then(|v| v.as_str()) { + Some(m) => m, + None => { + return CdpResponse::error( + req.id, + -32602, + "sendMessageToTarget requires a message string".into(), + req.session_id.clone(), + ); + } + }; + + let inner: CdpRequest = match serde_json::from_str(message) { + Ok(r) => r, + Err(e) => { + return CdpResponse::error( + req.id, + -32700, + format!("sendMessageToTarget message is not a valid CDP request: {e}"), + req.session_id.clone(), + ); + } + }; + + // Override the inner session with the one supplied by the wrapper so + // the inner dispatch routes against the right page. Boxing the future + // sidesteps the async-fn recursion limit. + let inner_with_session = CdpRequest { + id: inner.id, + method: inner.method.clone(), + params: inner.params, + session_id: session_id.clone().or(inner.session_id), + }; + let inner_response = Box::pin(dispatch(&inner_with_session, ctx)).await; + + // Re-emit the inner response as the legacy event headless_chrome (and + // older Puppeteer) listen for instead of correlating responses by id. + let inner_serialized = serde_json::to_string(&inner_response).unwrap_or_else(|_| "{}".into()); + ctx.pending_events.push(CdpEvent { + method: "Target.receivedMessageFromTarget".to_string(), + params: json!({ + "sessionId": session_id.clone().unwrap_or_default(), + "message": inner_serialized, + "targetId": session_id.clone().unwrap_or_default(), + }), + session_id: req.session_id.clone(), + }); + + CdpResponse::success(req.id, json!({}), req.session_id.clone()) +} + #[cfg(test)] mod tests { use super::*; @@ -217,4 +282,60 @@ mod tests { assert_eq!(err.code, -32601); assert!(err.message.contains("Unknown domain")); } + + #[tokio::test] + async fn send_message_to_target_unwraps_inner_call() { + let mut ctx = CdpContext::new(); + let inner = json!({ + "id": 42, + "method": "Browser.getVersion", + "params": {}, + }); + let outer = CdpRequest { + id: 99, + method: "Target.sendMessageToTarget".into(), + params: json!({ + "sessionId": "sess-1", + "message": inner.to_string(), + }), + session_id: None, + }; + + let resp = dispatch(&outer, &mut ctx).await; + assert!(resp.error.is_none(), "wrapper must succeed: {:?}", resp.error); + assert_eq!(resp.id, 99); + assert_eq!(resp.result, Some(json!({}))); + + // headless_chrome reads the inner response from the + // receivedMessageFromTarget event, not from the wrapper response. + let evt = ctx + .pending_events + .iter() + .find(|e| e.method == "Target.receivedMessageFromTarget") + .expect("receivedMessageFromTarget event must be emitted"); + assert_eq!(evt.params["sessionId"], "sess-1"); + let inner_msg = evt.params["message"].as_str().expect("message is a string"); + let parsed: serde_json::Value = serde_json::from_str(inner_msg).unwrap(); + assert_eq!(parsed["id"], 42); + // Browser.getVersion returns a populated result object, not an error. + assert!(parsed.get("result").is_some(), "inner response carries result"); + assert!(parsed.get("error").is_none(), "inner response is not an error"); + } + + #[tokio::test] + async fn send_message_to_target_rejects_invalid_message() { + let mut ctx = CdpContext::new(); + let outer = CdpRequest { + id: 5, + method: "Target.sendMessageToTarget".into(), + params: json!({ + "sessionId": "sess-1", + "message": "{not valid json", + }), + session_id: None, + }; + let resp = dispatch(&outer, &mut ctx).await; + let err = resp.error.expect("malformed inner messages must error"); + assert_eq!(err.code, -32700); + } }