Skip to content

Commit c61ef11

Browse files
committed
Add hang watchdog repro tests
1 parent 0f0952f commit c61ef11

3 files changed

Lines changed: 138 additions & 2 deletions

File tree

crates/browser-use-agent/src/mcp/tests.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,38 @@ for line in sys.stdin:
8383
"error": {"code": -32601, "message": "method not found"}})
8484
"#;
8585

86+
const STDIO_STALL_AFTER_INIT_PY: &str = r#"
87+
import sys, json, time
88+
89+
def send(obj):
90+
sys.stdout.write(json.dumps(obj) + "\n")
91+
sys.stdout.flush()
92+
93+
line = sys.stdin.readline()
94+
msg = json.loads(line)
95+
send({"jsonrpc": "2.0", "id": msg.get("id"), "result": {
96+
"protocolVersion": "2024-11-05",
97+
"capabilities": {},
98+
"serverInfo": {"name": "stall", "version": "0.0.1"},
99+
}})
100+
101+
# Do not read stdin again. A large client request can fill the pipe while the
102+
# transport is still in write_json, before its per-request response timeout is
103+
# armed.
104+
time.sleep(60)
105+
"#;
106+
86107
/// Write the python fixture into a tempdir and return its path. The tempdir is
87108
/// kept alive by the returned guard.
88109
fn write_stdio_fixture() -> (tempfile::TempDir, std::path::PathBuf) {
110+
write_stdio_fixture_source(STDIO_FIXTURE_PY)
111+
}
112+
113+
fn write_stdio_fixture_source(source: &str) -> (tempfile::TempDir, std::path::PathBuf) {
89114
let dir = tempfile::tempdir().expect("tempdir");
90115
let path = dir.path().join("server.py");
91116
let mut f = std::fs::File::create(&path).expect("create fixture");
92-
f.write_all(STDIO_FIXTURE_PY.as_bytes())
93-
.expect("write fixture");
117+
f.write_all(source.as_bytes()).expect("write fixture");
94118
f.flush().expect("flush fixture");
95119
(dir, path)
96120
}
@@ -161,6 +185,34 @@ async fn stdio_error_result_maps_is_error() {
161185
assert_eq!(mcp_result_tool_content(&result.into_seam()), "kaboom");
162186
}
163187

188+
#[tokio::test]
189+
#[ignore = "diagnostic repro: stdio MCP timeout does not cover a blocked stdin write"]
190+
async fn repro_stdio_call_can_remain_pending_before_response_timeout_is_armed() {
191+
let (_dir, script) = write_stdio_fixture_source(STDIO_STALL_AFTER_INIT_PY);
192+
let transport = StdioTransport::connect(
193+
"python3",
194+
&[script.to_string_lossy().to_string()],
195+
&HashMap::new(),
196+
None,
197+
Duration::from_secs(2),
198+
Duration::from_millis(50),
199+
)
200+
.await
201+
.expect("connect stalling stdio fixture");
202+
203+
let large_arg = "x".repeat(32 * 1024 * 1024);
204+
let result = tokio::time::timeout(
205+
Duration::from_secs(2),
206+
transport.call_tool("stall", Some(json!({ "blob": large_arg }))),
207+
)
208+
.await;
209+
210+
assert!(
211+
result.is_err(),
212+
"MCP call returned inside the outer watchdog; this repro expects write_json to remain pending before the tool timeout"
213+
);
214+
}
215+
164216
// ---------------------------------------------------------------------------
165217
// http transport (loopback TcpListener)
166218
// ---------------------------------------------------------------------------

crates/browser-use-llm/src/route/client.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,4 +1285,44 @@ mod tests {
12851285
// The bearer token must never leak into the transport error message.
12861286
assert!(!err.message.contains("sk-not-used"), "leaked token: {err}");
12871287
}
1288+
1289+
#[tokio::test]
1290+
#[ignore = "diagnostic repro: current ModelClient has no request/stream-open timeout"]
1291+
async fn repro_model_stream_open_can_remain_pending_without_client_timeout() {
1292+
use tokio::io::AsyncReadExt;
1293+
1294+
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1295+
.await
1296+
.expect("bind local listener");
1297+
let addr = listener.local_addr().expect("local addr");
1298+
let server = tokio::spawn(async move {
1299+
let Ok((mut socket, _)) = listener.accept().await else {
1300+
return;
1301+
};
1302+
let mut buf = [0u8; 1024];
1303+
let _ = socket.read(&mut buf).await;
1304+
tokio::time::sleep(Duration::from_secs(30)).await;
1305+
});
1306+
1307+
let client = ModelClient::with_retry(RetryPolicy {
1308+
max_attempts: 1,
1309+
..RetryPolicy::default()
1310+
});
1311+
let route = Route::new(
1312+
Box::new(OpenAiResponsesProtocol::new()),
1313+
Endpoint::new(format!("http://{addr}"), "/v1/responses"),
1314+
Auth::bearer("sk-not-used"),
1315+
);
1316+
let mut req = LlmRequest::new("gpt-5.1-codex", "openai");
1317+
req.messages.push(crate::schema::Message::user_text("hi"));
1318+
1319+
let result =
1320+
tokio::time::timeout(Duration::from_millis(250), client.stream(&route, &req)).await;
1321+
server.abort();
1322+
1323+
assert!(
1324+
result.is_err(),
1325+
"stream open returned inside the outer watchdog; this repro expects it to remain pending"
1326+
);
1327+
}
12881328
}

crates/browser-use-python-worker/src/lib.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,50 @@ mod tests {
555555
Ok(())
556556
}
557557

558+
#[test]
559+
#[ignore = "diagnostic repro: omitted timeout waits for snippet completion instead of self-recovering"]
560+
fn repro_worker_without_timeout_waits_for_snippet_completion() -> Result<()> {
561+
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
562+
.parent()
563+
.and_then(Path::parent)
564+
.context("repo root")?
565+
.to_path_buf();
566+
let temp = tempfile::tempdir()?;
567+
let cwd = temp.path().to_path_buf();
568+
let artifact_dir = temp.path().join("artifacts");
569+
let pythonpath = repo_root.join("python");
570+
let (tx, rx) = std::sync::mpsc::channel();
571+
572+
let handle = std::thread::spawn(move || {
573+
let result = (|| -> Result<RunPythonResponse> {
574+
let mut worker = PythonWorker::start_with_pythonpath("python3", pythonpath)?;
575+
worker.run_with_timeout(
576+
"s1",
577+
cwd,
578+
artifact_dir,
579+
"import time\ntime.sleep(1.5)\nresult = 'finished'",
580+
None,
581+
)
582+
})();
583+
let _ = tx.send(result);
584+
});
585+
586+
match rx.recv_timeout(Duration::from_millis(250)) {
587+
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
588+
other => panic!(
589+
"python call returned before the outer watchdog; expected no self-timeout: {other:?}"
590+
),
591+
}
592+
593+
let response = rx
594+
.recv_timeout(Duration::from_secs(3))
595+
.expect("worker should finish after snippet sleep")?;
596+
handle.join().expect("worker thread join");
597+
assert!(response.ok, "{response:?}");
598+
assert_eq!(response.data, Value::String("finished".to_string()));
599+
Ok(())
600+
}
601+
558602
#[test]
559603
fn worker_hard_times_out_threadpool_shutdown_hang_and_recovers() -> Result<()> {
560604
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))

0 commit comments

Comments
 (0)