From 3ff63a10e1c13ce89f5b628b3e957831ae0a2680 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 08:41:31 +0000 Subject: [PATCH] fix(python): reuse tokio runtime instead of creating per call Store a shared `tokio::runtime::Runtime` (current-thread) in PyBash, BashTool, and ScriptedTool structs. Previously each `execute_sync()` and `reset()` call created a new runtime, spawning OS threads that could exhaust thread/fd limits under rapid-fire usage. Fixes #414 https://claude.ai/code/session_01WZjYqxm5xMPAEe7FSHJkDy --- crates/bashkit-python/src/lib.rs | 53 ++++++++++++++------- crates/bashkit-python/tests/test_bashkit.py | 44 +++++++++++++++++ 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index 81abb30c..b17d164e 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -165,6 +165,9 @@ impl ExecResult { #[allow(dead_code)] pub struct PyBash { inner: Arc>, + /// Shared tokio runtime — reused across all sync calls to avoid + /// per-call OS thread/fd exhaustion (issue #414). + rt: tokio::runtime::Runtime, username: Option, hostname: Option, max_commands: Option, @@ -201,8 +204,14 @@ impl PyBash { let bash = builder.build(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?; + Ok(Self { inner: Arc::new(Mutex::new(bash)), + rt, username, hostname, max_commands, @@ -236,11 +245,9 @@ impl PyBash { /// Releases GIL before blocking on tokio to prevent deadlock with callbacks. fn execute_sync(&self, py: Python<'_>, commands: String) -> PyResult { let inner = self.inner.clone(); - let rt = tokio::runtime::Runtime::new() - .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?; py.detach(|| { - rt.block_on(async move { + self.rt.block_on(async move { let mut bash = inner.lock().await; match bash.exec(&commands).await { Ok(result) => Ok(ExecResult { @@ -264,11 +271,9 @@ impl PyBash { /// Releases GIL before blocking on tokio to prevent deadlock. fn reset(&self, py: Python<'_>) -> PyResult<()> { let inner = self.inner.clone(); - let rt = tokio::runtime::Runtime::new() - .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?; py.detach(|| { - rt.block_on(async move { + self.rt.block_on(async move { let mut bash = inner.lock().await; let builder = Bash::builder(); *bash = builder.build(); @@ -318,6 +323,9 @@ impl PyBash { #[allow(dead_code)] pub struct BashTool { inner: Arc>, + /// Shared tokio runtime — reused across all sync calls to avoid + /// per-call OS thread/fd exhaustion (issue #414). + rt: tokio::runtime::Runtime, username: Option, hostname: Option, max_commands: Option, @@ -354,8 +362,14 @@ impl BashTool { let bash = builder.build(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?; + Ok(Self { inner: Arc::new(Mutex::new(bash)), + rt, username, hostname, max_commands, @@ -387,11 +401,9 @@ impl BashTool { /// Releases GIL before blocking on tokio to prevent deadlock with callbacks. fn execute_sync(&self, py: Python<'_>, commands: String) -> PyResult { let inner = self.inner.clone(); - let rt = tokio::runtime::Runtime::new() - .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?; py.detach(|| { - rt.block_on(async move { + self.rt.block_on(async move { let mut bash = inner.lock().await; match bash.exec(&commands).await { Ok(result) => Ok(ExecResult { @@ -414,11 +426,9 @@ impl BashTool { /// Releases GIL before blocking on tokio to prevent deadlock. fn reset(&self, py: Python<'_>) -> PyResult<()> { let inner = self.inner.clone(); - let rt = tokio::runtime::Runtime::new() - .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?; py.detach(|| { - rt.block_on(async move { + self.rt.block_on(async move { let mut bash = inner.lock().await; let builder = Bash::builder(); *bash = builder.build(); @@ -521,6 +531,9 @@ pub struct ScriptedTool { short_desc: Option, tools: Vec, env_vars: Vec<(String, String)>, + /// Shared tokio runtime — reused across all sync calls to avoid + /// per-call OS thread/fd exhaustion (issue #414). + rt: tokio::runtime::Runtime, max_commands: Option, max_loop_iterations: Option, } @@ -594,15 +607,21 @@ impl ScriptedTool { short_description: Option, max_commands: Option, max_loop_iterations: Option, - ) -> Self { - Self { + ) -> PyResult { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?; + + Ok(Self { name, short_desc: short_description, tools: Vec::new(), env_vars: Vec::new(), + rt, max_commands, max_loop_iterations, - } + }) } /// Register a tool command. @@ -667,11 +686,9 @@ impl ScriptedTool { /// Releases GIL before blocking on tokio to prevent deadlock with callbacks. fn execute_sync(&self, py: Python<'_>, commands: String) -> PyResult { let mut tool = self.build_rust_tool(); - let rt = tokio::runtime::Runtime::new() - .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?; let resp = py.detach(|| { - rt.block_on(async move { + self.rt.block_on(async move { tool.execute(ToolRequest { commands, timeout_ms: None, diff --git a/crates/bashkit-python/tests/test_bashkit.py b/crates/bashkit-python/tests/test_bashkit.py index 8a15eea2..731476e1 100644 --- a/crates/bashkit-python/tests/test_bashkit.py +++ b/crates/bashkit-python/tests/test_bashkit.py @@ -848,3 +848,47 @@ def run_in_thread(idx): assert not t1.is_alive(), "Thread 1 deadlocked (GIL not released)" assert errors[0] is None, f"Thread 0 error: {errors[0]}" assert errors[1] is None, f"Thread 1 error: {errors[1]}" + + +# =========================================================================== +# Runtime reuse (issue #414) +# =========================================================================== + + +def test_bash_rapid_sync_calls_no_resource_exhaustion(): + """Rapid execute_sync calls reuse a single runtime (no thread/fd leak).""" + bash = Bash() + for i in range(200): + r = bash.execute_sync(f"echo {i}") + assert r.exit_code == 0 + assert r.stdout.strip() == str(i) + + +def test_bashtool_rapid_sync_calls_no_resource_exhaustion(): + """Rapid execute_sync calls reuse a single runtime (no thread/fd leak).""" + tool = BashTool() + for i in range(200): + r = tool.execute_sync(f"echo {i}") + assert r.exit_code == 0 + assert r.stdout.strip() == str(i) + + +def test_bashtool_rapid_reset_no_resource_exhaustion(): + """Rapid reset calls reuse a single runtime (no thread/fd leak).""" + tool = BashTool() + for _ in range(200): + tool.reset() + # After many resets, tool still works + r = tool.execute_sync("echo ok") + assert r.exit_code == 0 + assert r.stdout.strip() == "ok" + + +def test_scripted_tool_rapid_sync_calls_no_resource_exhaustion(): + """Rapid execute_sync calls on ScriptedTool reuse a single runtime.""" + tool = ScriptedTool("api") + tool.add_tool("ping", "Ping", callback=lambda p, s=None: "pong\n") + for i in range(200): + r = tool.execute_sync("ping") + assert r.exit_code == 0 + assert r.stdout.strip() == "pong"