Skip to main content

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}