Skip to content

Commit 3ff63a1

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 aadf3d6 commit 3ff63a1

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,
@@ -236,11 +245,9 @@ impl PyBash {
236245
/// Releases GIL before blocking on tokio to prevent deadlock with callbacks.
237246
fn execute_sync(&self, py: Python<'_>, commands: String) -> PyResult<ExecResult> {
238247
let inner = self.inner.clone();
239-
let rt = tokio::runtime::Runtime::new()
240-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?;
241248

242249
py.detach(|| {
243-
rt.block_on(async move {
250+
self.rt.block_on(async move {
244251
let mut bash = inner.lock().await;
245252
match bash.exec(&commands).await {
246253
Ok(result) => Ok(ExecResult {
@@ -264,11 +271,9 @@ impl PyBash {
264271
/// Releases GIL before blocking on tokio to prevent deadlock.
265272
fn reset(&self, py: Python<'_>) -> PyResult<()> {
266273
let inner = self.inner.clone();
267-
let rt = tokio::runtime::Runtime::new()
268-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?;
269274

270275
py.detach(|| {
271-
rt.block_on(async move {
276+
self.rt.block_on(async move {
272277
let mut bash = inner.lock().await;
273278
let builder = Bash::builder();
274279
*bash = builder.build();
@@ -318,6 +323,9 @@ impl PyBash {
318323
#[allow(dead_code)]
319324
pub struct BashTool {
320325
inner: Arc<Mutex<Bash>>,
326+
/// Shared tokio runtime — reused across all sync calls to avoid
327+
/// per-call OS thread/fd exhaustion (issue #414).
328+
rt: tokio::runtime::Runtime,
321329
username: Option<String>,
322330
hostname: Option<String>,
323331
max_commands: Option<u64>,
@@ -354,8 +362,14 @@ impl BashTool {
354362

355363
let bash = builder.build();
356364

365+
let rt = tokio::runtime::Builder::new_current_thread()
366+
.enable_all()
367+
.build()
368+
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?;
369+
357370
Ok(Self {
358371
inner: Arc::new(Mutex::new(bash)),
372+
rt,
359373
username,
360374
hostname,
361375
max_commands,
@@ -387,11 +401,9 @@ impl BashTool {
387401
/// Releases GIL before blocking on tokio to prevent deadlock with callbacks.
388402
fn execute_sync(&self, py: Python<'_>, commands: String) -> PyResult<ExecResult> {
389403
let inner = self.inner.clone();
390-
let rt = tokio::runtime::Runtime::new()
391-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?;
392404

393405
py.detach(|| {
394-
rt.block_on(async move {
406+
self.rt.block_on(async move {
395407
let mut bash = inner.lock().await;
396408
match bash.exec(&commands).await {
397409
Ok(result) => Ok(ExecResult {
@@ -414,11 +426,9 @@ impl BashTool {
414426
/// Releases GIL before blocking on tokio to prevent deadlock.
415427
fn reset(&self, py: Python<'_>) -> PyResult<()> {
416428
let inner = self.inner.clone();
417-
let rt = tokio::runtime::Runtime::new()
418-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?;
419429

420430
py.detach(|| {
421-
rt.block_on(async move {
431+
self.rt.block_on(async move {
422432
let mut bash = inner.lock().await;
423433
let builder = Bash::builder();
424434
*bash = builder.build();
@@ -521,6 +531,9 @@ pub struct ScriptedTool {
521531
short_desc: Option<String>,
522532
tools: Vec<PyToolEntry>,
523533
env_vars: Vec<(String, String)>,
534+
/// Shared tokio runtime — reused across all sync calls to avoid
535+
/// per-call OS thread/fd exhaustion (issue #414).
536+
rt: tokio::runtime::Runtime,
524537
max_commands: Option<u64>,
525538
max_loop_iterations: Option<u64>,
526539
}
@@ -594,15 +607,21 @@ impl ScriptedTool {
594607
short_description: Option<String>,
595608
max_commands: Option<u64>,
596609
max_loop_iterations: Option<u64>,
597-
) -> Self {
598-
Self {
610+
) -> PyResult<Self> {
611+
let rt = tokio::runtime::Builder::new_current_thread()
612+
.enable_all()
613+
.build()
614+
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?;
615+
616+
Ok(Self {
599617
name,
600618
short_desc: short_description,
601619
tools: Vec::new(),
602620
env_vars: Vec::new(),
621+
rt,
603622
max_commands,
604623
max_loop_iterations,
605-
}
624+
})
606625
}
607626

608627
/// Register a tool command.
@@ -667,11 +686,9 @@ impl ScriptedTool {
667686
/// Releases GIL before blocking on tokio to prevent deadlock with callbacks.
668687
fn execute_sync(&self, py: Python<'_>, commands: String) -> PyResult<ExecResult> {
669688
let mut tool = self.build_rust_tool();
670-
let rt = tokio::runtime::Runtime::new()
671-
.map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?;
672689

673690
let resp = py.detach(|| {
674-
rt.block_on(async move {
691+
self.rt.block_on(async move {
675692
tool.execute(ToolRequest {
676693
commands,
677694
timeout_ms: None,

crates/bashkit-python/tests/test_bashkit.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,3 +848,47 @@ def run_in_thread(idx):
848848
assert not t1.is_alive(), "Thread 1 deadlocked (GIL not released)"
849849
assert errors[0] is None, f"Thread 0 error: {errors[0]}"
850850
assert errors[1] is None, f"Thread 1 error: {errors[1]}"
851+
852+
853+
# ===========================================================================
854+
# Runtime reuse (issue #414)
855+
# ===========================================================================
856+
857+
858+
def test_bash_rapid_sync_calls_no_resource_exhaustion():
859+
"""Rapid execute_sync calls reuse a single runtime (no thread/fd leak)."""
860+
bash = Bash()
861+
for i in range(200):
862+
r = bash.execute_sync(f"echo {i}")
863+
assert r.exit_code == 0
864+
assert r.stdout.strip() == str(i)
865+
866+
867+
def test_bashtool_rapid_sync_calls_no_resource_exhaustion():
868+
"""Rapid execute_sync calls reuse a single runtime (no thread/fd leak)."""
869+
tool = BashTool()
870+
for i in range(200):
871+
r = tool.execute_sync(f"echo {i}")
872+
assert r.exit_code == 0
873+
assert r.stdout.strip() == str(i)
874+
875+
876+
def test_bashtool_rapid_reset_no_resource_exhaustion():
877+
"""Rapid reset calls reuse a single runtime (no thread/fd leak)."""
878+
tool = BashTool()
879+
for _ in range(200):
880+
tool.reset()
881+
# After many resets, tool still works
882+
r = tool.execute_sync("echo ok")
883+
assert r.exit_code == 0
884+
assert r.stdout.strip() == "ok"
885+
886+
887+
def test_scripted_tool_rapid_sync_calls_no_resource_exhaustion():
888+
"""Rapid execute_sync calls on ScriptedTool reuse a single runtime."""
889+
tool = ScriptedTool("api")
890+
tool.add_tool("ping", "Ping", callback=lambda p, s=None: "pong\n")
891+
for i in range(200):
892+
r = tool.execute_sync("ping")
893+
assert r.exit_code == 0
894+
assert r.stdout.strip() == "pong"

0 commit comments

Comments
 (0)