Skip to content

Commit 77b0693

Browse files
authored
fix(js): use shared runtime and concurrency limit for tool callbacks (#1047)
## Summary - Replace unbounded `std::thread::spawn` + per-call `Runtime::new()` with a shared lazily-initialized tokio runtime (2 worker threads) - Add semaphore with 10 permits to cap concurrent in-flight tool callbacks - Prevents OS thread exhaustion from scripts invoking tools in loops ## Test plan - [ ] Tool callbacks still execute correctly - [ ] Concurrent tool invocations bounded by semaphore - [ ] `cargo check -p bashkit-js` passes - [ ] No thread exhaustion under heavy tool callback load Closes #982
1 parent bec5aa2 commit 77b0693

1 file changed

Lines changed: 38 additions & 10 deletions

File tree

crates/bashkit-js/src/lib.rs

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,33 @@ use std::sync::Arc;
2727
use std::sync::atomic::{AtomicBool, Ordering};
2828
use tokio::sync::Mutex;
2929

30+
// ---------------------------------------------------------------------------
31+
// Shared tokio runtime + concurrency limiter for JS tool callbacks (issue #982).
32+
// A single multi-thread runtime is created lazily and reused for every callback
33+
// invocation, replacing the previous pattern of spawning an unbounded number of
34+
// OS threads each with its own single-threaded runtime. A semaphore caps the
35+
// maximum number of concurrent in-flight callbacks to prevent DoS.
36+
// ---------------------------------------------------------------------------
37+
const MAX_CONCURRENT_TOOL_CALLBACKS: usize = 10;
38+
39+
fn callback_runtime() -> &'static tokio::runtime::Runtime {
40+
use std::sync::OnceLock;
41+
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
42+
RT.get_or_init(|| {
43+
tokio::runtime::Builder::new_multi_thread()
44+
.worker_threads(2)
45+
.enable_all()
46+
.build()
47+
.expect("failed to create shared callback runtime")
48+
})
49+
}
50+
51+
fn callback_semaphore() -> &'static tokio::sync::Semaphore {
52+
use std::sync::OnceLock;
53+
static SEM: OnceLock<tokio::sync::Semaphore> = OnceLock::new();
54+
SEM.get_or_init(|| tokio::sync::Semaphore::new(MAX_CONCURRENT_TOOL_CALLBACKS))
55+
}
56+
3057
// ============================================================================
3158
// MontyObject <-> JSON conversion
3259
// ============================================================================
@@ -1064,20 +1091,21 @@ impl ScriptedTool {
10641091
});
10651092
let request_str = serde_json::to_string(&request).map_err(|e| e.to_string())?;
10661093

1067-
// Use a dedicated thread so the TSFN can dispatch to the JS event loop.
1068-
// The main thread must NOT be blocked (use async `execute`, not `executeSync`).
1094+
// Dispatch the TSFN call on the shared callback runtime with a
1095+
// concurrency semaphore to prevent unbounded thread/task creation
1096+
// (see issue #982).
10691097
let tsfn_clone = tsfn.clone();
10701098
let tool_name_clone = tool_name.clone();
1099+
let rt = callback_runtime();
1100+
let sem = callback_semaphore();
10711101
let (tx, rx) = std::sync::mpsc::channel();
1072-
std::thread::spawn(move || {
1073-
let rt = tokio::runtime::Builder::new_current_thread()
1074-
.enable_all()
1075-
.build();
1076-
let result = match rt {
1077-
Ok(rt) => rt
1078-
.block_on(tsfn_clone.call_async((request_str,)))
1102+
rt.spawn(async move {
1103+
let result = match sem.acquire().await {
1104+
Ok(_permit) => tsfn_clone
1105+
.call_async((request_str,))
1106+
.await
10791107
.map_err(|e| format!("{}: {}", tool_name_clone, e)),
1080-
Err(e) => Err(format!("{}: runtime error: {}", tool_name_clone, e)),
1108+
Err(e) => Err(format!("{}: semaphore error: {}", tool_name_clone, e)),
10811109
};
10821110
let _ = tx.send(result);
10831111
});

0 commit comments

Comments
 (0)