fix: add WebSocket keepalive to prevent CDP connection drops on remote browsers (#936)
The v0.20.0 Rust rewrite dropped two keepalive mechanisms that the Node.js/Playwright daemon provided, causing CDP connections to remote Browserless instances to silently die between commands when traversing multi-hop proxy topologies (Istio Envoy, OpenResty, etc.). Restore parity with v0.19.0 and improve on it: 1. TCP SO_KEEPALIVE (v0.19.0 parity): Playwright's WebSocketTransport used HTTP agents with keepAlive: true, which set SO_KEEPALIVE on the underlying TCP socket. Restored via socket2::SockRef on the tokio_tungstenite stream before splitting. 2. WebSocket Ping frames (improvement): Send Ping frames every 30s on idle connections. This goes beyond v0.19.0 because L7 proxies (Envoy, nginx, OpenResty) can see WebSocket pings but not TCP keepalive probes, making this effective through application-layer proxy hops. The keepalive task is coordinated with the reader loop via a watch channel and stops automatically when the connection closes. Fixes #934 Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
Generated
+1
@@ -60,6 +60,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"similar",
|
||||
"socket2",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
|
||||
@@ -26,6 +26,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
||||
sha2 = "0.10"
|
||||
aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
socket2 = "0.6"
|
||||
similar = "2"
|
||||
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
|
||||
@@ -12,6 +12,10 @@ use super::types::{CdpCommand, CdpEvent, CdpMessage};
|
||||
|
||||
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
|
||||
|
||||
/// Interval between WebSocket ping frames sent to keep the connection alive
|
||||
/// through intermediate proxies (reverse proxies, load balancers, service meshes).
|
||||
const WS_KEEPALIVE_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
/// Raw incoming CDP message (text) broadcast to all subscribers.
|
||||
/// Used by the inspect proxy to forward responses and events to DevTools.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -36,6 +40,7 @@ pub struct CdpClient {
|
||||
event_tx: broadcast::Sender<CdpEvent>,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
_reader_handle: tokio::task::JoinHandle<()>,
|
||||
_keepalive_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CdpClient {
|
||||
@@ -54,6 +59,13 @@ impl CdpClient {
|
||||
.await
|
||||
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
|
||||
|
||||
// Enable TCP SO_KEEPALIVE on the underlying socket. This matches the
|
||||
// behavior of Playwright's WebSocket transport (pre-v0.20.0) which used
|
||||
// Node.js HTTP agents with keepAlive: true. TCP-level keepalive probes
|
||||
// maintain the connection at the transport layer, complementing the
|
||||
// WebSocket-level Ping frames sent by the keepalive task below.
|
||||
enable_tcp_keepalive(ws_stream.get_ref());
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
@@ -65,6 +77,9 @@ impl CdpClient {
|
||||
let event_tx_clone = event_tx.clone();
|
||||
let raw_tx_clone = raw_tx.clone();
|
||||
|
||||
// Notify used to stop the keepalive task when the reader loop exits.
|
||||
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
|
||||
|
||||
let reader_handle = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_rx.next().await {
|
||||
// Accept both Text and Binary frames — remote CDP proxies
|
||||
@@ -76,6 +91,7 @@ impl CdpClient {
|
||||
Err(_) => continue,
|
||||
},
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(Message::Pong(_)) => continue,
|
||||
Ok(_) => continue,
|
||||
Err(_) => break,
|
||||
};
|
||||
@@ -120,6 +136,28 @@ impl CdpClient {
|
||||
// command senders so callers get an immediate channel-closed error
|
||||
// instead of waiting for the 30-second timeout.
|
||||
pending_clone.lock().await.clear();
|
||||
|
||||
// Stop the keepalive task — the connection is gone.
|
||||
let _ = cancel_tx.send(true);
|
||||
});
|
||||
|
||||
// Spawn a keepalive task that sends WebSocket Ping frames at a regular
|
||||
// interval. This prevents intermediate proxies (Envoy, nginx, OpenResty,
|
||||
// cloud load balancers) from closing idle WebSocket connections. If the
|
||||
// send fails, the connection is dead and we stop pinging.
|
||||
let keepalive_tx = ws_tx.clone();
|
||||
let keepalive_handle = tokio::spawn(async move {
|
||||
let interval = std::time::Duration::from_secs(WS_KEEPALIVE_INTERVAL_SECS);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(interval) => {}
|
||||
_ = cancel_rx.changed() => break,
|
||||
}
|
||||
let mut tx = keepalive_tx.lock().await;
|
||||
if tx.send(Message::Ping(Vec::new())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
@@ -129,6 +167,7 @@ impl CdpClient {
|
||||
event_tx,
|
||||
raw_tx,
|
||||
_reader_handle: reader_handle,
|
||||
_keepalive_handle: keepalive_handle,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -265,3 +304,26 @@ impl InspectProxyHandle {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable TCP SO_KEEPALIVE on the underlying socket of a WebSocket connection.
|
||||
/// This is best-effort: failures are silently ignored since the WebSocket-level
|
||||
/// Ping keepalive provides the primary connection liveness mechanism.
|
||||
fn enable_tcp_keepalive(stream: &tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>) {
|
||||
let tcp_stream = match stream {
|
||||
tokio_tungstenite::MaybeTlsStream::Plain(s) => s,
|
||||
tokio_tungstenite::MaybeTlsStream::Rustls(s) => s.get_ref().0,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// SockRef borrows the fd without taking ownership.
|
||||
let sock = socket2::SockRef::from(tcp_stream);
|
||||
let keepalive = socket2::TcpKeepalive::new().with_time(std::time::Duration::from_secs(30));
|
||||
|
||||
// with_interval sets TCP_KEEPINTVL — the time between probes after the
|
||||
// first keepalive probe goes unanswered. Available on most platforms
|
||||
// (Linux, macOS, Windows, FreeBSD, etc.) but not OpenBSD or Haiku.
|
||||
#[cfg(not(any(target_os = "openbsd", target_os = "haiku")))]
|
||||
let keepalive = keepalive.with_interval(std::time::Duration::from_secs(10));
|
||||
|
||||
let _ = sock.set_tcp_keepalive(&keepalive);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user