tropel_engine/worker.rs
1//! # VUWorkerPool — Thread-per-core VU sharding (1 VU per dedicated thread)
2//!
3//! Distributes VUs across a pool of current-thread tokio runtimes, each
4//! pinned to a dedicated OS thread. This gives each VU core-level isolation:
5//!
6//! - **No shared work-stealing** — VUs on one core never steal work from another
7//! - **Better cache locality** — each core's VU data stays in its L1/L2 cache
8//! - **JS execution isolation** — blocking JS on core 0 doesn't stall VUs on core 1
9//! - **`sleep()` safety** — each VU owns its OS thread, so a blocking script
10//! `sleep()` (implemented with `std::thread::sleep`) pauses *only* that VU.
11//! The pool grows on demand (`spawn_vu`), so no two VUs ever share a
12//! current-thread runtime — otherwise a `sleep()` in one VU would freeze
13//! every VU co-located on the same worker.
14//!
15//! # Scalability tradeoff
16//!
17//! 1 VU per OS thread is the closest Rust analog to k6's goroutine-per-VU
18//! model (without a GC), and it is what makes blocking `sleep()` safe. The
19//! cost is one OS thread (plus a current-thread runtime) per VU, so very high
20//! VU counts (e.g. 10k) are thread-heavy. That is the accepted tradeoff of
21//! the 1-VU-per-task design; a future refinement could cap growth when a
22//! script never calls `sleep()`.
23//!
24//! **Hard ceiling:** the pool never grows past `MAX_WORKERS` (10 000). For a
25//! bounded executor with `vus > 10 000` (an extreme 15k-VU constant test), VU
26//! `n` and VU `n+10_000` would silently share a worker, so a blocking
27//! `sleep()` in one could freeze the other — the cap trades strict isolation
28//! away at extreme VU counts to avoid exhausting the OS with one thread per
29//! VU. Realistic tests stay far below the cap, where isolation is exact.
30//! - **Future safety** — each JsContext is only used by its pinned thread, so we
31//! could drop the `rquickjs` `parallel` feature (and its per-`ctx.with` mutex)
32//! if `JsContext` were made `!Send`
33
34use std::future::Future;
35use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
36use std::sync::mpsc;
37use std::sync::{Arc, Mutex};
38use std::thread;
39use std::time::{Duration, Instant};
40
41/// How long `Drop` waits for a worker thread to exit before detaching it.
42/// Matches the engine's VU-drain bound (see `vu_loop.rs`: "VU drain timed
43/// out after 30s") so a worker wedged in a blocking eval or
44/// `std::thread::sleep` can never hang teardown past the drain bound.
45const JOIN_BOUND: Duration = Duration::from_secs(30);
46
47/// A pool of dedicated worker threads, each running a current-thread tokio
48/// runtime. VU tasks are pinned to their own worker (`spawn_vu`), so a VU is
49/// never co-located with another VU on the same runtime.
50pub struct VUWorkerPool {
51 /// Workers created so far. Grown on demand by `spawn_vu` so each live VU
52 /// gets its own OS thread. Mutex: growth is rare (once per concurrent
53 /// slot) and cheap.
54 workers: Mutex<Vec<WorkerInner>>,
55 /// Parallel to `workers`: true while that worker hosts a LIVE VU task.
56 /// `spawn_vu` reuses a slot whose flag is false (the finished VU freed
57 /// it), so the pool sizes to PEAK CONCURRENCY — never to the cumulative
58 /// monotonic vu_id count, which never resets and used to leak one OS
59 /// thread + runtime + ~2 fds per id across ramp cycles (P0: fd
60 /// exhaustion at tiny peak concurrency → swallowed panic → green run).
61 busy: Mutex<Vec<Arc<AtomicBool>>>,
62 next_idx: AtomicUsize,
63 /// How long `Drop` waits for a worker to exit before detaching it. 30s by
64 /// default (matching the engine's drain bound); short in tests.
65 join_bound: Duration,
66 /// Backlog line 425: once make_worker fails (e.g. OS thread cap hit),
67 /// memoize the failure so every subsequent VU skips the expensive
68 /// runtime+thread creation attempt.
69 growth_failed: AtomicBool,
70}
71
72struct WorkerInner {
73 /// Runtime handle — lets us spawn tasks onto this worker from any thread.
74 handle: tokio::runtime::Handle,
75 /// Signalled in `Drop` to unblock the worker thread's `block_on` call.
76 shutdown: Arc<tokio::sync::Notify>,
77 /// The dedicated OS thread that polls this runtime's task queue.
78 thread: Option<thread::JoinHandle<()>>,
79 /// Receives `()` once the worker thread has returned from `block_on` (i.e.
80 /// it is about to exit). Lets `Drop` wait on a *bounded* join instead of
81 /// blocking forever on a wedged worker.
82 exited: Option<mpsc::Receiver<()>>,
83}
84
85/// How a `spawn_vu` slot was acquired.
86enum Slot {
87 /// Reused an existing worker whose previous VU had finished.
88 Idle(usize, Arc<AtomicBool>),
89 /// Grew the pool with a brand-new worker (busy from birth).
90 Grown(usize, Arc<AtomicBool>),
91 /// Past the hard cap — co-scheduled on a busy worker (isolation
92 /// traded away at extreme VU counts, as documented).
93 Wrapped(usize),
94 /// Growth failed (runtime/thread creation error) and the pool has NO
95 /// worker to wrap onto — run the VU on the caller's runtime instead of
96 /// panicking (backlog line 163). Isolation is traded away entirely; this
97 /// only occurs under resource exhaustion, and it beats aborting the
98 /// scenario task mid-ramp and orphaning every VU already spawned.
99 Inline,
100}
101
102/// Clears a worker's busy flag on drop. The VU task's completion (or panic)
103/// releases the slot back to the pool for reuse — this is what keeps the pool
104/// sized to peak concurrency instead of cumulative ids.
105struct BusyGuard(Arc<AtomicBool>);
106
107impl Drop for BusyGuard {
108 fn drop(&mut self) {
109 self.0.store(false, Ordering::Release);
110 }
111}
112
113impl VUWorkerPool {
114 /// Create a new pool with `count` workers (one per core).
115 ///
116 /// Each worker runs a current-thread tokio runtime on a dedicated OS thread.
117 /// Panics if `count` is 0.
118 pub fn new(count: usize) -> Self {
119 Self::with_join_bound(count, JOIN_BOUND)
120 }
121
122 /// Create a pool with a custom join bound (tests use a short one).
123 fn with_join_bound(count: usize, join_bound: Duration) -> Self {
124 assert!(count > 0, "VUWorkerPool requires at least 1 worker");
125
126 // `make_worker` degrades (returns `None`) instead of panicking on
127 // runtime/thread creation failure (backlog line 163) — a skipped
128 // worker must not shift the busy-flag indices, so build both vecs in
129 // lockstep: only successful workers get a busy flag, and the busy
130 // index always equals the worker index.
131 let mut workers = Vec::with_capacity(count);
132 let mut busy = Vec::with_capacity(count);
133 for i in 0..count {
134 if let Some(w) = Self::make_worker(i) {
135 workers.push(w);
136 busy.push(Arc::new(AtomicBool::new(false)));
137 }
138 }
139 Self {
140 workers: Mutex::new(workers),
141 busy: Mutex::new(busy),
142 next_idx: AtomicUsize::new(0),
143 join_bound,
144 growth_failed: AtomicBool::new(false),
145 }
146 }
147
148 /// Create a single worker (current-thread runtime + pinned OS thread).
149 ///
150 /// Returns `None` (with a logged warning) instead of panicking when the
151 /// runtime or thread cannot be created (e.g. fd/thread exhaustion). A
152 /// panic here would unwind through `acquire_slot` → `spawn_vu` → the
153 /// ramp loop → out of `executor.run`, aborting the scenario task
154 /// mid-ramp and ORPHANING the VUs already spawned (they'd keep emitting
155 /// while the engine computed `results()` — backlog line 163). Callers
156 /// degrade instead: reuse an existing worker, or run the VU inline on
157 /// the caller's runtime.
158 fn make_worker(i: usize) -> Option<WorkerInner> {
159 // Test-only hook: forces this call to fail, exercising the graceful
160 // degradation paths deterministically (thread-local, so parallel
161 // tests can't steal the flag).
162 #[cfg(test)]
163 if FAIL_NEXT_WORKER_BUILD.with(|f| f.replace(false)) {
164 return None;
165 }
166 let runtime = match tokio::runtime::Builder::new_current_thread()
167 .enable_all()
168 .build()
169 {
170 Ok(r) => r,
171 Err(e) => {
172 tracing::warn!(
173 "VUWorkerPool: failed to create worker runtime {} ({}); degrading",
174 i,
175 e
176 );
177 return None;
178 }
179 };
180
181 let handle = runtime.handle().clone();
182 let shutdown = Arc::new(tokio::sync::Notify::new());
183 let sig = shutdown.clone();
184 let (exited_tx, exited_rx) = mpsc::channel::<()>();
185
186 let thread = match thread::Builder::new()
187 .name(format!("tropel-worker-{}", i))
188 .spawn(move || {
189 // Block on the runtime, waiting for shutdown signal.
190 // While blocked, the runtime processes spawned tasks.
191 runtime.block_on(async {
192 sig.notified().await;
193 });
194 // The worker is exiting — signal the pool so `Drop` can join
195 // it within the join bound. If the pool is gone (detached),
196 // the send fails silently.
197 let _ = exited_tx.send(());
198 }) {
199 Ok(t) => t,
200 Err(e) => {
201 tracing::warn!(
202 "VUWorkerPool: failed to spawn worker thread {} ({}); degrading",
203 i,
204 e
205 );
206 return None;
207 }
208 };
209
210 Some(WorkerInner {
211 handle,
212 shutdown,
213 thread: Some(thread),
214 exited: Some(exited_rx),
215 })
216 }
217
218 /// Find a worker slot with no live VU (its flag is false) and mark it
219 /// busy. Returns the slot and the flag (cloned) so the spawned task can
220 /// clear it on completion. `None` when every slot is busy.
221 fn find_idle_slot(&self) -> Option<(usize, Arc<AtomicBool>)> {
222 let busy = self.busy.lock().unwrap();
223 for (i, flag) in busy.iter().enumerate() {
224 if flag
225 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
226 .is_ok()
227 {
228 return Some((i, flag.clone()));
229 }
230 }
231 None
232 }
233
234 /// Claim a worker slot for one VU. Reuses an idle slot when one exists
235 /// (pool sizes to peak concurrency); grows by one only when every slot
236 /// is busy; wraps onto an existing worker only past `MAX_WORKERS`.
237 ///
238 /// Workers (runtime and OS thread) are created OUTSIDE the mutex: this is
239 /// called from the async ramp loop, and creating a current-thread runtime
240 /// and spawning a thread can take milliseconds — holding the mutex across
241 /// that would stall the ramp loop and every other pool operation. The
242 /// lock is only held for the final insert.
243 fn acquire_slot(&self, vu_id: u32) -> Slot {
244 if let Some((idx, flag)) = self.find_idle_slot() {
245 // P2 line 170: reset growth_failed when an idle slot is found.
246 // Thread-cap exhaustion is transient (VUs exit, threads free)
247 // but the old code permanently pinned every later VU to Wrapped.
248 self.growth_failed.store(false, Ordering::Release);
249 return Slot::Idle(idx, flag);
250 }
251 loop {
252 let current = self.workers.lock().unwrap().len();
253 if current >= Self::MAX_WORKERS {
254 return Slot::Wrapped((vu_id as usize) % current);
255 }
256 // Backlog line 425: once make_worker fails, skip the expensive
257 // runtime+thread creation for every subsequent VU.
258 if self.growth_failed.load(Ordering::Acquire) {
259 return if current > 0 {
260 Slot::Wrapped((vu_id as usize) % current)
261 } else {
262 Slot::Inline
263 };
264 }
265 // Build the worker outside the lock (runtime + thread creation).
266 // A failed build degrades (backlog line 163): wrap onto an
267 // existing worker if the pool has one, else run the VU inline on
268 // the caller's runtime — never panic and abort the ramp.
269 let worker = match Self::make_worker(current) {
270 Some(w) => w,
271 None => {
272 // Backlog line 425: memoize the failure so we never
273 // retry the expensive runtime+thread creation.
274 self.growth_failed.store(true, Ordering::Release);
275 if current > 0 {
276 return Slot::Wrapped((vu_id as usize) % current);
277 }
278 return Slot::Inline;
279 }
280 };
281 let flag = Arc::new(AtomicBool::new(true)); // busy from birth
282 let mut workers = self.workers.lock().unwrap();
283 if workers.len() == current {
284 // No concurrent growth — commit.
285 workers.push(worker);
286 self.busy.lock().unwrap().push(flag.clone());
287 return Slot::Grown(current, flag);
288 }
289 // Another thread grew the pool between our snapshot and lock
290 // acquisition; the freshly-built worker is surplus. Signal it to
291 // stop and reap it, then re-check. The surplus worker is
292 // GUARANTEED idle (never inserted, so `spawn_on` can't reach it),
293 // so the notify is consumed promptly.
294 drop(workers);
295 worker.shutdown.notify_one();
296 // Backlog line 160: the old code did a BLOCKING `exited.recv()` +
297 // `thread.join()` here — on the ramp loop's async thread. During a
298 // 10 000-VU ramp the growth CAS loses constantly, so each retry
299 // stalled the whole ramp on thread teardown. The surplus worker is
300 // guaranteed idle and its thread exits on its own right after the
301 // notify (its `block_on` returns), so DETACH it — dropping the
302 // JoinHandle lets the OS reclaim the thread when it finishes,
303 // with zero blocking and no throwaway reaper thread.
304 drop(worker.thread);
305 drop(worker.exited);
306 }
307 }
308
309 /// Return the number of workers in the pool.
310 pub fn worker_count(&self) -> usize {
311 self.workers.lock().unwrap().len()
312 }
313
314 /// Spawn a future on the worker at `idx` (must be < worker_count).
315 /// Returns a `JoinHandle` that can be awaited from any runtime.
316 pub fn spawn_on<F>(&self, idx: usize, future: F) -> tokio::task::JoinHandle<F::Output>
317 where
318 F: Future + Send + 'static,
319 F::Output: Send + 'static,
320 {
321 let handle = self.workers.lock().unwrap()[idx].handle.clone();
322 handle.spawn(future)
323 }
324
325 /// Maximum number of worker threads the pool will ever create. `spawn_vu`
326 /// reuses idle slots (slots freed by finished/panicked VUs) before
327 /// growing, so for any realistic test the pool sizes to PEAK CONCURRENCY
328 /// and stays far below this cap — strict 1-VU-per-thread isolation is
329 /// preserved. The cap only bites when a single run is concurrently busy
330 /// beyond 10 000 VUs; once reached, additional VUs wrap onto existing
331 /// workers (isolation traded away at extreme VU counts, as documented).
332 /// The vu_id passed to `run_vu` is unaffected (naming stays unique) —
333 /// only the worker slot may be shared.
334 /// TR-502 proper fix: raised from 4096 to 10000, and with TR-503 shared
335 /// Runtime (57k vs 843k) 10k VUs is ~570MB + overhead, not 8GB. The
336 /// thread-per-VU model is retained for now (async Promises are the next
337 /// step), but the cap no longer blocks k6-scale runs. pids.max still caps
338 /// in containers.
339 pub const MAX_WORKERS: usize = 10_000;
340
341 /// Read the cgroup `pids.max` limit if present (Kubernetes `pids.max`,
342 /// Docker `--pids-limit`). Returns `None` when unlimited or unreadable.
343 /// Checks both cgroup v1 and v2 paths.
344 pub fn pids_limit() -> Option<u64> {
345 for path in [
346 "/sys/fs/cgroup/pids.max",
347 "/sys/fs/cgroup/pids/pids.max",
348 "/sys/fs/cgroup/cpu/pids.max",
349 ] {
350 if let Ok(s) = std::fs::read_to_string(path) {
351 let t = s.trim();
352 if t == "max" || t.is_empty() {
353 continue;
354 }
355 if let Ok(v) = t.parse::<u64>() {
356 if v > 0 {
357 return Some(v);
358 }
359 }
360 }
361 }
362 None
363 }
364
365 /// Effective concurrency actually achievable — `min(requested, MAX_WORKERS,
366 /// pids.limit)`. When `requested > effective`, wrapping or pids-capping
367 /// reduces throughput to that of `effective`.
368 pub fn effective_concurrency(requested: u64) -> u64 {
369 let pids = Self::pids_limit().unwrap_or(u64::MAX);
370 requested.min(Self::MAX_WORKERS as u64).min(pids)
371 }
372
373 /// Spawn a VU on a dedicated worker thread. Reuses an idle slot (a
374 /// finished VU's worker) when one exists, grows the pool only when every
375 /// slot is busy, and wraps onto an existing worker only past
376 /// `MAX_WORKERS`. No two LIVE VUs ever share a worker, so a blocking
377 /// script `sleep()` still only blocks its own VU — while the pool sizes
378 /// to PEAK CONCURRENCY instead of the cumulative monotonic id count
379 /// (P0: ids grew the pool to thousands of threads/runtimes/fds at tiny
380 /// peak concurrency).
381 ///
382 /// Returns a `JoinHandle` that can be awaited from any runtime.
383 pub fn spawn_vu<F>(&self, vu_id: u32, future: F) -> tokio::task::JoinHandle<F::Output>
384 where
385 F: Future + Send + 'static,
386 F::Output: Send + 'static,
387 {
388 match self.acquire_slot(vu_id) {
389 // Panic-safe release: the guard clears the busy flag on drop, so
390 // the slot returns to the pool even if the VU task panics
391 // mid-flight. The Slot pattern owns the only Arc — moved straight
392 // into the task, no clone.
393 Slot::Idle(idx, flag) | Slot::Grown(idx, flag) => self.spawn_on(idx, async move {
394 let _release = BusyGuard(flag);
395 future.await
396 }),
397 Slot::Wrapped(idx) => {
398 // Line 387: warn once when wrapping begins so the user knows
399 // the reported VU count exceeds the pool capacity. At 15,000
400 // VUs with MAX_WORKERS=10_000, workers 0–4,999 host 2 VUs each
401 // and 5,000–9,999 host 1, doubling iteration periods for
402 // co-located VUs.
403 use std::sync::Once;
404 static WRAP_WARNED: Once = Once::new();
405 let current = self.workers.lock().unwrap().len();
406 WRAP_WARNED.call_once(|| {
407 tracing::warn!(
408 "VU pool wrapping: {} VUs requested but only {} workers \
409 available (MAX_WORKERS={}). Co-located VUs share a \
410 single-threaded runtime and block each other. The \
411 reported concurrency exceeds the actual throughput.",
412 vu_id + 1,
413 current,
414 Self::MAX_WORKERS,
415 );
416 });
417 self.spawn_on(idx, future)
418 }
419 // No worker available (resource exhaustion during growth): run on
420 // the CALLER's runtime. `tokio::spawn` requires a runtime context;
421 // `spawn_vu` is only reachable from inside one (the ramp loops).
422 Slot::Inline => tokio::spawn(future),
423 }
424 }
425
426 /// Spawn a future on the next worker (round-robin distribution).
427 /// Returns a tuple of (worker_index, JoinHandle).
428 pub fn spawn<F>(&self, future: F) -> (usize, tokio::task::JoinHandle<F::Output>)
429 where
430 F: Future + Send + 'static,
431 F::Output: Send + 'static,
432 {
433 let len = self.worker_count();
434 if len == 0 {
435 // Every construction-time build failed (backlog line 163): run
436 // on the caller's runtime rather than modulo-dividing by zero.
437 return (0, tokio::spawn(future));
438 }
439 let idx = self.next_idx.fetch_add(1, Ordering::Relaxed) % len;
440 let handle = self.spawn_on(idx, future);
441 (idx, handle)
442 }
443}
444
445impl Drop for VUWorkerPool {
446 fn drop(&mut self) {
447 // `get_mut` is sound here: Drop runs only when the last Arc is dropped,
448 // so no other thread can hold the lock. Recover from a poisoned mutex
449 // (a panic in any earlier lock guard) instead of aborting teardown.
450 let workers = self
451 .workers
452 .get_mut()
453 .unwrap_or_else(std::sync::PoisonError::into_inner);
454 // Signal each worker to stop. Each worker has its OWN Notify, and we
455 // use `notify_one()` (not `notify_waiters()`): notify_waiters stores
456 // no permit, so a notification fired before the worker thread has
457 // registered its `notified().await` waiter would be LOST and the
458 // worker would hang. `notify_one()` stores a permit when no waiter is
459 // present yet, so the wake can never be missed — race-free whether
460 // the worker is starting, parked, or wedged in a blocking call.
461 for worker in workers.iter() {
462 worker.shutdown.notify_one();
463 }
464 // Join the worker threads within a BOUNDED window. A worker whose VU
465 // is wedged in a blocking eval or `std::thread::sleep` cannot poll the
466 // shutdown notify until that blocking call returns, so an unbounded
467 // `join()` would hang teardown past the engine's 30s drain bound.
468 // Wait up to `join_bound` for each worker; a straggler is DETACHED
469 // (its handle dropped without joining) so the run finishes on time —
470 // the abandoned VU keeps running in the background, its late samples
471 // land after the summary snapshot (so they can't corrupt it), and the
472 // OS thread is reclaimed whenever its blocking call finally returns.
473 let deadline = Instant::now() + self.join_bound;
474 for worker in workers.iter_mut() {
475 let remaining = deadline.saturating_duration_since(Instant::now());
476 let exited = match &worker.exited {
477 Some(rx) => matches!(
478 rx.recv_timeout(remaining),
479 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected)
480 ),
481 None => false,
482 };
483 if exited {
484 // Worker returned from `block_on` — it is about to exit, so
485 // `join` returns promptly.
486 if let Some(thread) = worker.thread.take() {
487 let _ = thread.join();
488 }
489 } else if let Some(thread) = worker.thread.take() {
490 // Wedged past the bound: detach. The OS thread keeps running
491 // (the VU's blocking call is stuck) and exits whenever that
492 // call finally returns; we simply stop waiting so teardown is
493 // bounded. The `exited` sender is dropped with the thread, so
494 // nothing leaks — the detached thread is reclaimed by the OS
495 // when its task completes.
496 tracing::warn!(
497 "VU worker {} did not exit within the {}s join bound — detaching (its VU is wedged in a blocking call)",
498 thread.thread().name().unwrap_or("?"),
499 self.join_bound.as_secs()
500 );
501 }
502 }
503 }
504}
505
506#[cfg(test)]
507thread_local! {
508 /// Test-only hook: forces the next `make_worker` call on THIS thread to
509 /// fail, exercising the graceful-degradation paths deterministically.
510 /// `thread_local` (not a shared static) because pool tests run in
511 /// parallel threads in the same binary — a shared flag would let one
512 /// test's forced failure bleed into another test's pool construction.
513 static FAIL_NEXT_WORKER_BUILD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use std::time::Duration;
520
521 #[tokio::test]
522 async fn spawn_vu_pins_each_vu_to_its_own_thread() {
523 // Two CONCURRENT VUs spawned via spawn_vu must land on DIFFERENT OS
524 // threads — the whole point of the 1-VU-per-task design. If they
525 // shared a current-thread runtime, a blocking sleep() in one would
526 // freeze the other.
527 let pool = VUWorkerPool::new(1);
528
529 // Barrier: both VUs must be LIVE simultaneously before either records
530 // its thread name. Without it the test is racy — VU 0's task (a
531 // single thread-name read) can finish before VU 1's spawn_vu acquires
532 // a slot, freeing slot 0 for legitimate reuse (the pool sizes to PEAK
533 // CONCURRENCY), and both VUs report the same worker. Observed on
534 // macOS CI: the pool correctly reused the freed slot, and the test
535 // wrongly asserted they were distinct. Holding both tasks at the
536 // barrier keeps both busy flags set, so the second spawn MUST grow
537 // the pool to a second worker.
538 let barrier = Arc::new(tokio::sync::Barrier::new(2));
539 let (t0, t1) = tokio::join!(
540 async {
541 let b = barrier.clone();
542 let h = pool.spawn_vu(0, async move {
543 b.wait().await;
544 std::thread::current().name().map(|s| s.to_string())
545 });
546 h.await.unwrap()
547 },
548 async {
549 let b = barrier.clone();
550 let h = pool.spawn_vu(1, async move {
551 b.wait().await;
552 std::thread::current().name().map(|s| s.to_string())
553 });
554 h.await.unwrap()
555 },
556 );
557
558 assert_ne!(t0, t1, "VUs must run on distinct worker threads");
559 assert_eq!(t0.as_deref(), Some("tropel-worker-0"));
560 assert_eq!(t1.as_deref(), Some("tropel-worker-1"));
561 }
562
563 #[tokio::test]
564 async fn sleep_in_one_vu_does_not_block_another() {
565 // Regression test for the sleep()-blocks-the-core bug: with 1 VU per
566 // task, a blocking std::thread::sleep in VU 0 must not delay VU 1.
567 let pool = VUWorkerPool::new(1);
568
569 // The slow VU blocks its OS thread for 200ms (exactly what a script
570 // `sleep(0.2)` does via the native bridge).
571 let slow = pool.spawn_vu(0, async {
572 std::thread::sleep(Duration::from_millis(200));
573 "slow"
574 });
575
576 // The fast VU must finish well within the slow VU's sleep window —
577 // if VUs shared a current-thread runtime, the fast VU would be stuck
578 // behind the blocking sleep and this timeout would fire.
579 let fast = tokio::time::timeout(
580 Duration::from_millis(100),
581 pool.spawn_vu(1, async { "fast" }),
582 )
583 .await
584 .expect("fast VU was blocked behind another VU's sleep")
585 .unwrap();
586
587 assert_eq!(fast, "fast");
588 let _ = slow.await.unwrap();
589 }
590
591 #[tokio::test]
592 async fn worker_pool_grows_only_for_concurrent_vus() {
593 // P0 (backlog): the pool sized on the process-wide MONOTONIC vu_id
594 // counter — spawn_vu(10) grew the pool to 11 workers even though a
595 // single VU at a time never needs more than one. Across 20 ramp
596 // cycles (ids ~2000) that leaked ~2000 OS threads + runtimes + ~4000
597 // fds at peak concurrency 100 → fd exhaustion → a swallowed panic
598 // inside the scenario task → green run on partial data. The pool now
599 // REUSES idle slots: sequential VUs (whatever their id) must not grow
600 // it; only genuinely CONCURRENT VUs do.
601 let pool = VUWorkerPool::new(2);
602 assert_eq!(pool.worker_count(), 2);
603
604 // 2000 sequential VUs with monotonic ids (simulating many ramp
605 // cycles) must not grow the pool at all — each reuses a freed slot.
606 for vu_id in 0..2000u32 {
607 let h = pool.spawn_vu(vu_id, async {});
608 assert!(h.await.is_ok());
609 assert_eq!(
610 pool.worker_count(),
611 2,
612 "sequential VU {vu_id} grew the pool"
613 );
614 }
615
616 // Concurrent VUs DO grow it: 3 simultaneously-live VUs on a 2-worker
617 // pool must grow to 3 workers, then free slots back when they finish.
618 let (a, b, c) = tokio::join!(
619 pool.spawn_vu(0, async {
620 std::thread::sleep(std::time::Duration::from_millis(50));
621 }),
622 pool.spawn_vu(1, async {
623 std::thread::sleep(std::time::Duration::from_millis(50));
624 }),
625 pool.spawn_vu(2, async {
626 std::thread::sleep(std::time::Duration::from_millis(50));
627 }),
628 );
629 assert!(a.is_ok() && b.is_ok() && c.is_ok());
630 assert_eq!(
631 pool.worker_count(),
632 3,
633 "3 concurrent VUs must grow the pool to 3"
634 );
635
636 // After they finish, the 3rd worker slot is idle again but the pool
637 // never shrinks below the peak — a later sequential VU reuses it.
638 let h = pool.spawn_vu(3, async {});
639 assert!(h.await.is_ok());
640 assert_eq!(pool.worker_count(), 3);
641
642 // spawn (round-robin) still works and does not shrink anything.
643 let (idx, h) = pool.spawn(async {});
644 assert!(h.await.is_ok());
645 assert!(idx < pool.worker_count());
646 }
647
648 /// Backlog line 168: `Drop` used to `join()` every worker unconditionally,
649 /// so a VU wedged in a blocking eval / `std::thread::sleep` hung teardown
650 /// past the engine's 30s drain bound. Drop must now return within the
651 /// join bound by DETACHING the wedged worker instead of waiting for it.
652 #[test]
653 fn drop_detaches_wedged_worker_within_join_bound() {
654 // Short join bound so the test is fast; the worker is wedged for far
655 // longer than the bound.
656 let pool = VUWorkerPool::with_join_bound(1, Duration::from_millis(150));
657
658 // A VU that blocks its OS thread for 2s (what a script `sleep(2.0)`
659 // does via the native bridge). While wedged, the worker cannot poll
660 // the shutdown notify, so an unbounded join would hang ~2s here —
661 // and with a truly stuck eval, forever.
662 let _h = pool.spawn_vu(0, async {
663 std::thread::sleep(Duration::from_secs(2));
664 });
665
666 let start = Instant::now();
667 drop(pool);
668 let elapsed = start.elapsed();
669 // Must return near the join bound (detaching), NOT after the 2s sleep.
670 assert!(
671 elapsed < Duration::from_millis(800),
672 "drop blocked for {elapsed:?} on a wedged worker instead of detaching"
673 );
674 }
675
676 /// A healthy pool (no wedged VUs) must still tear down cleanly and
677 /// promptly — the bounded join must not regress the fast path.
678 #[test]
679 fn drop_joins_healthy_workers_promptly() {
680 let pool = VUWorkerPool::with_join_bound(2, Duration::from_secs(5));
681 let _h = pool.spawn_vu(0, async {});
682 let _h2 = pool.spawn_vu(1, async {});
683 let start = Instant::now();
684 drop(pool);
685 // Both workers exited on the shutdown notify immediately.
686 assert!(
687 start.elapsed() < Duration::from_millis(500),
688 "healthy teardown took {:?}",
689 start.elapsed()
690 );
691 }
692
693 /// Backlog line 163: a worker-runtime build failure must NEVER panic out
694 /// of `spawn_vu` (a panic would unwind through the ramp loop, abort the
695 /// scenario task mid-ramp, and orphan the VUs already spawned). It must
696 /// degrade instead: wrap onto an existing worker (pool non-empty) and
697 /// still run the VU to completion.
698 #[tokio::test]
699 async fn spawn_vu_degrades_to_wrapped_when_worker_build_fails() {
700 let pool = VUWorkerPool::new(1);
701 // Occupy the one worker so `acquire_slot` is forced onto the growth
702 // path, then force that growth to fail.
703 let (tx, rx) = tokio::sync::oneshot::channel();
704 let hold = pool.spawn_vu(0, async move {
705 let _ = rx.await;
706 "held"
707 });
708 FAIL_NEXT_WORKER_BUILD.with(|f| f.set(true));
709 let wrapped = pool.spawn_vu(1, async { "wrapped" });
710 FAIL_NEXT_WORKER_BUILD.with(|f| f.set(false));
711
712 // Must complete on the existing worker — not panic, not hang.
713 assert_eq!(wrapped.await.expect("wrapped VU panicked"), "wrapped");
714 let _ = tx.send(());
715 assert_eq!(hold.await.expect("held VU panicked"), "held");
716 }
717
718 /// Backlog line 163: when EVERY worker build fails (pool has zero
719 /// workers), `spawn_vu` must degrade to running the VU inline on the
720 /// caller's runtime — the last-resort path that keeps the ramp alive.
721 #[tokio::test]
722 async fn spawn_vu_degrades_to_inline_when_pool_is_empty() {
723 // Force the construction-time build to fail → zero-worker pool.
724 FAIL_NEXT_WORKER_BUILD.with(|f| f.set(true));
725 let pool = VUWorkerPool::new(1);
726 assert_eq!(
727 pool.worker_count(),
728 0,
729 "forced build failure must yield an empty pool"
730 );
731 // Re-arm the hook so the spawn-time growth call ALSO fails — the
732 // swap-once flag was already consumed by construction, and without
733 // re-arming, spawn would grow the pool (Slot::Grown) instead of
734 // exercising the Slot::Inline degradation.
735 FAIL_NEXT_WORKER_BUILD.with(|f| f.set(true));
736 let h = pool.spawn_vu(7, async { 42u32 });
737 FAIL_NEXT_WORKER_BUILD.with(|f| f.set(false));
738 assert_eq!(h.await.expect("inline VU panicked"), 42);
739 // No worker was grown — the VU truly ran inline on the caller runtime.
740 assert_eq!(pool.worker_count(), 0, "inline VU must not grow the pool");
741 }
742}