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, ErrorClass, ErrorType};
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::ResponsePlaneMode;
30use crate::pipeline::network::ResponseService;
31use crate::pipeline::network::ResponseType;
32use crate::pipeline::network::StreamOptions;
33use crate::pipeline::network::StreamPrologueError;
34use crate::pipeline::network::StreamProvider;
35use crate::pipeline::network::StreamReceiver;
36use crate::pipeline::network::StreamSender;
37use crate::pipeline::network::TwoPartCodec;
38use crate::pipeline::network::codec::TwoPartMessage;
39use crate::pipeline::network::quic_response;
40use crate::pipeline::network::tcp;
41use crate::pipeline::{ManyIn, ManyOut, PipelineError, ResponseStream, SingleIn};
42use crate::protocols::maybe_error::MaybeError;
43use crate::traits::DistributedRuntimeProvider;
44
45use anyhow::{Error, Result};
46use futures::stream::Stream;
47use parking_lot::Mutex;
48use std::pin::Pin;
49use std::task::{Context, Poll};
50use tokio::sync::{OwnedSemaphorePermit, Semaphore};
51use tokio_stream::{StreamExt, StreamNotifyClose, wrappers::ReceiverStream};
52use tracing::Instrument;
53
54/// Error reasons that must never be attached as the cause of a pre-stream
55/// failure, because migration classification walks the whole cause chain.
56///
57/// This must match `MIGRATION_BLOCKING_REASONS` in `lib/llm/src/migration.rs`.
58pub(crate) const MIGRATION_SENSITIVE_ERROR_REASONS: &[&str] = &[
59    "request.cancelled",
60    "request.deadline_exceeded",
61    "backend.cancelled",
62    "capacity.exhausted",
63    "capacity.pool_exhausted",
64];
65
66/// Whether any link of `err`'s chain carries a migration-sensitive reason.
67fn is_migration_sensitive(err: &DynamoError) -> bool {
68    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
69    while let Some(source) = current {
70        if let Some(error) = source.downcast_ref::<DynamoError>()
71            && MIGRATION_SENSITIVE_ERROR_REASONS.contains(&error.reason().as_str())
72        {
73            return true;
74        }
75        current = source.source();
76    }
77    false
78}
79
80/// Build the error returned when the worker fails before any response bytes.
81///
82/// A typed [`ErrorClass::InvalidRequest`] refusal from the worker's prologue is
83/// returned as the worker's own error. The worker is reachable and refused the
84/// request itself, so a [`ErrorType::CannotConnect`] ancestor would make fault
85/// detection quarantine a healthy worker and make migration resend the request
86/// to every other worker, which would refuse it too.
87///
88/// Every other failure keeps the outer [`ErrorType::CannotConnect`], so retry
89/// classification of the outer error is unchanged. A typed error from the
90/// worker's prologue is attached as the cause, which consumers reach with the
91/// semantic migration classifier.
92///
93/// Because that walk covers the whole chain, an attached cause is as visible as
94/// the outer type, so causes typed one of [`MIGRATION_SENSITIVE_ERROR_REASONS`]
95/// are withheld rather than attached. The worker's text stays in the message
96/// either way; only the machine-readable type is withheld.
97pub(crate) fn pre_stream_failure_error(error: StreamPrologueError) -> DynamoError {
98    let builder = DynamoError::builder()
99        .error_type(ErrorType::CannotConnect)
100        .message(format!(
101            "Worker generate() failed before response stream: {error}"
102        ));
103
104    match error.typed_error {
105        Some(typed)
106            if !is_migration_sensitive(&typed) && typed.class() == ErrorClass::InvalidRequest =>
107        {
108            typed
109        }
110        Some(typed) if !is_migration_sensitive(&typed) => builder.cause(typed).build(),
111        _ => builder.build(),
112    }
113}
114
115/// White-box handles for the cross-crate tests in `dynamo-llm`. Gated so a
116/// normal build of this crate exposes no public API for them.
117#[cfg(any(test, feature = "testing"))]
118#[doc(hidden)]
119pub mod testing {
120    use super::{DynamoError, StreamPrologueError};
121
122    /// The semantic reason set is pinned by a cross-crate test in `lib/llm/src/migration.rs`.
123    pub fn migration_sensitive_error_reasons() -> &'static [&'static str] {
124        super::MIGRATION_SENSITIVE_ERROR_REASONS
125    }
126
127    pub fn pre_stream_failure_error(error: StreamPrologueError) -> DynamoError {
128        super::pre_stream_failure_error(error)
129    }
130}
131
132const FIRST_RESPONSE_GUARD_CONTEXT_KEY: &str = "dynamo.request_plane.first_response_guard";
133// A timeout cannot safely release registered memory while a remote read may
134// still be active. Bound the detached pre-first-response phase process-wide.
135const MAX_RETAINED_FIRST_RESPONSE_DISPATCHES: usize = 1024;
136static RETAINED_FIRST_RESPONSE_DISPATCH_PERMITS: LazyLock<Arc<Semaphore>> =
137    LazyLock::new(|| Arc::new(Semaphore::new(MAX_RETAINED_FIRST_RESPONSE_DISPATCHES)));
138
139#[derive(Clone)]
140struct FirstResponseGuard {
141    guard: Arc<Mutex<Option<EngineContextGuard>>>,
142}
143
144impl FirstResponseGuard {
145    fn new(guard: EngineContextGuard) -> Self {
146        Self {
147            guard: Arc::new(Mutex::new(Some(guard))),
148        }
149    }
150
151    fn take(&self) -> Option<EngineContextGuard> {
152        self.guard.lock().take()
153    }
154}
155
156/// Keep a frontend-owned resource alive until the addressed worker produces
157/// its first response item or closes the response stream.
158pub fn attach_first_response_guard<T: Data>(
159    context: &mut context::Context<T>,
160    guard: EngineContextGuard,
161) {
162    context.insert(
163        FIRST_RESPONSE_GUARD_CONTEXT_KEY,
164        FirstResponseGuard::new(guard),
165    );
166}
167
168/// Share a take-once first-response guard with a derived request context.
169pub fn propagate_first_response_guard<S: Data, T: Data>(
170    source: &context::Context<S>,
171    target: &mut context::Context<T>,
172) -> Result<(), Error> {
173    if let Some(guard) = source
174        .get_optional::<FirstResponseGuard>(FIRST_RESPONSE_GUARD_CONTEXT_KEY)
175        .map_err(Error::msg)?
176    {
177        target.insert(FIRST_RESPONSE_GUARD_CONTEXT_KEY, guard.as_ref().clone());
178    }
179    Ok(())
180}
181
182fn try_acquire_retained_dispatch_permit(
183    permits: &Arc<Semaphore>,
184) -> Result<OwnedSemaphorePermit, Error> {
185    permits.clone().try_acquire_owned().map_err(|_| {
186        DynamoError::builder()
187            .error_type(ErrorType::ResourceExhausted)
188            .message("retained request dispatch limit reached")
189            .build()
190            .into()
191    })
192}
193
194// Only dispatch and the first response are detached from the caller. The tail
195// is handed back so normal stream polling and cancellation stay on the caller.
196async fn dispatch_with_first_response_guard<F, U>(
197    dispatch: F,
198    guard: EngineContextGuard,
199    permit: OwnedSemaphorePermit,
200) -> Result<ManyOut<U>, Error>
201where
202    F: Future<Output = Result<ManyOut<U>, Error>> + Send + 'static,
203    U: Data + MaybeError,
204{
205    let (dispatch_tx, dispatch_rx) = tokio::sync::oneshot::channel();
206
207    tokio::spawn(
208        async move {
209            let mut response = match dispatch.await {
210                Ok(response) => response,
211                Err(error) => {
212                    let _ = dispatch_tx.send(Err(error));
213                    return;
214                }
215            };
216
217            let response_context = response.context();
218            let (first_tx, first_rx) = tokio::sync::oneshot::channel::<(Option<U>, ManyOut<U>)>();
219            let stream = async_stream::stream! {
220                match first_rx.await {
221                    Ok((first, mut tail)) => {
222                        if let Some(first) = first {
223                            yield first;
224                        }
225                        while let Some(item) = tail.next().await {
226                            yield item;
227                        }
228                    }
229                    Err(_) => {
230                        yield U::from_err(DynamoError::msg(
231                            "retained request dispatch ended before first response handoff",
232                        ));
233                    }
234                }
235            };
236            let handoff: ManyOut<U> = ResponseStream::new(Box::pin(stream), response_context);
237            let _ = dispatch_tx.send(Ok(handoff));
238
239            let first = response.next().await;
240            drop(guard);
241            drop(permit);
242            let _ = first_tx.send((first, response));
243        }
244        .in_current_span(),
245    );
246
247    dispatch_rx
248        .await
249        .map_err(|_| anyhow::anyhow!("retained request dispatch ended before setup completed"))?
250}
251
252/// Stream transformation helper that:
253/// - decodes a response byte stream from network into the fully-shaped `ManyOut<U>`
254/// - emits TTFT and transport-roundtrip metrics on first response
255/// - hands off the `InflightGuard` to a stream-lifetime `InflightDecStream` so
256///   the inflight gauge stays accurate for the whole response lifetime.
257fn decode_response_stream<U>(
258    response_rx: tokio::sync::mpsc::Receiver<bytes::Bytes>,
259    engine_ctx: Arc<dyn crate::engine::AsyncEngineContext>,
260    queue_start: Instant,
261    tx_start: Instant,
262    inflight_guard: InflightGuard,
263    payload_codec: RequestPlanePayloadCodec,
264) -> ManyOut<U>
265where
266    U: Data + for<'de> Deserialize<'de> + MaybeError,
267{
268    let engine_ctx_for_stream = engine_ctx.clone();
269    let mut is_complete_final = false;
270    let mut first_response = true;
271    let stream = StreamNotifyClose::new(ReceiverStream::new(response_rx)).filter_map(move |res| {
272        if let Some(res_bytes) = res {
273            if first_response {
274                first_response = false;
275                REQUEST_PLANE_ROUNDTRIP_TTFT_SECONDS.observe(tx_start.elapsed().as_secs_f64());
276                STAGE_DURATION_SECONDS
277                    .with_label_values(&["transport_roundtrip"])
278                    .observe(queue_start.elapsed().as_secs_f64());
279            }
280            if is_complete_final {
281                let err = DynamoError::msg(
282                    "Response received after generation ended - this should never happen",
283                );
284                return Some(U::from_err(err));
285            }
286            match payload_codec.decode::<NetworkStreamWrapper<U>>(&res_bytes) {
287                Ok(item) => {
288                    is_complete_final = item.complete_final;
289                    if let Some(data) = item.data {
290                        Some(data)
291                    } else if is_complete_final {
292                        None
293                    } else {
294                        let err =
295                            DynamoError::msg("Empty response received - this should never happen");
296                        Some(U::from_err(err))
297                    }
298                }
299                Err(err) => {
300                    let response_bytes_len = res_bytes.len();
301                    tracing::warn!(
302                        %err,
303                        codec = payload_codec.name(),
304                        response_bytes_len,
305                        "failed deserializing request-plane response"
306                    );
307                    Some(U::from_err(DynamoError::msg(err.to_string())))
308                }
309            }
310        } else if is_complete_final {
311            None
312        } else if engine_ctx_for_stream.is_stopped() {
313            tracing::debug!("Request cancelled and then trying to read a response");
314            None
315        } else {
316            let err = DynamoError::builder()
317                .error_type(ErrorType::Disconnected)
318                .message("Stream ended before generation completed")
319                .build();
320            tracing::debug!("{err}");
321            Some(U::from_err(err))
322        }
323    });
324
325    inflight_guard.disarm();
326    let stream = InflightDecStream { inner: stream };
327    ResponseStream::new(Box::pin(stream), engine_ctx)
328}
329
330const CONTROL_MESSAGE_MAX_BYTES: usize = 128 * 1024;
331
332fn serialize_control_message(control_message: &RequestControlMessage) -> Result<Vec<u8>, Error> {
333    let ctrl = serde_json::to_vec(control_message)?;
334    if ctrl.len() > CONTROL_MESSAGE_MAX_BYTES {
335        return Err(PipelineError::Generic(format!(
336            "request control message too large: {} bytes exceeds limit {}",
337            ctrl.len(),
338            CONTROL_MESSAGE_MAX_BYTES
339        ))
340        .into());
341    }
342    Ok(ctrl)
343}
344
345/// Build the request control message, and serialize for transfer.
346///
347/// `request` provides the optional unary request payload. Should set for
348/// SingleIn generation.
349/// `send_conn_info` provides the connection info for the request stream.
350/// Should set for ManyIn generation.
351fn build_request_envelope<T>(
352    context: &context::Context<()>,
353    recv_conn_info: ConnectionInfo,
354    send_conn_info: Option<ConnectionInfo>,
355    request: Option<&T>,
356    payload_codec: RequestPlanePayloadCodec,
357) -> Result<bytes::Bytes, Error>
358where
359    T: serde::Serialize,
360{
361    let request_id = context.id();
362    let request_type = if send_conn_info.is_some() {
363        RequestType::ManyIn
364    } else {
365        RequestType::SingleIn
366    };
367    let control_message = RequestControlMessage {
368        id: request_id.to_string(),
369        request_type,
370        response_type: ResponseType::ManyOut,
371        payload_codec,
372        connection_info: recv_conn_info,
373        metadata: context.metadata().clone(),
374        frontend_send_ts_ns: None,
375        request_stream_connection_info: send_conn_info,
376    };
377
378    let ctrl = serialize_control_message(&control_message)?;
379    let data: Option<Vec<u8>> = match request {
380        Some(req) => Some(payload_codec.encode(req)?),
381        None => None,
382    };
383
384    let msg = match data {
385        Some(d) => {
386            tracing::trace!(
387                request_id,
388                "packaging two-part message; ctrl: {} bytes, data: {} bytes",
389                ctrl.len(),
390                d.len(),
391            );
392            TwoPartMessage::from_parts(ctrl.into(), d.into())
393        }
394        None => {
395            tracing::trace!(
396                request_id,
397                "packaging bidirectional header-only envelope; ctrl: {} bytes",
398                ctrl.len(),
399            );
400            TwoPartMessage::from_header(ctrl.into())
401        }
402    };
403
404    let codec = TwoPartCodec::default();
405    let buffer = codec.encode_message(msg)?;
406    Ok(buffer)
407}
408
409fn payload_codec_for_worker(instance: Option<&Instance>) -> RequestPlanePayloadCodec {
410    instance
411        .and_then(|instance| instance.request_plane_codec)
412        .unwrap_or(RequestPlanePayloadCodec::Json)
413}
414
415/// Await the network request-stream dial-in (if `request_stream_provider` is `Some`)
416/// and spawn a detached task that forwards every item from `input_stream` onto
417/// the request stream. Returns once the forwarder is spawned; `Err` if request-stream
418/// dial-in fails.
419async fn spawn_request_stream_forwarder<T>(
420    request_stream_provider: Option<StreamProvider<StreamSender>>,
421    mut input_stream: crate::engine::DataStream<T>,
422    engine_ctx: Arc<dyn crate::engine::AsyncEngineContext>,
423    payload_codec: RequestPlanePayloadCodec,
424) -> Result<(), Error>
425where
426    T: serde::Serialize + Send + 'static,
427{
428    let Some(provider) = request_stream_provider else {
429        return Ok(());
430    };
431
432    let request_sender = match provider.await {
433        Ok(Ok(sender)) => sender,
434        Ok(Err(e)) => {
435            return Err(anyhow::anyhow!(
436                DynamoError::builder()
437                    .error_type(ErrorType::CannotConnect)
438                    .message(format!("Worker dial-in failed for request stream: {e}"))
439                    .build()
440            ));
441        }
442        Err(_) => {
443            return Err(anyhow::anyhow!(
444                DynamoError::builder()
445                    .error_type(ErrorType::Disconnected)
446                    .message("Worker disconnected before request stream was established")
447                    .build()
448            ));
449        }
450    };
451
452    // The task exits on stream end, context kill/stop, send error (worker
453    // dropped its receiver), or local serialize failure. On any exit
454    // `request_sender` drops and triggers transport shutdown (see server.rs for details)
455    // which closes the upstream mpsc, triggering the server-side handler to emit
456    // `Sentinel`, which signals the worker's reader to end cleanly.
457    tokio::spawn(async move {
458        loop {
459            let item = tokio::select! {
460                biased;
461                _ = engine_ctx.killed() => break,
462                _ = engine_ctx.stopped() => break,
463                item = input_stream.next() => match item {
464                    Some(item) => item,
465                    None => break,
466                },
467            };
468            let bytes = match payload_codec.encode(&item) {
469                Ok(b) => b,
470                Err(e) => {
471                    // Stream-side framing failure: the engine sees a
472                    // partial input, so kill the context to abort both
473                    // directions consistently rather than silently
474                    // dropping frames.
475                    tracing::error!(
476                        error = %e,
477                        codec = payload_codec.name(),
478                        "failed to serialize bidirectional request frame; killing context"
479                    );
480                    engine_ctx.kill();
481                    break;
482                }
483            };
484            if request_sender.send(bytes.into()).await.is_err() {
485                tracing::debug!("worker request-stream receiver dropped; forwarder exiting");
486                break;
487            }
488        }
489    });
490
491    Ok(())
492}
493
494/// RAII guard that decrements REQUEST_PLANE_INFLIGHT on drop unless disarmed.
495/// Protects against gauge leaks when `?` operators cause early returns between
496/// the increment and `InflightDecStream` construction.
497struct InflightGuard {
498    armed: bool,
499}
500
501impl InflightGuard {
502    fn new() -> Self {
503        Self { armed: true }
504    }
505
506    /// Consume the guard without decrementing. Call this when `InflightDecStream`
507    /// takes over responsibility for the decrement.
508    fn disarm(mut self) {
509        self.armed = false;
510    }
511}
512
513impl Drop for InflightGuard {
514    fn drop(&mut self) {
515        if self.armed {
516            REQUEST_PLANE_INFLIGHT.dec();
517        }
518    }
519}
520
521/// Wrapper that decrements request-plane inflight gauge when the stream is dropped.
522struct InflightDecStream<S> {
523    inner: S,
524}
525
526impl<S, T> Stream for InflightDecStream<S>
527where
528    S: Stream<Item = T> + Unpin,
529{
530    type Item = T;
531
532    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
533        Pin::new(&mut self.inner).poll_next(cx)
534    }
535}
536
537impl<S> Drop for InflightDecStream<S> {
538    fn drop(&mut self) {
539        REQUEST_PLANE_INFLIGHT.dec();
540    }
541}
542
543fn tcp_subject_of(conn_info: &ConnectionInfo) -> Option<String> {
544    serde_json::from_str::<tcp::TcpStreamConnectionInfo>(&conn_info.info)
545        .ok()
546        .map(|ci| ci.subject)
547}
548
549pub struct AddressedRequest<T> {
550    request: T,
551    address: String,
552    /// Carries endpoint name + instance_id so cancellation is scoped to the
553    /// exact (endpoint, instance) pair, not all endpoints on the same runtime.
554    instance: Option<Instance>,
555}
556
557impl<T> AddressedRequest<T> {
558    pub fn new(request: T, address: String) -> Self {
559        Self {
560            request,
561            address,
562            instance: None,
563        }
564    }
565
566    pub fn with_instance(request: T, address: String, instance: Instance) -> Self {
567        Self {
568            request,
569            address,
570            instance: Some(instance),
571        }
572    }
573
574    pub fn for_instance(request: T, instance: Instance) -> Self {
575        let address = instance.transport.address().to_string();
576        Self::with_instance(request, address, instance)
577    }
578
579    /// `(request, address, instance)` — public so an external [`StreamingDispatch`]
580    /// impl can read the routed address + instance.
581    pub fn into_parts(self) -> (T, String, Option<Instance>) {
582        (self.request, self.address, self.instance)
583    }
584}
585
586#[derive(Clone)]
587pub struct AddressedPushRouter {
588    // Request transport (unified trait object - works with all transports)
589    req_client: Arc<dyn RequestPlaneClient>,
590
591    request_callbacks: Arc<tcp::server::TcpStreamServer>,
592    responses: ResponseServer,
593}
594
595#[derive(Clone)]
596enum ResponseServer {
597    Tcp(Arc<tcp::server::TcpStreamServer>),
598    Quic(Arc<quic_response::QuicResponseServer>),
599}
600
601impl AddressedPushRouter {
602    /// Create a new router with a request plane client
603    ///
604    /// This is the unified constructor that works with any transport type.
605    /// The client is provided as a trait object, hiding the specific implementation.
606    pub fn new(
607        req_client: Arc<dyn RequestPlaneClient>,
608        responses: Arc<tcp::server::TcpStreamServer>,
609    ) -> Result<Arc<Self>> {
610        Ok(Arc::new(Self {
611            req_client,
612            request_callbacks: responses.clone(),
613            responses: ResponseServer::Tcp(responses),
614        }))
615    }
616
617    fn new_quic(
618        req_client: Arc<dyn RequestPlaneClient>,
619        request_callbacks: Arc<tcp::server::TcpStreamServer>,
620        responses: Arc<quic_response::QuicResponseServer>,
621    ) -> Arc<Self> {
622        Arc::new(Self {
623            req_client,
624            request_callbacks,
625            responses: ResponseServer::Quic(responses),
626        })
627    }
628
629    pub async fn from_runtime_provider(
630        provider: &impl DistributedRuntimeProvider,
631    ) -> Result<Arc<Self>> {
632        let manager = provider.drt().network_manager();
633        let req_client = manager.create_client()?;
634        let request_callbacks = provider.drt().tcp_server().await?;
635
636        tracing::debug!(
637            transport = req_client.transport_name(),
638            "Creating AddressedPushRouter with request plane client"
639        );
640
641        match provider.drt().response_plane() {
642            ResponsePlaneMode::Tcp => Self::new(req_client, request_callbacks),
643            ResponsePlaneMode::Quic => {
644                let responses = provider.drt().quic_response_server().await?;
645                Ok(Self::new_quic(req_client, request_callbacks, responses))
646            }
647        }
648    }
649
650    /// Cancel stream registrations for an instance. The TCP response plane cancels
651    /// pending handshakes and lets established response streams drain; the QUIC
652    /// response plane also cancels active streams.
653    pub async fn cancel_instance_streams(&self, instance_id: &EndpointInstanceId) -> usize {
654        match &self.responses {
655            ResponseServer::Tcp(responses) => responses.cancel_instance_streams(instance_id).await,
656            ResponseServer::Quic(responses) => {
657                let response_count = responses.cancel_instance_streams(instance_id).await;
658                response_count
659                    + self
660                        .request_callbacks
661                        .cancel_instance_streams(instance_id)
662                        .await
663            }
664        }
665    }
666
667    /// Clear the tombstone after an instance reappears in discovery.
668    pub async fn clear_instance_tombstone(&self, instance_id: &EndpointInstanceId) {
669        match &self.responses {
670            ResponseServer::Tcp(responses) => {
671                responses.clear_instance_tombstone(instance_id).await;
672            }
673            ResponseServer::Quic(responses) => {
674                responses.clear_instance_tombstone(instance_id).await;
675                self.request_callbacks
676                    .clear_instance_tombstone(instance_id)
677                    .await;
678            }
679        }
680    }
681
682    /// Bidirectional generation. Note that it doesn't implement the AsyncEngine trait directly
683    /// because there is no trivial way to wrap (instance and address) into ManyIn style.
684    /// May wrap as `SingleIn<AddressedStreamRequest<T>>` and unwrap here but really just syntax
685    /// sugar, so we just do it inline here. Will consider only if we do want to call this from
686    /// typed erased AsyncEngine impls.
687    pub async fn dispatch_bidirectional<T, U>(
688        &self,
689        instance: Instance,
690        address: String,
691        input: ManyIn<T>,
692    ) -> Result<ManyOut<U>, Error>
693    where
694        T: Data + Serialize,
695        U: Data + for<'de> Deserialize<'de> + MaybeError,
696    {
697        let (request_stream, context) = input.into_parts();
698        let input_stream = request_stream.take().ok_or_else(|| {
699            anyhow::anyhow!("RequestStream::take called twice on bidirectional dispatch input")
700        })?;
701
702        self.dispatch_and_finalize::<T, U>(
703            &context,
704            address,
705            Some(&instance),
706            None,
707            Some(input_stream),
708            false,
709        )
710        .await
711    }
712
713    /// Shared dispatch core for both unary and bidirectional requests. Wire
714    /// shape is inferred from the inputs:
715    ///   - `input_stream = Some(_)` + `request = None` → bidirectional,
716    ///     header-only envelope. The worker opens the optional TCP request
717    ///     callback and sends responses over QUIC.
718    ///   - `input_stream = None` + `request = Some(_)` → unary, two-part
719    ///     `[ctrl, data]` envelope. The payload travels in the data part.
720    async fn dispatch_and_finalize<T, U>(
721        &self,
722        context: &context::Context<()>,
723        address: String,
724        instance: Option<&Instance>,
725        request: Option<&T>,
726        input_stream: Option<crate::engine::DataStream<T>>,
727        defer_cancellation_until_prologue: bool,
728    ) -> Result<ManyOut<U>, Error>
729    where
730        T: Data + Serialize,
731        U: Data + for<'de> Deserialize<'de> + MaybeError,
732    {
733        let engine_ctx = context.context();
734
735        let queue_start = Instant::now();
736        REQUEST_PLANE_INFLIGHT.inc();
737        let inflight_guard = InflightGuard::new();
738
739        let enable_request_stream = input_stream.is_some();
740        let payload_codec = payload_codec_for_worker(instance);
741
742        // Keep registration cleanup armed through dispatch errors. The response
743        // registration also stays armed while waiting for the response prologue;
744        // the request-stream registration is handed off to its forwarder below.
745        let (send_registered, recv_registered) = self
746            .register_streams(
747                engine_ctx.clone(),
748                enable_request_stream,
749                defer_cancellation_until_prologue,
750            )
751            .await?;
752
753        // Tombstone check: if discovery already removed the worker, fail fast
754        // with a migratable error rather than writing to the request plane.
755        // Dropping the held registrations on this return runs their cleanup.
756        let response_registration = recv_registered.registration_id();
757        let response_subject = tcp_subject_of(&recv_registered.connection_info);
758        let send_subject = send_registered
759            .as_ref()
760            .and_then(|r| tcp_subject_of(&r.connection_info));
761        if let Some(inst) = instance {
762            let instance_id = inst.endpoint_instance_id();
763            let associated = match &self.responses {
764                ResponseServer::Tcp(responses) => match response_subject.as_deref() {
765                    Some(subject) => {
766                        responses
767                            .associate_instance(subject, send_subject.as_deref(), &instance_id)
768                            .await
769                    }
770                    None => false,
771                },
772                ResponseServer::Quic(responses) => {
773                    let response_ok = match response_registration {
774                        Some(registration_id) => {
775                            responses
776                                .associate_instance(registration_id, &instance_id)
777                                .await
778                        }
779                        None => false,
780                    };
781                    let request_ok = match send_subject.as_deref() {
782                        Some(subject) => {
783                            self.request_callbacks
784                                .associate_request_instance(subject, &instance_id)
785                                .await
786                        }
787                        None => true,
788                    };
789                    if !request_ok && let Some(registration_id) = response_registration {
790                        responses.cancel_response(registration_id).await;
791                    }
792                    response_ok && request_ok
793                }
794            };
795            if !associated {
796                return Err(anyhow::anyhow!(
797                    DynamoError::builder()
798                        .error_type(ErrorType::Disconnected)
799                        .message(
800                            "Worker removed before request could be sent (tombstoned instance)"
801                        )
802                        .build()
803                ));
804            }
805        }
806
807        let buffer = build_request_envelope(
808            context,
809            recv_registered.connection_info.clone(),
810            send_registered.as_ref().map(|r| r.connection_info.clone()),
811            request,
812            payload_codec,
813        )?;
814        REQUEST_PLANE_QUEUE_SECONDS.observe(queue_start.elapsed().as_secs_f64());
815
816        let tx_start = Instant::now();
817        let request_plane_response = self.dispatch_buffer(address, buffer, context.id()).await?;
818        REQUEST_PLANE_SEND_SECONDS.observe(tx_start.elapsed().as_secs_f64());
819
820        // A worker rejection surfaces on the request-plane ACK, not the response
821        // stream. Short-circuit before waiting on a response-plane connection the
822        // worker will never open; returning early drops `recv_registered` and
823        // `inflight_guard` (their Drop cleans up).
824        if let Some(err) = detect_worker_rejection_response(&request_plane_response) {
825            tracing::warn!(
826                request_id = context.id(),
827                worker_response = %err.to_string(),
828                "Request rejected by worker"
829            );
830            return Err(err.into());
831        }
832
833        // Spawn the forwarder before awaiting the response prologue so request
834        // frames pre-load into the worker's input buffer while the engine
835        // initialises in parallel. The response provider only resolves after
836        // `engine.generate()` returns; awaiting it second avoids stalling the
837        // request-side handshake on engine setup latency.
838        if let Some(stream) = input_stream {
839            let request_stream_provider = send_registered.map(|r| {
840                let (_conn_info, provider) = r.into_parts();
841                provider
842            });
843            spawn_request_stream_forwarder(
844                request_stream_provider,
845                stream,
846                engine_ctx.clone(),
847                payload_codec,
848            )
849            .await?;
850        }
851
852        let _nvtx_wait = dynamo_nvtx_range!("transport.response.wait_backend");
853        tracing::trace!(request_id = context.id(), "awaiting transport handshake");
854
855        // RecvError → migratable Disconnected (watcher cancelled the subject
856        // or the worker died before establishing the response stream).
857        let response_stream = match recv_registered.wait().await {
858            Ok(Ok(stream)) => stream,
859            Err(_) | Ok(Err(_)) if engine_ctx.is_stopped() || engine_ctx.is_killed() => {
860                return Ok(ResponseStream::new(
861                    Box::pin(futures::stream::empty()),
862                    engine_ctx,
863                ));
864            }
865            Ok(Err(e)) => {
866                return Err(anyhow::anyhow!(pre_stream_failure_error(e)));
867            }
868            Err(_recv_err) => {
869                // oneshot dropped: either the discovery watcher cancelled
870                // this subject or the worker died mid-handshake.
871                return Err(anyhow::anyhow!(
872                    DynamoError::builder()
873                        .error_type(ErrorType::Disconnected)
874                        .message("Worker disconnected before response stream was established")
875                        .build()
876                ));
877            }
878        };
879        drop(_nvtx_wait);
880
881        Ok(decode_response_stream(
882            response_stream.rx,
883            engine_ctx,
884            queue_start,
885            tx_start,
886            inflight_guard,
887            payload_codec,
888        ))
889    }
890
891    /// Register the optional TCP request callback and selected response stream.
892    async fn register_streams(
893        &self,
894        engine_ctx: Arc<dyn crate::engine::AsyncEngineContext>,
895        enable_request_stream: bool,
896        defer_cancellation_until_prologue: bool,
897    ) -> Result<(
898        Option<RegisteredStream<StreamSender>>,
899        RegisteredStream<StreamReceiver>,
900    )> {
901        let (send_stream, recv_stream) = match &self.responses {
902            ResponseServer::Tcp(responses) => {
903                let options = StreamOptions::builder()
904                    .context(engine_ctx)
905                    .enable_request_stream(enable_request_stream)
906                    .enable_response_stream(true)
907                    .defer_cancellation_until_prologue(defer_cancellation_until_prologue)
908                    .build()?;
909                let pending: PendingConnections = responses.register(options).await;
910                pending.into_parts()
911            }
912            ResponseServer::Quic(responses) => {
913                let send_stream = if enable_request_stream {
914                    let options = StreamOptions::builder()
915                        .context(engine_ctx.clone())
916                        .enable_request_stream(true)
917                        .enable_response_stream(false)
918                        .build()?;
919                    let pending: PendingConnections =
920                        self.request_callbacks.register(options).await;
921                    pending.send_stream
922                } else {
923                    None
924                };
925                (send_stream, Some(responses.register_response(engine_ctx)))
926            }
927        };
928        let recv_stream = recv_stream.ok_or_else(|| {
929            anyhow::anyhow!("response stream registration missing despite response being enabled")
930        })?;
931
932        // Transport-layer invariant: the data plane produces exactly the halves
933        // we requested. A mismatch is a bug in the transport, not a runtime
934        // error path, so assert only in debug builds rather than panicking prod.
935        debug_assert_eq!(
936            send_stream.is_some(),
937            enable_request_stream,
938            "data-plane registration: request-stream presence does not match request"
939        );
940        Ok((send_stream, recv_stream))
941    }
942
943    /// Build standard request-plane headers (trace propagation, request-id,
944    /// frontend send-timestamp) and write the encoded buffer through the
945    /// request-plane client.
946    ///
947    /// Returns the request-plane ACK bytes (empty `TcpResponseMessage` on the
948    /// success path; a rejection-marker payload when the worker rejects the
949    /// request — see [`detect_worker_rejection_response`]).
950    async fn dispatch_buffer(
951        &self,
952        address: String,
953        buffer: bytes::Bytes,
954        request_id: &str,
955    ) -> Result<bytes::Bytes, Error> {
956        let mut headers = std::collections::HashMap::new();
957        inject_trace_headers_into_map(&mut headers);
958        headers.insert("request-id".to_string(), request_id.to_string());
959        let send_ts_ns = std::time::SystemTime::now()
960            .duration_since(std::time::UNIX_EPOCH)
961            .unwrap_or_default()
962            .as_nanos() as u64;
963        headers.insert("x-frontend-send-ts-ns".to_string(), send_ts_ns.to_string());
964
965        let _nvtx_send = dynamo_nvtx_range!("transport.tcp.send");
966        let ack = self
967            .req_client
968            .send_request(address, buffer, headers)
969            .await?;
970        drop(_nvtx_send);
971        Ok(ack)
972    }
973}
974
975/// Map a worker rejection ACK to the corresponding typed error. `None` for
976/// normal responses, including the empty "queued" ACK.
977fn detect_worker_rejection_response(res_bytes: &[u8]) -> Option<DynamoError> {
978    const OVERLOAD_PREFIX: &[u8] = b"Server overloaded:";
979    let unavailable_prefix = crate::pipeline::network::ACK_UNAVAILABLE_PREFIX.as_bytes();
980
981    let error_type = if res_bytes.starts_with(OVERLOAD_PREFIX) {
982        // This ACK came from the one worker addressed by this dispatch. It says
983        // nothing about capacity elsewhere in the eligible pool, so preserve
984        // worker scope for migration instead of reporting pool exhaustion.
985        ErrorType::WorkerOverloaded
986    } else if res_bytes.starts_with(unavailable_prefix) {
987        // Same scope: the addressed server is up but has no handler for this
988        // instance, or is closing its worker pool. Other instances may still
989        // serve the endpoint, so this stays migratable.
990        ErrorType::WorkerUnavailable
991    } else {
992        return None;
993    };
994
995    let msg = String::from_utf8_lossy(res_bytes).into_owned();
996    Some(
997        DynamoError::builder()
998            .error_type(error_type)
999            .message(msg)
1000            .build(),
1001    )
1002}
1003
1004#[cfg(test)]
1005mod rejection_detection_tests {
1006    use super::*;
1007
1008    #[test]
1009    fn overload_payload_maps_to_worker_overloaded() {
1010        let err = detect_worker_rejection_response(b"Server overloaded: worker at capacity")
1011            .expect("should detect overload");
1012        assert_eq!(err.error_type(), ErrorType::WorkerOverloaded);
1013    }
1014
1015    #[test]
1016    fn unavailable_payload_maps_to_worker_unavailable() {
1017        let err = detect_worker_rejection_response(b"Server unavailable: unknown endpoint x")
1018            .expect("should detect unavailable");
1019        assert_eq!(err.error_type(), ErrorType::WorkerUnavailable);
1020    }
1021
1022    #[test]
1023    fn empty_ack_is_not_overload() {
1024        // The success-path ACK is empty; misreading it as overload breaks every request.
1025        assert!(detect_worker_rejection_response(b"").is_none());
1026        assert!(detect_worker_rejection_response(br#"{"data":"chunk"}"#).is_none());
1027    }
1028
1029    #[test]
1030    fn detected_overload_preserves_worker_scope() {
1031        let err =
1032            detect_worker_rejection_response(b"Server overloaded: test").expect("should detect");
1033        let any_err: anyhow::Error = err.into();
1034        assert!(crate::error::match_error_chain(
1035            any_err.as_ref(),
1036            &[ErrorType::WorkerOverloaded],
1037            &[]
1038        ));
1039    }
1040}
1041
1042#[async_trait::async_trait]
1043impl<T, U> AsyncEngine<SingleIn<AddressedRequest<T>>, ManyOut<U>, Error> for AddressedPushRouter
1044where
1045    T: Data + Serialize,
1046    U: Data + for<'de> Deserialize<'de> + MaybeError,
1047{
1048    async fn generate(&self, request: SingleIn<AddressedRequest<T>>) -> Result<ManyOut<U>, Error> {
1049        let (addressed_request, context) = request.transfer(());
1050        let (request, address, instance_info) = addressed_request.into_parts();
1051
1052        let first_response_guard = context
1053            .get_optional::<FirstResponseGuard>(FIRST_RESPONSE_GUARD_CONTEXT_KEY)
1054            .map_err(Error::msg)?;
1055
1056        if let Some(guard) = first_response_guard.and_then(|guard| guard.take()) {
1057            let permit =
1058                try_acquire_retained_dispatch_permit(&RETAINED_FIRST_RESPONSE_DISPATCH_PERMITS)?;
1059            let router = self.clone();
1060            let dispatch = async move {
1061                router
1062                    .dispatch_and_finalize::<T, U>(
1063                        &context,
1064                        address,
1065                        instance_info.as_ref(),
1066                        Some(&request),
1067                        None,
1068                        true,
1069                    )
1070                    .await
1071            };
1072            return dispatch_with_first_response_guard(dispatch, guard, permit).await;
1073        }
1074
1075        self.dispatch_and_finalize::<T, U>(
1076            &context,
1077            address,
1078            instance_info.as_ref(),
1079            Some(&request),
1080            None,
1081            false,
1082        )
1083        .await
1084    }
1085}
1086
1087/// Transport seam beneath `PushRouter`: given an already-selected worker (typed
1088/// request + resolved address), dispatch the final hop and return a typed stream.
1089/// Selection, occupancy, fault detection, and migration stay in `PushRouter`
1090/// above the seam; only the transport below it changes. [`AddressedPushRouter`]
1091/// (the request plane) is the default impl.
1092///
1093/// Impls MUST surface faults as top-level [`crate::error::ErrorType`] variants
1094/// (`CannotConnect` / `Disconnected` / `ConnectionTimeout` / `ResponseTimeout` /
1095/// `WorkerOverloaded` / `ResourceExhausted` / `Cancelled`), or
1096/// `wrap_with_fault_detection`'s
1097/// report-down / overload / migration won't fire.
1098///
1099/// The removal watcher behind `on_instance_removed` / `on_instance_added` is
1100/// one-per-endpoint, so only one dispatch per endpoint receives them; an impl
1101/// holding per-instance state must share it per endpoint (the default cleans up
1102/// shared per-runtime state, so it is unaffected).
1103#[async_trait::async_trait]
1104pub trait StreamingDispatch<T, U>: Send + Sync
1105where
1106    T: Data + Serialize,
1107    U: Data + for<'de> Deserialize<'de> + MaybeError,
1108{
1109    /// Unary final hop: typed request in, typed response stream out.
1110    async fn generate(&self, request: SingleIn<AddressedRequest<T>>) -> Result<ManyOut<U>, Error>;
1111
1112    /// Bidirectional final hop (streaming input).
1113    async fn generate_bidirectional(
1114        &self,
1115        instance: Instance,
1116        address: String,
1117        input: ManyIn<T>,
1118    ) -> Result<ManyOut<U>, Error>;
1119
1120    /// Release per-instance transport state when discovery removes an instance.
1121    async fn on_instance_removed(&self, _id: &EndpointInstanceId) {}
1122
1123    /// Discovery-driven notification when an instance (re)appears — the request
1124    /// plane clears its tombstone.
1125    async fn on_instance_added(&self, _id: &EndpointInstanceId) {}
1126}
1127
1128#[async_trait::async_trait]
1129impl<T, U> StreamingDispatch<T, U> for AddressedPushRouter
1130where
1131    T: Data + Serialize,
1132    U: Data + for<'de> Deserialize<'de> + MaybeError,
1133{
1134    async fn generate(&self, request: SingleIn<AddressedRequest<T>>) -> Result<ManyOut<U>, Error> {
1135        // Delegate to the existing `AsyncEngine` impl (still used directly by the
1136        // KV recovery worker-query path); behavior unchanged.
1137        <Self as AsyncEngine<SingleIn<AddressedRequest<T>>, ManyOut<U>, Error>>::generate(
1138            self, request,
1139        )
1140        .await
1141    }
1142
1143    async fn generate_bidirectional(
1144        &self,
1145        instance: Instance,
1146        address: String,
1147        input: ManyIn<T>,
1148    ) -> Result<ManyOut<U>, Error> {
1149        self.dispatch_bidirectional(instance, address, input).await
1150    }
1151
1152    async fn on_instance_removed(&self, id: &EndpointInstanceId) {
1153        let n = self.cancel_instance_streams(id).await;
1154        if n > 0 {
1155            tracing::warn!(
1156                namespace = %id.namespace,
1157                component = %id.component,
1158                endpoint = %id.endpoint,
1159                instance_id = id.instance_id,
1160                cancelled = n,
1161                "Cancelled stream registrations for removed instance (discovery-driven cleanup)"
1162            );
1163        }
1164    }
1165
1166    async fn on_instance_added(&self, id: &EndpointInstanceId) {
1167        self.clear_instance_tombstone(id).await;
1168    }
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    use super::{
1174        AddressedPushRouter, AddressedRequest, CONTROL_MESSAGE_MAX_BYTES, ConnectionInfo,
1175        FIRST_RESPONSE_GUARD_CONTEXT_KEY, FirstResponseGuard, RequestControlMessage,
1176        RequestPlaneClient, RequestPlanePayloadCodec, RequestType, ResponseType, TwoPartCodec,
1177        attach_first_response_guard, build_request_envelope, dispatch_with_first_response_guard,
1178        payload_codec_for_worker, propagate_first_response_guard, serialize_control_message,
1179        try_acquire_retained_dispatch_permit,
1180    };
1181    use crate::{
1182        component::{Instance, TransportType},
1183        engine::AsyncEngine,
1184        error::{ErrorType, match_error_chain},
1185        pipeline::{
1186            AsyncEngineContextProvider, Context, ManyOut, ResponseStream,
1187            network::{
1188                egress::unified_client::Headers,
1189                tcp::{client::TcpClient, server::TcpStreamServer},
1190            },
1191        },
1192        protocols::annotated::Annotated,
1193    };
1194    use serde::{Deserialize, Serialize};
1195    use std::{collections::BTreeMap, sync::Arc, time::Duration};
1196    use tokio::sync::{Semaphore, oneshot, oneshot::error::TryRecvError};
1197    use tokio_stream::{StreamExt, wrappers::ReceiverStream};
1198
1199    struct DropSignal(Option<oneshot::Sender<()>>);
1200
1201    impl Drop for DropSignal {
1202        fn drop(&mut self) {
1203            if let Some(sender) = self.0.take() {
1204                let _ = sender.send(());
1205            }
1206        }
1207    }
1208
1209    fn base_control_message(metadata: BTreeMap<String, String>) -> RequestControlMessage {
1210        RequestControlMessage {
1211            id: "request-123".to_string(),
1212            request_type: RequestType::SingleIn,
1213            response_type: ResponseType::ManyOut,
1214            payload_codec: RequestPlanePayloadCodec::Json,
1215            connection_info: ConnectionInfo {
1216                transport: "tcp".to_string(),
1217                info: "{}".to_string(),
1218            },
1219            metadata,
1220            frontend_send_ts_ns: None,
1221            request_stream_connection_info: None,
1222        }
1223    }
1224
1225    #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
1226    struct TestRequest {
1227        value: u64,
1228    }
1229
1230    #[tokio::test]
1231    async fn quic_router_delivers_data_after_successful_wait() {
1232        if crate::test_utils::run_isolated(
1233            concat!(
1234                module_path!(),
1235                "::quic_router_delivers_data_after_successful_wait"
1236            ),
1237            &[],
1238        ) {
1239            return;
1240        }
1241
1242        use super::{
1243            AddressedPushRouter, AddressedRequest, RequestPlaneClient, quic_response, tcp,
1244        };
1245        use crate::pipeline::{AsyncEngine, SingleIn};
1246        use bytes::Bytes;
1247        use tokio::sync::mpsc;
1248        use tokio_util::sync::CancellationToken;
1249
1250        // Capture the request envelope, but exercise the real router and QUIC response plane.
1251        struct CaptureRequest(mpsc::Sender<RequestControlMessage>);
1252
1253        #[async_trait::async_trait]
1254        impl RequestPlaneClient for CaptureRequest {
1255            async fn send_request(
1256                &self,
1257                _address: String,
1258                payload: Bytes,
1259                _headers: super::super::unified_client::Headers,
1260            ) -> anyhow::Result<Bytes> {
1261                let message = TwoPartCodec::default().decode_message(payload)?;
1262                let control = serde_json::from_slice(&message.header)?;
1263                self.0.send(control).await?;
1264                Ok(Bytes::new())
1265            }
1266
1267            fn transport_name(&self) -> &'static str {
1268                "test"
1269            }
1270
1271            fn is_healthy(&self) -> bool {
1272                true
1273            }
1274        }
1275
1276        let shutdown = CancellationToken::new();
1277        let _shutdown_guard = shutdown.clone().drop_guard();
1278        tokio::time::timeout(Duration::from_secs(10), async {
1279            let address = "127.0.0.1:0".parse().unwrap();
1280            let responses =
1281                quic_response::QuicResponseServer::new(address, address, shutdown).unwrap();
1282            // The router also needs a TCP request-callback server. Isolate its TLS
1283            // configuration from tests that change the process-wide environment.
1284            let callbacks = temp_env::async_with_vars(
1285                [
1286                    ("DYN_TCP_TLS_CERT_PATH", None::<&str>),
1287                    ("DYN_TCP_TLS_KEY_PATH", None),
1288                    ("DYN_TCP_TLS_CLIENT_CA_CERT_PATH", None),
1289                ],
1290                tcp::server::TcpStreamServer::new(
1291                    tcp::server::ServerOptions::builder()
1292                        .interface(Some("127.0.0.1".to_string()))
1293                        .build()
1294                        .unwrap(),
1295                ),
1296            )
1297            .await
1298            .unwrap();
1299            let (request_tx, mut request_rx) = mpsc::channel(1);
1300            let router = AddressedPushRouter::new_quic(
1301                Arc::new(CaptureRequest(request_tx)),
1302                callbacks,
1303                responses,
1304            );
1305            let request = SingleIn::new(AddressedRequest::new(42_u64, "test".to_string()));
1306            let pool = quic_response::QuicResponseClientPool::from_env().unwrap();
1307            let (response, (mut sender, payload_codec)) =
1308                tokio::join!(router.generate(request), async {
1309                    let control = request_rx.recv().await.unwrap();
1310                    let worker_context =
1311                        Context::with_id_and_metadata((), control.id, control.metadata);
1312                    let mut sender = pool
1313                        .sender(worker_context.context(), control.connection_info)
1314                        .await
1315                        .unwrap();
1316                    sender.send_prologue(None).await.unwrap();
1317                    (sender, control.payload_codec)
1318                });
1319
1320            // Send data only after generate() returns through wait(), so cleanup on
1321            // success cannot be hidden by data buffered before the handshake finishes.
1322            let mut response: ManyOut<Annotated<u64>> = response.unwrap();
1323            // The first item uses QUIC's priority lane; the second uses its bulk lane.
1324            for value in [1_u64, 2] {
1325                sender
1326                    .send(
1327                        payload_codec
1328                            .encode(&super::NetworkStreamWrapper {
1329                                data: Some(Annotated::from_data(value)),
1330                                complete_final: value == 2,
1331                            })
1332                            .unwrap()
1333                            .into(),
1334                    )
1335                    .await
1336                    .unwrap();
1337                let item = response
1338                    .next()
1339                    .await
1340                    .expect("QUIC stream ended before delivering data");
1341                assert!(
1342                    item.error.is_none(),
1343                    "unexpected QUIC response error: {:?}",
1344                    item.error
1345                );
1346                assert_eq!(item.data, Some(value));
1347            }
1348            sender.finish().await.unwrap();
1349            assert!(
1350                response.next().await.is_none(),
1351                "QUIC stream must end after all data"
1352            );
1353        })
1354        .await
1355        .expect("QUIC router did not deliver the established response stream");
1356    }
1357
1358    #[test]
1359    fn legacy_worker_without_codec_metadata_receives_json() {
1360        let worker = Instance {
1361            component: "worker".to_string(),
1362            endpoint: "generate".to_string(),
1363            namespace: "default".to_string(),
1364            instance_id: 42,
1365            transport: TransportType::Nats("worker.generate".to_string()),
1366            device_type: None,
1367            request_plane_codec: None,
1368        };
1369        let payload_codec = payload_codec_for_worker(Some(&worker));
1370        assert_eq!(payload_codec, RequestPlanePayloadCodec::Json);
1371
1372        let request = TestRequest { value: 123 };
1373        let buffer = build_request_envelope(
1374            &Context::new(()),
1375            ConnectionInfo {
1376                transport: "tcp".to_string(),
1377                info: "{}".to_string(),
1378            },
1379            None,
1380            Some(&request),
1381            payload_codec,
1382        )
1383        .expect("legacy-worker request envelope should encode");
1384        let message = TwoPartCodec::default()
1385            .decode_message(buffer)
1386            .expect("request envelope should decode");
1387
1388        let control: RequestControlMessage = serde_json::from_slice(&message.header).unwrap();
1389        assert_eq!(control.payload_codec, RequestPlanePayloadCodec::Json);
1390        assert_eq!(
1391            serde_json::from_slice::<TestRequest>(&message.data).unwrap(),
1392            request
1393        );
1394    }
1395
1396    #[test]
1397    fn serialize_control_message_succeeds_under_limit() {
1398        let mut metadata = BTreeMap::new();
1399        metadata.insert("x-tiny-blob".to_string(), "alpha".to_string());
1400
1401        let ctrl = serialize_control_message(&base_control_message(metadata))
1402            .expect("control message should serialize under the limit");
1403        assert!(ctrl.len() <= CONTROL_MESSAGE_MAX_BYTES);
1404    }
1405
1406    #[test]
1407    fn serialize_control_message_errors_over_limit() {
1408        let mut metadata = BTreeMap::new();
1409        metadata.insert(
1410            "x-large-blob".to_string(),
1411            "x".repeat(CONTROL_MESSAGE_MAX_BYTES),
1412        );
1413
1414        let err = serialize_control_message(&base_control_message(metadata))
1415            .expect_err("oversized control message should fail")
1416            .to_string();
1417        assert!(err.contains("request control message too large"));
1418        assert!(err.contains(&CONTROL_MESSAGE_MAX_BYTES.to_string()));
1419    }
1420
1421    #[test]
1422    fn propagated_first_response_guard_is_taken_once() {
1423        let (guard_dropped_tx, mut guard_dropped_rx) = oneshot::channel();
1424        let mut source_context = Context::new(());
1425        attach_first_response_guard(
1426            &mut source_context,
1427            Arc::new(DropSignal(Some(guard_dropped_tx))),
1428        );
1429        let mut derived_context = Context::new(());
1430        propagate_first_response_guard(&source_context, &mut derived_context).unwrap();
1431
1432        let source_guard = source_context
1433            .get::<FirstResponseGuard>(FIRST_RESPONSE_GUARD_CONTEXT_KEY)
1434            .unwrap();
1435        let derived_guard = derived_context
1436            .get::<FirstResponseGuard>(FIRST_RESPONSE_GUARD_CONTEXT_KEY)
1437            .unwrap();
1438        let retained = derived_guard.take().expect("derived context should win");
1439        assert!(source_guard.take().is_none());
1440
1441        drop(retained);
1442        assert_eq!(guard_dropped_rx.try_recv(), Ok(()));
1443    }
1444
1445    #[tokio::test]
1446    async fn first_response_guard_defers_tcp_cancellation_until_prologue() {
1447        struct CapturingClient(tokio::sync::mpsc::Sender<ConnectionInfo>);
1448
1449        #[async_trait::async_trait]
1450        impl RequestPlaneClient for CapturingClient {
1451            async fn send_request(
1452                &self,
1453                _address: String,
1454                payload: bytes::Bytes,
1455                _headers: Headers,
1456            ) -> anyhow::Result<bytes::Bytes> {
1457                let message = TwoPartCodec::default().decode_message(payload)?;
1458                let control: RequestControlMessage = serde_json::from_slice(&message.header)?;
1459                self.0.send(control.connection_info).await?;
1460                Ok(bytes::Bytes::new())
1461            }
1462
1463            fn transport_name(&self) -> &'static str {
1464                "capturing"
1465            }
1466
1467            fn is_healthy(&self) -> bool {
1468                true
1469            }
1470        }
1471
1472        temp_env::async_with_vars(
1473            [
1474                ("DYN_TCP_TLS_CERT_PATH", None::<&str>),
1475                ("DYN_TCP_TLS_KEY_PATH", None),
1476                ("DYN_TCP_TLS_CLIENT_CA_CERT_PATH", None),
1477                ("DYN_TCP_TLS_CA_CERT_PATH", None),
1478                ("DYN_TCP_TLS_INSECURE", None),
1479                ("DYN_TCP_TLS_CLIENT_CERT_PATH", None),
1480                ("DYN_TCP_TLS_CLIENT_KEY_PATH", None),
1481            ],
1482            async {
1483                tokio::time::timeout(Duration::from_secs(5), async {
1484                    let server = TcpStreamServer::new(
1485                        TcpStreamServer::options_builder()
1486                            .interface(Some("127.0.0.1".to_string()))
1487                            .build()
1488                            .unwrap(),
1489                    )
1490                    .await
1491                    .unwrap();
1492                    let (connection_tx, mut connection_rx) = tokio::sync::mpsc::channel(1);
1493                    let router =
1494                        AddressedPushRouter::new(Arc::new(CapturingClient(connection_tx)), server)
1495                            .unwrap();
1496                    let (guard_dropped_tx, mut guard_dropped_rx) = oneshot::channel();
1497                    let mut request = Context::new(AddressedRequest::new(1_u64, "worker".into()));
1498                    attach_first_response_guard(
1499                        &mut request,
1500                        Arc::new(DropSignal(Some(guard_dropped_tx))),
1501                    );
1502                    let context = request.context();
1503                    let mut generation = tokio::spawn(async move {
1504                        let response: ManyOut<Annotated<u64>> = router.generate(request).await?;
1505                        Ok::<_, anyhow::Error>(response)
1506                    });
1507                    let connection_info = connection_rx.recv().await.unwrap();
1508                    let worker_context = Context::with_id_and_metadata(
1509                        (),
1510                        context.id().to_string(),
1511                        Default::default(),
1512                    )
1513                    .context();
1514                    let mut sender = TcpClient::create_response_stream(
1515                        worker_context.clone(),
1516                        connection_info,
1517                        None,
1518                    )
1519                    .await
1520                    .unwrap();
1521
1522                    for kill in [false, true] {
1523                        if kill {
1524                            context.kill();
1525                        } else {
1526                            context.stop_generating();
1527                        }
1528                        tokio::select! {
1529                            _ = worker_context.stopped() => panic!("guarded worker was cancelled before prologue"),
1530                            _ = &mut guard_dropped_rx => panic!("guard was released before prologue"),
1531                            _ = &mut generation => panic!("guarded generation completed before prologue"),
1532                            _ = tokio::time::sleep(Duration::from_millis(100)) => {}
1533                        }
1534                        assert!(!worker_context.is_stopped());
1535                        assert_eq!(guard_dropped_rx.try_recv(), Err(TryRecvError::Empty));
1536                        assert!(!generation.is_finished());
1537                    }
1538
1539                    sender
1540                        .send_prologue(Some("worker setup failed".into()))
1541                        .await
1542                        .unwrap();
1543                    let mut response = generation.await.unwrap().unwrap();
1544                    assert!(response.context().is_killed());
1545                    assert!(response.next().await.is_none());
1546                    guard_dropped_rx.await.unwrap();
1547                })
1548                .await
1549                .expect("guarded TCP dispatch did not complete after the error prologue");
1550            },
1551        )
1552        .await;
1553    }
1554
1555    #[tokio::test]
1556    async fn first_response_guard_outlives_cancelled_dispatch_waiter() {
1557        let (raw_tx, raw_rx) = tokio::sync::mpsc::channel(1);
1558        let response_context = Context::new(()).context();
1559        let (dispatch_started_tx, dispatch_started_rx) = oneshot::channel();
1560        let (release_dispatch_tx, release_dispatch_rx) = oneshot::channel();
1561        let (guard_dropped_tx, mut guard_dropped_rx) = oneshot::channel();
1562        let mut source_context = Context::new(());
1563        attach_first_response_guard(
1564            &mut source_context,
1565            Arc::new(DropSignal(Some(guard_dropped_tx))),
1566        );
1567        let mut derived_context = Context::new(());
1568        propagate_first_response_guard(&source_context, &mut derived_context).unwrap();
1569        let guard = derived_context
1570            .get::<FirstResponseGuard>(FIRST_RESPONSE_GUARD_CONTEXT_KEY)
1571            .unwrap()
1572            .take()
1573            .unwrap();
1574        drop(source_context);
1575        drop(derived_context);
1576        let permits = Arc::new(Semaphore::new(1));
1577        let permit = try_acquire_retained_dispatch_permit(&permits).unwrap();
1578
1579        let waiter = tokio::spawn(dispatch_with_first_response_guard(
1580            async move {
1581                let _ = dispatch_started_tx.send(());
1582                let _ = release_dispatch_rx.await;
1583                let response: ManyOut<Annotated<u64>> =
1584                    ResponseStream::new(Box::pin(ReceiverStream::new(raw_rx)), response_context);
1585                Ok(response)
1586            },
1587            guard,
1588            permit,
1589        ));
1590
1591        dispatch_started_rx.await.unwrap();
1592        waiter.abort();
1593        let _ = waiter.await;
1594        assert_eq!(guard_dropped_rx.try_recv(), Err(TryRecvError::Empty));
1595        let error = try_acquire_retained_dispatch_permit(&permits).unwrap_err();
1596        assert!(match_error_chain(
1597            error.as_ref(),
1598            &[ErrorType::ResourceExhausted],
1599            &[],
1600        ));
1601
1602        release_dispatch_tx.send(()).unwrap();
1603        raw_tx.send(Annotated::from_data(1_u64)).await.unwrap();
1604        tokio::time::timeout(Duration::from_secs(1), guard_dropped_rx)
1605            .await
1606            .expect("source guard was not released after the first worker response")
1607            .unwrap();
1608        let released_permit = tokio::time::timeout(Duration::from_secs(1), async {
1609            loop {
1610                if let Ok(permit) = try_acquire_retained_dispatch_permit(&permits) {
1611                    break permit;
1612                }
1613                tokio::task::yield_now().await;
1614            }
1615        })
1616        .await
1617        .expect("retained dispatch permit was not released");
1618        drop(released_permit);
1619    }
1620
1621    #[tokio::test]
1622    async fn dropping_handed_off_tail_closes_upstream() {
1623        let (raw_tx, raw_rx) = tokio::sync::mpsc::channel(1);
1624        let response_context = Context::new(()).context();
1625        let (guard_dropped_tx, guard_dropped_rx) = oneshot::channel();
1626        let permits = Arc::new(Semaphore::new(1));
1627        let permit = try_acquire_retained_dispatch_permit(&permits).unwrap();
1628        let mut response = dispatch_with_first_response_guard(
1629            async move {
1630                let response: ManyOut<Annotated<u64>> =
1631                    ResponseStream::new(Box::pin(ReceiverStream::new(raw_rx)), response_context);
1632                Ok(response)
1633            },
1634            Arc::new(DropSignal(Some(guard_dropped_tx))),
1635            permit,
1636        )
1637        .await
1638        .unwrap();
1639
1640        raw_tx.send(Annotated::from_data(1_u64)).await.unwrap();
1641        assert_eq!(response.next().await.unwrap().data, Some(1));
1642        tokio::time::timeout(Duration::from_secs(1), guard_dropped_rx)
1643            .await
1644            .expect("source guard was not released after the first worker response")
1645            .unwrap();
1646
1647        drop(response);
1648        tokio::time::timeout(Duration::from_secs(1), raw_tx.closed())
1649            .await
1650            .expect("dropping the caller stream did not close the upstream tail");
1651    }
1652}