Skip to content

Commit c3c0466

Browse files
committed
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
1 parent 15fd62e commit c3c0466

File tree

2 files changed

+79
-18
lines changed

2 files changed

+79
-18
lines changed

crates/bashkit-python/src/lib.rs

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ impl ExecResult {
165165
#[allow(dead_code)]
166166
pub struct PyBash {
167167
inner: Arc<Mutex<Bash>>,
168+
/// Shared tokio runtime — reused across all sync calls to avoid
169+
/// per-call OS thread/fd exhaustion (issue #414).
170+
rt: tokio::runtime::Runtime,
168171
username: Option<String>,
169172
hostname: Option<String>,
170173
max_commands: Option<u64>,
@@ -201,8 +204,14 @@ impl PyBash {
201204

202205
let bash = builder.build();
203206

207+
let rt = tokio::runtime::Builder::new_current_thread()
208+
.enable_all()
209+
.build()
210+
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?;
211+
204212
Ok(Self {
205213
inner: Arc::new(Mutex::new(bash)),
214+
rt,
206215
username,
207216
hostname,
208217
max_commands,
@@ -235,10 +244,8 @@ impl PyBash {
235244
/// Execute commands synchronously (blocking).
236245
fn execute_sync(&self, commands: String) -> PyResult<ExecResult> {
237246
let inner = self.inner.clone();
238-
let rt = tokio::runtime::Runtime::new()
239-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?;
240247

241-
rt.block_on(async move {
248+
self.rt.block_on(async move {
242249
let mut bash = inner.lock().await;
243250
match bash.exec(&commands).await {
244251
Ok(result) => Ok(ExecResult {
@@ -260,10 +267,8 @@ impl PyBash {
260267
/// Reset interpreter to fresh state.
261268
fn reset(&self) -> PyResult<()> {
262269
let inner = self.inner.clone();
263-
let rt = tokio::runtime::Runtime::new()
264-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?;
265270

266-
rt.block_on(async move {
271+
self.rt.block_on(async move {
267272
let mut bash = inner.lock().await;
268273
let builder = Bash::builder();
269274
*bash = builder.build();
@@ -312,6 +317,9 @@ impl PyBash {
312317
#[allow(dead_code)]
313318
pub struct BashTool {
314319
inner: Arc<Mutex<Bash>>,
320+
/// Shared tokio runtime — reused across all sync calls to avoid
321+
/// per-call OS thread/fd exhaustion (issue #414).
322+
rt: tokio::runtime::Runtime,
315323
username: Option<String>,
316324
hostname: Option<String>,
317325
max_commands: Option<u64>,
@@ -348,8 +356,14 @@ impl BashTool {
348356

349357
let bash = builder.build();
350358

359+
let rt = tokio::runtime::Builder::new_current_thread()
360+
.enable_all()
361+
.build()
362+
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?;
363+
351364
Ok(Self {
352365
inner: Arc::new(Mutex::new(bash)),
366+
rt,
353367
username,
354368
hostname,
355369
max_commands,
@@ -380,10 +394,8 @@ impl BashTool {
380394

381395
fn execute_sync(&self, commands: String) -> PyResult<ExecResult> {
382396
let inner = self.inner.clone();
383-
let rt = tokio::runtime::Runtime::new()
384-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?;
385397

386-
rt.block_on(async move {
398+
self.rt.block_on(async move {
387399
let mut bash = inner.lock().await;
388400
match bash.exec(&commands).await {
389401
Ok(result) => Ok(ExecResult {
@@ -404,10 +416,8 @@ impl BashTool {
404416

405417
fn reset(&self) -> PyResult<()> {
406418
let inner = self.inner.clone();
407-
let rt = tokio::runtime::Runtime::new()
408-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?;
409419

410-
rt.block_on(async move {
420+
self.rt.block_on(async move {
411421
let mut bash = inner.lock().await;
412422
let builder = Bash::builder();
413423
*bash = builder.build();
@@ -509,6 +519,9 @@ pub struct ScriptedTool {
509519
short_desc: Option<String>,
510520
tools: Vec<PyToolEntry>,
511521
env_vars: Vec<(String, String)>,
522+
/// Shared tokio runtime — reused across all sync calls to avoid
523+
/// per-call OS thread/fd exhaustion (issue #414).
524+
rt: tokio::runtime::Runtime,
512525
max_commands: Option<u64>,
513526
max_loop_iterations: Option<u64>,
514527
}
@@ -582,15 +595,21 @@ impl ScriptedTool {
582595
short_description: Option<String>,
583596
max_commands: Option<u64>,
584597
max_loop_iterations: Option<u64>,
585-
) -> Self {
586-
Self {
598+
) -> PyResult<Self> {
599+
let rt = tokio::runtime::Builder::new_current_thread()
600+
.enable_all()
601+
.build()
602+
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?;
603+
604+
Ok(Self {
587605
name,
588606
short_desc: short_description,
589607
tools: Vec::new(),
590608
env_vars: Vec::new(),
609+
rt,
591610
max_commands,
592611
max_loop_iterations,
593-
}
612+
})
594613
}
595614

596615
/// Register a tool command.
@@ -654,10 +673,8 @@ impl ScriptedTool {
654673
/// Execute a bash script synchronously (blocking).
655674
fn execute_sync(&self, commands: String) -> PyResult<ExecResult> {
656675
let mut tool = self.build_rust_tool();
657-
let rt = tokio::runtime::Runtime::new()
658-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?;
659676

660-
let resp = rt.block_on(async move {
677+
let resp = self.rt.block_on(async move {
661678
tool.execute(ToolRequest {
662679
commands,
663680
timeout_ms: None,

crates/bashkit-python/tests/test_bashkit.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,3 +728,47 @@ async def test_async_multiple_tools():
728728
assert r.exit_code == 0
729729
assert "A" in r.stdout
730730
assert "B" in r.stdout
731+
732+
733+
# ===========================================================================
734+
# Runtime reuse (issue #414)
735+
# ===========================================================================
736+
737+
738+
def test_bash_rapid_sync_calls_no_resource_exhaustion():
739+
"""Rapid execute_sync calls reuse a single runtime (no thread/fd leak)."""
740+
bash = Bash()
741+
for i in range(200):
742+
r = bash.execute_sync(f"echo {i}")
743+
assert r.exit_code == 0
744+
assert r.stdout.strip() == str(i)
745+
746+
747+
def test_bashtool_rapid_sync_calls_no_resource_exhaustion():
748+
"""Rapid execute_sync calls reuse a single runtime (no thread/fd leak)."""
749+
tool = BashTool()
750+
for i in range(200):
751+
r = tool.execute_sync(f"echo {i}")
752+
assert r.exit_code == 0
753+
assert r.stdout.strip() == str(i)
754+
755+
756+
def test_bashtool_rapid_reset_no_resource_exhaustion():
757+
"""Rapid reset calls reuse a single runtime (no thread/fd leak)."""
758+
tool = BashTool()
759+
for _ in range(200):
760+
tool.reset()
761+
# After many resets, tool still works
762+
r = tool.execute_sync("echo ok")
763+
assert r.exit_code == 0
764+
assert r.stdout.strip() == "ok"
765+
766+
767+
def test_scripted_tool_rapid_sync_calls_no_resource_exhaustion():
768+
"""Rapid execute_sync calls on ScriptedTool reuse a single runtime."""
769+
tool = ScriptedTool("api")
770+
tool.add_tool("ping", "Ping", callback=lambda p, s=None: "pong\n")
771+
for i in range(200):
772+
r = tool.execute_sync("ping")
773+
assert r.exit_code == 0
774+
assert r.stdout.strip() == "pong"

0 commit comments

Comments
 (0)