Bound code-mode execution with timeout and cancellation (#10214)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vincenzo Palazzo 2026-07-06 05:38:15 +02:00 committed by GitHub
parent 1d51452e60
commit 56dc935350
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 325 additions and 47 deletions

View file

@ -23,6 +23,7 @@ use std::future::Future;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@ -145,6 +146,7 @@ impl CodeExecutionClient {
&self, &self,
ctx: &ToolCallContext, ctx: &ToolCallContext,
code_mode: &CodeMode, code_mode: &CodeMode,
cancellation_token: CancellationToken,
) -> Result<PctxRegistry, String> { ) -> Result<PctxRegistry, String> {
let manager = self let manager = self
.context .context
@ -163,7 +165,12 @@ impl CodeExecutionClient {
.unwrap_or_default(), .unwrap_or_default(),
&cfg.name &cfg.name
); );
let callback = create_tool_callback(ctx.clone(), full_name, manager.clone()); let callback = create_tool_callback(
ctx.clone(),
full_name,
manager.clone(),
cancellation_token.clone(),
);
registry registry
.add_callback(&cfg.id(), callback) .add_callback(&cfg.id(), callback)
.map_err(|e| format!("Failed to register callback: {e}"))?; .map_err(|e| format!("Failed to register callback: {e}"))?;
@ -203,6 +210,7 @@ impl CodeExecutionClient {
&self, &self,
session_id: &str, session_id: &str,
arguments: Option<JsonObject>, arguments: Option<JsonObject>,
cancellation_token: CancellationToken,
) -> Result<Vec<Content>, String> { ) -> Result<Vec<Content>, String> {
let input: ExecuteBashInput = arguments let input: ExecuteBashInput = arguments
.map(|args| serde_json::from_value(Value::Object(args))) .map(|args| serde_json::from_value(Value::Object(args)))
@ -212,23 +220,19 @@ impl CodeExecutionClient {
let command = input.command; let command = input.command;
let code_mode = self.get_code_mode(session_id).await?; let code_mode = self.get_code_mode(session_id).await?;
// Deno runtime is not Send, so we need to run it in a blocking task let dispatch_token = cancellation_token.child_token();
// with its own tokio runtime let output = run_in_deno_runtime(
let output = tokio::task::spawn_blocking(move || { execution_timeout(),
let rt = tokio::runtime::Builder::new_current_thread() cancellation_token,
.enable_all() dispatch_token,
.build() move || async move {
.map_err(|e| format!("Failed to create runtime: {e}"))?;
rt.block_on(async move {
code_mode code_mode
.execute_bash(&command) .execute_bash(&command)
.await .await
.map_err(|e| format!("Typescript execution error: {e}")) .map_err(|e| format!("Bash execution error: {e}"))
}) },
}) )
.await .await?;
.map_err(|e| format!("Typescript execution task failed: {e}"))??;
Ok(vec![Content::text(output.markdown())]) Ok(vec![Content::text(output.markdown())])
} }
@ -238,6 +242,7 @@ impl CodeExecutionClient {
&self, &self,
ctx: &ToolCallContext, ctx: &ToolCallContext,
arguments: Option<JsonObject>, arguments: Option<JsonObject>,
cancellation_token: CancellationToken,
) -> Result<Vec<Content>, String> { ) -> Result<Vec<Content>, String> {
let args: ExecuteWithToolGraph = arguments let args: ExecuteWithToolGraph = arguments
.map(|args| serde_json::from_value(Value::Object(args))) .map(|args| serde_json::from_value(Value::Object(args)))
@ -247,41 +252,110 @@ impl CodeExecutionClient {
let session_id = &ctx.session_id; let session_id = &ctx.session_id;
let code_mode = self.get_code_mode(session_id).await?; let code_mode = self.get_code_mode(session_id).await?;
let registry = self.build_callback_registry(ctx, &code_mode)?; let dispatch_token = cancellation_token.child_token();
let registry = self.build_callback_registry(ctx, &code_mode, dispatch_token.clone())?;
let code = args.input.code.clone(); let code = args.input.code.clone();
let disclosure = self.disclosure; let disclosure = self.disclosure;
// Deno runtime is not Send, so we need to run it in a blocking task let output = run_in_deno_runtime(
// with its own tokio runtime execution_timeout(),
let output = tokio::task::spawn_blocking(move || { cancellation_token,
let rt = tokio::runtime::Builder::new_current_thread() dispatch_token,
.enable_all() move || async move {
.build()
.map_err(|e| format!("Failed to create runtime: {e}"))?;
rt.block_on(async move {
code_mode code_mode
.execute_typescript(&code, disclosure, Some(registry)) .execute_typescript(&code, disclosure, Some(registry))
.await .await
.map_err(|e| format!("Typescript execution error: {e}")) .map_err(|e| format!("Typescript execution error: {e}"))
}) },
}) )
.await .await?;
.map_err(|e| format!("Typescript execution task failed: {e}"))??;
Ok(vec![Content::text(output.markdown())]) Ok(vec![Content::text(output.markdown())])
} }
} }
fn execution_timeout() -> Duration {
let secs = crate::config::Config::global()
.get_goose_default_extension_timeout()
.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT);
Duration::from_secs(secs)
}
/// Deno runtime is not Send, so execution runs in a blocking task with its
/// own tokio runtime. pctx serializes all executions behind a process-wide
/// V8 mutex, so a hung script would wedge code execution for every session:
/// bound the wait with the extension timeout and honor cancellation.
///
/// `dispatch_token` is the child token shared with the callback dispatches that
/// a script makes back into Goose tools. When execution is abandoned (timeout
/// or cancellation), the token is cancelled so an in-flight nested tool call
/// (e.g. a long `developer.shell` command) is told to stop instead of running
/// on in the background.
/// Grace period for nested tool calls to observe a dispatched cancellation
/// signal and clean up (e.g. kill child processes) before the task future is
/// abandoned.
const DISPATCH_DRAIN_TIMEOUT: Duration = Duration::from_millis(500);
async fn run_in_deno_runtime<T, F, Fut>(
timeout: Duration,
cancellation_token: CancellationToken,
dispatch_token: CancellationToken,
task: F,
) -> Result<T, String>
where
F: FnOnce() -> Fut + Send + 'static,
Fut: Future<Output = Result<T, String>>,
T: Send + 'static,
{
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("Failed to create runtime: {e}"))?;
rt.block_on(async move {
let task_future = task();
tokio::pin!(task_future);
tokio::select! {
_ = cancellation_token.cancelled() => {
dispatch_token.cancel();
let _ = tokio::time::timeout(
DISPATCH_DRAIN_TIMEOUT,
&mut task_future,
).await;
Err("Execution cancelled".to_string())
}
_ = tokio::time::sleep(timeout) => {
dispatch_token.cancel();
let _ = tokio::time::timeout(
DISPATCH_DRAIN_TIMEOUT,
&mut task_future,
).await;
Err(format!(
"Execution timed out after {} seconds",
timeout.as_secs()
))
}
result = &mut task_future => result,
}
})
})
.await
.map_err(|e| format!("Execution task failed: {e}"))?
}
fn create_tool_callback( fn create_tool_callback(
ctx: ToolCallContext, ctx: ToolCallContext,
full_name: String, full_name: String,
manager: Arc<crate::agents::ExtensionManager>, manager: Arc<crate::agents::ExtensionManager>,
cancellation_token: CancellationToken,
) -> CallbackFn { ) -> CallbackFn {
Arc::new(move |args: Option<Value>| { Arc::new(move |args: Option<Value>| {
let ctx = ctx.clone(); let ctx = ctx.clone();
let full_name = full_name.clone(); let full_name = full_name.clone();
let manager = manager.clone(); let manager = manager.clone();
let cancellation_token = cancellation_token.clone();
Box::pin(async move { Box::pin(async move {
let tool_call = { let tool_call = {
let mut params = CallToolRequestParams::new(full_name); let mut params = CallToolRequestParams::new(full_name);
@ -291,7 +365,7 @@ fn create_tool_callback(
params params
}; };
match manager match manager
.dispatch_tool_call(&ctx, tool_call, CancellationToken::new()) .dispatch_tool_call(&ctx, tool_call, cancellation_token)
.await .await
{ {
Ok(dispatch_result) => match dispatch_result.result.await { Ok(dispatch_result) => match dispatch_result.result.await {
@ -447,7 +521,7 @@ impl McpClientTrait for CodeExecutionClient {
ctx: &ToolCallContext, ctx: &ToolCallContext,
name: &str, name: &str,
arguments: Option<JsonObject>, arguments: Option<JsonObject>,
_cancellation_token: CancellationToken, cancellation_token: CancellationToken,
) -> Result<CallToolResult, Error> { ) -> Result<CallToolResult, Error> {
let session_id = &ctx.session_id; let session_id = &ctx.session_id;
let result = match name { let result = match name {
@ -456,8 +530,14 @@ impl McpClientTrait for CodeExecutionClient {
self.handle_get_function_details(session_id, arguments) self.handle_get_function_details(session_id, arguments)
.await .await
} }
"execute_bash" => self.handle_execute_bash(session_id, arguments).await, "execute_bash" => {
"execute_typescript" => self.handle_execute_typescript(ctx, arguments).await, self.handle_execute_bash(session_id, arguments, cancellation_token)
.await
}
"execute_typescript" => {
self.handle_execute_typescript(ctx, arguments, cancellation_token)
.await
}
_ => Err(format!("Unknown tool: {name}")), _ => Err(format!("Unknown tool: {name}")),
}; };
@ -556,6 +636,148 @@ impl CodeModeState {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn run_in_deno_runtime_times_out_on_hung_execution() {
let result: Result<(), String> = run_in_deno_runtime(
Duration::from_millis(50),
CancellationToken::new(),
CancellationToken::new(),
std::future::pending,
)
.await;
assert!(result.unwrap_err().contains("timed out"));
}
#[tokio::test]
async fn run_in_deno_runtime_honors_cancellation() {
let token = CancellationToken::new();
token.cancel();
let result: Result<(), String> = run_in_deno_runtime(
Duration::from_secs(60),
token,
CancellationToken::new(),
std::future::pending,
)
.await;
assert_eq!(result.unwrap_err(), "Execution cancelled");
}
#[tokio::test]
async fn run_in_deno_runtime_cancels_dispatch_token_when_abandoned() {
// On timeout, an in-flight nested tool call (via the dispatch token)
// must be told to stop rather than left running in the background.
let dispatch_token = CancellationToken::new();
let result: Result<(), String> = run_in_deno_runtime(
Duration::from_millis(50),
CancellationToken::new(),
dispatch_token.clone(),
std::future::pending,
)
.await;
assert!(result.unwrap_err().contains("timed out"));
assert!(dispatch_token.is_cancelled());
// On cancellation, the child dispatch token is likewise cancelled.
let outer = CancellationToken::new();
outer.cancel();
let dispatch_token = outer.child_token();
let result: Result<(), String> = run_in_deno_runtime(
Duration::from_secs(60),
outer,
dispatch_token.clone(),
std::future::pending,
)
.await;
assert_eq!(result.unwrap_err(), "Execution cancelled");
assert!(dispatch_token.is_cancelled());
}
#[tokio::test]
async fn run_in_deno_runtime_drains_task_on_timeout() {
use std::sync::atomic::{AtomicBool, Ordering};
let dispatch_token = CancellationToken::new();
let observed = Arc::new(AtomicBool::new(false));
let task_token = dispatch_token.clone();
let task_observed = observed.clone();
let result: Result<(), String> = run_in_deno_runtime(
Duration::from_millis(50),
CancellationToken::new(),
dispatch_token.clone(),
move || async move {
task_token.cancelled().await;
task_observed.store(true, Ordering::SeqCst);
Ok(())
},
)
.await;
assert!(
result.unwrap_err().contains("timed out"),
"should report timeout"
);
assert!(
observed.load(Ordering::SeqCst),
"task should observe dispatch token cancellation before being dropped"
);
}
/// Exercises the real Deno/V8 stack: a script whose event loop never
/// resolves must time out instead of wedging forever, and a normal
/// script must run right after, proving pctx's process-wide V8 mutex
/// was released (i.e. one hung execution no longer blocks other sessions).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn real_v8_hung_script_times_out_and_frees_the_runtime() {
let hung = CodeMode::default();
let hung_result = run_in_deno_runtime(
Duration::from_secs(2),
CancellationToken::new(),
CancellationToken::new(),
move || async move {
hung.execute_typescript(
"async function run() { await new Promise(() => {}); }",
ToolDisclosure::default(),
None,
)
.await
.map_err(|e| format!("execution error: {e}"))
},
)
.await;
assert!(
hung_result.unwrap_err().contains("timed out"),
"hung script should time out"
);
let normal = CodeMode::default();
let normal_result = run_in_deno_runtime(
Duration::from_secs(60),
CancellationToken::new(),
CancellationToken::new(),
move || async move {
normal
.execute_typescript(
"async function run() { return 1 + 1; }",
ToolDisclosure::default(),
None,
)
.await
.map_err(|e| format!("execution error: {e}"))
},
)
.await
.expect("normal script should run after a prior timeout");
assert!(
normal_result.success,
"normal script should succeed once the V8 mutex is released: {}",
normal_result.stderr
);
}
#[test] #[test]
fn catalog_moim_mentions_inspection_tools_without_function_names() { fn catalog_moim_mentions_inspection_tools_without_function_names() {
let moim = catalog_disclosure_moim(3); let moim = catalog_disclosure_moim(3);

View file

@ -192,12 +192,15 @@ impl McpClientTrait for DeveloperClient {
ctx: &ToolCallContext, ctx: &ToolCallContext,
name: &str, name: &str,
arguments: Option<JsonObject>, arguments: Option<JsonObject>,
_cancel_token: CancellationToken, cancel_token: CancellationToken,
) -> Result<CallToolResult, Error> { ) -> Result<CallToolResult, Error> {
let working_dir = ctx.working_dir.as_deref(); let working_dir = ctx.working_dir.as_deref();
match name { match name {
"shell" => match Self::parse_args::<ShellParams>(arguments) { "shell" => match Self::parse_args::<ShellParams>(arguments) {
Ok(params) => Ok(self.shell_tool.shell_with_cwd(params, working_dir).await), Ok(params) => Ok(self
.shell_tool
.shell_with_cwd(params, working_dir, cancel_token)
.await),
Err(error) => Ok(ShellTool::error_result(&format!("Error: {error}"), None)), Err(error) => Ok(ShellTool::error_result(&format!("Error: {error}"), None)),
}, },
"write" => match Self::parse_args::<FileWriteParams>(arguments) { "write" => match Self::parse_args::<FileWriteParams>(arguments) {

View file

@ -18,6 +18,7 @@ use tokio::sync::OnceCell;
#[cfg(not(windows))] #[cfg(not(windows))]
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio_stream::{wrappers::SplitStream, StreamExt}; use tokio_stream::{wrappers::SplitStream, StreamExt};
use tokio_util::sync::CancellationToken;
use crate::subprocess::SubprocessExt; use crate::subprocess::SubprocessExt;
@ -345,13 +346,15 @@ impl ShellTool {
} }
pub async fn shell(&self, params: ShellParams) -> CallToolResult { pub async fn shell(&self, params: ShellParams) -> CallToolResult {
self.shell_with_cwd(params, None).await self.shell_with_cwd(params, None, CancellationToken::new())
.await
} }
pub async fn shell_with_cwd( pub async fn shell_with_cwd(
&self, &self,
params: ShellParams, params: ShellParams,
working_dir: Option<&std::path::Path>, working_dir: Option<&std::path::Path>,
cancellation_token: CancellationToken,
) -> CallToolResult { ) -> CallToolResult {
if params.command.trim().is_empty() { if params.command.trim().is_empty() {
return Self::error_result("Command cannot be empty.", None); return Self::error_result("Command cannot be empty.", None);
@ -369,6 +372,7 @@ impl ShellTool {
params.timeout_secs, params.timeout_secs,
working_dir, working_dir,
login_path_ref, login_path_ref,
cancellation_token,
) )
.await .await
{ {
@ -510,6 +514,7 @@ async fn run_command(
timeout_secs: Option<u64>, timeout_secs: Option<u64>,
working_dir: Option<&std::path::Path>, working_dir: Option<&std::path::Path>,
login_path: Option<&str>, login_path: Option<&str>,
cancellation_token: CancellationToken,
) -> Result<ExecutionOutput, String> { ) -> Result<ExecutionOutput, String> {
let mut command = build_shell_command(command_line, working_dir, login_path); let mut command = build_shell_command(command_line, working_dir, login_path);
@ -536,23 +541,35 @@ async fn run_command(
let mut timed_out = false; let mut timed_out = false;
let exit_code = if let Some(timeout_secs) = timeout_secs.filter(|value| *value > 0) { let exit_code = if let Some(timeout_secs) = timeout_secs.filter(|value| *value > 0) {
match tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait()).await { tokio::select! {
Ok(wait_result) => wait_result result = tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait()) => match result {
.map_err(|error| format!("Failed waiting on shell command: {}", error))? Ok(wait_result) => wait_result
.code(), .map_err(|error| format!("Failed waiting on shell command: {}", error))?
Err(_) => { .code(),
timed_out = true; Err(_) => {
timed_out = true;
let _ = child.start_kill();
let _ = child.wait().await;
None
}
},
_ = cancellation_token.cancelled() => {
let _ = child.start_kill(); let _ = child.start_kill();
let _ = child.wait().await; let _ = child.wait().await;
None None
} }
} }
} else { } else {
child tokio::select! {
.wait() result = child.wait() => result
.await .map_err(|error| format!("Failed waiting on shell command: {}", error))?
.map_err(|error| format!("Failed waiting on shell command: {}", error))? .code(),
.code() _ = cancellation_token.cancelled() => {
let _ = child.start_kill();
let _ = child.wait().await;
None
}
}
}; };
const OUTPUT_DRAIN_TIMEOUT_MILLIS: u64 = 500; const OUTPUT_DRAIN_TIMEOUT_MILLIS: u64 = 500;
@ -847,6 +864,7 @@ mod tests {
timeout_secs: None, timeout_secs: None,
}, },
Some(dir.path()), Some(dir.path()),
CancellationToken::new(),
) )
.await; .await;
@ -856,6 +874,41 @@ mod tests {
assert_eq!(observed, expected); assert_eq!(observed, expected);
} }
#[cfg(not(windows))]
#[tokio::test]
async fn shell_kills_child_on_cancellation() {
let tool = ShellTool::new_for_test().unwrap();
let token = CancellationToken::new();
let token_clone = token.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
token_clone.cancel();
});
let start = std::time::Instant::now();
let result = tool
.shell_with_cwd(
ShellParams {
command: "sleep 30".to_string(),
timeout_secs: None,
},
None,
token,
)
.await;
assert!(
start.elapsed().as_secs() < 5,
"shell should return quickly after cancellation, not wait for the command"
);
let shell_output = extract_shell_output(&result);
assert!(
shell_output.exit_code.is_none(),
"cancelled process should have no exit code"
);
}
#[cfg(not(windows))] #[cfg(not(windows))]
#[test] #[test]
fn unix_shell_flavor_detects_nushell_names() { fn unix_shell_flavor_detects_nushell_names() {