test(cdp): add heavy-page concurrent-connection regression for #430
concurrent_navigations uses data: URLs, which settle instantly and so never drove the abort. This test serves a local page with slow subresources plus a setInterval so the navigation settle loop keeps pumping, then drives it from four independent CDP connections at once. It aborts the process on the old single-LocalSet server (SIGTRAP, the V8 heap->isolate() == TryGetCurrent() fatal) and passes on the thread-per-connection server. Refs #430.
This commit is contained in:
1 parent
76fc3b9cae
commit
6c7b2b0cec
1 file changed
+245
@@ -0,0 +1,245 @@
|
|||||||
|
//! Issue #430 regression: concurrent CDP connections driving a subresource-heavy
|
||||||
|
//! page must not abort the process.
|
||||||
|
//!
|
||||||
|
//! Why this and not `concurrent_navigations` (data: URLs): the #430 abort needs
|
||||||
|
//! a page whose event loop is still busy when the navigation settle loop's
|
||||||
|
//! per-tick `tokio::time::timeout` around `run_event_loop` fires. A cancelled
|
||||||
|
//! `run_event_loop` future left an isolate entered on the shared LocalSet
|
||||||
|
//! thread; a second connection's `execute_script` then tripped V8's
|
||||||
|
//! `heap->isolate() == Isolate::TryGetCurrent()` check and `abort(3)`'d. `data:`
|
||||||
|
//! URLs settle instantly (no subresources, no busy loop), so they never drive
|
||||||
|
//! it. This test serves a local page with SLOW subresources plus a `setInterval`
|
||||||
|
//! so the settle loop keeps pumping, then drives it from several independent
|
||||||
|
//! connections at once.
|
||||||
|
//!
|
||||||
|
//! On the single-LocalSet server this aborted deterministically. The
|
||||||
|
//! thread-per-connection server confines each connection's isolates to their own
|
||||||
|
//! OS thread, so the abort cannot happen (V8's check is per-thread) and all
|
||||||
|
//! clients complete.
|
||||||
|
//!
|
||||||
|
//! Run with `cargo test -p obscura-cdp --test concurrent_connections_heavy_page
|
||||||
|
//! -- --nocapture`.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||||
|
|
||||||
|
const CLIENTS: u64 = 4;
|
||||||
|
|
||||||
|
async fn pick_port() -> u16 {
|
||||||
|
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = l.local_addr().unwrap().port();
|
||||||
|
drop(l);
|
||||||
|
port
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal HTTP fixture: a root page with two subresources that respond after a
|
||||||
|
/// delay (keeping the navigation settle loop pumping) and an inline
|
||||||
|
/// `setInterval` that keeps the event loop non-idle. Serves every connection it
|
||||||
|
/// accepts until the test ends.
|
||||||
|
async fn serve_heavy_fixture(listener: TcpListener) {
|
||||||
|
let body = "<!DOCTYPE html><html><head>\
|
||||||
|
<script src=\"/slow.js\"></script>\
|
||||||
|
</head><body><h1>heavy</h1><img src=\"/slow.png\">\
|
||||||
|
<script>setInterval(function(){var x=0;for(var i=0;i<2000;i++){x+=i;}}, 3);</script>\
|
||||||
|
</body></html>";
|
||||||
|
loop {
|
||||||
|
let (mut sock, _) = match listener.accept().await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
tokio::task::spawn_local(async move {
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||||
|
if n == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let req = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
let path = req
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or("/")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Slow subresources: respond after a delay so `active_requests` stays
|
||||||
|
// > 0 through several 10ms settle ticks, which is what forces the
|
||||||
|
// per-tick timeout to cancel `run_event_loop` mid-poll on the old
|
||||||
|
// single-thread server.
|
||||||
|
let (ctype, payload): (&str, Vec<u8>) = if path == "/slow.js" {
|
||||||
|
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||||
|
("application/javascript", b"void 0;".to_vec())
|
||||||
|
} else if path == "/slow.png" {
|
||||||
|
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||||
|
// 1x1 transparent PNG.
|
||||||
|
(
|
||||||
|
"image/png",
|
||||||
|
vec![
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||||
|
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
|
||||||
|
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||||
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
||||||
|
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
("text/html", body.as_bytes().to_vec())
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
ctype,
|
||||||
|
payload.len()
|
||||||
|
);
|
||||||
|
let _ = sock.write_all(header.as_bytes()).await;
|
||||||
|
let _ = sock.write_all(&payload).await;
|
||||||
|
let _ = sock.flush().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One client: open its own CDP connection, create a target at the heavy page,
|
||||||
|
/// navigate, then repeatedly `Runtime.evaluate` an awaited promise. Returns Err
|
||||||
|
/// on protocol failure; a V8 abort would take the whole process down instead.
|
||||||
|
async fn one_client(ws_port: u16, page_url: String, id_base: u64) -> Result<(), String> {
|
||||||
|
let url = format!("ws://127.0.0.1:{}/devtools/browser", ws_port);
|
||||||
|
let (mut ws, _) = connect_async(&url).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let create = json!({
|
||||||
|
"id": id_base,
|
||||||
|
"method": "Target.createTarget",
|
||||||
|
"params": {"url": page_url},
|
||||||
|
});
|
||||||
|
ws.send(Message::Text(create.to_string().into()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut session_id: Option<String> = None;
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
|
||||||
|
while session_id.is_none() {
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err("timeout waiting for sessionId".to_string());
|
||||||
|
}
|
||||||
|
let msg = tokio::time::timeout(Duration::from_secs(10), ws.next())
|
||||||
|
.await
|
||||||
|
.map_err(|_| "timeout waiting for createTarget".to_string())?
|
||||||
|
.ok_or("ws closed")?
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let text = match msg {
|
||||||
|
Message::Text(t) => t.to_string(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let v: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
|
||||||
|
if let Some(s) = v
|
||||||
|
.get("params")
|
||||||
|
.and_then(|p| p.get("sessionId"))
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
{
|
||||||
|
session_id = Some(s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sid = session_id.unwrap();
|
||||||
|
|
||||||
|
// A few awaited evaluations: each drives resolve_promises_until while the
|
||||||
|
// page's setInterval keeps the loop busy — the settle/eval cancellation path.
|
||||||
|
for k in 0..3u64 {
|
||||||
|
let eval = json!({
|
||||||
|
"id": id_base + 100 + k,
|
||||||
|
"method": "Runtime.evaluate",
|
||||||
|
"sessionId": sid,
|
||||||
|
"params": {
|
||||||
|
"expression": "new Promise(r => setTimeout(() => r(1 + 1), 40))",
|
||||||
|
"awaitPromise": true,
|
||||||
|
"returnByValue": true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.send(Message::Text(eval.to_string().into()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let d = tokio::time::Instant::now() + Duration::from_secs(15);
|
||||||
|
loop {
|
||||||
|
if tokio::time::Instant::now() >= d {
|
||||||
|
return Err(format!("timeout waiting for evaluate {}", k));
|
||||||
|
}
|
||||||
|
let remaining = d - tokio::time::Instant::now();
|
||||||
|
let msg = tokio::time::timeout(remaining, ws.next())
|
||||||
|
.await
|
||||||
|
.map_err(|_| "timeout".to_string())?
|
||||||
|
.ok_or("ws closed mid-evaluate")?
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let text = match msg {
|
||||||
|
Message::Text(t) => t.to_string(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let v: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
|
||||||
|
if v.get("id").and_then(|x| x.as_u64()) == Some(id_base + 100 + k) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn concurrent_connections_heavy_page_do_not_abort_v8() {
|
||||||
|
let ws_port = pick_port().await;
|
||||||
|
// Bind the fixture listener up front so we can hand its port to the clients.
|
||||||
|
let fixture = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let fixture_port = fixture.local_addr().unwrap().port();
|
||||||
|
let page_url = format!("http://127.0.0.1:{}/", fixture_port);
|
||||||
|
|
||||||
|
let local = tokio::task::LocalSet::new();
|
||||||
|
local
|
||||||
|
.run_until(async {
|
||||||
|
// Fixture HTTP server.
|
||||||
|
tokio::task::spawn_local(serve_heavy_fixture(fixture));
|
||||||
|
|
||||||
|
// CDP server. allow_private_network so it may fetch 127.0.0.1.
|
||||||
|
tokio::task::spawn_local(async move {
|
||||||
|
let _ = obscura_cdp::server::start_with_full_serve_options(
|
||||||
|
ws_port,
|
||||||
|
"127.0.0.1",
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for i in 0..CLIENTS {
|
||||||
|
let url = page_url.clone();
|
||||||
|
let id_base = (i + 1) * 1000;
|
||||||
|
handles.push(tokio::task::spawn_local(async move {
|
||||||
|
one_client(ws_port, url, id_base).await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ok = 0usize;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for (i, h) in handles.into_iter().enumerate() {
|
||||||
|
match h.await {
|
||||||
|
Ok(Ok(())) => ok += 1,
|
||||||
|
Ok(Err(e)) => errors.push(format!("client {}: {}", i, e)),
|
||||||
|
Err(e) => errors.push(format!("client {} join: {}", i, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
errors.is_empty(),
|
||||||
|
"clients failed (a V8 abort would instead kill the process): {:#?}",
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
assert_eq!(ok as u64, CLIENTS, "every concurrent client must complete");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user