Skip to main content

dynamo_runtime/pipeline/network/egress/
addressed_router.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::BTreeMap;
5use std::future::Future;
6use std::sync::{Arc, LazyLock};
7use std::time::Instant;
8
9use super::unified_client::RequestPlaneClient;
10use super::*;
11use crate::component::Instance;
12use crate::discovery::EndpointInstanceId;
13use crate::dynamo_nvtx_range;
14use crate::engine::{AsyncEngine, AsyncEngineContextProvider, Data, EngineContextGuard};
15use crate::error::{DynamoError, ErrorType, match_error_chain};
16use crate::logging::inject_trace_headers_into_map;
17use crate::metrics::frontend_perf::STAGE_DURATION_SECONDS;
18use crate::metrics::request_plane::{
19    REQUEST_PLANE_INFLIGHT, REQUEST_PLANE_QUEUE_SECONDS, REQUEST_PLANE_ROUNDTRIP_TTFT_SECONDS,
20    REQUEST_PLANE_SEND_SECONDS,
21};
22use crate::pipeline::network::ConnectionInfo;
23use crate::pipeline::network::NetworkStreamWrapper;
24use crate::pipeline::network::PendingConnections;
25use crate::pipeline::network::RegisteredStream;
26use crate::pipeline::network::RequestControlMessage;
27use crate::pipeline::network::RequestPlanePayloadCodec;
28use crate::pipeline::network::RequestType;
29use crate::pipeline::network::ResponseType;
30use crate::pipeline::network::StreamOptions;
31use crate::pipeline::network::StreamPrologueError;
32use crate::pipeline::network::StreamProvider;
33use crate::pipeline::network::StreamReceiver;
34use crate::pipeline::network::StreamSender;
35use crate::pipeline::network::TwoPartCodec;
36use crate::pipeline::network::codec::TwoPartMessage;
37use crate::pipeline::network::tcp;
38use crate::pipeline::{ManyIn, ManyOut, PipelineError, ResponseStream, SingleIn};
39use crate::protocols::maybe_error::MaybeError;
40use crate::traits::DistributedRuntimeProvider;
41
42use anyhow::{Error, Result};
43use futures::stream::Stream;
44use parking_lot::Mutex;
45use std::pin::Pin;
46use std::task::{Context, Poll};
47use tokio::sync::{OwnedSemaphorePermit, Semaphore};
48use tokio_stream::{StreamExt, StreamNotifyClose, wrappers::ReceiverStream};
49use tracing::Instrument;
50
51/// Error types that must never be attached as the cause of a pre-stream
52/// failure, because migration classification walks the whole cause chain.
53///
54/// Must hold the same set as `NON_MIGRATABLE` in `lib/llm/src/migration.rs`,
55/// which cannot be reused directly because `dynamo-llm` depends on
56/// `dynamo-runtime` and not the reverse.
57/// `migration_sensitive_types_match_the_exclusion_set` there fails if the two
58/// lists ever disagree.
59pub(crate) const MIGRATION_SENSITIVE_ERROR_TYPES: &[ErrorType] =
60    &[ErrorType::Cancelled, ErrorType::ResourceExhausted];
61
62/// Whether any link of `err`'s chain carries a migration-sensitive type.
63///
64/// An empty exclude set reduces [`crate::error::match_error_chain`] to "does
65/// any link match", and that is deliberately the same walk migration
66/// classification runs: an excluded type nested one link down short-circuits it
67/// just as an outer one does.
68fn is_migration_sensitive(err: &DynamoError) -> bool {
69    match_error_chain(err, MIGRATION_SENSITIVE_ERROR_TYPES, &[])
70}
71
72/// Build the error returned when the worker fails before any response bytes.
73///
74/// The outer type stays [`ErrorType::CannotConnect`], so retry classification
75/// of the outer error is unchanged. A typed error from the worker's prologue is
76/// attached as the cause, which consumers reach with
77/// [`crate::error::match_error_chain`].
78///
79/// Because that walk covers the whole chain, an attached cause is as visible as
80/// the outer type, so causes typed one of [`MIGRATION_SENSITIVE_ERROR_TYPES`]
81/// are withheld rather than attached. The worker's text stays in the message
82/// either way; only the machine-readable type is withheld.
83pub(crate) fn pre_stream_failure_error(error: StreamPrologueError) -> DynamoError {
84    let builder = DynamoError::builder()
85        .error_type(ErrorType::CannotConnect)
86        .message(format!(
87            "Worker generate() failed before response stream: {error}"
88        ));
89
90    match error.typed_error {
91        Some(typed) if !is_migration_sensitive(&typed) => builder.cause(typed).build(),
92        _ => builder.build(),
93    }
94}
95
96/// White-box handles for the cross-crate tests in `dynamo-llm`. Gated so a
97/// normal build of this crate exposes no public API for them.
98#[cfg(any(test, feature = "testing"))]
99#[doc(hidden)]
100pub mod testing {
101    use super::{DynamoError, ErrorType, StreamPrologueError};
102
103    /// The set `migration_sensitive_types_match_the_exclusion_set` in
104    /// `lib/llm/src/migration.rs` pins against its `NON_MIGRATABLE`.
105    pub fn migration_sensitive_error_types() -> &'static [ErrorType] {
106        super::MIGRATION_SENSITIVE_ERROR_TYPES
107    }
108
109    pub fn pre_stream_failure_error(error: StreamPrologueError) -> DynamoError {
110        super::pre_stream_failure_error(error)
111    }
112}
113
114const FIRST_RESPONSE_GUARD_CONTEXT_KEY: &str = "dynamo.request_plane.first_response_guard";
115// A timeout cannot safely release registered memory while a remote read may
116// still be active. Bound the detached pre-first-response phase process-wide.
117const MAX_RETAINED_FIRST_RESPONSE_DISPATCHES: usize = 1024;
118static RETAINED_FIRST_RESPONSE_DISPATCH_PERMITS: LazyLock<Arc<Semaphore>> =
119    LazyLock::new(|| Arc::new(Semaphore::new(MAX_RETAINED_FIRST_RESPONSE_DISPATCHES)));
120
121#[derive(Clone)]
122struct FirstResponseGuard {
123    guard: Arc<Mutex<Option<EngineContextGuard>>>,
124}
125
126impl FirstResponseGuard {
127    fn new(guard: EngineContextGuard) -> Self {
128        Self {
129            guard: Arc::new(Mutex::new(Some(guard))),
130        }
131    }
132
133    fn take(&self) -> Option<EngineContextGuard> {
134        self.guard.lock().take()
135    }
136}
137
138/// Keep a frontend-owned resource alive until the addressed worker produces
139/// its first response item or closes the response stream.
140pub fn attach_first_response_guard<T: Data>(
141    context: &mut context::Context<T>,
142    guard: EngineContextGuard,
143) {
144    context.insert(
145        FIRST_RESPONSE_GUARD_CONTEXT_KEY,
146        FirstResponseGuard::new(guard),
147    );
148}
149
150/// Share a take-once first-response guard with a derived request context.
151pub fn propagate_first_response_guard<S: Data, T: Data>(
152    source: &context::Context<S>,
153    target: &mut context::Context<T>,
154) -> Result<(), Error> {
155    if let Some(guard) = source
156        .get_optional::<FirstResponseGuard>(FIRST_RESPONSE_GUARD_CONTEXT_KEY)
157        .map_err(Error::msg)?
158    {
159        target.insert(FIRST_RESPONSE_GUARD_CONTEXT_KEY, guard.as_ref().clone());
160    }
161    Ok(())
162}
163
164fn try_acquire_retained_dispatch_permit(
165    permits: &Arc<Semaphore>,
166) -> Result<OwnedSemaphorePermit, Error> {
167    permits.clone().try_acquire_owned().map_err(|_| {
168        DynamoError::builder()
169            .error_type(ErrorType::ResourceExhausted)
170            .message("retained request dispatch limit reached")
171            .build()
172            .into()
173    })
174}
175
176// Only dispatch and the first response are detached from the caller. The tail
177// is handed back so normal stream polling and cancellation stay on the caller.
178async fn dispatch_with_first_response_guard<F, U>(
179    dispatch: F,
180    guard: EngineContextGuard,
181    permit: OwnedSemaphorePermit,
182) -> Result<ManyOut<U>, Error>
183where
184    F: Future<Output = Result<ManyOut<U>, Error>> + Send + 'static,
185    U: Data + MaybeError,
186{
187    let (dispatch_tx, dispatch_rx) = tokio::sync::oneshot::channel();
188
189    tokio::spawn(
190        async move {
191            let mut response = match dispatch.await {
192                Ok(response) => response,
193                Err(error) => {
194                    let _ = dispatch_tx.send(Err(error));
195                    return;
196                }
197            };
198
199            let response_context = response.context();
200            let (first_tx, first_rx) = tokio::sync::oneshot::channel::<(Option<U>, ManyOut<U>)>();
201            let stream = async_stream::stream! {
202                match first_rx.await {
203                    Ok((first, mut tail)) => {
204                        if let Some(first) = first {
205                            yield first;
206                        }
207                        while let Some(item) = tail.next().await {
208                            yield item;
209                        }
210                    }
211                    Err(_) => {
212                        yield U::from_err(DynamoError::msg(
213                            "retained request dispatch ended before first response handoff",
214                        ));
215                    }
216                }
217            };
218            let handoff: ManyOut<U> = ResponseStream::new(Box::pin(stream), response_context);
219            let _ = dispatch_tx.send(Ok(handoff));
220
221            let first = response.next().await;
222            drop(guard);
223            drop(permit);
224            let _ = first_tx.send((first, response));
225        }
226        .in_current_span(),
227    );
228
229    dispatch_rx
230        .await
231        .map_err(|_| anyhow::anyhow!("retained request dispatch ended before setup completed"))?
232}
233
234/// Stream transformation helper that:
235/// - decodes a response byte stream from network into the fully-shaped `ManyOut<U>`
236/// - emits TTFT and transport-roundtrip metrics on first response
237/// - hands off the `InflightGuard` to a stream-lifetime `InflightDecStream` so
238///   the inflight gauge stays accurate for the whole response lifetime.
239fn decode_response_stream<U>(
240    response_rx: tokio::sync::mpsc::Receiver<bytes::Bytes>,
241    engine_ctx: Arc<dyn crate::engine::AsyncEngineContext>,
242    queue_start: Instant,
243    tx_start: Instant,
244    inflight_guard: InflightGuard,
245    payload_codec: RequestPlanePayloadCodec,
246) -> ManyOut<U>
247where
248    U: Data + for<'de> Deserialize<'de> + MaybeError,
249{
250    let engine_ctx_for_stream = engine_ctx.clone();
251    let mut is_complete_final = false;
252    let mut first_response = true;
253    let stream = StreamNotifyClose::new(ReceiverStream::new(response_rx)).filter_map(move |res| {
254        if let Some(res_bytes) = res {
255            if first_response {
256                first_response = false;
257                REQUEST_PLANE_ROUNDTRIP_TTFT_SECONDS.observe(tx_start.elapsed().as_secs_f64());
258                STAGE_DURATION_SECONDS
259                    .with_label_values(&["transport_roundtrip"])
260                    .observe(queue_start.elapsed().as_secs_f64());
261            }
262            if is_complete_final {
263                let err = DynamoError::msg(
264                    "Response received after generation ended - this should never happen",
265                );
266                return Some(U::from_err(err));
267            }
268            match payload_codec.decode::<NetworkStreamWrapper<U>>(&res_bytes) {
269                Ok(item) => {
270                    is_complete_final = item.complete_final;
271                    if let Some(data) = item.data {
272                        Some(data)
273                    } else if is_complete_final {
274                        None
275                    } else {
276                        let err =
277                            DynamoError::msg("Empty response received - this should never happen");
278                        Some(U::from_err(err))
279                    }
280                }
281                Err(err) => {
282                    let response_bytes_len = res_bytes.len();
283                    tracing::warn!(
284                        %err,
285                        codec = payload_codec.name(),
286                        response_bytes_len,
287                        "failed deserializing request-plane response"
288                    );
289                    Some(U::from_err(DynamoError::msg(err.to_string())))
290                }
291            }
292        } else if is_complete_final {
293            None
294        } else if engine_ctx_for_stream.is_stopped() {
295            tracing::debug!("Request cancelled and then trying to read a response");
296            None
297        } else {
298            let err = DynamoError::builder()
299                .error_type(ErrorType::Disconnected)
300                .message("Stream ended before generation completed")
301                .build();
302            tracing::debug!("{err}");
303            Some(U::from_err(err))
304        }
305    });
306
307    inflight_guard.disarm();
308    let stream = InflightDecStream { inner: stream };
309    ResponseStream::new(Box::pin(stream), engine_ctx)
310}
311
312const CONTROL_MESSAGE_MAX_BYTES: usize = 128 * 1024;
313
314fn serialize_control_message(control_message: &RequestControlMessage) -> Result<Vec<u8>, Error> {
315    let ctrl = serde_json::to_vec(control_message)?;
316    if ctrl.len() > CONTROL_MESSAGE_MAX_BYTES {
317        return Err(PipelineError::Generic(format!(
318            "request control message too large: {} bytes exceeds limit {}",
319            ctrl.len(),
320            CONTROL_MESSAGE_MAX_BYTES
321        ))
322        .into());
323    }
324    Ok(ctrl)
325}
326
327/// Build the request control message, and serialize for transfer.
328///
329/// `request` provides the optional unary request payload. Should set for
330/// SingleIn generation.
331/// `send_conn_info` provides the connection info for the request stream.
332/// Should set for ManyIn generation.
333fn build_request_envelope<T>(
334    context: &context::Context<()>,
335    recv_conn_info: ConnectionInfo,
336    send_conn_info: Option<ConnectionInfo>,
337    request: Option<&T>,
338    payload_codec: RequestPlanePayloadCodec,
339) -> Result<bytes::Bytes, Error>
340where
341    T: serde::Serialize,
342{
343    let request_id = context.id();
344    let request_type = if send_conn_info.is_some() {
345        RequestType::ManyIn
346    } else {
347        RequestType::SingleIn
348    };
349    let control_message = RequestControlMessage {
350        id: request_id.to_string(),
351        request_type,
352        response_type: ResponseType::ManyOut,
353        payload_codec,
354        connection_info: recv_conn_info,
355        metadata: context.metadata().clone(),
356        frontend_send_ts_ns: None,
357        request_stream_connection_info: send_conn_info,
358    };
359
360    let ctrl = serialize_control_message(&control_message)?;
361    let data: Option<Vec<u8>> = match request {
362        Some(req) => Some(payload_codec.encode(req)?),
363        None => None,
364    };
365
366    let msg = match data {
367        Some(d) => {
368            tracing::trace!(
369                request_id,
370                "packaging two-part message; ctrl: {} bytes, data: {} bytes",
371                ctrl.len(),
372                d.len(),
373            );
374            TwoPartMessage::from_parts(ctrl.into(), d.into())
375        }
376        None => {
377            tracing::trace!(
378                request_id,
379                "packaging bidirectional header-only envelope; ctrl: {} bytes",
380                ctrl.len(),
381            );
382            TwoPartMessage::from_header(ctrl.into())
383        }
384    };
385
386    let codec = TwoPartCodec::default();
387    let buffer = codec.encode_message(msg)?;
388    Ok(buffer)
389}
390
391fn payload_codec_for_worker(instance: Option<&Instance>) -> RequestPlanePayloadCodec {
392    instance
393        .and_then(|instance| instance.request_plane_codec)
394        .unwrap_or(RequestPlanePayloadCodec::Json)
395}
396
397/// Await the network request-stream dial-in (if `request_stream_provider` is `Some`)
398/// and spawn a detached task that forwards every item from `input_stream` onto
399/// the request stream. Returns once the forwarder is spawned; `Err` if request-stream
400/// dial-in fails.
401async fn spawn_request_stream_forwarder<T>(
402    request_stream_provider: Option<StreamProvider<StreamSender>>,
403    mut input_stream: crate::engine::DataStream<T>,
404    engine_ctx: Arc<dyn crate::engine::AsyncEngineContext>,
405    payload_codec: RequestPlanePayloadCodec,
406) -> Result<(), Error>
407where
408    T: serde::Serialize + Send + 'static,
409{
410    let Some(provider) = request_stream_provider else {
411        return Ok(());
412    };
413
414    let request_sender = match provider.await {
415        Ok(Ok(sender)) => sender,
416        Ok(Err(e)) => {
417            return Err(anyhow::anyhow!(
418                DynamoError::builder()
419                    .error_type(ErrorType::CannotConnect)
420                    .message(format!("Worker dial-in failed for request stream: {e}"))
421                    .build()
422            ));
423        }
424        Err(_) => {
425            return Err(anyhow::anyhow!(
426                DynamoError::builder()
427                    .error_type(ErrorType::Disconnected)
428                    .message("Worker disconnected before request stream was established")
429                    .build()
430            ));
431        }
432    };
433
434    // The task exits on stream end, context kill/stop, send error (worker
435    // dropped its receiver), or local serialize failure. On any exit
436    // `request_sender` drops and triggers transport shutdown (see server.rs for details)
437    // which closes the upstream mpsc, triggering the server-side handler to emit
438    // `Sentinel`, which signals the worker's reader to end cleanly.
439    tokio::spawn(async move {
440        loop {
441            let item = tokio::select! {
442                biased;
443                _ = engine_ctx.killed() => break,
444                _ = engine_ctx.stopped() => break,
445                item = input_stream.next() => match item {
446                    Some(item) => item,
447                    None => break,
448                },
449            };
450            let bytes = match payload_codec.encode(&item) {
451                Ok(b) => b,
452                Err(e) => {
453                    // Stream-side framing failure: the engine sees a
454                    // partial input, so kill the context to abort both
455                    // directions consistently rather than silently
456                    // dropping frames.
457                    tracing::error!(
458                        error = %e,
459                        codec = payload_codec.name(),
460                        "failed to serialize bidirectional request frame; killing context"
461                    );
462                    engine_ctx.kill();
463                    break;
464                }
465            };
466            if request_sender.send(bytes.into()).await.is_err() {
467                tracing::debug!("worker request-stream receiver dropped; forwarder exiting");
468                break;
469            }
470        }
471    });
472
473    Ok(())
474}
475
476/// RAII guard that decrements REQUEST_PLANE_INFLIGHT on drop unless disarmed.
477/// Protects against gauge leaks when `?` operators cause early returns between
478/// the increment and `InflightDecStream` construction.
479struct InflightGuard {
480    armed: bool,
481}
482
483impl InflightGuard {
484    fn new() -> Self {
485        Self { armed: true }
486    }
487
488    /// Consume the guard without decrementing. Call this when `InflightDecStream`
489    /// takes over responsibility for the decrement.
490    fn disarm(mut self) {
491        self.armed = false;
492    }
493}
494
495impl Drop for InflightGuard {
496    fn drop(&mut self) {
497        if self.armed {
498            REQUEST_PLANE_INFLIGHT.dec();
499        }
500    }
501}
502
503/// Wrapper that decrements request-plane inflight gauge when the stream is dropped.
504struct InflightDecStream<S> {
505    inner: S,
506}
507
508impl<S, T> Stream for InflightDecStream<S>
509where
510    S: Stream<Item = T> + Unpin,
511{
512    type Item = T;
513
514    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
515        Pin::new(&mut self.inner).poll_next(cx)
516    }
517}
518
519impl<S> Drop for InflightDecStream<S> {
520    fn drop(&mut self) {
521        REQUEST_PLANE_INFLIGHT.dec();
522    }
523}
524
525/// Extract the TCP stream subject from a [`ConnectionInfo`], if it carries a
526/// well-formed [`tcp::TcpStreamConnectionInfo`]. Used for the pre-dispatch
527/// tombstone check.
528fn subject_of(conn_info: &ConnectionInfo) -> Option<String> {
529    serde_json::from_str::<tcp::TcpStreamConnectionInfo>(&conn_info.info)
530        .ok()
531        .map(|ci| ci.subject)
532}
533
534pub struct AddressedRequest<T> {
535    request: T,
536    address: String,
537    /// Carries endpoint name + instance_id so cancellation is scoped to the
538    /// exact (endpoint, instance) pair, not all endpoints on the same runtime.
539    instance: Option<Instance>,
540}
541
542impl<T> AddressedRequest<T> {
543    pub fn new(request: T, address: String) -> Self {
544        Self {
545            request,
546            address,
547            instance: None,
548        }
549    }
550
551    pub fn with_instance(request: T, address: String, instance: Instance) -> Self {
552        Self {
553            request,
554            address,
555            instance: Some(instance),
556        }
557    }
558
559    pub fn for_instance(request: T, instance: Instance) -> Self {
560        let address = instance.transport.address().to_string();
561        Self::with_instance(request, address, instance)
562    }
563
564    /// `(request, address, instance)` — public so an external [`StreamingDispatch`]
565    /// impl can read the routed address + instance.
566    pub fn into_parts(self) -> (T, String, Option<Instance>) {
567        (self.request, self.address, self.instance)
568    }
569}
570
571#[derive(Clone)]
572pub struct AddressedPushRouter {
573    // Request transport (unified trait object - works with all transports)
574    req_client: Arc<dyn RequestPlaneClient>,
575
576    // Response transport (TCP streaming - unchanged)
577    resp_transport: Arc<tcp::server::TcpStreamServer>,
578}
579
580impl AddressedPushRouter {
581    /// Create a new router with a request plane client
582    ///
583    /// This is the unified constructor that works with any transport type.
584    /// The client is provided as a trait object, hiding the specific implementation.
585    pub fn new(
586        req_client: Arc<dyn RequestPlaneClient>,
587        resp_transport: Arc<tcp::server::TcpStreamServer>,
588    ) -> Result<Arc<Self>> {
589        Ok(Arc::new(Self {
590            req_client,
591            resp_transport,
592        }))
593    }
594
595    pub async fn from_runtime_provider(
596        provider: &impl DistributedRuntimeProvider,
597    ) -> Result<Arc<Self>> {
598        let manager = provider.drt().network_manager();
599        let req_client = manager.create_client()?;
600        let resp_transport = provider.drt().tcp_server().await?;
601
602        tracing::debug!(
603            transport = req_client.transport_name(),
604            "Creating AddressedPushRouter with request plane client"
605        );
606
607        Self::new(req_client, resp_transport)
608    }
609
610    /// Cancel all pending response-stream registrations for an instance.
611    pub async fn cancel_instance_streams(&self, instance_id: &EndpointInstanceId) -> usize {
612        self.resp_transport
613            .cancel_instance_streams(instance_id)
614            .await
615    }
616
617    /// Clear the tombstone after an instance reappears in discovery.
618    pub async fn clear_instance_tombstone(&self, instance_id: &EndpointInstanceId) {
619        self.resp_transport
620            .clear_instance_tombstone(instance_id)
621            .await
622    }
623
624    /// Bidirectional generation. Note that it doesn't implement the AsyncEngine trait directly
625    /// because there is no trivial way to wrap (instance and address) into ManyIn style.
626    /// May wrap as `SingleIn<AddressedStreamRequest<T>>` and unwrap here but really just syntax
627    /// sugar, so we just do it inline here. Will consider only if we do want to call this from
628    /// typed erased AsyncEngine impls.
629    pub async fn dispatch_bidirectional<T, U>(
630        &self,
631        instance: Instance,
632        address: String,
633        input: ManyIn<T>,
634    ) -> Result<ManyOut<U>, Error>
635    where
636        T: Data + Serialize,
637        U: Data + for<'de> Deserialize<'de> + MaybeError,
638    {
639        let (request_stream, context) = input.into_parts();
640        let input_stream = request_stream.take().ok_or_else(|| {
641            anyhow::anyhow!("RequestStream::take called twice on bidirectional dispatch input")
642        })?;
643
644        self.dispatch_and_finalize::<T, U>(
645            &context,
646            address,
647            Some(&instance),
648            None,
649            Some(input_stream),
650        )
651        .await
652    }
653
654    /// Shared dispatch core for both unary and bidirectional requests. Wire
655    /// shape is inferred from the inputs:
656    ///   - `input_stream = Some(_)` + `request = None` → bidirectional,
657    ///     header-only envelope. The worker dials back for both halves and
658    ///     pulls request frames off the spawned forwarder.
659    ///   - `input_stream = None` + `request = Some(_)` → unary, two-part
660    ///     `[ctrl, data]` envelope. The payload travels in the data part.
661    async fn dispatch_and_finalize<T, U>(
662        &self,
663        context: &context::Context<()>,
664        address: String,
665        instance: Option<&Instance>,
666        request: Option<&T>,
667        input_stream: Option<crate::engine::DataStream<T>>,
668    ) -> Result<ManyOut<U>, Error>
669    where
670        T: Data + Serialize,
671        U: Data + for<'de> Deserialize<'de> + MaybeError,
672    {
673        let engine_ctx = context.context();
674
675        let queue_start = Instant::now();
676        REQUEST_PLANE_INFLIGHT.inc();
677        let inflight_guard = InflightGuard::new();
678
679        let enable_request_stream = input_stream.is_some();
680        let payload_codec = payload_codec_for_worker(instance);
681
682        // Hold the `RegisteredStream` as their RAII cleanup stays armed while held,
683        // which simplifies the cancellation of registration on error. Each side is
684        // disarmed by `into_parts()` on awaiting stream provider: past that point the
685        // subject is reaped by the worker's dial-in (instance healthy) or the discovery
686        // watcher (instance dropped), so no cleanup is owed.
687        let (send_registered, recv_registered) = self
688            .register_streams(engine_ctx.clone(), enable_request_stream, true)
689            .await?;
690        let recv_registered = recv_registered.ok_or_else(|| {
691            anyhow::anyhow!("response stream registration missing despite enable_response_stream")
692        })?;
693
694        // Tombstone check: if discovery already removed the worker, fail fast
695        // with a migratable error rather than writing to the request plane.
696        // Dropping the held registrations on this return runs their cleanup.
697        let recv_subject = subject_of(&recv_registered.connection_info);
698        let send_subject = send_registered
699            .as_ref()
700            .and_then(|r| subject_of(&r.connection_info));
701        if let (Some(subject), Some(inst)) = (&recv_subject, instance)
702            && !self
703                .resp_transport
704                .associate_instance(
705                    subject,
706                    send_subject.as_deref(),
707                    &inst.endpoint_instance_id(),
708                )
709                .await
710        {
711            return Err(anyhow::anyhow!(
712                DynamoError::builder()
713                    .error_type(ErrorType::Disconnected)
714                    .message("Worker removed before request could be sent (tombstoned instance)")
715                    .build()
716            ));
717        }
718
719        let buffer = build_request_envelope(
720            context,
721            recv_registered.connection_info.clone(),
722            send_registered.as_ref().map(|r| r.connection_info.clone()),
723            request,
724            payload_codec,
725        )?;
726        REQUEST_PLANE_QUEUE_SECONDS.observe(queue_start.elapsed().as_secs_f64());
727
728        let tx_start = Instant::now();
729        let request_plane_response = self.dispatch_buffer(address, buffer, context.id()).await?;
730        REQUEST_PLANE_SEND_SECONDS.observe(tx_start.elapsed().as_secs_f64());
731
732        // A worker rejection surfaces on the request-plane ACK, not the response
733        // stream. Short-circuit before waiting on a response-plane connection the
734        // worker will never open; returning early drops `recv_registered` and
735        // `inflight_guard` (their Drop cleans up).
736        if let Some(err) = detect_worker_rejection_response(&request_plane_response) {
737            tracing::warn!(
738                request_id = context.id(),
739                worker_response = %err.to_string(),
740                "Request rejected by worker"
741            );
742            return Err(err.into());
743        }
744
745        // Spawn the forwarder before awaiting the response prologue so request
746        // frames pre-load into the worker's input buffer while the engine
747        // initialises in parallel. The response provider only resolves after
748        // `engine.generate()` returns; awaiting it second avoids stalling the
749        // request-side handshake on engine setup latency.
750        if let Some(stream) = input_stream {
751            let request_stream_provider = send_registered.map(|r| {
752                let (_conn_info, provider) = r.into_parts();
753                provider
754            });
755            spawn_request_stream_forwarder(
756                request_stream_provider,
757                stream,
758                engine_ctx.clone(),
759                payload_codec,
760            )
761            .await?;
762        }
763
764        let _nvtx_wait = dynamo_nvtx_range!("transport.tcp.wait_backend");
765        tracing::trace!(request_id = context.id(), "awaiting transport handshake");
766
767        // Disarms the recv-side cleanup; see the holding rationale above.
768        let (_recv_conn_info, response_stream_provider) = recv_registered.into_parts();
769
770        // RecvError → migratable Disconnected (watcher cancelled the subject
771        // or the worker died before establishing the response stream).
772        let response_stream = match response_stream_provider.await {
773            Ok(Ok(stream)) => stream,
774            Ok(Err(e)) => {
775                return Err(anyhow::anyhow!(pre_stream_failure_error(e)));
776            }
777            Err(_recv_err) => {
778                // oneshot dropped: either the discovery watcher cancelled
779                // this subject or the worker died mid-handshake.
780                return Err(anyhow::anyhow!(
781                    DynamoError::builder()
782                        .error_type(ErrorType::Disconnected)
783                        .message("Worker disconnected before response stream was established")
784                        .build()
785                ));
786            }
787        };
788        drop(_nvtx_wait);
789
790        Ok(decode_response_stream(
791            response_stream.rx,
792            engine_ctx,
793            queue_start,
794            tx_start,
795            inflight_guard,
796            payload_codec,
797        ))
798    }
799
800    /// Register the requested halves of a data-plane stream with the response
801    /// transport. Returns `(send_stream, recv_stream)` mirroring the
802    /// `PendingConnections::into_parts` shape — either side is `None` when not
803    /// requested. Asserts post-registration that the transport produced
804    /// exactly the requested shape; a mismatch is a transport-layer bug, not
805    /// a runtime error path.
806    async fn register_streams(
807        &self,
808        engine_ctx: Arc<dyn crate::engine::AsyncEngineContext>,
809        enable_request_stream: bool,
810        enable_response_stream: bool,
811    ) -> Result<
812        (
813            Option<RegisteredStream<StreamSender>>,
814            Option<RegisteredStream<StreamReceiver>>,
815        ),
816        Error,
817    > {
818        let options = StreamOptions::builder()
819            .context(engine_ctx)
820            .enable_request_stream(enable_request_stream)
821            .enable_response_stream(enable_response_stream)
822            .build()?;
823
824        let pending: PendingConnections = self.resp_transport.register(options).await;
825        let (send_stream, recv_stream) = pending.into_parts();
826
827        // Transport-layer invariant: the data plane produces exactly the halves
828        // we requested. A mismatch is a bug in the transport, not a runtime
829        // error path, so assert only in debug builds rather than panicking prod.
830        debug_assert_eq!(
831            send_stream.is_some(),
832            enable_request_stream,
833            "data-plane registration: request-stream presence does not match request"
834        );
835        debug_assert_eq!(
836            recv_stream.is_some(),
837            enable_response_stream,
838            "data-plane registration: response-stream presence does not match request"
839        );
840
841        Ok((send_stream, recv_stream))
842    }
843
844    /// Build standard request-plane headers (trace propagation, request-id,
845    /// frontend send-timestamp) and write the encoded buffer through the
846    /// request-plane client.
847    ///
848    /// Returns the request-plane ACK bytes (empty `TcpResponseMessage` on the
849    /// success path; a rejection-marker payload when the worker rejects the
850    /// request — see [`detect_worker_rejection_response`]).
851    async fn dispatch_buffer(
852        &self,
853        address: String,
854        buffer: bytes::Bytes,
855        request_id: &str,
856    ) -> Result<bytes::Bytes, Error> {
857        let mut headers = std::collections::HashMap::new();
858        inject_trace_headers_into_map(&mut headers);
859        headers.insert("request-id".to_string(), request_id.to_string());
860        let send_ts_ns = std::time::SystemTime::now()
861            .duration_since(std::time::UNIX_EPOCH)
862            .unwrap_or_default()
863            .as_nanos() as u64;
864        headers.insert("x-frontend-send-ts-ns".to_string(), send_ts_ns.to_string());
865
866        let _nvtx_send = dynamo_nvtx_range!("transport.tcp.send");
867        let ack = self
868            .req_client
869            .send_request(address, buffer, headers)
870            .await?;
871        drop(_nvtx_send);
872        Ok(ack)
873    }
874}
875
876/// Map a worker rejection ACK to the corresponding typed error. `None` for
877/// normal responses, including the empty "queued" ACK.
878fn detect_worker_rejection_response(res_bytes: &[u8]) -> Option<DynamoError> {
879    const OVERLOAD_PREFIX: &[u8] = b"Server overloaded:";
880    const UNAVAILABLE_PREFIX: &[u8] = b"Server unavailable:";
881
882    let error_type = if res_bytes.starts_with(OVERLOAD_PREFIX) {
883        // This ACK came from the one worker addressed by this dispatch. It says
884        // nothing about capacity elsewhere in the eligible pool, so preserve
885        // worker scope for migration instead of reporting pool exhaustion.
886        ErrorType::WorkerOverloaded
887    } else if res_bytes.starts_with(UNAVAILABLE_PREFIX) {
888        ErrorType::Unavailable
889    } else {
890        return None;
891    };
892
893    let msg = String::from_utf8_lossy(res_bytes).into_owned();
894    Some(
895        DynamoError::builder()
896            .error_type(error_type)
897            .message(msg)
898            .build(),
899    )
900}
901
902#[cfg(test)]
903mod rejection_detection_tests {
904    use super::*;
905
906    #[test]
907    fn overload_payload_maps_to_worker_overloaded() {
908        let err = detect_worker_rejection_response(b"Server overloaded: worker at capacity")
909            .expect("should detect overload");
910        assert_eq!(err.error_type(), ErrorType::WorkerOverloaded);
911    }
912
913    #[test]
914    fn empty_ack_is_not_overload() {
915        // The success-path ACK is empty; misreading it as overload breaks every request.
916        assert!(detect_worker_rejection_response(b"").is_none());
917        assert!(detect_worker_rejection_response(br#"{"data":"chunk"}"#).is_none());
918    }
919
920    #[test]
921    fn detected_overload_preserves_worker_scope() {
922        let err =
923            detect_worker_rejection_response(b"Server overloaded: test").expect("should detect");
924        let any_err: anyhow::Error = err.into();
925        assert!(crate::error::match_error_chain(
926            any_err.as_ref(),
927            &[ErrorType::WorkerOverloaded],
928            &[]
929        ));
930    }
931}
932
933#[async_trait::async_trait]
934impl<T, U> AsyncEngine<SingleIn<AddressedRequest<T>>, ManyOut<U>, Error> for AddressedPushRouter
935where
936    T: Data + Serialize,
937    U: Data + for<'de> Deserialize<'de> + MaybeError,
938{
939    async fn generate(&self, request: SingleIn<AddressedRequest<T>>) -> Result<ManyOut<U>, Error> {
940        let (addressed_request, context) = request.transfer(());
941        let (request, address, instance_info) = addressed_request.into_parts();
942
943        let first_response_guard = context
944            .get_optional::<FirstResponseGuard>(FIRST_RESPONSE_GUARD_CONTEXT_KEY)
945            .map_err(Error::msg)?;
946
947        if let Some(guard) = first_response_guard.and_then(|guard| guard.take()) {
948            let permit =
949                try_acquire_retained_dispatch_permit(&RETAINED_FIRST_RESPONSE_DISPATCH_PERMITS)?;
950            let router = self.clone();
951            let dispatch = async move {
952                router
953                    .dispatch_and_finalize::<T, U>(
954                        &context,
955                        address,
956                        instance_info.as_ref(),
957                        Some(&request),
958                        None,
959                    )
960                    .await
961            };
962            return dispatch_with_first_response_guard(dispatch, guard, permit).await;
963        }
964
965        self.dispatch_and_finalize::<T, U>(
966            &context,
967            address,
968            instance_info.as_ref(),
969            Some(&request),
970            None,
971        )
972        .await
973    }
974}
975
976/// Transport seam beneath `PushRouter`: given an already-selected worker (typed
977/// request + resolved address), dispatch the final hop and return a typed stream.
978/// Selection, occupancy, fault detection, and migration stay in `PushRouter`
979/// above the seam; only the transport below it changes. [`AddressedPushRouter`]
980/// (the request plane) is the default impl.
981///
982/// Impls MUST surface faults as top-level [`crate::error::ErrorType`] variants
983/// (`CannotConnect` / `Disconnected` / `ConnectionTimeout` / `ResponseTimeout` /
984/// `WorkerOverloaded` / `ResourceExhausted` / `Cancelled`), or
985/// `wrap_with_fault_detection`'s
986/// report-down / overload / migration won't fire.
987///
988/// The removal watcher behind `on_instance_removed` / `on_instance_added` is
989/// one-per-endpoint, so only one dispatch per endpoint receives them; an impl
990/// holding per-instance state must share it per endpoint (the default cleans up
991/// shared per-runtime state, so it is unaffected).
992#[async_trait::async_trait]
993pub trait StreamingDispatch<T, U>: Send + Sync
994where
995    T: Data + Serialize,
996    U: Data + for<'de> Deserialize<'de> + MaybeError,
997{
998    /// Unary final hop: typed request in, typed response stream out.
999    async fn generate(&self, request: SingleIn<AddressedRequest<T>>) -> Result<ManyOut<U>, Error>;
1000
1001    /// Bidirectional final hop (streaming input).
1002    async fn generate_bidirectional(
1003        &self,
1004        instance: Instance,
1005        address: String,
1006        input: ManyIn<T>,
1007    ) -> Result<ManyOut<U>, Error>;
1008
1009    /// Discovery-driven cleanup when an instance leaves — the request plane
1010    /// cancels its call-home streams; another transport frees per-instance state.
1011    async fn on_instance_removed(&self, _id: &EndpointInstanceId) {}
1012
1013    /// Discovery-driven notification when an instance (re)appears — the request
1014    /// plane clears its tombstone.
1015    async fn on_instance_added(&self, _id: &EndpointInstanceId) {}
1016}
1017
1018#[async_trait::async_trait]
1019impl<T, U> StreamingDispatch<T, U> for AddressedPushRouter
1020where
1021    T: Data + Serialize,
1022    U: Data + for<'de> Deserialize<'de> + MaybeError,
1023{
1024    async fn generate(&self, request: SingleIn<AddressedRequest<T>>) -> Result<ManyOut<U>, Error> {
1025        // Delegate to the existing `AsyncEngine` impl (still used directly by the
1026        // KV recovery worker-query path); behavior unchanged.
1027        <Self as AsyncEngine<SingleIn<AddressedRequest<T>>, ManyOut<U>, Error>>::generate(
1028            self, request,
1029        )
1030        .await
1031    }
1032
1033    async fn generate_bidirectional(
1034        &self,
1035        instance: Instance,
1036        address: String,
1037        input: ManyIn<T>,
1038    ) -> Result<ManyOut<U>, Error> {
1039        self.dispatch_bidirectional(instance, address, input).await
1040    }
1041
1042    async fn on_instance_removed(&self, id: &EndpointInstanceId) {
1043        let n = self.cancel_instance_streams(id).await;
1044        if n > 0 {
1045            tracing::warn!(
1046                namespace = %id.namespace,
1047                component = %id.component,
1048                endpoint = %id.endpoint,
1049                instance_id = id.instance_id,
1050                cancelled = n,
1051                "Cancelled pending response streams for removed instance (discovery-driven cleanup)"
1052            );
1053        }
1054    }
1055
1056    async fn on_instance_added(&self, id: &EndpointInstanceId) {
1057        self.clear_instance_tombstone(id).await;
1058    }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063    use super::{
1064        CONTROL_MESSAGE_MAX_BYTES, ConnectionInfo, FIRST_RESPONSE_GUARD_CONTEXT_KEY,
1065        FirstResponseGuard, RequestControlMessage, RequestPlanePayloadCodec, RequestType,
1066        ResponseType, TwoPartCodec, attach_first_response_guard, build_request_envelope,
1067        dispatch_with_first_response_guard, payload_codec_for_worker,
1068        propagate_first_response_guard, serialize_control_message,
1069        try_acquire_retained_dispatch_permit,
1070    };
1071    use crate::{
1072        component::{Instance, TransportType},
1073        error::{ErrorType, match_error_chain},
1074        pipeline::{AsyncEngineContextProvider, Context, ManyOut, ResponseStream},
1075        protocols::annotated::Annotated,
1076    };
1077    use serde::{Deserialize, Serialize};
1078    use std::{collections::BTreeMap, sync::Arc, time::Duration};
1079    use tokio::sync::{Semaphore, oneshot, oneshot::error::TryRecvError};
1080    use tokio_stream::{StreamExt, wrappers::ReceiverStream};
1081
1082    struct DropSignal(Option<oneshot::Sender<()>>);
1083
1084    impl Drop for DropSignal {
1085        fn drop(&mut self) {
1086            if let Some(sender) = self.0.take() {
1087                let _ = sender.send(());
1088            }
1089        }
1090    }
1091
1092    fn base_control_message(metadata: BTreeMap<String, String>) -> RequestControlMessage {
1093        RequestControlMessage {
1094            id: "request-123".to_string(),
1095            request_type: RequestType::SingleIn,
1096            response_type: ResponseType::ManyOut,
1097            payload_codec: RequestPlanePayloadCodec::Json,
1098            connection_info: ConnectionInfo {
1099                transport: "tcp".to_string(),
1100                info: "{}".to_string(),
1101            },
1102            metadata,
1103            frontend_send_ts_ns: None,
1104            request_stream_connection_info: None,
1105        }
1106    }
1107
1108    #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
1109    struct TestRequest {
1110        value: u64,
1111    }
1112
1113    #[test]
1114    fn legacy_worker_without_codec_metadata_receives_json() {
1115        let worker = Instance {
1116            component: "worker".to_string(),
1117            endpoint: "generate".to_string(),
1118            namespace: "default".to_string(),
1119            instance_id: 42,
1120            transport: TransportType::Nats("worker.generate".to_string()),
1121            device_type: None,
1122            request_plane_codec: None,
1123        };
1124        let payload_codec = payload_codec_for_worker(Some(&worker));
1125        assert_eq!(payload_codec, RequestPlanePayloadCodec::Json);
1126
1127        let request = TestRequest { value: 123 };
1128        let buffer = build_request_envelope(
1129            &Context::new(()),
1130            ConnectionInfo {
1131                transport: "tcp".to_string(),
1132                info: "{}".to_string(),
1133            },
1134            None,
1135            Some(&request),
1136            payload_codec,
1137        )
1138        .expect("legacy-worker request envelope should encode");
1139        let message = TwoPartCodec::default()
1140            .decode_message(buffer)
1141            .expect("request envelope should decode");
1142
1143        let control: RequestControlMessage = serde_json::from_slice(&message.header).unwrap();
1144        assert_eq!(control.payload_codec, RequestPlanePayloadCodec::Json);
1145        assert_eq!(
1146            serde_json::from_slice::<TestRequest>(&message.data).unwrap(),
1147            request
1148        );
1149    }
1150
1151    #[test]
1152    fn serialize_control_message_succeeds_under_limit() {
1153        let mut metadata = BTreeMap::new();
1154        metadata.insert("x-tiny-blob".to_string(), "alpha".to_string());
1155
1156        let ctrl = serialize_control_message(&base_control_message(metadata))
1157            .expect("control message should serialize under the limit");
1158        assert!(ctrl.len() <= CONTROL_MESSAGE_MAX_BYTES);
1159    }
1160
1161    #[test]
1162    fn serialize_control_message_errors_over_limit() {
1163        let mut metadata = BTreeMap::new();
1164        metadata.insert(
1165            "x-large-blob".to_string(),
1166            "x".repeat(CONTROL_MESSAGE_MAX_BYTES),
1167        );
1168
1169        let err = serialize_control_message(&base_control_message(metadata))
1170            .expect_err("oversized control message should fail")
1171            .to_string();
1172        assert!(err.contains("request control message too large"));
1173        assert!(err.contains(&CONTROL_MESSAGE_MAX_BYTES.to_string()));
1174    }
1175
1176    #[test]
1177    fn propagated_first_response_guard_is_taken_once() {
1178        let (guard_dropped_tx, mut guard_dropped_rx) = oneshot::channel();
1179        let mut source_context = Context::new(());
1180        attach_first_response_guard(
1181            &mut source_context,
1182            Arc::new(DropSignal(Some(guard_dropped_tx))),
1183        );
1184        let mut derived_context = Context::new(());
1185        propagate_first_response_guard(&source_context, &mut derived_context).unwrap();
1186
1187        let source_guard = source_context
1188            .get::<FirstResponseGuard>(FIRST_RESPONSE_GUARD_CONTEXT_KEY)
1189            .unwrap();
1190        let derived_guard = derived_context
1191            .get::<FirstResponseGuard>(FIRST_RESPONSE_GUARD_CONTEXT_KEY)
1192            .unwrap();
1193        let retained = derived_guard.take().expect("derived context should win");
1194        assert!(source_guard.take().is_none());
1195
1196        drop(retained);
1197        assert_eq!(guard_dropped_rx.try_recv(), Ok(()));
1198    }
1199
1200    #[tokio::test]
1201    async fn first_response_guard_outlives_cancelled_dispatch_waiter() {
1202        let (raw_tx, raw_rx) = tokio::sync::mpsc::channel(1);
1203        let response_context = Context::new(()).context();
1204        let (dispatch_started_tx, dispatch_started_rx) = oneshot::channel();
1205        let (release_dispatch_tx, release_dispatch_rx) = oneshot::channel();
1206        let (guard_dropped_tx, mut guard_dropped_rx) = oneshot::channel();
1207        let mut source_context = Context::new(());
1208        attach_first_response_guard(
1209            &mut source_context,
1210            Arc::new(DropSignal(Some(guard_dropped_tx))),
1211        );
1212        let mut derived_context = Context::new(());
1213        propagate_first_response_guard(&source_context, &mut derived_context).unwrap();
1214        let guard = derived_context
1215            .get::<FirstResponseGuard>(FIRST_RESPONSE_GUARD_CONTEXT_KEY)
1216            .unwrap()
1217            .take()
1218            .unwrap();
1219        drop(source_context);
1220        drop(derived_context);
1221        let permits = Arc::new(Semaphore::new(1));
1222        let permit = try_acquire_retained_dispatch_permit(&permits).unwrap();
1223
1224        let waiter = tokio::spawn(dispatch_with_first_response_guard(
1225            async move {
1226                let _ = dispatch_started_tx.send(());
1227                let _ = release_dispatch_rx.await;
1228                let response: ManyOut<Annotated<u64>> =
1229                    ResponseStream::new(Box::pin(ReceiverStream::new(raw_rx)), response_context);
1230                Ok(response)
1231            },
1232            guard,
1233            permit,
1234        ));
1235
1236        dispatch_started_rx.await.unwrap();
1237        waiter.abort();
1238        let _ = waiter.await;
1239        assert_eq!(guard_dropped_rx.try_recv(), Err(TryRecvError::Empty));
1240        let error = try_acquire_retained_dispatch_permit(&permits).unwrap_err();
1241        assert!(match_error_chain(
1242            error.as_ref(),
1243            &[ErrorType::ResourceExhausted],
1244            &[],
1245        ));
1246
1247        release_dispatch_tx.send(()).unwrap();
1248        raw_tx.send(Annotated::from_data(1_u64)).await.unwrap();
1249        tokio::time::timeout(Duration::from_secs(1), guard_dropped_rx)
1250            .await
1251            .expect("source guard was not released after the first worker response")
1252            .unwrap();
1253        let released_permit = tokio::time::timeout(Duration::from_secs(1), async {
1254            loop {
1255                if let Ok(permit) = try_acquire_retained_dispatch_permit(&permits) {
1256                    break permit;
1257                }
1258                tokio::task::yield_now().await;
1259            }
1260        })
1261        .await
1262        .expect("retained dispatch permit was not released");
1263        drop(released_permit);
1264    }
1265
1266    #[tokio::test]
1267    async fn dropping_handed_off_tail_closes_upstream() {
1268        let (raw_tx, raw_rx) = tokio::sync::mpsc::channel(1);
1269        let response_context = Context::new(()).context();
1270        let (guard_dropped_tx, guard_dropped_rx) = oneshot::channel();
1271        let permits = Arc::new(Semaphore::new(1));
1272        let permit = try_acquire_retained_dispatch_permit(&permits).unwrap();
1273        let mut response = dispatch_with_first_response_guard(
1274            async move {
1275                let response: ManyOut<Annotated<u64>> =
1276                    ResponseStream::new(Box::pin(ReceiverStream::new(raw_rx)), response_context);
1277                Ok(response)
1278            },
1279            Arc::new(DropSignal(Some(guard_dropped_tx))),
1280            permit,
1281        )
1282        .await
1283        .unwrap();
1284
1285        raw_tx.send(Annotated::from_data(1_u64)).await.unwrap();
1286        assert_eq!(response.next().await.unwrap().data, Some(1));
1287        tokio::time::timeout(Duration::from_secs(1), guard_dropped_rx)
1288            .await
1289            .expect("source guard was not released after the first worker response")
1290            .unwrap();
1291
1292        drop(response);
1293        tokio::time::timeout(Duration::from_secs(1), raw_tx.closed())
1294            .await
1295            .expect("dropping the caller stream did not close the upstream tail");
1296    }
1297}