Skip to main content

leviath_runtime/
tool_bridge.rs

1//! The async worker side of the ECS tool stage - the sync-ECS ↔ async-I/O
2//! bridge for tool execution.
3//!
4//! When the pipeline decides an agent's response has tool calls to run, the
5//! tool-dispatch system builds a [`ToolJob`] (the agent plus a boxed async
6//! closure that executes that agent's batch of calls against its own tool
7//! registry / workdir / policy) and sends it to the tool lane. The lane runs the
8//! batch and reports its [`ToolOutcome`] back on the results channel, waking the
9//! tick loop; the tool-collect system applies the results on a later tick.
10//!
11//! **Concurrency**: the lane is a *semaphore*, not a pool of workers.
12//! [`ToolLane::serve`] reads jobs off the channel and spawns one task per batch;
13//! each task holds a permit for as long as it is executing, so
14//! `max_concurrent_tools` batches run at a time.
15//!
16//! It used to be a fixed pool of worker tasks, and that deadlocked. Several
17//! things a batch can await have no time bound at all: a tool-approval prompt, an
18//! `ask_user`, a `wait_for_agent` poll that only ends when some other run
19//! finishes. A worker sitting in one of those was a unit of capacity spent on
20//! waiting rather than working, and a parent waiting on a child it had spawned
21//! was holding capacity that child needed to finish. Eight of those and no
22//! agent's tools ran again, for as long as the daemon lived (issue #191).
23//!
24//! A permit, unlike a worker, can be handed back in the middle of a batch. That
25//! is what [`off_lane`] does, and it is what makes the deadlock impossible:
26//! waiting costs the lane nothing, and the batch takes a permit again when it has
27//! something to do.
28//!
29//! **Order**: which of two batches submitted together gets in first is not
30//! fixed - they race for the permit as separate tasks. Nothing depends on it,
31//! since an agent only ever has one batch in flight, and once both are actually
32//! waiting the semaphore hands out permits first-come-first-served, so nothing
33//! is starved either.
34
35use std::future::Future;
36use std::pin::Pin;
37use std::sync::Arc;
38use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
39
40use bevy_ecs::entity::Entity;
41use tokio::runtime::Handle;
42use tokio::sync::Notify;
43use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
44use tokio::sync::{OwnedSemaphorePermit, Semaphore};
45use tokio::task::{JoinHandle, JoinSet};
46
47use crate::inference_pool::expect_permit;
48
49/// The future produced by a boxed tool-execution closure: resolves to
50/// `(tool_call_id, result)` pairs - the same shape the engine's tool executors
51/// already return.
52pub type ToolExecFuture = Pin<Box<dyn Future<Output = Vec<(String, String)>> + Send>>;
53
54/// A boxed, per-agent tool-execution closure. Built by the dispatch system so it
55/// captures that agent's own tool registry, workdir, and policy; run once by the
56/// tool lane.
57pub type BoxedToolExec = Box<dyn FnOnce() -> ToolExecFuture + Send>;
58
59/// A batch of tool calls to execute for one agent.
60pub struct ToolJob {
61    /// The agent the calls belong to.
62    pub entity: Entity,
63    /// Runs the agent's batch of tool calls.
64    pub exec: BoxedToolExec,
65    /// Fires when the agent is cancelled, so the lane drops the batch instead of
66    /// running it to completion. The agent holds the other half.
67    pub cancel: crate::cancel::CancelToken,
68}
69
70/// The result of a [`ToolJob`], applied on a later tick by the tool-collect
71/// system.
72pub struct ToolOutcome {
73    /// The agent the results belong to.
74    pub entity: Entity,
75    /// `(tool_call_id, result)` pairs.
76    pub results: Vec<(String, String)>,
77    /// Wall-clock time the whole batch took. Per-call timing would require
78    /// every executor to report it through `BoxedToolExec`'s return shape, so
79    /// each call in the batch shares this one figure.
80    pub elapsed: std::time::Duration,
81}
82
83/// Live occupancy of the tool lane.
84///
85/// The lane reads an **unbounded** queue, so dispatch never blocks and a
86/// saturated lane is invisible from the outside: the batches just pile up.
87/// Counting what is queued, what is running, and what is parked on a wait is what
88/// makes that legible instead of guesswork.
89#[derive(Debug)]
90pub struct ToolLaneStats {
91    queued: AtomicUsize,
92    busy: AtomicUsize,
93    parked: AtomicUsize,
94    /// The concurrency cap. Atomic because the relief valve can raise it (see
95    /// [`ToolLane::relieve`]).
96    workers: AtomicUsize,
97}
98
99impl ToolLaneStats {
100    /// Stats for a lane that runs `workers` batches at a time.
101    pub fn new(workers: usize) -> Self {
102        Self {
103            queued: AtomicUsize::new(0),
104            busy: AtomicUsize::new(0),
105            parked: AtomicUsize::new(0),
106            workers: AtomicUsize::new(workers.max(1)),
107        }
108    }
109
110    /// Record a batch handed to the lane.
111    pub fn enqueued(&self) {
112        self.queued.fetch_add(1, Ordering::Relaxed);
113    }
114
115    /// Record a batch leaving the queue without ever running - cancelled while it
116    /// waited for capacity.
117    fn abandoned(&self) {
118        self.queued.fetch_sub(1, Ordering::Relaxed);
119    }
120
121    /// Record a batch taking a permit: it leaves the queue and occupies the lane.
122    fn started(&self) {
123        self.queued.fetch_sub(1, Ordering::Relaxed);
124        self.busy.fetch_add(1, Ordering::Relaxed);
125    }
126
127    /// Record a batch releasing its permit, however it ended.
128    fn finished(&self) {
129        self.busy.fetch_sub(1, Ordering::Relaxed);
130    }
131
132    /// Record a running batch stepping off the lane to wait for something
133    /// unbounded: it stops occupying the lane and starts being parked.
134    fn began_park(&self) {
135        self.busy.fetch_sub(1, Ordering::Relaxed);
136        self.parked.fetch_add(1, Ordering::Relaxed);
137    }
138
139    /// Record a parked batch that took a permit again and is running.
140    fn resumed(&self) {
141        self.parked.fetch_sub(1, Ordering::Relaxed);
142        self.busy.fetch_add(1, Ordering::Relaxed);
143    }
144
145    /// Record a parked batch that was dropped where it stood, without ever
146    /// taking a permit again.
147    fn ended_park(&self) {
148        self.parked.fetch_sub(1, Ordering::Relaxed);
149    }
150
151    /// Batches waiting for lane capacity.
152    pub fn queued(&self) -> usize {
153        self.queued.load(Ordering::Relaxed)
154    }
155
156    /// Batches holding a permit and running.
157    pub fn busy(&self) -> usize {
158        self.busy.load(Ordering::Relaxed)
159    }
160
161    /// Batches parked on an unbounded wait, holding no capacity.
162    pub fn parked(&self) -> usize {
163        self.parked.load(Ordering::Relaxed)
164    }
165
166    /// The lane's concurrency cap.
167    pub fn workers(&self) -> usize {
168        self.workers.load(Ordering::Relaxed)
169    }
170
171    /// Raise the cap by `extra`, to match permits added to the semaphore.
172    fn widen(&self, extra: usize) {
173        self.workers.fetch_add(extra, Ordering::Relaxed);
174    }
175
176    /// Lower the cap by `taken`, to match permits forgotten from the semaphore.
177    fn narrowed(&self, taken: usize) {
178        self.workers.fetch_sub(taken, Ordering::Relaxed);
179    }
180
181    /// Whether every unit of capacity is taken and batches are waiting behind
182    /// them.
183    #[must_use]
184    pub fn is_saturated(&self) -> bool {
185        self.busy() >= self.workers() && self.queued() > 0
186    }
187}
188
189/// The tool lane: the capacity that bounds how many batches execute at once, and
190/// the plumbing a batch needs to report its outcome.
191pub struct ToolLane {
192    /// One permit per concurrent batch.
193    permits: Arc<Semaphore>,
194    /// Shared with the world so `lane_snapshot` can read it.
195    stats: Arc<ToolLaneStats>,
196    /// Where finished batches report.
197    results: UnboundedSender<ToolOutcome>,
198    /// Notified whenever a batch finishes or frees capacity, so the tick loop
199    /// re-drives.
200    wake: Arc<Notify>,
201    /// Where batch tasks are spawned.
202    runtime: Handle,
203}
204
205impl ToolLane {
206    /// Build a lane that runs `concurrency` batches at a time (clamped to at
207    /// least one, matching [`ToolLaneStats::new`]).
208    pub fn new(
209        runtime: Handle,
210        results: UnboundedSender<ToolOutcome>,
211        wake: Arc<Notify>,
212        concurrency: usize,
213        stats: Arc<ToolLaneStats>,
214    ) -> Arc<Self> {
215        Arc::new(Self {
216            permits: Arc::new(Semaphore::new(concurrency.max(1))),
217            stats,
218            results,
219            wake,
220            runtime,
221        })
222    }
223
224    /// Start serving `jobs`. The returned handle completes once the job channel
225    /// closes (the world is shutting down) **and** every batch it started has
226    /// finished, so awaiting it drains the lane.
227    pub fn serve(self: &Arc<Self>, jobs: UnboundedReceiver<ToolJob>) -> JoinHandle<()> {
228        let lane = self.clone();
229        self.runtime.clone().spawn(serve_lane(lane, jobs))
230    }
231
232    /// Add `extra` permits, widening the lane.
233    ///
234    /// The relief valve under a lane that has stopped draining: handing out more
235    /// capacity lets the queued batches run without cancelling anything. Returns
236    /// how many were added. No longer "for good": once the jam is over,
237    /// [`Self::narrow`] hands the extra capacity back, so a single historical
238    /// wedge does not raise the daemon's peak concurrency (and with it, peak
239    /// memory) for the rest of its life.
240    pub fn relieve(&self, extra: usize) -> usize {
241        if extra == 0 {
242            return 0;
243        }
244        self.permits.add_permits(extra);
245        self.stats.widen(extra);
246        extra
247    }
248
249    /// Take up to `upto` *idle* permits back out of the lane, returning how many
250    /// were reclaimed.
251    ///
252    /// `forget_permits` only removes permits that are currently available, so
253    /// this can never stall a batch that is running or block waiting for one to
254    /// finish - a busy lane just gives back fewer (possibly zero) permits, and
255    /// the caller tries again on a later healthy cycle.
256    pub fn narrow(&self, upto: usize) -> usize {
257        if upto == 0 {
258            return 0;
259        }
260        let taken = self.permits.forget_permits(upto);
261        self.stats.narrowed(taken);
262        taken
263    }
264}
265
266/// Read jobs off the channel, spawning one task per batch, then wait for the
267/// batches still running once the channel closes.
268async fn serve_lane(lane: Arc<ToolLane>, mut jobs: UnboundedReceiver<ToolJob>) {
269    let mut batches = JoinSet::new();
270    loop {
271        tokio::select! {
272            job = jobs.recv() => match job {
273                Some(job) => {
274                    batches.spawn_on(run_batch(lane.clone(), job), &lane.runtime);
275                }
276                None => break, // channel closed → shutting down
277            },
278            // Reap finished batches as we go so the set can't grow without
279            // bound over a long-lived daemon. Disabled while empty, since
280            // `join_next` on an empty set is instantly ready and would spin.
281            Some(_) = batches.join_next(), if !batches.is_empty() => {}
282        }
283    }
284    while batches.join_next().await.is_some() {}
285}
286
287/// Run one batch: wait for capacity, execute it under a [`LaneTicket`], and
288/// report the outcome.
289async fn run_batch(lane: Arc<ToolLane>, job: ToolJob) {
290    let ToolJob {
291        entity,
292        exec,
293        cancel,
294    } = job;
295    // A cancel while the batch is still queued drops it without ever running -
296    // the same bargain the executing case makes below, one step earlier.
297    let permit = tokio::select! {
298        biased;
299        _ = cancel.cancelled() => {
300            lane.stats.abandoned();
301            return;
302        }
303        permit = lane.permits.clone().acquire_owned() => expect_permit(permit),
304    };
305    lane.stats.started();
306    let ticket = Arc::new(LaneTicket::new(lane.clone(), permit));
307    let started = std::time::Instant::now();
308    // A cancelled agent's batch is dropped rather than run to completion. This
309    // is what hands the capacity back: several of the things a batch can await
310    // are unbounded, so without this a cancelled agent would keep occupying the
311    // lane until whatever it was waiting for answered.
312    let out = LANE_TICKET
313        .scope(ticket, async move {
314            tokio::select! {
315                biased;
316                _ = cancel.cancelled() => None,
317                out = exec() => Some(out),
318            }
319        })
320        .await;
321    // The ticket is dropped with the scope above, so the permit is already back
322    // and the loop already woken by the time the outcome goes out.
323    let Some(out) = out else { return };
324    // Harmless no-op if the collect side has gone away.
325    let _ = lane.results.send(ToolOutcome {
326        entity,
327        results: out,
328        elapsed: started.elapsed(),
329    });
330    lane.wake.notify_one();
331}
332
333tokio::task_local! {
334    /// The running batch's claim on the lane, readable from anywhere inside it.
335    ///
336    /// A task-local rather than an argument threaded through [`BoxedToolExec`]:
337    /// the waits that need it are several layers down inside the tool service,
338    /// and passing a ticket to every executor - including the many that never
339    /// wait on anything - would put a concurrency detail in the signature of
340    /// every `ToolService` implementation.
341    static LANE_TICKET: Arc<LaneTicket>;
342}
343
344/// A batch's claim on the tool lane.
345///
346/// Holds a permit while the batch is executing and gives it up around an
347/// unbounded wait, so a batch parked on a person or on another run costs the lane
348/// nothing. Dropping it releases whatever it is holding.
349struct LaneTicket {
350    lane: Arc<ToolLane>,
351    /// The permit, absent exactly while the batch is parked.
352    permit: std::sync::Mutex<Option<OwnedSemaphorePermit>>,
353    /// Whether this ticket is currently counted as parked rather than busy.
354    parked: AtomicBool,
355}
356
357impl LaneTicket {
358    fn new(lane: Arc<ToolLane>, permit: OwnedSemaphorePermit) -> Self {
359        Self {
360            lane,
361            permit: std::sync::Mutex::new(Some(permit)),
362            parked: AtomicBool::new(false),
363        }
364    }
365
366    /// Give the permit up and start counting as parked.
367    fn release(&self) {
368        let held = self.take_permit();
369        // Release first, wake second, for the reason `InferencePermit::drop`
370        // spells out: the other order lets the woken tick re-check the lane
371        // while this permit is still held.
372        drop(held);
373        self.parked.store(true, Ordering::Relaxed);
374        self.lane.stats.began_park();
375        self.lane.wake.notify_one();
376    }
377
378    /// Take a permit again, waiting for one if the lane is full. Ordinary
379    /// backpressure: nothing is held while we wait, so the batches ahead can
380    /// always finish.
381    async fn reacquire(&self) {
382        let permit = expect_permit(self.lane.permits.clone().acquire_owned().await);
383        // Stop counting as parked only once the permit is actually in hand, so
384        // a ticket dropped mid-wait is accounted for as the parked batch it is.
385        self.lane.stats.resumed();
386        self.parked.store(false, Ordering::Relaxed);
387        *self
388            .permit
389            .lock()
390            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(permit);
391    }
392
393    fn take_permit(&self) -> Option<OwnedSemaphorePermit> {
394        self.permit
395            .lock()
396            .unwrap_or_else(std::sync::PoisonError::into_inner)
397            .take()
398    }
399}
400
401impl Drop for LaneTicket {
402    fn drop(&mut self) {
403        // Release first, wake second - see `release`.
404        drop(self.take_permit());
405        match self.parked.load(Ordering::Relaxed) {
406            true => self.lane.stats.ended_park(),
407            false => self.lane.stats.finished(),
408        }
409        self.lane.wake.notify_one();
410    }
411}
412
413/// Await something with no time bound without holding the tool lane.
414///
415/// The lane permit is handed back before `fut` is polled and taken again before
416/// this returns, so a batch waiting on a person (a tool-approval prompt, an
417/// `ask_user`) or on another run (`wait_for_agent`) occupies no capacity while it
418/// waits. That is what stops a lane full of waiters from starving the very runs
419/// they are waiting for (issue #191).
420///
421/// Outside a lane task - the embedded runtime, tests driving an executor
422/// directly - there is no ticket and this is just `fut.await`.
423pub async fn off_lane<T>(fut: impl Future<Output = T>) -> T {
424    let Ok(ticket) = LANE_TICKET.try_with(Arc::clone) else {
425        return fut.await;
426    };
427    ticket.release();
428    let out = fut.await;
429    ticket.reacquire().await;
430    out
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use std::time::Duration;
437    use tokio::sync::mpsc;
438
439    /// Everything a test lane needs: the lane itself, the job sender, and the
440    /// outcome receiver.
441    struct Harness {
442        lane: Arc<ToolLane>,
443        /// Taken by `drain`, which closes the lane by dropping it.
444        jobs: Option<UnboundedSender<ToolJob>>,
445        outcomes: mpsc::UnboundedReceiver<ToolOutcome>,
446        serving: Option<JoinHandle<()>>,
447        stats: Arc<ToolLaneStats>,
448    }
449
450    impl Harness {
451        fn new(concurrency: usize) -> Self {
452            let (jobs, job_rx) = mpsc::unbounded_channel();
453            let (result_tx, outcomes) = mpsc::unbounded_channel();
454            let stats = Arc::new(ToolLaneStats::new(concurrency));
455            let lane = ToolLane::new(
456                Handle::current(),
457                result_tx,
458                Arc::new(Notify::new()),
459                concurrency,
460                stats.clone(),
461            );
462            let serving = lane.serve(job_rx);
463            Self {
464                lane,
465                jobs: Some(jobs),
466                outcomes,
467                serving: Some(serving),
468                stats,
469            }
470        }
471
472        /// Hand a batch to the lane, counting it the way `dispatch_tools` does.
473        fn submit(&self, job: ToolJob) {
474            self.stats.enqueued();
475            self.sender().send(job).expect("the lane is serving");
476        }
477
478        fn sender(&self) -> &UnboundedSender<ToolJob> {
479            self.jobs.as_ref().expect("the lane is still open")
480        }
481
482        /// Close the lane and wait for every batch it started to finish.
483        async fn drain(&mut self) {
484            drop(self.jobs.take());
485            let serving = self.serving.take().expect("the lane was serving");
486            timeout(serving).await.expect("the lane task ended");
487        }
488
489        async fn next_outcome(&mut self) -> ToolOutcome {
490            timeout(self.outcomes.recv())
491                .await
492                .expect("an outcome arrived")
493        }
494
495        /// The next `n` outcomes' entity indices, sorted.
496        ///
497        /// Batches race for a permit as separate tasks, so which of two ready
498        /// batches gets in first is not fixed. Nothing depends on that order: a
499        /// given agent only ever has one batch in flight.
500        async fn next_indices(&mut self, n: usize) -> Vec<u64> {
501            let mut seen = Vec::new();
502            for _ in 0..n {
503                seen.push(self.next_outcome().await.entity.to_bits());
504            }
505            seen.sort_unstable();
506            seen
507        }
508    }
509
510    /// Bounded so a wedge fails the test instead of hanging it. Generous, since
511    /// a passing run never waits.
512    async fn timeout<T>(fut: impl Future<Output = T>) -> T {
513        tokio::time::timeout(Duration::from_secs(30), fut)
514            .await
515            .expect("the lane made progress")
516    }
517
518    /// Entity ids sorted the same way [`Harness::next_indices`] sorts them, so
519    /// an expectation does not depend on how bevy packs an id into its bits.
520    fn sorted_bits(entities: &[Entity]) -> Vec<u64> {
521        let mut bits: Vec<u64> = entities.iter().map(|e| e.to_bits()).collect();
522        bits.sort_unstable();
523        bits
524    }
525
526    fn entity(index: u32) -> Entity {
527        Entity::from_raw_u32(index).expect("a small literal index is a valid entity id")
528    }
529
530    fn job(index: u32, pairs: Vec<(&'static str, &'static str)>) -> ToolJob {
531        job_with(index, pairs, crate::cancel::CancelToken::new())
532    }
533
534    fn job_with(
535        index: u32,
536        pairs: Vec<(&'static str, &'static str)>,
537        cancel: crate::cancel::CancelToken,
538    ) -> ToolJob {
539        ToolJob {
540            entity: entity(index),
541            exec: Box::new(move || {
542                Box::pin(async move {
543                    pairs
544                        .into_iter()
545                        .map(|(a, b)| (a.to_string(), b.to_string()))
546                        .collect()
547                })
548            }),
549            cancel,
550        }
551    }
552
553    /// A job whose batch blocks until `release` fires, signalling `started` once
554    /// it is running.
555    ///
556    /// `notify_one` (not `notify_waiters`) on both signals: it stores a permit
557    /// when nobody is waiting yet, so neither side can lose the other's wakeup by
558    /// being slow to arm - a real flake on a loaded runner.
559    fn held_job(
560        index: u32,
561        started: Arc<Notify>,
562        release: Arc<Notify>,
563        cancel: crate::cancel::CancelToken,
564    ) -> ToolJob {
565        ToolJob {
566            entity: entity(index),
567            exec: Box::new(move || {
568                Box::pin(async move {
569                    started.notify_one();
570                    release.notified().await;
571                    vec![("held".to_string(), "done".to_string())]
572                })
573            }),
574            cancel,
575        }
576    }
577
578    /// The same, except the wait happens [`off_lane`] - the shape of a
579    /// `wait_for_agent` or a tool-approval prompt.
580    ///
581    /// `started` fires from *inside* the parked future, which `off_lane` only
582    /// polls once the permit is already back. Signalling before the call would
583    /// race the test against the release.
584    fn parking_job(
585        index: u32,
586        started: Arc<Notify>,
587        release: Arc<Notify>,
588        cancel: crate::cancel::CancelToken,
589    ) -> ToolJob {
590        ToolJob {
591            entity: entity(index),
592            exec: Box::new(move || {
593                Box::pin(async move {
594                    off_lane(async move {
595                        started.notify_one();
596                        release.notified().await;
597                    })
598                    .await;
599                    vec![("parked".to_string(), "done".to_string())]
600                })
601            }),
602            cancel,
603        }
604    }
605
606    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
607    async fn the_lane_runs_batches_and_reports_them() {
608        let mut h = Harness::new(1);
609        h.submit(job(1, vec![("c", "r")]));
610        h.submit(job(2, vec![("c", "r")]));
611
612        let first = h.next_outcome().await;
613        assert_eq!(
614            first.results,
615            vec![("c".to_string(), "r".to_string())],
616            "the batch reported its call"
617        );
618        let mut seen = vec![first.entity.to_bits()];
619        seen.extend(h.next_indices(1).await);
620        seen.sort_unstable();
621        assert_eq!(
622            seen,
623            sorted_bits(&[entity(1), entity(2)]),
624            "both batches were reported"
625        );
626
627        h.drain().await;
628        assert!(h.outcomes.try_recv().is_err(), "no more outcomes");
629    }
630
631    /// The issue #191 regression, at its narrowest.
632    ///
633    /// A one-wide lane, a batch parked on something only a *later* batch can
634    /// deliver. With a fixed worker pool this is a deadlock: the waiter owns the
635    /// only worker, so the batch that would release it never runs. Handing the
636    /// permit back while parked is what makes both finish.
637    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
638    async fn a_parked_batch_lets_the_batch_it_waits_on_run() {
639        let mut h = Harness::new(1);
640        let started = Arc::new(Notify::new());
641        let release = Arc::new(Notify::new());
642
643        h.submit(parking_job(
644            1,
645            started.clone(),
646            release.clone(),
647            crate::cancel::CancelToken::new(),
648        ));
649        timeout(started.notified()).await;
650        assert_eq!(
651            (h.stats.busy(), h.stats.parked()),
652            (0, 1),
653            "the waiter gave the lane back"
654        );
655
656        // Only reachable if the lane is genuinely free. It is what unblocks the
657        // waiter, exactly as a child run unblocks its parent.
658        let releaser = release.clone();
659        h.submit(ToolJob {
660            entity: entity(2),
661            exec: Box::new(move || {
662                Box::pin(async move {
663                    releaser.notify_one();
664                    vec![("c2".to_string(), "r2".to_string())]
665                })
666            }),
667            cancel: crate::cancel::CancelToken::new(),
668        });
669
670        assert_eq!(
671            h.next_indices(2).await,
672            sorted_bits(&[entity(1), entity(2)]),
673            "both batches finished"
674        );
675
676        h.drain().await;
677    }
678
679    /// The parked batch takes a permit again before it carries on, so the lane's
680    /// cap still means something after a wait.
681    #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
682    async fn a_resumed_batch_takes_a_permit_again() {
683        let mut h = Harness::new(1);
684        let started = Arc::new(Notify::new());
685        let release = Arc::new(Notify::new());
686        h.submit(parking_job(
687            1,
688            started.clone(),
689            release.clone(),
690            crate::cancel::CancelToken::new(),
691        ));
692        timeout(started.notified()).await;
693
694        // Fill the lane with a batch that will not finish on its own.
695        let held_started = Arc::new(Notify::new());
696        let held_release = Arc::new(Notify::new());
697        h.submit(held_job(
698            2,
699            held_started.clone(),
700            held_release.clone(),
701            crate::cancel::CancelToken::new(),
702        ));
703        timeout(held_started.notified()).await;
704        assert_eq!(h.stats.busy(), 1, "the lane is full again");
705
706        // Waking the parked batch is not enough: it has to queue for capacity,
707        // and the holder has the only permit. Asserting the absence is what
708        // proves the permit was really taken again rather than assumed.
709        release.notify_one();
710        assert!(
711            tokio::time::timeout(Duration::from_millis(250), h.outcomes.recv())
712                .await
713                .is_err(),
714            "the resumed batch waited for a permit instead of running"
715        );
716
717        held_release.notify_one();
718        let first = h.next_outcome().await;
719        assert_eq!(first.entity, entity(2), "the holder finished first");
720        let second = h.next_outcome().await;
721        assert_eq!(second.entity, entity(1), "then the resumed batch");
722
723        h.drain().await;
724        assert_eq!((h.stats.busy(), h.stats.parked()), (0, 0));
725    }
726
727    /// Outside a lane task there is no ticket, so `off_lane` is a plain await.
728    #[tokio::test]
729    async fn off_lane_outside_the_lane_just_awaits() {
730        assert_eq!(off_lane(async { 7 }).await, 7);
731    }
732
733    /// A cancelled batch is dropped rather than run to completion, and gives its
734    /// capacity straight back.
735    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
736    async fn a_cancelled_batch_is_abandoned_and_frees_the_lane() {
737        let mut h = Harness::new(1);
738        let cancel = crate::cancel::CancelToken::new();
739        let started = Arc::new(Notify::new());
740        let release = Arc::new(Notify::new());
741        h.submit(held_job(1, started.clone(), release, cancel.clone()));
742        timeout(started.notified()).await;
743        assert_eq!((h.stats.queued(), h.stats.busy()), (0, 1));
744
745        // Queued behind it, so it can only run once the cancel frees the lane.
746        h.submit(job(2, vec![("c2", "r2")]));
747        cancel.cancel();
748
749        let next = h.next_outcome().await;
750        assert_eq!(next.entity, entity(2), "the queued batch ran");
751        h.drain().await;
752        assert!(
753            h.outcomes.try_recv().is_err(),
754            "the cancelled batch reported nothing"
755        );
756        assert_eq!(h.stats.busy(), 0, "and gave its permit back");
757    }
758
759    /// Cancelling a batch that is parked on a wait leaves the counters straight:
760    /// it was never holding a permit, so nothing is handed back twice.
761    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
762    async fn a_cancelled_parked_batch_leaves_the_counters_straight() {
763        let mut h = Harness::new(1);
764        let cancel = crate::cancel::CancelToken::new();
765        let started = Arc::new(Notify::new());
766        let release = Arc::new(Notify::new());
767        h.submit(parking_job(1, started.clone(), release, cancel.clone()));
768        timeout(started.notified()).await;
769        assert_eq!((h.stats.busy(), h.stats.parked()), (0, 1));
770
771        cancel.cancel();
772        h.submit(job(2, vec![("c2", "r2")]));
773        let next = h.next_outcome().await;
774        assert_eq!(next.entity, entity(2));
775
776        h.drain().await;
777        assert_eq!((h.stats.busy(), h.stats.parked()), (0, 0));
778    }
779
780    /// A cancel that lands while the batch is still waiting for capacity drops it
781    /// without it ever running, and takes it off the queue count.
782    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
783    async fn a_batch_cancelled_while_queued_never_runs() {
784        let mut h = Harness::new(1);
785        let blocker = crate::cancel::CancelToken::new();
786        let started = Arc::new(Notify::new());
787        let release = Arc::new(Notify::new());
788        h.submit(held_job(1, started.clone(), release.clone(), blocker));
789        timeout(started.notified()).await;
790
791        let cancel = crate::cancel::CancelToken::new();
792        h.submit(job_with(2, vec![("c2", "r2")], cancel.clone()));
793        cancel.cancel();
794        release.notify_one();
795
796        let first = h.next_outcome().await;
797        assert_eq!(first.entity, entity(1));
798        h.drain().await;
799        assert!(
800            h.outcomes.try_recv().is_err(),
801            "the cancelled batch never produced results"
802        );
803        assert_eq!(h.stats.queued(), 0, "and left the queue count clean");
804    }
805
806    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
807    async fn the_lane_runs_batches_concurrently_up_to_its_cap() {
808        let h = Harness::new(3);
809        // Three jobs that each block on a rendezvous: they can only all finish if
810        // they run concurrently. `tokio::sync::Barrier` rather than a hand-rolled
811        // counter + Notify: `notify_waiters` only wakes ALREADY-registered
812        // waiters, so a counter check has a lost-wakeup window between loading
813        // the count and registering on `notified()`.
814        let barrier = Arc::new(tokio::sync::Barrier::new(3));
815        for i in 1..=3u32 {
816            let barrier = barrier.clone();
817            h.submit(ToolJob {
818                entity: entity(i),
819                exec: Box::new(move || {
820                    Box::pin(async move {
821                        barrier.wait().await;
822                        vec![("c".to_string(), "r".to_string())]
823                    })
824                }),
825                cancel: crate::cancel::CancelToken::new(),
826            });
827        }
828        let mut h = h;
829        h.drain().await;
830        for _ in 0..3 {
831            timeout(h.outcomes.recv()).await.expect("outcome present");
832        }
833    }
834
835    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
836    async fn the_lane_survives_a_dropped_outcome_receiver() {
837        let mut h = Harness::new(1);
838        h.submit(job(9, vec![("c", "r")]));
839        // Nobody to receive the outcome: the lane must still drain the job and
840        // not panic on the failed send.
841        h.outcomes.close();
842        h.drain().await;
843    }
844
845    /// Relief widens the lane so batches queued behind a wedge can run.
846    #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
847    async fn relief_widens_the_lane() {
848        let mut h = Harness::new(1);
849        let started = Arc::new(Notify::new());
850        let release = Arc::new(Notify::new());
851        h.submit(held_job(
852            1,
853            started.clone(),
854            release.clone(),
855            crate::cancel::CancelToken::new(),
856        ));
857        timeout(started.notified()).await;
858        h.submit(job(2, vec![("c2", "r2")]));
859        assert!(h.stats.is_saturated(), "full, with a batch behind it");
860
861        assert_eq!(h.lane.relieve(0), 0, "relieving nothing changes nothing");
862        assert_eq!(h.lane.relieve(1), 1);
863        assert_eq!(h.stats.workers(), 2, "the cap moved with the permits");
864
865        let freed = h.next_outcome().await;
866        assert_eq!(freed.entity, entity(2), "the queued batch got in");
867
868        release.notify_one();
869        let held = h.next_outcome().await;
870        assert_eq!(held.entity, entity(1));
871        h.drain().await;
872    }
873
874    /// `narrow` reclaims only *idle* permits: a busy lane gives back nothing,
875    /// an idle one gives back what was asked (bounded by availability), and
876    /// the cap tracks the permits in both directions.
877    #[tokio::test]
878    async fn narrow_reclaims_idle_permits_and_never_busy_ones() {
879        let mut h = Harness::new(1);
880        assert_eq!(h.lane.relieve(2), 2);
881        assert_eq!(h.stats.workers(), 3);
882
883        // All three permits idle: narrowing nothing is a no-op, narrowing one
884        // takes one.
885        assert_eq!(h.lane.narrow(0), 0, "narrowing nothing changes nothing");
886        assert_eq!(h.lane.narrow(1), 1);
887        assert_eq!(h.stats.workers(), 2);
888
889        // Occupy both remaining permits, then try to narrow: nothing is idle,
890        // so nothing is taken and the cap stays put.
891        let started_a = Arc::new(Notify::new());
892        let release_a = Arc::new(Notify::new());
893        h.submit(held_job(
894            1,
895            started_a.clone(),
896            release_a.clone(),
897            crate::cancel::CancelToken::new(),
898        ));
899        timeout(started_a.notified()).await;
900        let started_b = Arc::new(Notify::new());
901        let release_b = Arc::new(Notify::new());
902        h.submit(held_job(
903            2,
904            started_b.clone(),
905            release_b.clone(),
906            crate::cancel::CancelToken::new(),
907        ));
908        timeout(started_b.notified()).await;
909        assert_eq!(h.lane.narrow(1), 0, "a busy lane keeps its permits");
910        assert_eq!(h.stats.workers(), 2);
911
912        release_a.notify_one();
913        release_b.notify_one();
914        h.next_outcome().await;
915        h.next_outcome().await;
916        h.drain().await;
917    }
918
919    /// A lane with no capacity is still reported as one wide, matching
920    /// [`ToolLane::new`]'s own clamp - otherwise the saturation check compares
921    /// against a width that never existed.
922    #[tokio::test]
923    async fn a_zero_width_lane_is_clamped_to_one() {
924        assert_eq!(ToolLaneStats::new(0).workers(), 1);
925        let mut h = Harness::new(0);
926        h.submit(job(7, vec![("c", "r")]));
927        assert_eq!(h.next_outcome().await.entity, entity(7));
928        h.drain().await;
929    }
930
931    #[test]
932    fn lane_stats_track_queue_depth_and_saturation() {
933        let stats = ToolLaneStats::new(2);
934        assert_eq!((stats.queued(), stats.busy(), stats.parked()), (0, 0, 0));
935        assert!(!stats.is_saturated(), "an idle lane is not saturated");
936
937        stats.enqueued();
938        stats.enqueued();
939        stats.enqueued();
940        assert_eq!(stats.queued(), 3);
941        // Two batches take permits: two leave the queue, two occupy the lane.
942        stats.started();
943        stats.started();
944        assert_eq!((stats.queued(), stats.busy()), (1, 2));
945        assert!(
946            stats.is_saturated(),
947            "the lane is full with a batch still queued"
948        );
949
950        // One steps off to wait: it stops occupying the lane.
951        stats.began_park();
952        assert_eq!((stats.busy(), stats.parked()), (1, 1));
953        assert!(!stats.is_saturated(), "parked capacity is capacity");
954        stats.resumed();
955        assert_eq!((stats.busy(), stats.parked()), (2, 0));
956
957        stats.began_park();
958        stats.ended_park();
959        assert_eq!((stats.busy(), stats.parked()), (1, 0));
960
961        stats.finished();
962        stats.abandoned();
963        assert_eq!((stats.queued(), stats.busy()), (0, 0));
964    }
965}