traverse_runtime/executor/
thread_pool.rs1use std::panic::{AssertUnwindSafe, catch_unwind};
6
7use rayon::{ThreadPool, ThreadPoolBuildError, ThreadPoolBuilder};
8use serde_json::Value;
9
10use super::{ArtifactType, CapabilityExecutor, ExecutorCapability, ExecutorError};
11
12const MIN_CAPACITY: usize = 1;
13const MAX_CAPACITY: usize = 256;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct ThreadPoolExecutorConfig {
18 pub capacity: usize,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ConfigError {
25 InvalidCapacity {
27 given: usize,
29 min: usize,
31 max: usize,
33 },
34 PoolBuildFailed(String),
36}
37
38impl std::fmt::Display for ConfigError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 Self::InvalidCapacity { given, min, max } => {
42 write!(
43 f,
44 "invalid thread pool capacity {given}; expected {min}..={max}"
45 )
46 }
47 Self::PoolBuildFailed(msg) => write!(f, "thread pool build failed: {msg}"),
48 }
49 }
50}
51
52impl std::error::Error for ConfigError {}
53
54impl From<ThreadPoolBuildError> for ConfigError {
55 fn from(value: ThreadPoolBuildError) -> Self {
56 Self::PoolBuildFailed(value.to_string())
57 }
58}
59
60pub struct ThreadPoolExecutor {
62 pool: ThreadPool,
63 inner: Box<dyn CapabilityExecutor>,
64}
65
66impl std::fmt::Debug for ThreadPoolExecutor {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("ThreadPoolExecutor").finish_non_exhaustive()
69 }
70}
71
72impl ThreadPoolExecutor {
73 pub fn new(
80 config: ThreadPoolExecutorConfig,
81 inner: Box<dyn CapabilityExecutor>,
82 ) -> Result<Self, ConfigError> {
83 if !(MIN_CAPACITY..=MAX_CAPACITY).contains(&config.capacity) {
84 return Err(ConfigError::InvalidCapacity {
85 given: config.capacity,
86 min: MIN_CAPACITY,
87 max: MAX_CAPACITY,
88 });
89 }
90
91 let pool = ThreadPoolBuilder::new()
92 .num_threads(config.capacity)
93 .build()?;
94
95 Ok(Self { pool, inner })
96 }
97}
98
99impl CapabilityExecutor for ThreadPoolExecutor {
100 fn execute(
101 &self,
102 capability: &ExecutorCapability,
103 input: &Value,
104 ) -> Result<Value, ExecutorError> {
105 if capability.artifact_type == ArtifactType::Wasm {
106 return Err(ExecutorError::UnsupportedArtifactType);
107 }
108
109 let capability = capability.clone();
110 let input = input.clone();
111 let result = self
112 .pool
113 .install(|| catch_unwind(AssertUnwindSafe(|| self.inner.execute(&capability, &input))));
114
115 match result {
116 Ok(inner_result) => inner_result,
117 Err(_) => Err(ExecutorError::ExecutionFailed(
118 "capability panicked".to_string(),
119 )),
120 }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use std::io;
127 use std::sync::{
128 Arc,
129 atomic::{AtomicUsize, Ordering},
130 };
131 use std::thread;
132 use std::time::Duration;
133
134 use serde_json::json;
135
136 use super::*;
137 use crate::executor::NativeExecutor;
138
139 fn native_capability() -> ExecutorCapability {
140 ExecutorCapability {
141 capability_id: "test.native".to_string(),
142 artifact_type: ArtifactType::Native,
143 wasm_binary_path: None,
144 wasm_checksum: None,
145 host_abi_version: None,
146 }
147 }
148
149 fn wasm_capability() -> ExecutorCapability {
150 ExecutorCapability {
151 artifact_type: ArtifactType::Wasm,
152 ..native_capability()
153 }
154 }
155
156 fn new_executor(
157 capacity: usize,
158 inner: Box<dyn CapabilityExecutor>,
159 ) -> Result<ThreadPoolExecutor, ConfigError> {
160 ThreadPoolExecutor::new(ThreadPoolExecutorConfig { capacity }, inner)
161 }
162
163 fn clone_input(input: &Value) -> Result<Value, String> {
164 if input.get("__thread_pool_test_error").is_some() {
165 return Err("requested test error".to_string());
166 }
167 Ok(input.clone())
168 }
169
170 fn result_debug<T, E: std::fmt::Debug>(result: Result<T, E>) -> Result<T, String> {
171 result.map_err(|err| format!("{err:?}"))
172 }
173
174 fn executor(capacity: usize) -> Result<ThreadPoolExecutor, String> {
175 result_debug(new_executor(
176 capacity,
177 Box::new(NativeExecutor::new(clone_input)),
178 ))
179 }
180
181 fn execute_json(executor: &ThreadPoolExecutor, input: &Value) -> Result<Value, ExecutorError> {
182 executor.execute(&native_capability(), input)
183 }
184
185 #[test]
186 fn clone_input_helper_can_return_error() {
187 let result = clone_input(&json!({ "__thread_pool_test_error": true }));
188
189 assert_eq!(result, Err("requested test error".to_string()));
190 }
191
192 #[test]
193 fn config_capacity_zero_returns_error() {
194 let result = new_executor(0, Box::new(NativeExecutor::new(clone_input)));
195
196 assert_eq!(
197 result.err(),
198 Some(ConfigError::InvalidCapacity {
199 given: 0,
200 min: 1,
201 max: 256
202 })
203 );
204 }
205
206 #[test]
207 fn config_capacity_max_valid() {
208 let result = new_executor(256, Box::new(NativeExecutor::new(clone_input)));
209
210 assert!(result.is_ok());
211 }
212
213 #[test]
214 fn config_capacity_over_max_returns_error() {
215 let result = new_executor(257, Box::new(NativeExecutor::new(clone_input)));
216
217 assert_eq!(
218 result.err(),
219 Some(ConfigError::InvalidCapacity {
220 given: 257,
221 min: 1,
222 max: 256
223 })
224 );
225 }
226
227 #[test]
228 fn config_capacity_one_valid() {
229 let result = new_executor(1, Box::new(NativeExecutor::new(clone_input)));
230
231 assert!(result.is_ok());
232 }
233
234 #[test]
235 fn config_error_display_and_from_cover_failure_shapes() {
236 let invalid = ConfigError::InvalidCapacity {
237 given: 0,
238 min: 1,
239 max: 256,
240 };
241
242 assert_eq!(
243 invalid.to_string(),
244 "invalid thread pool capacity 0; expected 1..=256"
245 );
246
247 let build_error = ThreadPoolBuilder::new()
248 .num_threads(1)
249 .spawn_handler(|_| Err(io::Error::other("spawn denied")))
250 .build()
251 .map_err(ConfigError::from);
252
253 assert!(matches!(build_error, Err(ConfigError::PoolBuildFailed(_))));
254
255 let display = build_error.map_err(|err| err.to_string()).err();
256 assert_eq!(
257 display,
258 Some("thread pool build failed: spawn denied".to_string())
259 );
260 }
261
262 #[test]
263 fn thread_pool_executor_debug_is_non_exhaustive() -> Result<(), String> {
264 let debug = executor(1).map(|executor| format!("{executor:?}"))?;
265
266 assert_eq!(debug, "ThreadPoolExecutor { .. }");
267 Ok(())
268 }
269
270 #[test]
271 fn execute_native_returns_correct_output() -> Result<(), String> {
272 let executor = executor(2)?;
273 let result = execute_json(&executor, &json!({ "value": 42 }));
274
275 assert_eq!(result, Ok(json!({ "value": 42 })));
276 Ok(())
277 }
278
279 #[test]
280 fn execute_native_error_propagates() {
281 let result = new_executor(
282 2,
283 Box::new(NativeExecutor::new(|_| Err("inner failed".to_string()))),
284 )
285 .map(|executor| executor.execute(&native_capability(), &json!({})));
286
287 assert_eq!(
288 result,
289 Ok(Err(ExecutorError::ExecutionFailed(
290 "inner failed".to_string()
291 )))
292 );
293 }
294
295 #[test]
296 fn execute_wasm_artifact_type_returns_unsupported() {
297 let result = new_executor(2, Box::new(PanickingExecutor::new(1)))
298 .map(|executor| executor.execute(&wasm_capability(), &json!({})));
299
300 assert_eq!(result, Ok(Err(ExecutorError::UnsupportedArtifactType)));
301 }
302
303 #[test]
304 fn concurrent_calls_run_in_parallel() {
305 let active_calls = Arc::new(AtomicUsize::new(0));
306 let max_active_calls = Arc::new(AtomicUsize::new(0));
307 let active_for_handler = Arc::clone(&active_calls);
308 let max_for_handler = Arc::clone(&max_active_calls);
309
310 let result = new_executor(
311 2,
312 Box::new(NativeExecutor::new(move |input| {
313 let current = active_for_handler.fetch_add(1, Ordering::SeqCst) + 1;
314 max_for_handler.fetch_max(current, Ordering::SeqCst);
315 thread::sleep(Duration::from_millis(100));
316 active_for_handler.fetch_sub(1, Ordering::SeqCst);
317 Ok(input.clone())
318 })),
319 )
320 .map(|executor| {
321 let executor = Arc::new(executor);
322 let first = {
323 let executor = Arc::clone(&executor);
324 thread::spawn(move || execute_json(&executor, &json!({ "call": 1 })))
325 };
326 let second = {
327 let executor = Arc::clone(&executor);
328 thread::spawn(move || execute_json(&executor, &json!({ "call": 2 })))
329 };
330 (result_debug(first.join()), result_debug(second.join()))
331 });
332
333 let first_result = result
334 .as_ref()
335 .map(|(first, _)| first.as_ref().map_err(String::as_str));
336 let second_result = result
337 .as_ref()
338 .map(|(_, second)| second.as_ref().map_err(String::as_str));
339
340 assert_eq!(first_result, Ok(Ok(&Ok(json!({ "call": 1 })))));
341 assert_eq!(second_result, Ok(Ok(&Ok(json!({ "call": 2 })))));
342 assert!(
343 max_active_calls.load(Ordering::SeqCst) >= 2,
344 "expected two active calls to overlap"
345 );
346 }
347
348 #[test]
349 fn pool_size_one_serialises_calls() {
350 let result = new_executor(
351 1,
352 Box::new(NativeExecutor::new(|input| {
353 thread::sleep(Duration::from_millis(50));
354 Ok(input.clone())
355 })),
356 )
357 .map(|executor| {
358 let executor = Arc::new(executor);
359 let first = {
360 let executor = Arc::clone(&executor);
361 thread::spawn(move || execute_json(&executor, &json!({ "call": 1 })))
362 };
363 let second = {
364 let executor = Arc::clone(&executor);
365 thread::spawn(move || execute_json(&executor, &json!({ "call": 2 })))
366 };
367 (result_debug(first.join()), result_debug(second.join()))
368 });
369 let first_result = result
370 .as_ref()
371 .map(|(first, _)| first.as_ref().map_err(String::as_str));
372 let second_result = result
373 .as_ref()
374 .map(|(_, second)| second.as_ref().map_err(String::as_str));
375
376 assert_eq!(first_result, Ok(Ok(&Ok(json!({ "call": 1 })))));
377 assert_eq!(second_result, Ok(Ok(&Ok(json!({ "call": 2 })))));
378 }
379
380 #[test]
381 fn independent_calls_do_not_share_state() -> Result<(), String> {
382 let executor = Arc::new(executor(4)?);
383 let mut handles = Vec::new();
384
385 for value in 0..10 {
386 let executor = Arc::clone(&executor);
387 handles.push(thread::spawn(move || {
388 execute_json(&executor, &json!({ "value": value }))
389 }));
390 }
391
392 for (value, handle) in handles.into_iter().enumerate() {
393 let result = result_debug(handle.join())?;
394 assert_eq!(result, Ok(json!({ "value": value })));
395 }
396 Ok(())
397 }
398
399 struct PanickingExecutor {
400 remaining_panics: AtomicUsize,
401 }
402
403 impl PanickingExecutor {
404 fn new(remaining_panics: usize) -> Self {
405 Self {
406 remaining_panics: AtomicUsize::new(remaining_panics),
407 }
408 }
409 }
410
411 impl CapabilityExecutor for PanickingExecutor {
412 fn execute(
413 &self,
414 _capability: &ExecutorCapability,
415 input: &Value,
416 ) -> Result<Value, ExecutorError> {
417 let remaining = self.remaining_panics.load(Ordering::SeqCst);
418 if remaining > 0 {
419 self.remaining_panics.fetch_sub(1, Ordering::SeqCst);
420 std::panic::resume_unwind(Box::new("boom"));
421 }
422 Ok(input.clone())
423 }
424 }
425
426 #[test]
427 fn panicking_handler_returns_execution_failed() -> Result<(), String> {
428 let executor = result_debug(new_executor(1, Box::new(PanickingExecutor::new(1))))?;
429 let result = executor.execute(&native_capability(), &json!({}));
430
431 assert_eq!(
432 result,
433 Err(ExecutorError::ExecutionFailed(
434 "capability panicked".to_string()
435 ))
436 );
437 Ok(())
438 }
439
440 #[test]
441 fn pool_usable_after_panic() -> Result<(), String> {
442 let executor = result_debug(new_executor(1, Box::new(PanickingExecutor::new(1))))?;
443 let failed = executor.execute(&native_capability(), &json!({ "first": true }));
444 let recovered = executor.execute(&native_capability(), &json!({ "second": true }));
445
446 assert_eq!(
447 failed,
448 Err(ExecutorError::ExecutionFailed(
449 "capability panicked".to_string()
450 ))
451 );
452 assert_eq!(recovered, Ok(json!({ "second": true })));
453 Ok(())
454 }
455
456 #[test]
457 fn multiple_sequential_panics_do_not_exhaust_pool() -> Result<(), String> {
458 let executor = result_debug(new_executor(2, Box::new(PanickingExecutor::new(5))))?;
459
460 for _ in 0..5 {
461 let result = executor.execute(&native_capability(), &json!({}));
462 assert_eq!(
463 result,
464 Err(ExecutorError::ExecutionFailed(
465 "capability panicked".to_string()
466 ))
467 );
468 }
469
470 let recovered = executor.execute(&native_capability(), &json!({ "ok": true }));
471
472 assert_eq!(recovered, Ok(json!({ "ok": true })));
473 Ok(())
474 }
475
476 #[test]
477 fn executor_is_send_sync() {
478 fn assert_send_sync<T: Send + Sync>() {}
479
480 assert_send_sync::<ThreadPoolExecutor>();
481 }
482
483 #[test]
484 fn arc_wrapped_executor_callable_from_multiple_threads() -> Result<(), String> {
485 let executor = Arc::new(executor(4)?);
486 let mut handles = Vec::new();
487
488 for value in 0..4 {
489 let executor = Arc::clone(&executor);
490 handles.push(thread::spawn(move || {
491 execute_json(&executor, &json!({ "thread": value }))
492 }));
493 }
494
495 for (value, handle) in handles.into_iter().enumerate() {
496 let result = result_debug(handle.join())?;
497 assert_eq!(result, Ok(json!({ "thread": value })));
498 }
499 Ok(())
500 }
501
502 #[test]
503 fn executor_drops_cleanly_after_use() -> Result<(), String> {
504 let executor = executor(1)?;
505 let result = execute_json(&executor, &json!({ "used": true }));
506
507 assert_eq!(result, Ok(json!({ "used": true })));
508 drop(executor);
509 Ok(())
510 }
511
512 #[test]
513 fn executor_drops_cleanly_with_no_calls() -> Result<(), String> {
514 let executor = executor(1)?;
515
516 drop(executor);
517 Ok(())
518 }
519}