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    /// Whether every unit of capacity is taken and batches are waiting behind
177    /// them.
178    #[must_use]
179    pub fn is_saturated(&self) -> bool {
180        self.busy() >= self.workers() && self.queued() > 0
181    }
182}
183
184/// The tool lane: the capacity that bounds how many batches execute at once, and
185/// the plumbing a batch needs to report its outcome.
186pub struct ToolLane {
187    /// One permit per concurrent batch.
188    permits: Arc<Semaphore>,
189    /// Shared with the world so `lane_snapshot` can read it.
190    stats: Arc<ToolLaneStats>,
191    /// Where finished batches report.
192    results: UnboundedSender<ToolOutcome>,
193    /// Notified whenever a batch finishes or frees capacity, so the tick loop
194    /// re-drives.
195    wake: Arc<Notify>,
196    /// Where batch tasks are spawned.
197    runtime: Handle,
198}
199
200impl ToolLane {
201    /// Build a lane that runs `concurrency` batches at a time (clamped to at
202    /// least one, matching [`ToolLaneStats::new`]).
203    pub fn new(
204        runtime: Handle,
205        results: UnboundedSender<ToolOutcome>,
206        wake: Arc<Notify>,
207        concurrency: usize,
208        stats: Arc<ToolLaneStats>,
209    ) -> Arc<Self> {
210        Arc::new(Self {
211            permits: Arc::new(Semaphore::new(concurrency.max(1))),
212            stats,
213            results,
214            wake,
215            runtime,
216        })
217    }
218
219    /// Start serving `jobs`. The returned handle completes once the job channel
220    /// closes (the world is shutting down) **and** every batch it started has
221    /// finished, so awaiting it drains the lane.
222    pub fn serve(self: &Arc<Self>, jobs: UnboundedReceiver<ToolJob>) -> JoinHandle<()> {
223        let lane = self.clone();
224        self.runtime.clone().spawn(serve_lane(lane, jobs))
225    }
226
227    /// Add `extra` permits, widening the lane for good.
228    ///
229    /// The relief valve under a lane that has stopped draining: handing out more
230    /// capacity lets the queued batches run without cancelling anything. Returns
231    /// how many were added.
232    pub fn relieve(&self, extra: usize) -> usize {
233        if extra == 0 {
234            return 0;
235        }
236        self.permits.add_permits(extra);
237        self.stats.widen(extra);
238        extra
239    }
240}
241
242/// Read jobs off the channel, spawning one task per batch, then wait for the
243/// batches still running once the channel closes.
244async fn serve_lane(lane: Arc<ToolLane>, mut jobs: UnboundedReceiver<ToolJob>) {
245    let mut batches = JoinSet::new();
246    loop {
247        tokio::select! {
248            job = jobs.recv() => match job {
249                Some(job) => {
250                    batches.spawn_on(run_batch(lane.clone(), job), &lane.runtime);
251                }
252                None => break, // channel closed → shutting down
253            },
254            // Reap finished batches as we go so the set can't grow without
255            // bound over a long-lived daemon. Disabled while empty, since
256            // `join_next` on an empty set is instantly ready and would spin.
257            Some(_) = batches.join_next(), if !batches.is_empty() => {}
258        }
259    }
260    while batches.join_next().await.is_some() {}
261}
262
263/// Run one batch: wait for capacity, execute it under a [`LaneTicket`], and
264/// report the outcome.
265async fn run_batch(lane: Arc<ToolLane>, job: ToolJob) {
266    let ToolJob {
267        entity,
268        exec,
269        cancel,
270    } = job;
271    // A cancel while the batch is still queued drops it without ever running -
272    // the same bargain the executing case makes below, one step earlier.
273    let permit = tokio::select! {
274        biased;
275        _ = cancel.cancelled() => {
276            lane.stats.abandoned();
277            return;
278        }
279        permit = lane.permits.clone().acquire_owned() => expect_permit(permit),
280    };
281    lane.stats.started();
282    let ticket = Arc::new(LaneTicket::new(lane.clone(), permit));
283    let started = std::time::Instant::now();
284    // A cancelled agent's batch is dropped rather than run to completion. This
285    // is what hands the capacity back: several of the things a batch can await
286    // are unbounded, so without this a cancelled agent would keep occupying the
287    // lane until whatever it was waiting for answered.
288    let out = LANE_TICKET
289        .scope(ticket, async move {
290            tokio::select! {
291                biased;
292                _ = cancel.cancelled() => None,
293                out = exec() => Some(out),
294            }
295        })
296        .await;
297    // The ticket is dropped with the scope above, so the permit is already back
298    // and the loop already woken by the time the outcome goes out.
299    let Some(out) = out else { return };
300    // Harmless no-op if the collect side has gone away.
301    let _ = lane.results.send(ToolOutcome {
302        entity,
303        results: out,
304        elapsed: started.elapsed(),
305    });
306    lane.wake.notify_one();
307}
308
309tokio::task_local! {
310    /// The running batch's claim on the lane, readable from anywhere inside it.
311    ///
312    /// A task-local rather than an argument threaded through [`BoxedToolExec`]:
313    /// the waits that need it are several layers down inside the tool service,
314    /// and passing a ticket to every executor - including the many that never
315    /// wait on anything - would put a concurrency detail in the signature of
316    /// every `ToolService` implementation.
317    static LANE_TICKET: Arc<LaneTicket>;
318}
319
320/// A batch's claim on the tool lane.
321///
322/// Holds a permit while the batch is executing and gives it up around an
323/// unbounded wait, so a batch parked on a person or on another run costs the lane
324/// nothing. Dropping it releases whatever it is holding.
325struct LaneTicket {
326    lane: Arc<ToolLane>,
327    /// The permit, absent exactly while the batch is parked.
328    permit: std::sync::Mutex<Option<OwnedSemaphorePermit>>,
329    /// Whether this ticket is currently counted as parked rather than busy.
330    parked: AtomicBool,
331}
332
333impl LaneTicket {
334    fn new(lane: Arc<ToolLane>, permit: OwnedSemaphorePermit) -> Self {
335        Self {
336            lane,
337            permit: std::sync::Mutex::new(Some(permit)),
338            parked: AtomicBool::new(false),
339        }
340    }
341
342    /// Give the permit up and start counting as parked.
343    fn release(&self) {
344        let held = self.take_permit();
345        // Release first, wake second, for the reason `InferencePermit::drop`
346        // spells out: the other order lets the woken tick re-check the lane
347        // while this permit is still held.
348        drop(held);
349        self.parked.store(true, Ordering::Relaxed);
350        self.lane.stats.began_park();
351        self.lane.wake.notify_one();
352    }
353
354    /// Take a permit again, waiting for one if the lane is full. Ordinary
355    /// backpressure: nothing is held while we wait, so the batches ahead can
356    /// always finish.
357    async fn reacquire(&self) {
358        let permit = expect_permit(self.lane.permits.clone().acquire_owned().await);
359        // Stop counting as parked only once the permit is actually in hand, so
360        // a ticket dropped mid-wait is accounted for as the parked batch it is.
361        self.lane.stats.resumed();
362        self.parked.store(false, Ordering::Relaxed);
363        *self
364            .permit
365            .lock()
366            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(permit);
367    }
368
369    fn take_permit(&self) -> Option<OwnedSemaphorePermit> {
370        self.permit
371            .lock()
372            .unwrap_or_else(std::sync::PoisonError::into_inner)
373            .take()
374    }
375}
376
377impl Drop for LaneTicket {
378    fn drop(&mut self) {
379        // Release first, wake second - see `release`.
380        drop(self.take_permit());
381        match self.parked.load(Ordering::Relaxed) {
382            true => self.lane.stats.ended_park(),
383            false => self.lane.stats.finished(),
384        }
385        self.lane.wake.notify_one();
386    }
387}
388
389/// Await something with no time bound without holding the tool lane.
390///
391/// The lane permit is handed back before `fut` is polled and taken again before
392/// this returns, so a batch waiting on a person (a tool-approval prompt, an
393/// `ask_user`) or on another run (`wait_for_agent`) occupies no capacity while it
394/// waits. That is what stops a lane full of waiters from starving the very runs
395/// they are waiting for (issue #191).
396///
397/// Outside a lane task - the embedded runtime, tests driving an executor
398/// directly - there is no ticket and this is just `fut.await`.
399pub async fn off_lane<T>(fut: impl Future<Output = T>) -> T {
400    let Ok(ticket) = LANE_TICKET.try_with(Arc::clone) else {
401        return fut.await;
402    };
403    ticket.release();
404    let out = fut.await;
405    ticket.reacquire().await;
406    out
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use std::time::Duration;
413    use tokio::sync::mpsc;
414
415    /// Everything a test lane needs: the lane itself, the job sender, and the
416    /// outcome receiver.
417    struct Harness {
418        lane: Arc<ToolLane>,
419        /// Taken by `drain`, which closes the lane by dropping it.
420        jobs: Option<UnboundedSender<ToolJob>>,
421        outcomes: mpsc::UnboundedReceiver<ToolOutcome>,
422        serving: Option<JoinHandle<()>>,
423        stats: Arc<ToolLaneStats>,
424    }
425
426    impl Harness {
427        fn new(concurrency: usize) -> Self {
428            let (jobs, job_rx) = mpsc::unbounded_channel();
429            let (result_tx, outcomes) = mpsc::unbounded_channel();
430            let stats = Arc::new(ToolLaneStats::new(concurrency));
431            let lane = ToolLane::new(
432                Handle::current(),
433                result_tx,
434                Arc::new(Notify::new()),
435                concurrency,
436                stats.clone(),
437            );
438            let serving = lane.serve(job_rx);
439            Self {
440                lane,
441                jobs: Some(jobs),
442                outcomes,
443                serving: Some(serving),
444                stats,
445            }
446        }
447
448        /// Hand a batch to the lane, counting it the way `dispatch_tools` does.
449        fn submit(&self, job: ToolJob) {
450            self.stats.enqueued();
451            self.sender().send(job).expect("the lane is serving");
452        }
453
454        fn sender(&self) -> &UnboundedSender<ToolJob> {
455            self.jobs.as_ref().expect("the lane is still open")
456        }
457
458        /// Close the lane and wait for every batch it started to finish.
459        async fn drain(&mut self) {
460            drop(self.jobs.take());
461            let serving = self.serving.take().expect("the lane was serving");
462            timeout(serving).await.expect("the lane task ended");
463        }
464
465        async fn next_outcome(&mut self) -> ToolOutcome {
466            timeout(self.outcomes.recv())
467                .await
468                .expect("an outcome arrived")
469        }
470
471        /// The next `n` outcomes' entity indices, sorted.
472        ///
473        /// Batches race for a permit as separate tasks, so which of two ready
474        /// batches gets in first is not fixed. Nothing depends on that order: a
475        /// given agent only ever has one batch in flight.
476        async fn next_indices(&mut self, n: usize) -> Vec<u64> {
477            let mut seen = Vec::new();
478            for _ in 0..n {
479                seen.push(self.next_outcome().await.entity.to_bits());
480            }
481            seen.sort_unstable();
482            seen
483        }
484    }
485
486    /// Bounded so a wedge fails the test instead of hanging it. Generous, since
487    /// a passing run never waits.
488    async fn timeout<T>(fut: impl Future<Output = T>) -> T {
489        tokio::time::timeout(Duration::from_secs(30), fut)
490            .await
491            .expect("the lane made progress")
492    }
493
494    /// Entity ids sorted the same way [`Harness::next_indices`] sorts them, so
495    /// an expectation does not depend on how bevy packs an id into its bits.
496    fn sorted_bits(entities: &[Entity]) -> Vec<u64> {
497        let mut bits: Vec<u64> = entities.iter().map(|e| e.to_bits()).collect();
498        bits.sort_unstable();
499        bits
500    }
501
502    fn entity(index: u32) -> Entity {
503        Entity::from_raw_u32(index).expect("a small literal index is a valid entity id")
504    }
505
506    fn job(index: u32, pairs: Vec<(&'static str, &'static str)>) -> ToolJob {
507        job_with(index, pairs, crate::cancel::CancelToken::new())
508    }
509
510    fn job_with(
511        index: u32,
512        pairs: Vec<(&'static str, &'static str)>,
513        cancel: crate::cancel::CancelToken,
514    ) -> ToolJob {
515        ToolJob {
516            entity: entity(index),
517            exec: Box::new(move || {
518                Box::pin(async move {
519                    pairs
520                        .into_iter()
521                        .map(|(a, b)| (a.to_string(), b.to_string()))
522                        .collect()
523                })
524            }),
525            cancel,
526        }
527    }
528
529    /// A job whose batch blocks until `release` fires, signalling `started` once
530    /// it is running.
531    ///
532    /// `notify_one` (not `notify_waiters`) on both signals: it stores a permit
533    /// when nobody is waiting yet, so neither side can lose the other's wakeup by
534    /// being slow to arm - a real flake on a loaded runner.
535    fn held_job(
536        index: u32,
537        started: Arc<Notify>,
538        release: Arc<Notify>,
539        cancel: crate::cancel::CancelToken,
540    ) -> ToolJob {
541        ToolJob {
542            entity: entity(index),
543            exec: Box::new(move || {
544                Box::pin(async move {
545                    started.notify_one();
546                    release.notified().await;
547                    vec![("held".to_string(), "done".to_string())]
548                })
549            }),
550            cancel,
551        }
552    }
553
554    /// The same, except the wait happens [`off_lane`] - the shape of a
555    /// `wait_for_agent` or a tool-approval prompt.
556    ///
557    /// `started` fires from *inside* the parked future, which `off_lane` only
558    /// polls once the permit is already back. Signalling before the call would
559    /// race the test against the release.
560    fn parking_job(
561        index: u32,
562        started: Arc<Notify>,
563        release: Arc<Notify>,
564        cancel: crate::cancel::CancelToken,
565    ) -> ToolJob {
566        ToolJob {
567            entity: entity(index),
568            exec: Box::new(move || {
569                Box::pin(async move {
570                    off_lane(async move {
571                        started.notify_one();
572                        release.notified().await;
573                    })
574                    .await;
575                    vec![("parked".to_string(), "done".to_string())]
576                })
577            }),
578            cancel,
579        }
580    }
581
582    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
583    async fn the_lane_runs_batches_and_reports_them() {
584        let mut h = Harness::new(1);
585        h.submit(job(1, vec![("c", "r")]));
586        h.submit(job(2, vec![("c", "r")]));
587
588        let first = h.next_outcome().await;
589        assert_eq!(
590            first.results,
591            vec![("c".to_string(), "r".to_string())],
592            "the batch reported its call"
593        );
594        let mut seen = vec![first.entity.to_bits()];
595        seen.extend(h.next_indices(1).await);
596        seen.sort_unstable();
597        assert_eq!(
598            seen,
599            sorted_bits(&[entity(1), entity(2)]),
600            "both batches were reported"
601        );
602
603        h.drain().await;
604        assert!(h.outcomes.try_recv().is_err(), "no more outcomes");
605    }
606
607    /// The issue #191 regression, at its narrowest.
608    ///
609    /// A one-wide lane, a batch parked on something only a *later* batch can
610    /// deliver. With a fixed worker pool this is a deadlock: the waiter owns the
611    /// only worker, so the batch that would release it never runs. Handing the
612    /// permit back while parked is what makes both finish.
613    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
614    async fn a_parked_batch_lets_the_batch_it_waits_on_run() {
615        let mut h = Harness::new(1);
616        let started = Arc::new(Notify::new());
617        let release = Arc::new(Notify::new());
618
619        h.submit(parking_job(
620            1,
621            started.clone(),
622            release.clone(),
623            crate::cancel::CancelToken::new(),
624        ));
625        timeout(started.notified()).await;
626        assert_eq!(
627            (h.stats.busy(), h.stats.parked()),
628            (0, 1),
629            "the waiter gave the lane back"
630        );
631
632        // Only reachable if the lane is genuinely free. It is what unblocks the
633        // waiter, exactly as a child run unblocks its parent.
634        let releaser = release.clone();
635        h.submit(ToolJob {
636            entity: entity(2),
637            exec: Box::new(move || {
638                Box::pin(async move {
639                    releaser.notify_one();
640                    vec![("c2".to_string(), "r2".to_string())]
641                })
642            }),
643            cancel: crate::cancel::CancelToken::new(),
644        });
645
646        assert_eq!(
647            h.next_indices(2).await,
648            sorted_bits(&[entity(1), entity(2)]),
649            "both batches finished"
650        );
651
652        h.drain().await;
653    }
654
655    /// The parked batch takes a permit again before it carries on, so the lane's
656    /// cap still means something after a wait.
657    #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
658    async fn a_resumed_batch_takes_a_permit_again() {
659        let mut h = Harness::new(1);
660        let started = Arc::new(Notify::new());
661        let release = Arc::new(Notify::new());
662        h.submit(parking_job(
663            1,
664            started.clone(),
665            release.clone(),
666            crate::cancel::CancelToken::new(),
667        ));
668        timeout(started.notified()).await;
669
670        // Fill the lane with a batch that will not finish on its own.
671        let held_started = Arc::new(Notify::new());
672        let held_release = Arc::new(Notify::new());
673        h.submit(held_job(
674            2,
675            held_started.clone(),
676            held_release.clone(),
677            crate::cancel::CancelToken::new(),
678        ));
679        timeout(held_started.notified()).await;
680        assert_eq!(h.stats.busy(), 1, "the lane is full again");
681
682        // Waking the parked batch is not enough: it has to queue for capacity,
683        // and the holder has the only permit. Asserting the absence is what
684        // proves the permit was really taken again rather than assumed.
685        release.notify_one();
686        assert!(
687            tokio::time::timeout(Duration::from_millis(250), h.outcomes.recv())
688                .await
689                .is_err(),
690            "the resumed batch waited for a permit instead of running"
691        );
692
693        held_release.notify_one();
694        let first = h.next_outcome().await;
695        assert_eq!(first.entity, entity(2), "the holder finished first");
696        let second = h.next_outcome().await;
697        assert_eq!(second.entity, entity(1), "then the resumed batch");
698
699        h.drain().await;
700        assert_eq!((h.stats.busy(), h.stats.parked()), (0, 0));
701    }
702
703    /// Outside a lane task there is no ticket, so `off_lane` is a plain await.
704    #[tokio::test]
705    async fn off_lane_outside_the_lane_just_awaits() {
706        assert_eq!(off_lane(async { 7 }).await, 7);
707    }
708
709    /// A cancelled batch is dropped rather than run to completion, and gives its
710    /// capacity straight back.
711    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
712    async fn a_cancelled_batch_is_abandoned_and_frees_the_lane() {
713        let mut h = Harness::new(1);
714        let cancel = crate::cancel::CancelToken::new();
715        let started = Arc::new(Notify::new());
716        let release = Arc::new(Notify::new());
717        h.submit(held_job(1, started.clone(), release, cancel.clone()));
718        timeout(started.notified()).await;
719        assert_eq!((h.stats.queued(), h.stats.busy()), (0, 1));
720
721        // Queued behind it, so it can only run once the cancel frees the lane.
722        h.submit(job(2, vec![("c2", "r2")]));
723        cancel.cancel();
724
725        let next = h.next_outcome().await;
726        assert_eq!(next.entity, entity(2), "the queued batch ran");
727        h.drain().await;
728        assert!(
729            h.outcomes.try_recv().is_err(),
730            "the cancelled batch reported nothing"
731        );
732        assert_eq!(h.stats.busy(), 0, "and gave its permit back");
733    }
734
735    /// Cancelling a batch that is parked on a wait leaves the counters straight:
736    /// it was never holding a permit, so nothing is handed back twice.
737    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
738    async fn a_cancelled_parked_batch_leaves_the_counters_straight() {
739        let mut h = Harness::new(1);
740        let cancel = crate::cancel::CancelToken::new();
741        let started = Arc::new(Notify::new());
742        let release = Arc::new(Notify::new());
743        h.submit(parking_job(1, started.clone(), release, cancel.clone()));
744        timeout(started.notified()).await;
745        assert_eq!((h.stats.busy(), h.stats.parked()), (0, 1));
746
747        cancel.cancel();
748        h.submit(job(2, vec![("c2", "r2")]));
749        let next = h.next_outcome().await;
750        assert_eq!(next.entity, entity(2));
751
752        h.drain().await;
753        assert_eq!((h.stats.busy(), h.stats.parked()), (0, 0));
754    }
755
756    /// A cancel that lands while the batch is still waiting for capacity drops it
757    /// without it ever running, and takes it off the queue count.
758    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
759    async fn a_batch_cancelled_while_queued_never_runs() {
760        let mut h = Harness::new(1);
761        let blocker = crate::cancel::CancelToken::new();
762        let started = Arc::new(Notify::new());
763        let release = Arc::new(Notify::new());
764        h.submit(held_job(1, started.clone(), release.clone(), blocker));
765        timeout(started.notified()).await;
766
767        let cancel = crate::cancel::CancelToken::new();
768        h.submit(job_with(2, vec![("c2", "r2")], cancel.clone()));
769        cancel.cancel();
770        release.notify_one();
771
772        let first = h.next_outcome().await;
773        assert_eq!(first.entity, entity(1));
774        h.drain().await;
775        assert!(
776            h.outcomes.try_recv().is_err(),
777            "the cancelled batch never produced results"
778        );
779        assert_eq!(h.stats.queued(), 0, "and left the queue count clean");
780    }
781
782    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
783    async fn the_lane_runs_batches_concurrently_up_to_its_cap() {
784        let h = Harness::new(3);
785        // Three jobs that each block on a rendezvous: they can only all finish if
786        // they run concurrently. `tokio::sync::Barrier` rather than a hand-rolled
787        // counter + Notify: `notify_waiters` only wakes ALREADY-registered
788        // waiters, so a counter check has a lost-wakeup window between loading
789        // the count and registering on `notified()`.
790        let barrier = Arc::new(tokio::sync::Barrier::new(3));
791        for i in 1..=3u32 {
792            let barrier = barrier.clone();
793            h.submit(ToolJob {
794                entity: entity(i),
795                exec: Box::new(move || {
796                    Box::pin(async move {
797                        barrier.wait().await;
798                        vec![("c".to_string(), "r".to_string())]
799                    })
800                }),
801                cancel: crate::cancel::CancelToken::new(),
802            });
803        }
804        let mut h = h;
805        h.drain().await;
806        for _ in 0..3 {
807            timeout(h.outcomes.recv()).await.expect("outcome present");
808        }
809    }
810
811    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
812    async fn the_lane_survives_a_dropped_outcome_receiver() {
813        let mut h = Harness::new(1);
814        h.submit(job(9, vec![("c", "r")]));
815        // Nobody to receive the outcome: the lane must still drain the job and
816        // not panic on the failed send.
817        h.outcomes.close();
818        h.drain().await;
819    }
820
821    /// Relief widens the lane so batches queued behind a wedge can run.
822    #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
823    async fn relief_widens_the_lane() {
824        let mut h = Harness::new(1);
825        let started = Arc::new(Notify::new());
826        let release = Arc::new(Notify::new());
827        h.submit(held_job(
828            1,
829            started.clone(),
830            release.clone(),
831            crate::cancel::CancelToken::new(),
832        ));
833        timeout(started.notified()).await;
834        h.submit(job(2, vec![("c2", "r2")]));
835        assert!(h.stats.is_saturated(), "full, with a batch behind it");
836
837        assert_eq!(h.lane.relieve(0), 0, "relieving nothing changes nothing");
838        assert_eq!(h.lane.relieve(1), 1);
839        assert_eq!(h.stats.workers(), 2, "the cap moved with the permits");
840
841        let freed = h.next_outcome().await;
842        assert_eq!(freed.entity, entity(2), "the queued batch got in");
843
844        release.notify_one();
845        let held = h.next_outcome().await;
846        assert_eq!(held.entity, entity(1));
847        h.drain().await;
848    }
849
850    /// A lane with no capacity is still reported as one wide, matching
851    /// [`ToolLane::new`]'s own clamp - otherwise the saturation check compares
852    /// against a width that never existed.
853    #[tokio::test]
854    async fn a_zero_width_lane_is_clamped_to_one() {
855        assert_eq!(ToolLaneStats::new(0).workers(), 1);
856        let mut h = Harness::new(0);
857        h.submit(job(7, vec![("c", "r")]));
858        assert_eq!(h.next_outcome().await.entity, entity(7));
859        h.drain().await;
860    }
861
862    #[test]
863    fn lane_stats_track_queue_depth_and_saturation() {
864        let stats = ToolLaneStats::new(2);
865        assert_eq!((stats.queued(), stats.busy(), stats.parked()), (0, 0, 0));
866        assert!(!stats.is_saturated(), "an idle lane is not saturated");
867
868        stats.enqueued();
869        stats.enqueued();
870        stats.enqueued();
871        assert_eq!(stats.queued(), 3);
872        // Two batches take permits: two leave the queue, two occupy the lane.
873        stats.started();
874        stats.started();
875        assert_eq!((stats.queued(), stats.busy()), (1, 2));
876        assert!(
877            stats.is_saturated(),
878            "the lane is full with a batch still queued"
879        );
880
881        // One steps off to wait: it stops occupying the lane.
882        stats.began_park();
883        assert_eq!((stats.busy(), stats.parked()), (1, 1));
884        assert!(!stats.is_saturated(), "parked capacity is capacity");
885        stats.resumed();
886        assert_eq!((stats.busy(), stats.parked()), (2, 0));
887
888        stats.began_park();
889        stats.ended_park();
890        assert_eq!((stats.busy(), stats.parked()), (1, 0));
891
892        stats.finished();
893        stats.abandoned();
894        assert_eq!((stats.queued(), stats.busy()), (0, 0));
895    }
896}