Skip to main content

camel_processor/
wire_tap.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, Mutex};
5use std::task::{Context, Poll};
6use std::time::Duration;
7
8use async_trait::async_trait;
9use tokio::sync::{Semaphore, TryAcquireError};
10use tokio_util::sync::CancellationToken;
11use tokio_util::task::TaskTracker;
12use tower::{Service, ServiceExt};
13
14use camel_api::{CamelError, Exchange, StepLifecycle, StepShutdownReason};
15
16/// Configuration for [`WireTapService`].
17///
18/// Default concurrency bound is 20 (Camel-faithful flat-semaphore).
19/// `shutdown_grace` defaults to 5 seconds.
20#[derive(Clone)]
21pub struct WireTapConfig {
22    /// Maximum number of concurrent tap tasks. `None` means unlimited.
23    pub max_concurrent: Option<usize>,
24    /// Grace period for in-flight tap tasks to complete on shutdown.
25    /// A value of zero means "skip drain, cancel immediately".
26    pub shutdown_grace: std::time::Duration,
27}
28
29impl Default for WireTapConfig {
30    fn default() -> Self {
31        Self {
32            max_concurrent: Some(20),
33            shutdown_grace: std::time::Duration::from_secs(5),
34        }
35    }
36}
37
38impl WireTapConfig {
39    /// Validate the config, panicking on invalid states.
40    ///
41    /// `shutdown_grace` of zero is valid (means "skip drain, cancel immediately").
42    pub fn validate(&self) {
43        if self.max_concurrent == Some(0) {
44            panic!("max_concurrent must be > 0 when set");
45        }
46    }
47
48    /// Create a config with a bounded concurrency limit.
49    pub fn bounded(max_concurrent: usize) -> Self {
50        assert!(max_concurrent > 0, "max_concurrent must be > 0");
51        Self {
52            max_concurrent: Some(max_concurrent),
53            shutdown_grace: std::time::Duration::from_secs(5),
54        }
55    }
56}
57
58/// Mutable admission-gate state guarded by [`WireTapShared::inner`].
59///
60/// The `Mutex` over this struct serializes the "check `open` → register task"
61/// critical section so a `shutdown()` racing with a `call()` cannot orphan a
62/// task: either `call()` registers under the lock (and `shutdown` drains it via
63/// `tracker.wait`), or `shutdown` closes admission first (and `call()` returns
64/// early without registering). There is no `await` point while the lock is held.
65#[derive(Debug)]
66struct WireTapSharedInner {
67    open: bool,
68    tracker: TaskTracker,
69    cancel: CancellationToken,
70    semaphore: Option<Arc<Semaphore>>,
71    shutdown_grace: Duration,
72}
73
74/// Shared admission gate, liveness tracker, and cancellation token for a
75/// [`WireTapService`] and its clones.
76///
77/// All clones of a `WireTapService` share the SAME `Arc<WireTapShared>`: per-
78/// request clones drop an `Arc` ref but do NOT close admission or cancel taps.
79/// Only the last-ref drop (canonical-service teardown) fires [`Drop`], which
80/// cancels every in-flight tap. This is defense-in-depth alongside the runtime-
81/// driven `StepLifecycle::shutdown` path (ADR-0022 mandates shutdown-before-drop,
82/// but Drop guarantees cleanup if the runtime fails to call shutdown).
83#[derive(Debug)]
84struct WireTapShared {
85    inner: Mutex<WireTapSharedInner>,
86}
87
88impl Drop for WireTapShared {
89    fn drop(&mut self) {
90        // Defense-in-depth for the cancel-and-drain contract: when the last
91        // `Arc<WireTapShared>` ref drops (canonical-service teardown), cancel
92        // every in-flight tap so spawned tasks unwind promptly via the
93        // `cancel.cancelled()` select branch in `run_tap`. The runtime calls
94        // `StepLifecycle::shutdown` before drop per ADR-0022, but Drop
95        // guarantees cleanup if it does not.
96        //
97        // Cancel BEFORE the inner fields drop: once `cancel.cancel()` fires the
98        // cancellation state is latched into the token (and all clones held by
99        // in-flight tasks), so the subsequent `CancellationToken::drop` and
100        // `TaskTracker::drop` (which detaches rather than aborts) do not race
101        // the cancellation signal.
102        self.inner
103            .lock()
104            .expect("WireTapShared mutex poisoned") // allow-unwrap
105            .cancel
106            .cancel();
107    }
108}
109
110pub struct WireTapService {
111    tap_endpoint: camel_api::BoxProcessor,
112    shared: Arc<WireTapShared>,
113}
114
115// The shared admission gate, liveness tracker, and cancellation token live in
116// `Arc<WireTapShared>`: each clone gets a new ref to the SAME shared state.
117// This is required because the route pipeline clones the `BoxProcessor` per
118// request (`BoxCloneSyncService` contract) and drops the clone once `call()`'s
119// immediate-return future resolves. Per-clone state would close admission on
120// every request drop. Sharing keeps the gate open until canonical teardown.
121impl Clone for WireTapService {
122    fn clone(&self) -> Self {
123        Self {
124            tap_endpoint: self.tap_endpoint.clone(),
125            shared: Arc::clone(&self.shared),
126        }
127    }
128}
129
130impl WireTapService {
131    /// Create a new `WireTapService` with default (bounded-20) concurrency.
132    pub fn new(tap_endpoint: camel_api::BoxProcessor) -> Self {
133        Self::with_config(tap_endpoint, WireTapConfig::default())
134    }
135
136    /// Create a new `WireTapService` from a [`WireTapConfig`].
137    pub fn with_config(tap_endpoint: camel_api::BoxProcessor, config: WireTapConfig) -> Self {
138        config.validate();
139        let semaphore = config
140            .max_concurrent
141            .map(|limit| Arc::new(Semaphore::new(limit)));
142        let shared = Arc::new(WireTapShared {
143            inner: Mutex::new(WireTapSharedInner {
144                open: true,
145                tracker: TaskTracker::new(),
146                cancel: CancellationToken::new(),
147                semaphore,
148                shutdown_grace: config.shutdown_grace,
149            }),
150        });
151        Self {
152            tap_endpoint,
153            shared,
154        }
155    }
156
157    /// Test-only accessor for the count of currently-tracked (detached) tap
158    /// tasks. This counts ONLY detached tasks registered with the
159    /// [`TaskTracker`]; the inline CallerRuns path is NOT counted. Therefore
160    /// the bound invariant (`bound + 1` total concurrent execution, where the
161    /// +1 is the inline tap) is not observable through this accessor.
162    #[cfg(test)]
163    pub(crate) fn in_flight_count(&self) -> usize {
164        self.shared
165            .inner
166            .lock()
167            .expect("WireTapShared mutex poisoned") // allow-unwrap
168            .tracker
169            .len()
170    }
171}
172
173/// A lifecycle handle for a [`WireTapService`] implementing graceful-drain-
174/// then-abort teardown via [`StepLifecycle::shutdown`].
175///
176/// Obtain via [`WireTapService::lifecycle`]. The handle shares the same
177/// [`Arc<WireTapShared>`] as the service, so `shutdown` observes the live
178/// admission gate and task tracker.
179#[derive(Debug)]
180pub struct WireTapLifecycle {
181    shared: Arc<WireTapShared>,
182    shutdown_called: AtomicBool,
183}
184
185#[async_trait]
186impl StepLifecycle for WireTapLifecycle {
187    fn name(&self) -> &'static str {
188        "wiretap"
189    }
190
191    /// Reopen admission after a shutdown (route restart, ADR-0022).
192    ///
193    /// A start-after-shutdown installs a fresh cancellation token and task
194    /// tracker, so taps admitted after the restart are not born cancelled,
195    /// and clears the shutdown idempotency flag, so the next `shutdown`
196    /// again closes admission and drains in-flight copies.
197    async fn start(&self) -> Result<(), CamelError> {
198        {
199            let mut guard = self
200                .shared
201                .inner
202                .lock()
203                .expect("WireTapShared mutex poisoned"); // allow-unwrap
204            guard.open = true;
205            guard.cancel = CancellationToken::new();
206            guard.tracker = TaskTracker::new();
207        }
208        self.shutdown_called.store(false, Ordering::SeqCst);
209        Ok(())
210    }
211
212    async fn shutdown(&self, _reason: StepShutdownReason) -> Result<(), CamelError> {
213        // Idempotency gate.
214        if self.shutdown_called.swap(true, Ordering::SeqCst) {
215            return Ok(());
216        }
217
218        // (a) Close admission, close the tracker, clone handles out of the lock.
219        let (tracker, cancel, grace) = {
220            let mut guard = self
221                .shared
222                .inner
223                .lock()
224                .expect("WireTapShared mutex poisoned"); // allow-unwrap
225            guard.open = false;
226            guard.tracker.close();
227            (
228                guard.tracker.clone(),
229                guard.cancel.clone(),
230                guard.shutdown_grace,
231            )
232            // MutexGuard dropped here — NO .await held across the guard.
233        };
234
235        // (b) If zero grace, skip drain — go straight to cancel.
236        // (c) DRAIN FIRST: await in-flight taps that complete naturally
237        //     within the grace period.
238        if !grace.is_zero() {
239            let _ = tokio::time::timeout(grace, tracker.wait()).await;
240        }
241
242        // (d) CANCEL: abort any stragglers that did not drain within grace.
243        cancel.cancel();
244
245        // (e) Await cancel-completions (tasks that abort on token fire).
246        let _ = tracker.wait().await;
247
248        Ok(())
249    }
250}
251
252impl WireTapService {
253    /// Obtain a shared lifecycle handle for this service's admission gate and
254    /// task tracker. Callers can invoke [`StepLifecycle::shutdown`] on the
255    /// returned handle for graceful-drain-then-abort teardown.
256    pub fn lifecycle(&self) -> Arc<dyn StepLifecycle> {
257        Arc::new(WireTapLifecycle {
258            shared: Arc::clone(&self.shared),
259            shutdown_called: AtomicBool::new(false),
260        })
261    }
262}
263
264impl Service<Exchange> for WireTapService {
265    type Response = Exchange;
266    type Error = CamelError;
267    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
268
269    /// Always ready (ADR-0019): the main route never blocks on tap readiness.
270    /// Tap endpoint readiness is driven inside [`run_tap`] on the fire-and-
271    /// forgetget path; a tap readiness error is logged and suppressed, never
272    /// propagated to the main exchange.
273    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
274        Poll::Ready(Ok(()))
275    }
276
277    fn call(&mut self, exchange: Exchange) -> Self::Future {
278        let tap_endpoint = self.tap_endpoint.clone();
279        let tap_exchange = exchange.clone();
280
281        // Admission critical section: hold the lock across "check open →
282        // admit-or-inline decision → register tracked task" so a racing
283        // `shutdown` cannot close the tracker between the open-check and the
284        // task registration. There is NO await point while the lock is held;
285        // `try_acquire_owned`, `tracker.spawn`, and the open-check are all sync.
286        let inner = self
287            .shared
288            .inner
289            .lock()
290            .expect("WireTapShared mutex poisoned"); // allow-unwrap
291        if !inner.open {
292            tracing::warn!("WireTap admission closed, dropping tap");
293            drop(inner);
294            return Box::pin(async move { Ok(exchange) });
295        }
296
297        match &inner.semaphore {
298            Some(sem) => match Arc::clone(sem).try_acquire_owned() {
299                Ok(permit) => {
300                    // Admit: register a detached tracked task holding the permit.
301                    // The OwnedSemaphorePermit is MOVED into the task body and
302                    // lives for the task's lifetime, releasing on completion.
303                    let cancel = inner.cancel.clone();
304                    inner.tracker.spawn(async move {
305                        let _permit = permit;
306                        run_tap(tap_endpoint, tap_exchange, cancel).await;
307                    });
308                    drop(inner);
309                    Box::pin(async move { Ok(exchange) })
310                }
311                Err(TryAcquireError::NoPermits) => {
312                    // Saturated: run the tap INLINE on the caller's future. No
313                    // permit is acquired or held, so total concurrent execution
314                    // transiently reaches `bound + 1` (this inline tap alongside
315                    // the `bound` detached permit-holders). The caller is
316                    // back-pressured until the inline tap finishes (CallerRuns).
317                    let cancel = inner.cancel.clone();
318                    drop(inner);
319                    Box::pin(async move {
320                        run_tap(tap_endpoint, tap_exchange, cancel).await;
321                        Ok(exchange)
322                    })
323                }
324                Err(TryAcquireError::Closed) => {
325                    tracing::warn!("WireTap semaphore closed, dropping tap");
326                    drop(inner);
327                    Box::pin(async move { Ok(exchange) })
328                }
329            },
330            None => {
331                // Unbounded: register a detached tracked task with no permit.
332                let cancel = inner.cancel.clone();
333                inner.tracker.spawn(async move {
334                    run_tap(tap_endpoint, tap_exchange, cancel).await;
335                });
336                drop(inner);
337                Box::pin(async move { Ok(exchange) })
338            }
339        }
340    }
341}
342
343/// Single private helper shared by the detached path and the inline CallerRuns
344/// path. Drives the tap endpoint to readiness then calls it, racing against the
345/// shared `cancel` token so shutdown/abort unwinds promptly. Tap readiness and
346/// processing errors are logged at `warn!` (category handler-owned per
347/// ADR-0012) and suppressed — the main exchange proceeds unchanged.
348async fn run_tap(
349    mut tap_endpoint: camel_api::BoxProcessor,
350    tap_exchange: Exchange,
351    cancel: CancellationToken,
352) {
353    // Readiness phase: cancel races against `tap_endpoint.ready()`.
354    {
355        let ready_fut = tap_endpoint.ready();
356        tokio::pin!(ready_fut);
357        let ready_result = tokio::select! {
358            biased;
359            _ = cancel.cancelled() => { return; }
360            r = &mut ready_fut => r,
361        };
362        if let Err(e) = ready_result {
363            // log-policy: handler-owned
364            tracing::warn!("WireTap endpoint poll_ready failed: {}", e);
365            return;
366        }
367    }
368    // Call phase: tap_endpoint is now Ready; cancel races against the call.
369    {
370        let call_fut = tap_endpoint.call(tap_exchange);
371        tokio::pin!(call_fut);
372        let call_result = tokio::select! {
373            biased;
374            _ = cancel.cancelled() => { return; }
375            r = &mut call_fut => r,
376        };
377        if let Err(e) = call_result {
378            // log-policy: handler-owned
379            tracing::warn!("WireTap processing error: {}", e);
380        }
381    }
382}
383
384/// A Tower layer that produces `WireTapService` instances.
385pub struct WireTapLayer {
386    tap_endpoint: camel_api::BoxProcessor,
387    config: WireTapConfig,
388}
389
390impl WireTapLayer {
391    /// Create a new WireTapLayer with the given tap endpoint processor (default bounded-20 concurrency).
392    pub fn new(tap_endpoint: camel_api::BoxProcessor) -> Self {
393        Self {
394            tap_endpoint,
395            config: WireTapConfig::default(),
396        }
397    }
398
399    /// Create a new WireTapLayer with bounded concurrency.
400    pub fn bounded(tap_endpoint: camel_api::BoxProcessor, max_concurrent: usize) -> Self {
401        Self {
402            tap_endpoint,
403            config: WireTapConfig::bounded(max_concurrent),
404        }
405    }
406}
407
408impl<S> tower::Layer<S> for WireTapLayer {
409    type Service = WireTapService;
410
411    fn layer(&self, _inner: S) -> Self::Service {
412        WireTapService::with_config(self.tap_endpoint.clone(), self.config.clone())
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use camel_api::{BoxProcessor, BoxProcessorExt, Message};
420    use std::sync::Arc;
421    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
422    use tower::ServiceExt;
423
424    // --- Existing tests retained / adapted to the new shared-state model ---
425
426    #[tokio::test]
427    async fn test_wire_tap_returns_original_immediately() {
428        let tap_processor = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
429
430        let mut wire_tap = WireTapService::new(tap_processor);
431        let exchange = Exchange::new(Message::new("test message"));
432
433        let result = wire_tap
434            .ready()
435            .await
436            .unwrap()
437            .call(exchange)
438            .await
439            .unwrap();
440
441        assert_eq!(result.input.body.as_text(), Some("test message"));
442    }
443
444    #[tokio::test]
445    async fn test_wire_tap_endpoint_receives_clone() {
446        let received_count = Arc::new(AtomicUsize::new(0));
447        let count_clone = received_count.clone();
448
449        let tap_processor = BoxProcessor::from_fn(move |ex| {
450            let count = count_clone.clone();
451            Box::pin(async move {
452                count.fetch_add(1, Ordering::SeqCst);
453                Ok(ex)
454            })
455        });
456
457        let mut wire_tap = WireTapService::new(tap_processor);
458        let exchange = Exchange::new(Message::new("test"));
459
460        let _result = wire_tap
461            .ready()
462            .await
463            .unwrap()
464            .call(exchange)
465            .await
466            .unwrap();
467
468        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
469
470        assert_eq!(received_count.load(Ordering::SeqCst), 1);
471    }
472
473    #[tokio::test]
474    async fn test_wire_tap_isolates_errors() {
475        let tap_processor = BoxProcessor::from_fn(|_ex| {
476            Box::pin(async move { Err(CamelError::ProcessorError("tap error".into())) })
477        });
478
479        let mut wire_tap = WireTapService::new(tap_processor);
480        let exchange = Exchange::new(Message::new("test"));
481
482        let result = wire_tap.ready().await.unwrap().call(exchange).await;
483
484        assert!(result.is_ok());
485        assert_eq!(result.unwrap().input.body.as_text(), Some("test"));
486    }
487
488    #[tokio::test]
489    async fn test_wire_tap_layer() {
490        use tower::Layer;
491
492        let tap_processor = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
493
494        let layer = super::WireTapLayer::new(tap_processor);
495        let inner = camel_api::IdentityProcessor;
496        let mut svc = layer.layer(inner);
497
498        let exchange = Exchange::new(Message::new("test"));
499        let result = svc.ready().await.unwrap().call(exchange).await.unwrap();
500
501        assert_eq!(result.input.body.as_text(), Some("test"));
502    }
503
504    #[tokio::test]
505    async fn test_wiretap_bounded_concurrency() {
506        // Under the new CallerRuns admission model, when the bound is saturated
507        // the next call runs its tap INLINE on the caller's future (without
508        // acquiring a permit). The transient peak concurrent execution is
509        // therefore `bound + 1` (the inline tap alongside the `bound` detached
510        // permit-holders), matching the spec invariant. The old `<= bound`
511        // assertion reflected the leaky spawn-then-acquire model.
512        let concurrent = Arc::new(AtomicUsize::new(0));
513        let max_concurrent = Arc::new(AtomicUsize::new(0));
514
515        let c = Arc::clone(&concurrent);
516        let mc = Arc::clone(&max_concurrent);
517        let tap_processor = BoxProcessor::from_fn(move |ex| {
518            let c = Arc::clone(&c);
519            let mc = Arc::clone(&mc);
520            Box::pin(async move {
521                let current = c.fetch_add(1, Ordering::SeqCst) + 1;
522                mc.fetch_max(current, Ordering::SeqCst);
523                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
524                c.fetch_sub(1, Ordering::SeqCst);
525                Ok(ex)
526            })
527        });
528
529        let config = super::WireTapConfig::bounded(2);
530        let mut svc = super::WireTapService::with_config(tap_processor, config);
531
532        for _ in 0..3 {
533            let ex = Exchange::new(Message::new("test"));
534            let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
535        }
536
537        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
538
539        let observed_max = max_concurrent.load(Ordering::SeqCst);
540        // CallerRuns allows `bound + 1` (the inline tap).
541        assert!(
542            observed_max <= 3,
543            "max concurrency was {observed_max}, expected <= bound+1 (=3) under CallerRuns"
544        );
545    }
546
547    #[tokio::test]
548    async fn test_wire_tap_survives_per_request_clone_drop() {
549        // Regression for the clone-abort bug (rc-vq91): the route pipeline
550        // clones the BoxProcessor per request and drops the clone once call()'s
551        // immediate-return future resolves. With per-clone state, that drop
552        // would close admission. Sharing `Arc<WireTapShared>` keeps the gate
553        // open across clone drops; only the last-ref drop fires cancellation.
554        let completed = Arc::new(AtomicUsize::new(0));
555        let completed_clone = completed.clone();
556
557        let tap_processor = BoxProcessor::from_fn(move |ex| {
558            let c = completed_clone.clone();
559            Box::pin(async move {
560                tokio::time::sleep(std::time::Duration::from_millis(150)).await;
561                c.fetch_add(1, Ordering::SeqCst);
562                Ok(ex)
563            })
564        });
565
566        let canonical = WireTapService::new(tap_processor);
567
568        for _ in 0..3 {
569            let mut clone = canonical.clone();
570            let _ = clone
571                .ready()
572                .await
573                .unwrap()
574                .call(Exchange::new(Message::new("req")))
575                .await
576                .unwrap();
577        }
578
579        let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
580            while completed.load(Ordering::SeqCst) < 3 {
581                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
582            }
583        })
584        .await;
585        assert_eq!(
586            completed.load(Ordering::SeqCst),
587            3,
588            "all tap tasks must complete despite per-request clone drops"
589        );
590    }
591
592    #[test]
593    fn test_wiretap_config_default_is_bounded_20() {
594        let cfg = WireTapConfig::default();
595        assert_eq!(cfg.max_concurrent, Some(20));
596        assert_eq!(cfg.shutdown_grace, std::time::Duration::from_secs(5));
597    }
598
599    #[test]
600    fn test_wiretap_config_bounded_zero_panics() {
601        let result = std::panic::catch_unwind(|| WireTapConfig::bounded(0));
602        assert!(result.is_err());
603        if let Err(payload) = result {
604            let msg = payload
605                .downcast_ref::<&str>()
606                .expect("panic payload should be &str");
607            assert!(
608                msg.contains("max_concurrent"),
609                "panic message should contain 'max_concurrent', got: {msg}"
610            );
611        }
612    }
613
614    #[test]
615    fn test_wiretap_config_validate_rejects_zero_bound() {
616        let cfg = WireTapConfig {
617            max_concurrent: Some(0),
618            shutdown_grace: std::time::Duration::from_secs(5),
619        };
620        let result = std::panic::catch_unwind(|| cfg.validate());
621        assert!(result.is_err());
622        let payload = result.unwrap_err();
623        let msg = payload
624            .downcast_ref::<&str>()
625            .expect("panic payload should be &str");
626        assert!(
627            msg.contains("max_concurrent"),
628            "panic message should contain 'max_concurrent', got: {msg}"
629        );
630    }
631
632    #[tokio::test]
633    async fn test_wire_tap_drop_aborts_spawned_tasks() {
634        // Under the new shared-state model, dropping the canonical service
635        // drops the last `Arc<WireTapShared>` ref, firing `WireTapShared::drop`
636        // which cancels the token. The spawned tap's `run_tap` selects on
637        // `cancel.cancelled()` and returns promptly, so the 10s sleep is
638        // aborted and `task_completed` stays false.
639        let task_started = Arc::new(AtomicBool::new(false));
640        let task_completed = Arc::new(AtomicBool::new(false));
641        let started_clone = task_started.clone();
642        let completed_clone = task_completed.clone();
643
644        let tap_processor = BoxProcessor::from_fn(move |_ex| {
645            let started = started_clone.clone();
646            let completed = completed_clone.clone();
647            Box::pin(async move {
648                started.store(true, Ordering::SeqCst);
649                tokio::time::sleep(std::time::Duration::from_secs(10)).await;
650                completed.store(true, Ordering::SeqCst);
651                Ok(Exchange::default())
652            })
653        });
654
655        let mut service = WireTapService::new(tap_processor);
656        let _ = service
657            .ready()
658            .await
659            .unwrap()
660            .call(Exchange::default())
661            .await
662            .unwrap();
663
664        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
665        assert!(
666            task_started.load(Ordering::SeqCst),
667            "tap task should be running"
668        );
669        assert!(
670            !task_completed.load(Ordering::SeqCst),
671            "task should not have completed yet"
672        );
673
674        drop(service);
675
676        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
677
678        assert!(
679            !task_completed.load(Ordering::SeqCst),
680            "task should have been aborted, not completed"
681        );
682    }
683
684    // --- New tests for the bounded-admission + TaskTracker + cancellation model ---
685
686    #[tokio::test]
687    async fn test_wiretap_bounded_detached_count_never_exceeds_bound() {
688        // Detached tracked count must stay `<= bound`. The inline CallerRuns tap
689        // is NOT tracked so it cannot be observed here (transient total
690        // execution may briefly reach `bound + 1` — that invariant is exercised
691        // by `test_wiretap_bounded_concurrency` above).
692        let tap_processor = BoxProcessor::from_fn(|_ex| {
693            Box::pin(async move {
694                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
695                Ok(Exchange::default())
696            })
697        });
698
699        let canonical = WireTapService::with_config(tap_processor, WireTapConfig::bounded(2));
700        let max_seen = Arc::new(AtomicUsize::new(0));
701        let stop = Arc::new(AtomicBool::new(false));
702
703        // Background sampler: continuously polls in_flight_count() via a shared
704        // clone and tracks the peak observed detached task count.
705        let sampler_svc = canonical.clone();
706        let sampler_max = Arc::clone(&max_seen);
707        let sampler_stop = Arc::clone(&stop);
708        let sampler = tokio::spawn(async move {
709            while !sampler_stop.load(Ordering::SeqCst) {
710                let n = sampler_svc.in_flight_count();
711                sampler_max.fetch_max(n, Ordering::SeqCst);
712                tokio::task::yield_now().await;
713            }
714        });
715
716        // Fire 5 call() futures from spawned callers under contention.
717        let mut callers = Vec::new();
718        for _ in 0..5 {
719            let mut caller_svc = canonical.clone();
720            callers.push(tokio::spawn(async move {
721                let _ = caller_svc
722                    .ready()
723                    .await
724                    .unwrap()
725                    .call(Exchange::new(Message::new("x")))
726                    .await;
727            }));
728        }
729        for h in callers {
730            let _ = h.await;
731        }
732
733        stop.store(true, Ordering::SeqCst);
734        let _ = sampler.await;
735
736        let observed = max_seen.load(Ordering::SeqCst);
737        assert!(
738            observed <= 2,
739            "detached tracked task count peaked at {observed}, expected <= bound (=2)"
740        );
741    }
742
743    #[tokio::test]
744    async fn test_wiretap_caller_backpressured_when_saturated() {
745        // CallerRuns: when bound is saturated, the next call's tap runs INLINE
746        // on the caller's future. The caller is back-pressured until the inline
747        // tap finishes. The leaky spawn-then-acquire version would resolve the
748        // call immediately regardless of the tap's progress.
749        use tokio::sync::Notify;
750
751        let notify = Arc::new(Notify::new());
752        let tap_notify = Arc::clone(&notify);
753        let tap_processor = BoxProcessor::from_fn(move |_ex| {
754            let n = Arc::clone(&tap_notify);
755            Box::pin(async move {
756                n.notified().await;
757                Ok(Exchange::default())
758            })
759        });
760
761        let mut svc = WireTapService::with_config(tap_processor, WireTapConfig::bounded(1));
762
763        // First call: acquires the sole permit, spawns detached tap awaiting Notify.
764        let _ = svc
765            .ready()
766            .await
767            .unwrap()
768            .call(Exchange::default())
769            .await
770            .unwrap();
771        // Yield to let the spawned tap actually register its `notified()` waiter.
772        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
773
774        // Second call: try_acquire fails (NoPermits). CallerRuns path runs the
775        // tap inline on this future, which awaits Notify.
776        let mut svc2 = svc.clone();
777        let mut fut2 = Box::pin(
778            svc2.ready()
779                .await
780                .unwrap()
781                .call(Exchange::new(Message::new("inline"))),
782        );
783
784        // Race fut2 against a 50ms sleep: fut2 should still be Pending (it is
785        // running the tap inline, awaiting Notify).
786        let pending_after_50ms = tokio::select! {
787            r = &mut fut2 => {
788                panic!(
789                    "fut2 should be Pending after 50ms under CallerRuns back-pressure; resolved early: {:?}",
790                    r.is_ok()
791                );
792            }
793            _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => true,
794        };
795        assert!(
796            pending_after_50ms,
797            "fut2 should be Pending (inline tap awaiting Notify) after 50ms under CallerRuns back-pressure"
798        );
799
800        // Release all waiters: notify_waiters wakes both the detached tap 1 and
801        // the inline tap (fut2). fut2 resolves Ok.
802        notify.notify_waiters();
803        let result = fut2.await;
804        assert!(
805            result.is_ok(),
806            "fut2 should resolve Ok after notify_waiters"
807        );
808    }
809
810    #[tokio::test]
811    async fn test_wiretap_unbounded_none_path_detaches_without_permit() {
812        // Explicit unbounded (max_concurrent = None) path: tasks detach into the
813        // tracker without acquiring a permit. The tracker count rises above 0
814        // then drains to 0 as tasks complete.
815        let tap_processor = BoxProcessor::from_fn(|_ex| {
816            Box::pin(async move {
817                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
818                Ok(Exchange::default())
819            })
820        });
821
822        let mut svc = WireTapService::with_config(
823            tap_processor,
824            WireTapConfig {
825                max_concurrent: None,
826                shutdown_grace: std::time::Duration::from_secs(5),
827            },
828        );
829
830        let sampler_svc = svc.clone();
831        let peak = Arc::new(AtomicUsize::new(0));
832        let peak_clone = Arc::clone(&peak);
833        let done = Arc::new(AtomicBool::new(false));
834        let done_clone = Arc::clone(&done);
835        let sampler = tokio::spawn(async move {
836            while !done_clone.load(Ordering::SeqCst) {
837                let n = sampler_svc.in_flight_count();
838                peak_clone.fetch_max(n, Ordering::SeqCst);
839                tokio::task::yield_now().await;
840            }
841        });
842
843        for _ in 0..50 {
844            let ex = Exchange::new(Message::new("x"));
845            let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
846        }
847
848        // Poll until the tracker drains to 0 within 2s.
849        let drained = tokio::time::timeout(std::time::Duration::from_secs(2), async {
850            loop {
851                if svc.in_flight_count() == 0 {
852                    return;
853                }
854                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
855            }
856        })
857        .await
858        .is_ok();
859        done.store(true, Ordering::SeqCst);
860        let _ = sampler.await;
861
862        assert!(drained, "unbounded path tasks should drain to 0 within 2s");
863        assert!(
864            peak.load(Ordering::SeqCst) > 0,
865            "unbounded path should have observed tracked tasks (peak > 0)"
866        );
867    }
868
869    #[tokio::test]
870    async fn test_wiretap_no_unbounded_task_growth_across_bursts() {
871        // Regression for the leaky spawn-then-acquire model: completed tasks
872        // MUST decrement the tracker's len() so subsequent bursts do not
873        // accumulate.
874        let tap_processor =
875            BoxProcessor::from_fn(|_ex| Box::pin(async move { Ok(Exchange::default()) }));
876
877        let svc = WireTapService::with_config(tap_processor, WireTapConfig::default());
878
879        let drain_to_zero = |svc: &WireTapService| {
880            let s = svc.clone();
881            async move {
882                tokio::time::timeout(std::time::Duration::from_secs(2), async {
883                    loop {
884                        if s.in_flight_count() == 0 {
885                            return;
886                        }
887                        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
888                    }
889                })
890                .await
891                .is_ok()
892            }
893        };
894
895        // Burst 1.
896        let mut callers = Vec::new();
897        for _ in 0..1000 {
898            let mut s = svc.clone();
899            callers.push(tokio::spawn(async move {
900                let _ = s.ready().await.unwrap().call(Exchange::default()).await;
901            }));
902        }
903        for h in callers {
904            let _ = h.await;
905        }
906        assert!(
907            drain_to_zero(&svc).await,
908            "burst 1 must drain to in_flight_count == 0 within 2s"
909        );
910
911        // Burst 2.
912        let mut callers = Vec::new();
913        for _ in 0..1000 {
914            let mut s = svc.clone();
915            callers.push(tokio::spawn(async move {
916                let _ = s.ready().await.unwrap().call(Exchange::default()).await;
917            }));
918        }
919        for h in callers {
920            let _ = h.await;
921        }
922        assert!(
923            drain_to_zero(&svc).await,
924            "burst 2 must drain to in_flight_count == 0 within 2s (no accumulation across bursts)"
925        );
926    }
927
928    // --- Tracing capture helper for warn-log assertions ---
929
930    /// `MakeWriter` that appends formatted events to a shared `Vec<u8>` sink.
931    /// Used by the warn-suppression tests to assert that a `warn!` record was
932    /// emitted. The sink collects the ANSI-stripped fmt layer output.
933    #[derive(Clone)]
934    struct CapturingWriter {
935        sink: Arc<Mutex<Vec<u8>>>,
936    }
937
938    impl std::io::Write for CapturingWriter {
939        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
940            self.sink.lock().unwrap().extend_from_slice(buf); // allow-unwrap: test-only
941            Ok(buf.len())
942        }
943        fn flush(&mut self) -> std::io::Result<()> {
944            Ok(())
945        }
946    }
947
948    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturingWriter {
949        type Writer = CapturingWriter;
950        fn make_writer(&'a self) -> Self::Writer {
951            self.clone()
952        }
953    }
954
955    fn capture_sink() -> (Arc<Mutex<Vec<u8>>>, impl tracing::Subscriber) {
956        let sink: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
957        let writer = CapturingWriter {
958            sink: Arc::clone(&sink),
959        };
960        let subscriber = tracing_subscriber::fmt()
961            .with_writer(writer)
962            .with_ansi(false)
963            .finish();
964        (sink, subscriber)
965    }
966
967    /// Custom Service whose `poll_ready` always returns `Err`. Used to exercise
968    /// the tap-readiness-error suppression path.
969    #[derive(Clone)]
970    struct ReadyFailingSvc {
971        err_msg: &'static str,
972    }
973
974    impl Service<Exchange> for ReadyFailingSvc {
975        type Response = Exchange;
976        type Error = CamelError;
977        type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
978        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
979            Poll::Ready(Err(CamelError::ProcessorError(self.err_msg.into())))
980        }
981        fn call(&mut self, ex: Exchange) -> Self::Future {
982            Box::pin(async move { Ok(ex) })
983        }
984    }
985
986    #[tokio::test]
987    async fn test_wiretap_tap_readiness_error_suppressed_with_log() {
988        let tap: camel_api::BoxProcessor = camel_api::BoxProcessor::new(ReadyFailingSvc {
989            err_msg: "ready-boom",
990        });
991        let mut svc = WireTapService::new(tap);
992
993        let (sink, subscriber) = capture_sink();
994        let exchange = Exchange::new(Message::new("main"));
995
996        // set_default propagates to tasks spawned via tokio within this scope.
997        let _guard = tracing::subscriber::set_default(subscriber);
998        // Parallel tests race tracing's per-callsite interest cache against
999        // this thread-local subscriber; force a rebuild so the callsites
1000        // below re-evaluate against it (bd rc-u9hs).
1001        tracing::callsite::rebuild_interest_cache();
1002        let result = svc.ready().await.unwrap().call(exchange).await;
1003
1004        assert!(result.is_ok(), "tap readiness error must be suppressed");
1005        assert_eq!(result.unwrap().input.body.as_text(), Some("main"));
1006
1007        // Give the spawned tap task time to run ready() and log warn.
1008        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1009        drop(_guard);
1010
1011        let captured = String::from_utf8(sink.lock().unwrap().clone()).unwrap(); // allow-unwrap: test-only
1012        assert!(
1013            captured.contains("ready-boom"),
1014            "a warn! record mentioning the readiness error should have been emitted; got: {captured}"
1015        );
1016    }
1017
1018    #[tokio::test]
1019    async fn test_wiretap_tap_processing_error_suppressed_with_log() {
1020        let tap_processor = BoxProcessor::from_fn(|_ex| {
1021            Box::pin(async move { Err(CamelError::ProcessorError("call-boom".into())) })
1022        });
1023        let mut svc = WireTapService::new(tap_processor);
1024
1025        let (sink, subscriber) = capture_sink();
1026        let exchange = Exchange::new(Message::new("main"));
1027
1028        let _guard = tracing::subscriber::set_default(subscriber);
1029        // Parallel tests race tracing's per-callsite interest cache against
1030        // this thread-local subscriber; force a rebuild so the callsites
1031        // below re-evaluate against it (bd rc-u9hs).
1032        tracing::callsite::rebuild_interest_cache();
1033        let result = svc.ready().await.unwrap().call(exchange).await;
1034
1035        assert!(result.is_ok(), "tap processing error must be suppressed");
1036        assert_eq!(result.unwrap().input.body.as_text(), Some("main"));
1037
1038        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1039        drop(_guard);
1040
1041        let captured = String::from_utf8(sink.lock().unwrap().clone()).unwrap(); // allow-unwrap: test-only
1042        assert!(
1043            captured.contains("call-boom"),
1044            "a warn! record mentioning the processing error should have been emitted; got: {captured}"
1045        );
1046    }
1047
1048    #[tokio::test]
1049    async fn test_wiretap_poll_ready_always_ready() {
1050        // poll_ready returns Ready(Ok(())) unconditionally (ADR-0019), even
1051        // when the tap endpoint's own readiness would fail.
1052        let tap: camel_api::BoxProcessor = camel_api::BoxProcessor::new(ReadyFailingSvc {
1053            err_msg: "would-fail",
1054        });
1055        let mut svc = WireTapService::new(tap);
1056
1057        let waker = futures::task::noop_waker();
1058        let mut cx = Context::from_waker(&waker);
1059        let poll = svc.poll_ready(&mut cx);
1060        assert!(
1061            matches!(poll, Poll::Ready(Ok(()))),
1062            "poll_ready must be Ready(Ok(())) unconditionally (ADR-0019), got opposite"
1063        );
1064    }
1065
1066    // --- WireTapLifecycle + StepLifecycle shutdown tests (Task 4) ---
1067
1068    #[tokio::test]
1069    async fn test_wiretap_shutdown_drains_fast_aborts_slow() {
1070        let fast_done = Arc::new(AtomicBool::new(false));
1071        let slow_done = Arc::new(AtomicBool::new(false));
1072        let call_idx = Arc::new(AtomicUsize::new(0));
1073
1074        let fd = fast_done.clone();
1075        let sd = slow_done.clone();
1076        let ci = call_idx.clone();
1077        let tap_processor = BoxProcessor::from_fn(move |ex| {
1078            let fd = fd.clone();
1079            let sd = sd.clone();
1080            let ci = ci.clone();
1081            Box::pin(async move {
1082                let n = ci.fetch_add(1, Ordering::SeqCst);
1083                if n == 0 {
1084                    tokio::time::sleep(Duration::from_millis(10)).await;
1085                    fd.store(true, Ordering::SeqCst);
1086                } else {
1087                    tokio::time::sleep(std::time::Duration::from_secs(10)).await;
1088                    sd.store(true, Ordering::SeqCst);
1089                }
1090                Ok(ex)
1091            })
1092        });
1093
1094        let config = WireTapConfig {
1095            max_concurrent: Some(20),
1096            shutdown_grace: Duration::from_millis(200),
1097        };
1098        let mut svc = WireTapService::with_config(tap_processor, config);
1099
1100        let _ = svc
1101            .ready()
1102            .await
1103            .unwrap()
1104            .call(Exchange::new(Message::new("fast")))
1105            .await
1106            .unwrap();
1107        let _ = svc
1108            .ready()
1109            .await
1110            .unwrap()
1111            .call(Exchange::new(Message::new("slow")))
1112            .await
1113            .unwrap();
1114
1115        tokio::time::sleep(Duration::from_millis(20)).await;
1116
1117        let lifecycle = svc.lifecycle();
1118        let start = tokio::time::Instant::now();
1119        lifecycle
1120            .shutdown(StepShutdownReason::RouteStop)
1121            .await
1122            .unwrap();
1123        let elapsed = start.elapsed();
1124
1125        assert!(
1126            fast_done.load(Ordering::SeqCst),
1127            "fast tap should drain before grace expires"
1128        );
1129        assert!(
1130            !slow_done.load(Ordering::SeqCst),
1131            "slow tap should be aborted after grace, not complete"
1132        );
1133        assert!(
1134            elapsed < Duration::from_millis(500),
1135            "shutdown took {:?}, expected < 500ms",
1136            elapsed
1137        );
1138    }
1139
1140    #[tokio::test]
1141    async fn test_wiretap_shutdown_idempotent() {
1142        let slow_done = Arc::new(AtomicBool::new(false));
1143        let sd = slow_done.clone();
1144        let tap_processor = BoxProcessor::from_fn(move |ex| {
1145            let sd = sd.clone();
1146            Box::pin(async move {
1147                tokio::time::sleep(std::time::Duration::from_secs(10)).await;
1148                sd.store(true, Ordering::SeqCst);
1149                Ok(ex)
1150            })
1151        });
1152
1153        let config = WireTapConfig {
1154            max_concurrent: Some(20),
1155            shutdown_grace: Duration::from_millis(50),
1156        };
1157        let mut svc = WireTapService::with_config(tap_processor, config);
1158
1159        let _ = svc
1160            .ready()
1161            .await
1162            .unwrap()
1163            .call(Exchange::new(Message::new("slow")))
1164            .await
1165            .unwrap();
1166
1167        tokio::time::sleep(Duration::from_millis(20)).await;
1168
1169        let lifecycle = svc.lifecycle();
1170        lifecycle
1171            .shutdown(StepShutdownReason::RouteStop)
1172            .await
1173            .unwrap();
1174
1175        let start = tokio::time::Instant::now();
1176        let result = lifecycle.shutdown(StepShutdownReason::HotSwap).await;
1177        let elapsed = start.elapsed();
1178
1179        assert!(result.is_ok(), "second shutdown must return Ok");
1180        assert!(
1181            elapsed < Duration::from_millis(100),
1182            "second shutdown must return promptly, took {:?}",
1183            elapsed
1184        );
1185        assert!(
1186            !slow_done.load(Ordering::SeqCst),
1187            "slow tap must be aborted, not completed"
1188        );
1189    }
1190
1191    #[tokio::test]
1192    async fn test_wiretap_calls_after_close_rejected() {
1193        let tap_invoked = Arc::new(AtomicBool::new(false));
1194        let ti = tap_invoked.clone();
1195        let tap_processor = BoxProcessor::from_fn(move |ex| {
1196            let ti = ti.clone();
1197            Box::pin(async move {
1198                ti.store(true, Ordering::SeqCst);
1199                Ok(ex)
1200            })
1201        });
1202
1203        let mut svc = WireTapService::new(tap_processor);
1204        let lifecycle = svc.lifecycle();
1205        lifecycle
1206            .shutdown(StepShutdownReason::RouteStop)
1207            .await
1208            .unwrap();
1209
1210        let result = svc
1211            .ready()
1212            .await
1213            .unwrap()
1214            .call(Exchange::new(Message::new("post-close")))
1215            .await;
1216
1217        assert!(
1218            result.is_ok(),
1219            "call after close must return Ok(original exchange)"
1220        );
1221        assert!(
1222            !tap_invoked.load(Ordering::SeqCst),
1223            "tap must not be invoked after admission closed"
1224        );
1225    }
1226
1227    #[tokio::test]
1228    async fn test_wiretap_cancellation_while_pending_readiness() {
1229        // Service whose poll_ready returns Pending indefinitely, so the
1230        // spawned task blocks in run_tap's ready() phase. Shutdown cancels
1231        // the token, the biased select! picks it up, and the task exits
1232        // cleanly without reaching call().
1233        #[derive(Clone)]
1234        struct ForeverPendingSvc {
1235            called: Arc<AtomicBool>,
1236        }
1237
1238        impl Service<Exchange> for ForeverPendingSvc {
1239            type Response = Exchange;
1240            type Error = CamelError;
1241            type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1242
1243            fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1244                Poll::Pending
1245            }
1246
1247            fn call(&mut self, ex: Exchange) -> Self::Future {
1248                self.called.store(true, Ordering::SeqCst);
1249                Box::pin(async move { Ok(ex) })
1250            }
1251        }
1252
1253        let called = Arc::new(AtomicBool::new(false));
1254        let tap: camel_api::BoxProcessor = camel_api::BoxProcessor::new(ForeverPendingSvc {
1255            called: called.clone(),
1256        });
1257        let mut svc = WireTapService::new(tap);
1258
1259        let _ = svc
1260            .ready()
1261            .await
1262            .unwrap()
1263            .call(Exchange::new(Message::new("hanging")))
1264            .await
1265            .unwrap();
1266
1267        tokio::time::sleep(Duration::from_millis(20)).await;
1268
1269        let lifecycle = svc.lifecycle();
1270        let result = lifecycle.shutdown(StepShutdownReason::RouteStop).await;
1271
1272        assert!(
1273            result.is_ok(),
1274            "shutdown must succeed even with pending readiness: {:?}",
1275            result
1276        );
1277        assert!(
1278            !called.load(Ordering::SeqCst),
1279            "tap call() must never be reached — cancelled during readiness phase"
1280        );
1281    }
1282
1283    #[tokio::test]
1284    async fn test_wiretap_zero_grace_immediate_cancel() {
1285        let slow_done = Arc::new(AtomicBool::new(false));
1286        let sd = slow_done.clone();
1287        let tap_processor = BoxProcessor::from_fn(move |ex| {
1288            let sd = sd.clone();
1289            Box::pin(async move {
1290                tokio::time::sleep(std::time::Duration::from_secs(10)).await;
1291                sd.store(true, Ordering::SeqCst);
1292                Ok(ex)
1293            })
1294        });
1295
1296        let config = WireTapConfig {
1297            max_concurrent: Some(20),
1298            shutdown_grace: Duration::ZERO,
1299        };
1300        let mut svc = WireTapService::with_config(tap_processor, config);
1301
1302        let _ = svc
1303            .ready()
1304            .await
1305            .unwrap()
1306            .call(Exchange::new(Message::new("slow")))
1307            .await
1308            .unwrap();
1309
1310        tokio::time::sleep(Duration::from_millis(20)).await;
1311
1312        let lifecycle = svc.lifecycle();
1313        let start = tokio::time::Instant::now();
1314        lifecycle
1315            .shutdown(StepShutdownReason::RouteStop)
1316            .await
1317            .unwrap();
1318        let elapsed = start.elapsed();
1319
1320        assert!(
1321            !slow_done.load(Ordering::SeqCst),
1322            "slow tap must be aborted immediately (zero grace)"
1323        );
1324        assert!(
1325            elapsed < Duration::from_millis(200),
1326            "zero-grace shutdown must return quickly, took {:?}",
1327            elapsed
1328        );
1329    }
1330
1331    #[tokio::test(flavor = "multi_thread")]
1332    async fn test_wiretap_admission_shutdown_no_orphan_task() {
1333        // Stress test: fire concurrent call()s and shutdown() across many
1334        // randomized iterations. Verify in_flight_count() == 0 after shutdown
1335        // completes — no orphan task escaped the tracker.
1336        const ITERATIONS: usize = 200;
1337
1338        for _ in 0..ITERATIONS {
1339            let tap_processor = BoxProcessor::from_fn(|_ex| {
1340                Box::pin(async move {
1341                    tokio::time::sleep(Duration::from_millis(1)).await;
1342                    Ok(Exchange::default())
1343                })
1344            });
1345
1346            let svc = WireTapService::new(tap_processor);
1347            let lifecycle = svc.lifecycle();
1348
1349            // Fire several concurrent callers.
1350            let mut handles = Vec::new();
1351            for _ in 0..4 {
1352                let mut c = svc.clone();
1353                handles.push(tokio::spawn(async move {
1354                    let _ = c.ready().await.unwrap().call(Exchange::default()).await;
1355                }));
1356            }
1357
1358            // Yield to let spawns register in the tracker.
1359            tokio::task::yield_now().await;
1360            tokio::time::sleep(Duration::from_millis(1)).await;
1361
1362            // Shutdown concurrently with callers still in-flight.
1363            lifecycle
1364                .shutdown(StepShutdownReason::RouteStop)
1365                .await
1366                .unwrap();
1367
1368            for h in handles {
1369                let _ = h.await;
1370            }
1371
1372            // Poll until tracker drains (already waited in shutdown, but
1373            // defensive check).
1374            let drained = tokio::time::timeout(Duration::from_secs(2), async {
1375                loop {
1376                    if svc.in_flight_count() == 0 {
1377                        return;
1378                    }
1379                    tokio::time::sleep(Duration::from_millis(5)).await;
1380                }
1381            })
1382            .await
1383            .is_ok();
1384
1385            assert!(
1386                drained,
1387                "iteration: in_flight_count must drain to 0 after shutdown"
1388            );
1389        }
1390    }
1391}