Skip to main content

dynamo_runtime/pipeline/network/ingress/
push_handler.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use super::*;
5
6use crate::engine::AsyncEngineContext;
7use crate::error::DynamoError;
8use crate::metrics::prometheus_names::work_handler;
9use crate::metrics::work_handler_perf::{
10    WORK_HANDLER_NETWORK_TRANSIT_SECONDS, WORK_HANDLER_TIME_TO_FIRST_RESPONSE_SECONDS,
11};
12use crate::pipeline::network::StreamPrologueError;
13use crate::pipeline::{ManyIn, RequestStream};
14use crate::telemetry::{LifecycleStage, LifecycleTrace};
15use futures::StreamExt;
16use prometheus::{Histogram, IntCounter, IntCounterVec, IntGauge};
17use serde::Deserialize;
18use std::sync::Arc;
19use std::time::Instant;
20use tracing::Instrument;
21use tracing::info_span;
22
23/// Metrics configuration for profiling work handlers
24#[derive(Clone, Debug)]
25pub struct WorkHandlerMetrics {
26    pub request_counter: IntCounter,
27    pub request_duration: Histogram,
28    pub inflight_requests: IntGauge,
29    pub request_bytes: IntCounter,
30    pub response_bytes: IntCounter,
31    pub error_counter: IntCounterVec,
32    pub cancellation_total: IntCounter,
33}
34
35impl WorkHandlerMetrics {
36    pub fn new(
37        request_counter: IntCounter,
38        request_duration: Histogram,
39        inflight_requests: IntGauge,
40        request_bytes: IntCounter,
41        response_bytes: IntCounter,
42        error_counter: IntCounterVec,
43        cancellation_total: IntCounter,
44    ) -> Self {
45        Self {
46            request_counter,
47            request_duration,
48            inflight_requests,
49            request_bytes,
50            response_bytes,
51            error_counter,
52            cancellation_total,
53        }
54    }
55
56    /// Create WorkHandlerMetrics from an endpoint using its built-in labeling
57    pub fn from_endpoint(
58        endpoint: &crate::component::Endpoint,
59        metrics_labels: Option<&[(&str, &str)]>,
60    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
61        let metrics_labels = metrics_labels.unwrap_or(&[]);
62        let metrics = endpoint.metrics();
63        let request_counter = metrics.create_intcounter(
64            work_handler::REQUESTS_TOTAL,
65            "Total number of requests processed by work handler",
66            metrics_labels,
67        )?;
68
69        // Custom buckets for inference workloads: retain sub-second resolution for
70        // fast operations, extend well beyond the default 10s ceiling to capture
71        // long-running generation requests that can last minutes.
72        let request_duration_buckets = vec![
73            0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 20.0, 30.0, 60.0, 120.0,
74            300.0, 600.0,
75        ];
76        let request_duration = metrics.create_histogram(
77            work_handler::REQUEST_DURATION_SECONDS,
78            "Time spent processing requests by work handler",
79            metrics_labels,
80            Some(request_duration_buckets),
81        )?;
82
83        let inflight_requests = metrics.create_intgauge(
84            work_handler::INFLIGHT_REQUESTS,
85            "Number of requests currently being processed by work handler",
86            metrics_labels,
87        )?;
88
89        let request_bytes = metrics.create_intcounter(
90            work_handler::REQUEST_BYTES_TOTAL,
91            "Total number of bytes received in requests by work handler",
92            metrics_labels,
93        )?;
94
95        let response_bytes = metrics.create_intcounter(
96            work_handler::RESPONSE_BYTES_TOTAL,
97            "Total number of bytes sent in responses by work handler",
98            metrics_labels,
99        )?;
100
101        let error_counter = metrics.create_intcountervec(
102            work_handler::ERRORS_TOTAL,
103            "Total number of errors in work handler processing",
104            &[work_handler::ERROR_TYPE_LABEL],
105            metrics_labels,
106        )?;
107
108        let cancellation_total = metrics.create_intcounter(
109            work_handler::CANCELLATION_TOTAL,
110            "Total number of requests cancelled by work handler",
111            metrics_labels,
112        )?;
113
114        Ok(Self::new(
115            request_counter,
116            request_duration,
117            inflight_requests,
118            request_bytes,
119            response_bytes,
120            error_counter,
121            cancellation_total,
122        ))
123    }
124}
125
126// RAII guard to ensure inflight gauge is decremented, request duration is observed,
127// and lifecycle logs are emitted on all code paths.
128struct RequestMetricsGuard {
129    inflight_requests: prometheus::IntGauge,
130    request_duration: prometheus::Histogram,
131    start_time: Instant,
132    request_id: Option<String>,
133}
134
135impl Drop for RequestMetricsGuard {
136    fn drop(&mut self) {
137        self.inflight_requests.dec();
138        self.request_duration
139            .observe(self.start_time.elapsed().as_secs_f64());
140        if let Some(request_id) = &self.request_id {
141            tracing::info!(request_id = %request_id, "request completed");
142        }
143    }
144}
145
146trait ResponsePublisher {
147    async fn send(&self, payload: Bytes) -> anyhow::Result<()>;
148    async fn send_prologue(&mut self, error: Option<String>) -> anyhow::Result<()>;
149
150    /// Send a failure prologue keeping the worker's [`crate::error::ErrorType`]
151    /// where the transport can carry it.
152    ///
153    /// The default drops the type and sends the text alone. TCP and QUIC
154    /// override this so a typed refusal survives the response plane.
155    async fn send_prologue_typed(
156        &mut self,
157        error: Option<StreamPrologueError>,
158    ) -> anyhow::Result<()> {
159        self.send_prologue(error.map(|error| error.message)).await
160    }
161    async fn finish(&mut self) -> anyhow::Result<()>;
162    async fn abort(&mut self) -> anyhow::Result<()>;
163
164    fn reset_on_stop(&self) -> bool {
165        false
166    }
167
168    fn strict_prologue(&self) -> bool {
169        false
170    }
171}
172
173impl ResponsePublisher for quic_response::QuicResponseSender {
174    async fn send(&self, payload: Bytes) -> anyhow::Result<()> {
175        quic_response::QuicResponseSender::send(self, payload).await
176    }
177
178    async fn send_prologue(&mut self, error: Option<String>) -> anyhow::Result<()> {
179        quic_response::QuicResponseSender::send_prologue(self, error)
180            .await
181            .map_err(anyhow::Error::msg)
182    }
183
184    async fn send_prologue_typed(
185        &mut self,
186        error: Option<StreamPrologueError>,
187    ) -> anyhow::Result<()> {
188        quic_response::QuicResponseSender::send_prologue_typed(self, error)
189            .await
190            .map_err(anyhow::Error::msg)
191    }
192
193    async fn finish(&mut self) -> anyhow::Result<()> {
194        quic_response::QuicResponseSender::finish(self).await
195    }
196
197    async fn abort(&mut self) -> anyhow::Result<()> {
198        quic_response::QuicResponseSender::abort(self).await
199    }
200
201    fn reset_on_stop(&self) -> bool {
202        true
203    }
204
205    fn strict_prologue(&self) -> bool {
206        true
207    }
208}
209
210impl ResponsePublisher for StreamSender {
211    async fn send(&self, payload: Bytes) -> anyhow::Result<()> {
212        StreamSender::send(self, payload).await
213    }
214
215    async fn send_prologue(&mut self, error: Option<String>) -> anyhow::Result<()> {
216        StreamSender::send_prologue(self, error)
217            .await
218            .map_err(anyhow::Error::msg)
219    }
220
221    async fn send_prologue_typed(
222        &mut self,
223        error: Option<StreamPrologueError>,
224    ) -> anyhow::Result<()> {
225        StreamSender::send_prologue_typed(self, error)
226            .await
227            .map_err(anyhow::Error::msg)
228    }
229
230    async fn finish(&mut self) -> anyhow::Result<()> {
231        Ok(())
232    }
233
234    async fn abort(&mut self) -> anyhow::Result<()> {
235        Ok(())
236    }
237}
238
239impl<Req, Resp, Adapter> Ingress<Req, Resp, Adapter>
240where
241    Req: PipelineIO + Sync,
242    Resp: PipelineIO,
243    Adapter: Send + Sync + 'static,
244{
245    /// Pump every chunk from the engine's response stream out to the
246    /// upstream response transport, plus the terminal complete-final
247    /// frame. Captures the per-frame metrics, the publish-failure error
248    /// classification (client-side disconnect vs. real failure), and the
249    /// health-check notifier policy (notify only on non-error chunks and
250    /// at clean stream end).
251    async fn pump_response_stream<U>(
252        &self,
253        mut stream: ManyOut<U>,
254        publisher: &impl ResponsePublisher,
255        payload_codec: RequestPlanePayloadCodec,
256    ) where
257        U: Data + std::fmt::Debug,
258        Adapter: IngressResponseEncoder<U>,
259    {
260        let context = stream.context();
261
262        // TODO: Detect end-of-stream using Server-Sent Events (SSE)
263        let mut send_complete_final = true;
264        let mut saw_error_response = false;
265        while let Some(resp) = stream.next().await {
266            tracing::trace!("Sending response: {:?}", resp);
267            let encoded = match self
268                .payload_adapter
269                .encode_response(payload_codec, Some(resp), false)
270                .await
271            {
272                Ok(encoded) => encoded,
273                Err(err) => {
274                    tracing::error!(%err, "failed to encode request-plane response");
275                    saw_error_response = true;
276                    send_complete_final = false;
277                    if let Some(m) = self.metrics() {
278                        m.error_counter
279                            .with_label_values(&[work_handler::error_types::SERIALIZATION])
280                            .inc();
281                    }
282                    break;
283                }
284            };
285            let is_error = encoded.is_error;
286            saw_error_response |= is_error;
287            let resp_bytes = encoded.bytes;
288            if let Some(m) = self.metrics() {
289                m.response_bytes.inc_by(resp_bytes.len() as u64);
290            }
291            if (publisher.send(resp_bytes).await).is_err() {
292                send_complete_final = false;
293                if context.is_stopped() {
294                    // Say there are 2 threads accessing `context`, the sequence can be either:
295                    // 1. context.stop_generating (other) -> publisher.send failure (this)
296                    //    -> context.is_stopped (this)
297                    // 2. publisher.send failure (this) -> context.stop_generating (other)
298                    //    -> context.is_stopped (this)
299                    // Case 1 can happen when client closed the connection after receiving the
300                    // complete response from frontend. Hence, send failure can be expected in this
301                    // case.
302                    tracing::warn!("Failed to publish response for stream {}", context.id());
303                } else {
304                    // Otherwise, this is an error.
305                    tracing::error!("Failed to publish response for stream {}", context.id());
306                    context.stop_generating();
307                }
308                // Account errors in all cases, including cancellation. Therefore this metric can be
309                // inflated.
310                if let Some(m) = self.metrics() {
311                    m.error_counter
312                        .with_label_values(&[work_handler::error_types::PUBLISH_RESPONSE])
313                        .inc();
314                }
315                break;
316            } else if !is_error {
317                // Only notify on non-error chunks — error responses don't prove
318                // the engine is healthy and should not reset the canary timer.
319                if let Some(notifier) = self.endpoint_health_check_notifier.get() {
320                    notifier.notify_one();
321                }
322            }
323            if encoded.stop_stream {
324                // Dropping the engine stream after the terminal frame is sent
325                // propagates cancellation to a producer that is still running.
326                // Stopping the context here can close the response transport
327                // before the queued error and clean terminal frames are read.
328                break;
329            }
330        }
331        // The TCP response writer exits without its clean sentinel when the
332        // worker context is stopped. Preserve that behavior on QUIC: the
333        // caller sends a logical reset instead of a clean terminal frame.
334        if publisher.reset_on_stop() && context.is_stopped() && !context.is_killed() {
335            send_complete_final = false;
336        }
337        if send_complete_final {
338            let encoded = match self
339                .payload_adapter
340                .encode_response(payload_codec, None, true)
341                .await
342            {
343                Ok(encoded) => encoded,
344                Err(err) => {
345                    tracing::error!(%err, "failed to encode request-plane final response");
346                    if let Some(m) = self.metrics() {
347                        m.error_counter
348                            .with_label_values(&[work_handler::error_types::PUBLISH_FINAL])
349                            .inc();
350                    }
351                    return;
352                }
353            };
354            let resp_bytes = encoded.bytes;
355            if let Some(m) = self.metrics() {
356                m.response_bytes.inc_by(resp_bytes.len() as u64);
357            }
358            if (publisher.send(resp_bytes).await).is_err() {
359                // `is_stopped()` is `state != Live`, so it is also true after
360                // `kill()` — which the response-stream reader does on a TCP read
361                // error. Excluding killed narrows this to `state == Stopped` so
362                // real connection failures stay counted. `&&` reads `is_killed()`
363                // last, so a Stopped -> Killed upgrade between the two reads
364                // falls to the error path.
365                //
366                // Reachable only with a peer-sent `Stop`: the local
367                // `stop_generating()` above clears `send_complete_final` and
368                // breaks first. That invariant is load-bearing.
369                if context.is_stopped() && !context.is_killed() {
370                    // The peer asked us to stop, so a failed marker write is
371                    // attributable to that teardown, not to a fault here. Unlike
372                    // the per-frame branch, this also skips the counter.
373                    tracing::debug!(
374                        "Failed to publish complete final for stream {}; client already torn down",
375                        context.id()
376                    );
377                } else {
378                    // Still attached, or killed (hard cancel, protocol violation,
379                    // connection error): the client sees a stream with no
380                    // end-of-stream marker, so this stays a counted error.
381                    tracing::error!(
382                        "Failed to publish complete final for stream {}",
383                        context.id()
384                    );
385                    if let Some(m) = self.metrics() {
386                        m.error_counter
387                            .with_label_values(&[work_handler::error_types::PUBLISH_FINAL])
388                            .inc();
389                    }
390                }
391            }
392            // Only notify on stream completion if no error responses were seen
393            if let (false, Some(notifier)) = (
394                saw_error_response,
395                self.endpoint_health_check_notifier.get(),
396            ) {
397                notifier.notify_one();
398            }
399        }
400    }
401
402    /// Decode the wire envelope into its [`RequestControlMessage`] and the
403    /// optional data payload, shared by every [`IngressDispatch`] shape:
404    ///   - `HeaderAndData` → `(control, Some(data))` — the unary wire shape,
405    ///     where the request body travels in the data half.
406    ///   - `HeaderOnly` → `(control, None)` — the bidirectional wire shape,
407    ///     where request frames flow on the request-stream socket instead.
408    ///
409    /// The caller decides whether its path expects the data payload. The
410    /// deserialization and invalid-message error counters are incremented
411    /// here so every shape reports them consistently.
412    fn decode_control_message(
413        &self,
414        payload: Bytes,
415    ) -> Result<(RequestControlMessage, Option<Bytes>), PipelineError> {
416        let msg = TwoPartCodec::default()
417            .decode_message(payload)?
418            .into_message_type();
419
420        let (header, data) = match msg {
421            TwoPartMessageType::HeaderAndData(header, data) => (header, Some(data)),
422            TwoPartMessageType::HeaderOnly(header) => (header, None),
423            _ => {
424                if let Some(m) = self.metrics() {
425                    m.error_counter
426                        .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
427                        .inc();
428                }
429                return Err(PipelineError::Generic(String::from(
430                    "Unexpected message from work queue; expected a header-only or header-and-data TwoPartMessage",
431                )));
432            }
433        };
434
435        let control_msg: RequestControlMessage =
436            serde_json::from_slice(&header).map_err(|err| {
437                if let Some(m) = self.metrics() {
438                    m.error_counter
439                        .with_label_values(&[work_handler::error_types::DESERIALIZATION])
440                        .inc();
441                }
442                let json_str = String::from_utf8_lossy(&header);
443                PipelineError::DeserializationError(format!(
444                    "Failed deserializing to RequestControlMessage. err={err}, json_str={json_str}, header_len={}",
445                    header.len(),
446                ))
447            })?;
448
449        Ok((control_msg, data))
450    }
451}
452/// The output of [`IngressDispatch::parse_and_build_request`]: the typed
453/// request the engine consumes, plus the bits of the on-wire control
454/// message the shared handler needs after parsing (the response-stream
455/// connection info and the frontend send timestamp).
456struct ParsedRequest<Req> {
457    request: Req,
458    response_connection_info: ConnectionInfo,
459    frontend_send_ts_ns: Option<u64>,
460    payload_codec: RequestPlanePayloadCodec,
461}
462
463#[derive(Clone, Copy)]
464struct ResponsePlaneModes {
465    configured: ResponsePlaneMode,
466    advertised: ResponsePlaneMode,
467}
468
469/// Per-shape strategy for turning a raw payload into a typed engine
470/// request. Captures the wire-shape divergence between the unary
471/// (`HeaderAndData`) and bidirectional (`HeaderOnly` + dial-in for the
472/// request stream) paths; everything else — metrics-guard, response stream
473/// open, `segment.generate`, prologue, pump — lives in
474/// [`Ingress::handle_payload_shared`] below.
475#[async_trait]
476trait IngressDispatch: Send + Sync {
477    type Request: PipelineIO;
478
479    async fn parse_and_build_request(
480        &self,
481        control_msg: RequestControlMessage,
482        data: Option<Bytes>,
483    ) -> Result<ParsedRequest<Self::Request>, PipelineError>;
484}
485
486#[async_trait]
487impl<T, U, Adapter> IngressDispatch for Ingress<SingleIn<T>, ManyOut<U>, Adapter>
488where
489    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
490    U: Data + std::fmt::Debug,
491    Adapter: IngressRequestDecoder<T> + Send + Sync + 'static,
492{
493    type Request = SingleIn<T>;
494
495    async fn parse_and_build_request(
496        &self,
497        control_msg: RequestControlMessage,
498        data: Option<Bytes>,
499    ) -> Result<ParsedRequest<SingleIn<T>>, PipelineError> {
500        // The unary path carries the request body in the data half; a
501        // header-only envelope means the sender used the bidirectional shape.
502        let data = data.ok_or_else(|| {
503            if let Some(m) = self.metrics() {
504                m.error_counter
505                    .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
506                    .inc();
507            }
508            PipelineError::Generic(String::from(
509                "unary engine received a header-only envelope; expected a request payload",
510            ))
511        })?;
512        let payload_codec = control_msg.payload_codec;
513        let request_t: T = self
514            .payload_adapter
515            .decode_request(payload_codec, data)
516            .await
517            .inspect_err(|_| {
518                if let Some(m) = self.metrics() {
519                    m.error_counter
520                        .with_label_values(&[work_handler::error_types::DESERIALIZATION])
521                        .inc();
522                }
523            })?;
524
525        tracing::trace!(
526            request_id = %control_msg.id,
527            metadata_entries = control_msg.metadata.len(),
528            "received control message"
529        );
530        tracing::trace!("received request: {:?}", request_t);
531
532        let request: context::Context<T> =
533            Context::with_id_and_metadata(request_t, control_msg.id, control_msg.metadata);
534
535        Ok(ParsedRequest {
536            request,
537            response_connection_info: control_msg.connection_info,
538            frontend_send_ts_ns: control_msg.frontend_send_ts_ns,
539            payload_codec,
540        })
541    }
542}
543
544#[async_trait]
545impl<T, U, Adapter> IngressDispatch for Ingress<ManyIn<T>, ManyOut<U>, Adapter>
546where
547    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
548    U: Data + std::fmt::Debug,
549    Adapter: IngressRequestDecoder<T> + Send + Sync + 'static,
550{
551    type Request = ManyIn<T>;
552
553    async fn parse_and_build_request(
554        &self,
555        control_msg: RequestControlMessage,
556        data: Option<Bytes>,
557    ) -> Result<ParsedRequest<ManyIn<T>>, PipelineError> {
558        // Bidirectional envelopes are header-only — all request frames
559        // (including the first) flow on the request-stream socket once it's
560        // dialed in. A data payload means the sender used the unary wire
561        // shape; reject it.
562        if data.is_some() {
563            if let Some(m) = self.metrics() {
564                m.error_counter
565                    .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
566                    .inc();
567            }
568            return Err(PipelineError::Generic(String::from(
569                "bidirectional engine received a non-header-only envelope",
570            )));
571        }
572
573        if !matches!(control_msg.request_type, RequestType::ManyIn) {
574            if let Some(m) = self.metrics() {
575                m.error_counter
576                    .with_label_values(&[work_handler::error_types::INVALID_MESSAGE])
577                    .inc();
578            }
579            return Err(PipelineError::Generic(String::from(
580                "bidirectional engine received a non-ManyIn request envelope",
581            )));
582        }
583
584        let req_stream_conn_info = control_msg
585            .request_stream_connection_info
586            .clone()
587            .ok_or_else(|| {
588                PipelineError::Generic(String::from(
589                    "bidirectional control message missing request_stream_connection_info",
590                ))
591            })?;
592
593        let request_context: context::Context<()> = context::Context::with_id_and_metadata(
594            (),
595            control_msg.id.clone(),
596            control_msg.metadata.clone(),
597        );
598        let payload_codec = control_msg.payload_codec;
599        let context_arc: Arc<dyn AsyncEngineContext> = request_context.context();
600
601        // Open the request stream (upstream → worker) up front. The shared
602        // handler opens the response stream uniformly after we return. If
603        // response-stream open subsequently fails, the forwarder task
604        // spawned below exits cleanly when `frame_tx.send` observes the
605        // dropped `frame_rx`.
606        let request_stream_recv = tcp::client::TcpClient::create_request_stream(
607            context_arc.clone(),
608            req_stream_conn_info,
609            None,
610        )
611        .await
612        .map_err(|e| {
613            if let Some(m) = self.metrics() {
614                m.error_counter
615                    .with_label_values(&[work_handler::error_types::RESPONSE_STREAM])
616                    .inc();
617            }
618            PipelineError::Generic(format!("Failed to create request stream: {e}"))
619        })?;
620
621        // Forwarder: deserialize raw bytes off the request socket into `T`
622        // and feed the engine's `ManyIn<T>` input. Every request frame
623        // (including the first) flows over this socket — the envelope is
624        // header-only.
625        let (frame_tx, frame_rx) = tokio::sync::mpsc::channel::<T>(8);
626        let forwarder_ctx = context_arc.clone();
627        let payload_adapter = self.payload_adapter.clone();
628        tokio::spawn(async move {
629            let mut rx = request_stream_recv.rx;
630            while let Some(bytes) = rx.recv().await {
631                // Stop forwarding on either kill or soft-stop, matching the
632                // send-side `spawn_request_stream_forwarder`. Without the
633                // `stopped()` check, a `stop_generating()` would leave this
634                // task pumping frames into a channel the engine has abandoned.
635                if forwarder_ctx.is_killed() || forwarder_ctx.is_stopped() {
636                    break;
637                }
638                match payload_adapter.decode_request(payload_codec, bytes).await {
639                    Ok(item) => {
640                        if frame_tx.send(item).await.is_err() {
641                            tracing::debug!(
642                                "engine consumer dropped; bidirectional input forwarder exiting"
643                            );
644                            break;
645                        }
646                    }
647                    Err(e) => {
648                        tracing::error!(
649                            error = %e,
650                            codec = payload_codec.name(),
651                            "failed to deserialize bidirectional request frame; killing context"
652                        );
653                        forwarder_ctx.kill();
654                        break;
655                    }
656                }
657            }
658        });
659
660        let input_stream: crate::engine::DataStream<T> =
661            Box::pin(tokio_stream::wrappers::ReceiverStream::new(frame_rx));
662        let request: ManyIn<T> = request_context.map(|_| RequestStream::new(input_stream));
663
664        Ok(ParsedRequest {
665            request,
666            response_connection_info: control_msg.connection_info,
667            frontend_send_ts_ns: control_msg.frontend_send_ts_ns,
668            payload_codec,
669        })
670    }
671}
672
673impl<Req, U, Adapter> Ingress<Req, ManyOut<U>, Adapter>
674where
675    Req: PipelineIO + Sync,
676    U: Data + std::fmt::Debug,
677    Adapter: IngressResponseEncoder<U> + Send + Sync + 'static,
678{
679    async fn generate_and_publish<P>(
680        &self,
681        request: Req,
682        payload_codec: RequestPlanePayloadCodec,
683        start_time: Instant,
684        response_modes: ResponsePlaneModes,
685        lifecycle: &LifecycleTrace,
686        mut publisher: P,
687    ) -> Result<(), PipelineError>
688    where
689        Self: IngressDispatch<Request = Req>,
690        P: ResponsePublisher,
691    {
692        let ResponsePlaneModes {
693            configured: configured_mode,
694            advertised: advertised_mode,
695        } = response_modes;
696
697        if configured_mode != advertised_mode {
698            let message = format!(
699                "response plane mismatch: frontend requested {}, worker configured {}",
700                advertised_mode.name(),
701                configured_mode.name()
702            );
703            let _ = publisher.send_prologue(Some(message.clone())).await;
704            let _ = publisher.finish().await;
705            return Err(PipelineError::Generic(message));
706        }
707
708        let request_context = request.context();
709        tracing::trace!("calling generate");
710        let worker_operation = lifecycle.start_worker_operation();
711        let stream = async {
712            self.segment
713                .get()
714                .expect("segment not set")
715                .generate(request)
716                .instrument(lifecycle.start(LifecycleStage::RequestDispatch))
717                .await
718        }
719        .instrument(worker_operation.clone())
720        .await
721        .map_err(|error| {
722            if let Some(metrics) = self.metrics() {
723                metrics
724                    .error_counter
725                    .with_label_values(&[work_handler::error_types::GENERATE])
726                    .inc();
727            }
728            PipelineError::GenerateError(error)
729        });
730
731        let stream = match stream {
732            Ok(stream) => {
733                tracing::trace!("Successfully generated response stream; sending prologue");
734                let result = publisher.send_prologue(None).await;
735                if publisher.strict_prologue() {
736                    result.map_err(|error| {
737                        if let Some(metrics) = self.metrics() {
738                            metrics
739                                .error_counter
740                                .with_label_values(&[work_handler::error_types::RESPONSE_STREAM])
741                                .inc();
742                        }
743                        PipelineError::Generic(format!(
744                            "Failed to open {} response stream: {error}",
745                            configured_mode.name()
746                        ))
747                    })?;
748                }
749                WORK_HANDLER_TIME_TO_FIRST_RESPONSE_SECONDS
750                    .observe(start_time.elapsed().as_secs_f64());
751                stream
752            }
753            Err(error) => {
754                let error_string = error.to_string();
755
756                #[cfg(debug_assertions)]
757                tracing::debug!(
758                    "Failed to generate response stream (with debug backtrace): {:?}",
759                    error
760                );
761                #[cfg(not(debug_assertions))]
762                tracing::error!("Failed to generate response stream: {error_string}");
763
764                if publisher.reset_on_stop()
765                    && request_context.is_stopped()
766                    && !request_context.is_killed()
767                {
768                    let _ = publisher.abort().await;
769                } else {
770                    // Send the worker's error type with the display text, so a
771                    // frontend can tell a request the backend cannot serve from
772                    // a transport failure.
773                    let prologue_error = StreamPrologueError::new(
774                        error_string,
775                        typed_error_from_pipeline_error(&error),
776                    );
777                    let _ = publisher.send_prologue_typed(Some(prologue_error)).await;
778                }
779                return Err(error);
780            }
781        };
782
783        async {
784            self.pump_response_stream(stream, &publisher, payload_codec)
785                .instrument(lifecycle.start_worker_response_streaming())
786                .await
787        }
788        .instrument(worker_operation)
789        .await;
790        let finish = if publisher.reset_on_stop()
791            && request_context.is_stopped()
792            && !request_context.is_killed()
793        {
794            publisher.abort().await
795        } else {
796            publisher.finish().await
797        };
798        finish.map_err(|error| {
799            PipelineError::Generic(format!(
800                "Failed to finish {} response stream: {error}",
801                configured_mode.name()
802            ))
803        })
804    }
805
806    /// Shared body of `PushWorkHandler::handle_payload` for every
807    /// `Ingress<Req, ManyOut<U>>` shape that has an [`IngressDispatch`]
808    /// impl. Sets up the inflight metrics guard, calls
809    /// `parse_and_build_request` for the wire-shape-specific request
810    /// building, opens the response stream uniformly, dispatches via
811    /// the engine, sends the prologue, and pumps the response through
812    /// [`Self::pump_response_stream`].
813    async fn handle_payload_shared(
814        &self,
815        payload: Bytes,
816        request_id: Option<String>,
817    ) -> Result<(), PipelineError>
818    where
819        Self: IngressDispatch<Request = Req>,
820    {
821        let t2_wallclock_ns = std::time::SystemTime::now()
822            .duration_since(std::time::UNIX_EPOCH)
823            .unwrap_or_default()
824            .as_nanos() as u64;
825        let start_time = std::time::Instant::now();
826        // Increment inflight and ensure it's decremented on all exits via RAII guard
827        let _inflight_guard = self.metrics().map(|m| {
828            m.request_counter.inc();
829            m.inflight_requests.inc();
830            m.request_bytes.inc_by(payload.len() as u64);
831            if let Some(rid) = &request_id {
832                tracing::info!(request_id = %rid, "request received");
833            }
834            RequestMetricsGuard {
835                inflight_requests: m.inflight_requests.clone(),
836                request_duration: m.request_duration.clone(),
837                start_time,
838                request_id: request_id.clone(),
839            }
840        });
841
842        let (control_msg, data) = self.decode_control_message(payload)?;
843        let lifecycle = match self.registered_lifecycle_role() {
844            Some(role)
845                if control_msg
846                    .metadata
847                    .get(crate::telemetry::LIFECYCLE_ROOT_METADATA_KEY)
848                    .is_some_and(|version| version == "v1") =>
849            {
850                LifecycleTrace::from_request_id_with_role(control_msg.id.clone(), role)
851            }
852            _ => LifecycleTrace::new(false),
853        };
854
855        // Admission begins after the envelope selects capture, before payload
856        // decoding and response setup. Hold without entering it: response
857        // readers capture the current span and live until the request ends.
858        // They must inherit handle_payload, not prolong worker.admission.
859        let worker_admission = lifecycle.start(LifecycleStage::WorkerAdmission);
860        let ParsedRequest {
861            request,
862            response_connection_info,
863            frontend_send_ts_ns,
864            payload_codec,
865        } = self.parse_and_build_request(control_msg, data).await?;
866
867        // Compute network transit time (T2 - T1) using cross-process wall-clock timestamps
868        if let Some(t1_ns) = frontend_send_ts_ns {
869            let transit_ns = t2_wallclock_ns.saturating_sub(t1_ns);
870            WORK_HANDLER_NETWORK_TRANSIT_SECONDS.observe(transit_ns as f64 / 1_000_000_000.0);
871        }
872
873        let advertised_mode =
874            ResponsePlaneMode::from_transport_name(&response_connection_info.transport)
875                .map_err(|error| PipelineError::Generic(error.to_string()))?;
876        let configured_mode = match self.response_plane.get() {
877            Some(mode) => *mode,
878            None => ResponsePlaneMode::configured()
879                .map_err(|error| PipelineError::Generic(error.to_string()))?,
880        };
881        let response_modes = ResponsePlaneModes {
882            configured: configured_mode,
883            advertised: advertised_mode,
884        };
885        let cancellation_counter = self
886            .metrics()
887            .map(|metrics| metrics.cancellation_total.clone());
888
889        match advertised_mode {
890            ResponsePlaneMode::Tcp => {
891                tracing::trace!("creating tcp response stream");
892                let publisher = tcp::client::TcpClient::create_response_stream(
893                    request.context(),
894                    response_connection_info,
895                    cancellation_counter,
896                )
897                .await
898                .map_err(|error| {
899                    if let Some(metrics) = self.metrics() {
900                        metrics
901                            .error_counter
902                            .with_label_values(&[work_handler::error_types::RESPONSE_STREAM])
903                            .inc();
904                    }
905                    PipelineError::Generic(format!("Failed to create response stream: {error}"))
906                })?;
907                drop(worker_admission);
908                self.generate_and_publish(
909                    request,
910                    payload_codec,
911                    start_time,
912                    response_modes,
913                    &lifecycle,
914                    publisher,
915                )
916                .await?;
917            }
918            ResponsePlaneMode::Quic => {
919                tracing::trace!("creating QUIC response sender");
920                let response_pool = self.quic_response_client_pool()?;
921                let publisher = response_pool
922                    .sender_with_cancellation_metric(
923                        request.context(),
924                        response_connection_info,
925                        cancellation_counter,
926                    )
927                    .await
928                    .map_err(|error| {
929                        if let Some(metrics) = self.metrics() {
930                            metrics
931                                .error_counter
932                                .with_label_values(&[work_handler::error_types::RESPONSE_STREAM])
933                                .inc();
934                        }
935                        PipelineError::Generic(format!(
936                            "Failed to create QUIC response stream: {error}"
937                        ))
938                    })?;
939                drop(worker_admission);
940                self.generate_and_publish(
941                    request,
942                    payload_codec,
943                    start_time,
944                    response_modes,
945                    &lifecycle,
946                    publisher,
947                )
948                .await?;
949            }
950        }
951
952        // Ensure the metrics guard is not dropped until the end of the function.
953        // Drop fires "request completed" log via RAII.
954        drop(_inflight_guard);
955
956        Ok(())
957    }
958}
959
960#[async_trait]
961impl<T, U, Adapter> PushWorkHandler for Ingress<SingleIn<T>, ManyOut<U>, Adapter>
962where
963    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
964    U: Data + std::fmt::Debug,
965    Adapter: IngressPayloadAdapter<T, U> + Send + Sync + 'static,
966{
967    fn bind_endpoint(&self, endpoint: &crate::component::Endpoint) {
968        self.bind_endpoint_config(endpoint);
969    }
970
971    fn add_metrics(
972        &self,
973        endpoint: &crate::component::Endpoint,
974        metrics_labels: Option<&[(&str, &str)]>,
975    ) -> Result<()> {
976        // Call the inherent `Ingress::add_metrics`, not this trait method.
977        Ingress::add_metrics(self, endpoint, metrics_labels)
978    }
979
980    fn set_endpoint_health_check_notifier(&self, notifier: Arc<tokio::sync::Notify>) -> Result<()> {
981        self.endpoint_health_check_notifier
982            .set(notifier)
983            .map_err(|_| anyhow::anyhow!("Endpoint health check notifier already set"))?;
984        Ok(())
985    }
986
987    async fn handle_payload(
988        &self,
989        payload: Bytes,
990        request_id: Option<String>,
991    ) -> Result<(), PipelineError> {
992        self.handle_payload_shared(payload, request_id).await
993    }
994}
995
996#[async_trait]
997impl<T, U, Adapter> PushWorkHandler for Ingress<ManyIn<T>, ManyOut<U>, Adapter>
998where
999    T: Data + for<'de> Deserialize<'de> + std::fmt::Debug,
1000    U: Data + std::fmt::Debug,
1001    Adapter: IngressPayloadAdapter<T, U> + Send + Sync + 'static,
1002{
1003    fn bind_endpoint(&self, endpoint: &crate::component::Endpoint) {
1004        self.bind_endpoint_config(endpoint);
1005    }
1006
1007    fn add_metrics(
1008        &self,
1009        endpoint: &crate::component::Endpoint,
1010        metrics_labels: Option<&[(&str, &str)]>,
1011    ) -> Result<()> {
1012        // Call the inherent `Ingress::add_metrics`, not this trait method.
1013        Ingress::add_metrics(self, endpoint, metrics_labels)
1014    }
1015
1016    fn set_endpoint_health_check_notifier(&self, notifier: Arc<tokio::sync::Notify>) -> Result<()> {
1017        self.endpoint_health_check_notifier
1018            .set(notifier)
1019            .map_err(|_| anyhow::anyhow!("Endpoint health check notifier already set"))?;
1020        Ok(())
1021    }
1022
1023    async fn handle_payload(
1024        &self,
1025        payload: Bytes,
1026        request_id: Option<String>,
1027    ) -> Result<(), PipelineError> {
1028        self.handle_payload_shared(payload, request_id).await
1029    }
1030}
1031
1032/// Recover the worker's typed error from a pipeline failure, for the prologue.
1033///
1034/// `GenerateError` must unwrap its `anyhow::Error` payload first. `anyhow::Error`
1035/// does not implement `std::error::Error`, so that variant exposes no `source()`
1036/// and converting the enclosing `PipelineError` yields a bare
1037/// `ErrorType::Unknown`, losing the worker error type. Any other variant carries
1038/// no worker error and converts to `ErrorType::Unknown`.
1039pub(crate) fn typed_error_from_pipeline_error(e: &PipelineError) -> DynamoError {
1040    let source: &(dyn std::error::Error + 'static) = match e {
1041        PipelineError::GenerateError(inner) => inner.as_ref(),
1042        other => other,
1043    };
1044    DynamoError::from(source)
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050    use crate::pipeline::network::{Ingress, RequestPlanePayloadCodec, StreamSender};
1051    use crate::pipeline::{Context, ManyOut, ResponseStream, SingleIn};
1052    use crate::protocols::annotated::Annotated;
1053    use futures::stream;
1054    use prometheus::{Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, Opts};
1055    use std::sync::atomic::{AtomicBool, Ordering};
1056
1057    use crate::error::{BackendError, ErrorType};
1058
1059    type TestRequest = serde_json::Value;
1060    type TestResponse = Annotated<serde_json::Value>;
1061    type TestIngress = Ingress<SingleIn<TestRequest>, ManyOut<TestResponse>>;
1062
1063    /// The positive half of the recovery hop: a worker's typed refusal, boxed
1064    /// into the `anyhow::Error` payload of `PipelineError::GenerateError`,
1065    /// comes back out with its type intact.
1066    #[test]
1067    fn generate_error_payload_keeps_the_workers_error_type() {
1068        let e = PipelineError::GenerateError(anyhow::Error::new(
1069            DynamoError::builder()
1070                .error_type(ErrorType::Backend(BackendError::InvalidArgument))
1071                .message("multimodal input is not supported by this backend")
1072                .build(),
1073        ));
1074
1075        assert_eq!(
1076            typed_error_from_pipeline_error(&e).error_type(),
1077            ErrorType::Backend(BackendError::InvalidArgument),
1078            "the worker's type must survive the anyhow payload"
1079        );
1080    }
1081
1082    /// The negative half: a failure that is not a worker's `generate()` error
1083    /// has no worker classification and uses the canonical internal fallback.
1084    #[test]
1085    fn non_generate_pipeline_error_is_internal_unclassified() {
1086        let e = PipelineError::DeserializationError("bad request payload".to_string());
1087        let error = typed_error_from_pipeline_error(&e);
1088
1089        assert_eq!(error.class(), ErrorType::Internal);
1090        assert_eq!(error.reason().as_str(), "runtime.unclassified");
1091    }
1092
1093    #[derive(Clone, Default)]
1094    struct AdmissionCapture {
1095        started: Arc<AtomicBool>,
1096        closed: Arc<AtomicBool>,
1097    }
1098
1099    impl<S> tracing_subscriber::Layer<S> for AdmissionCapture
1100    where
1101        S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
1102    {
1103        fn on_new_span(
1104            &self,
1105            attrs: &tracing::span::Attributes<'_>,
1106            _id: &tracing::span::Id,
1107            _ctx: tracing_subscriber::layer::Context<'_, S>,
1108        ) {
1109            if attrs.metadata().name() == "worker.admission" {
1110                self.started.store(true, Ordering::SeqCst);
1111            }
1112        }
1113
1114        fn on_close(&self, id: tracing::span::Id, ctx: tracing_subscriber::layer::Context<'_, S>) {
1115            if ctx.span(&id).unwrap().metadata().name() == "worker.admission" {
1116                self.closed.store(true, Ordering::SeqCst);
1117            }
1118        }
1119    }
1120
1121    struct AdmissionProbe(AdmissionCapture, bool);
1122
1123    #[async_trait]
1124    impl crate::engine::AsyncEngine<SingleIn<TestRequest>, ManyOut<TestResponse>, anyhow::Error>
1125        for AdmissionProbe
1126    {
1127        async fn generate(
1128            &self,
1129            _request: SingleIn<TestRequest>,
1130        ) -> anyhow::Result<ManyOut<TestResponse>> {
1131            assert_eq!(self.0.started.load(Ordering::SeqCst), self.1);
1132            assert_eq!(
1133                self.0.closed.load(Ordering::SeqCst),
1134                self.1,
1135                "admission must close before generation, while the TCP reader is still alive"
1136            );
1137            anyhow::bail!("admission probe finished")
1138        }
1139    }
1140
1141    #[tokio::test]
1142    async fn lifecycle_admission_closes_before_generation_and_skips_control_calls() {
1143        use tracing::instrument::WithSubscriber;
1144        use tracing_subscriber::prelude::*;
1145
1146        // Configuration is process-scoped. Isolate this enabled-mode test from
1147        // other tests which may have initialized the disabled default already.
1148        const CHILD: &str = "DYNAMO_LIFECYCLE_ADMISSION_TEST_CHILD";
1149        if std::env::var_os(CHILD).is_none() {
1150            let status = std::process::Command::new(std::env::current_exe().unwrap())
1151                .args([
1152                    "--exact",
1153                    "pipeline::network::ingress::push_handler::tests::lifecycle_admission_closes_before_generation_and_skips_control_calls",
1154                    "--nocapture",
1155                ])
1156                .env(CHILD, "1")
1157                .env("DYN_LIFECYCLE_TRACE_ENABLED", "true")
1158                .status()
1159                .unwrap();
1160            assert!(status.success(), "isolated admission regression failed");
1161            return;
1162        }
1163
1164        temp_env::async_with_vars(
1165            [
1166                ("DYN_LIFECYCLE_TRACE_ENABLED", Some("true")),
1167                ("DYN_RESPONSE_PLANE", Some("tcp")),
1168            ],
1169            async {
1170                for (inference, rooted) in
1171                    [(true, true), (true, false), (false, true), (false, false)]
1172                {
1173                    let capture = AdmissionCapture::default();
1174                    let subscriber = tracing_subscriber::registry().with(capture.clone());
1175                    async {
1176                        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1177                        let address = listener.local_addr().unwrap().to_string();
1178                        let peer = tokio::spawn(async move {
1179                            let (mut socket, _) = listener.accept().await.unwrap();
1180                            let _ = tokio::io::copy(&mut socket, &mut tokio::io::sink()).await;
1181                        });
1182                        let engine = Arc::new(AdmissionProbe(capture, inference && rooted));
1183                        let ingress = if inference {
1184                            TestIngress::for_engine_with_lifecycle_role(
1185                                engine,
1186                                crate::telemetry::LifecycleOperationRole::Worker,
1187                            )
1188                        } else {
1189                            TestIngress::for_engine(engine)
1190                        }
1191                        .unwrap();
1192                        let connection: ConnectionInfo = tcp::TcpStreamConnectionInfo {
1193                            address,
1194                            subject: "admission-probe".to_string(),
1195                            context: "admission-probe".to_string(),
1196                            stream_type: crate::pipeline::network::StreamType::Response,
1197                        }
1198                        .into();
1199                        let metadata = if rooted {
1200                            std::collections::BTreeMap::from([(
1201                                crate::telemetry::LIFECYCLE_ROOT_METADATA_KEY,
1202                                "v1",
1203                            )])
1204                        } else {
1205                            std::collections::BTreeMap::new()
1206                        };
1207                        let header = serde_json::to_vec(&serde_json::json!({
1208                            "id": "admission-probe",
1209                            "request_type": "single_in",
1210                            "response_type": "many_out",
1211                            "connection_info": connection,
1212                            "metadata": metadata,
1213                        }))
1214                        .unwrap();
1215                        let payload = TwoPartCodec::default()
1216                            .encode_message(TwoPartMessage::from_parts(
1217                                header.into(),
1218                                Bytes::from_static(b"{}"),
1219                            ))
1220                            .unwrap();
1221                        let error = tokio::time::timeout(
1222                            std::time::Duration::from_secs(10),
1223                            ingress
1224                                .handle_payload_shared(payload, Some("admission-probe".to_string()))
1225                                .instrument(tracing::info_span!("handle_payload")),
1226                        )
1227                        .await
1228                        .expect("local admission probe timed out")
1229                        .unwrap_err();
1230                        assert!(error.to_string().contains("admission probe finished"));
1231                        peer.abort();
1232                        let _ = peer.await;
1233                    }
1234                    .with_subscriber(subscriber)
1235                    .await;
1236                }
1237            },
1238        )
1239        .await;
1240    }
1241
1242    #[derive(Default)]
1243    struct MismatchPublisher {
1244        prologue: Arc<std::sync::Mutex<Option<Option<String>>>>,
1245        finished: Arc<AtomicBool>,
1246    }
1247
1248    impl ResponsePublisher for MismatchPublisher {
1249        async fn send(&self, _payload: Bytes) -> anyhow::Result<()> {
1250            panic!("mismatch must not send response data")
1251        }
1252
1253        async fn send_prologue(&mut self, error: Option<String>) -> anyhow::Result<()> {
1254            *self.prologue.lock().unwrap() = Some(error);
1255            Ok(())
1256        }
1257
1258        async fn finish(&mut self) -> anyhow::Result<()> {
1259            self.finished.store(true, Ordering::Release);
1260            Ok(())
1261        }
1262
1263        async fn abort(&mut self) -> anyhow::Result<()> {
1264            panic!("mismatch must finish with a frontend-visible error")
1265        }
1266    }
1267
1268    #[tokio::test]
1269    async fn response_plane_mismatch_reports_error_before_generate() {
1270        for (configured, advertised) in [
1271            (ResponsePlaneMode::Tcp, ResponsePlaneMode::Quic),
1272            (ResponsePlaneMode::Quic, ResponsePlaneMode::Tcp),
1273        ] {
1274            let ingress = TestIngress::new();
1275            let publisher = MismatchPublisher::default();
1276            let prologue = publisher.prologue.clone();
1277            let finished = publisher.finished.clone();
1278            let lifecycle = LifecycleTrace::new(false);
1279
1280            let error = ingress
1281                .generate_and_publish(
1282                    Context::new(serde_json::json!({})),
1283                    RequestPlanePayloadCodec::Json,
1284                    Instant::now(),
1285                    ResponsePlaneModes {
1286                        configured,
1287                        advertised,
1288                    },
1289                    &lifecycle,
1290                    publisher,
1291                )
1292                .await
1293                .expect_err("mismatched response planes must fail");
1294
1295            let expected = format!(
1296                "response plane mismatch: frontend requested {}, worker configured {}",
1297                advertised.name(),
1298                configured.name()
1299            );
1300            assert!(error.to_string().contains(&expected));
1301            assert_eq!(*prologue.lock().unwrap(), Some(Some(expected)));
1302            assert!(finished.load(Ordering::Acquire));
1303        }
1304    }
1305
1306    /// Standalone metrics, not bound to an `Endpoint`, so the test needs no DRT.
1307    fn test_metrics() -> WorkHandlerMetrics {
1308        WorkHandlerMetrics::new(
1309            IntCounter::with_opts(Opts::new("requests_total", "t")).unwrap(),
1310            Histogram::with_opts(HistogramOpts::new("request_duration_seconds", "t")).unwrap(),
1311            IntGauge::with_opts(Opts::new("inflight_requests", "t")).unwrap(),
1312            IntCounter::with_opts(Opts::new("request_bytes_total", "t")).unwrap(),
1313            IntCounter::with_opts(Opts::new("response_bytes_total", "t")).unwrap(),
1314            IntCounterVec::new(
1315                Opts::new(work_handler::ERRORS_TOTAL, "t"),
1316                &[work_handler::ERROR_TYPE_LABEL],
1317            )
1318            .unwrap(),
1319            IntCounter::with_opts(Opts::new("cancellation_total", "t")).unwrap(),
1320        )
1321    }
1322
1323    #[test]
1324    fn test_quic_client_pool_initializes_without_add_metrics() {
1325        let ingress = TestIngress::new();
1326        assert!(ingress.metrics().is_none());
1327
1328        let first = ingress.quic_response_client_pool().unwrap();
1329        let second = ingress.quic_response_client_pool().unwrap();
1330        assert!(Arc::ptr_eq(&first, &second));
1331    }
1332
1333    /// Which half of the teardown race a given run exercises.
1334    #[derive(Clone, Copy, Debug)]
1335    enum Teardown {
1336        /// Frontend sent `ControlMessage::Stop`; the control reader called
1337        /// `context.stop()` (see `tcp/client.rs`).
1338        Stop,
1339        /// Frontend sent `ControlMessage::Kill`; the control reader called
1340        /// `context.kill()`.
1341        Kill,
1342        /// The response-stream reader hit a TCP read error and called
1343        /// `context.kill()` (`tcp/client.rs`, "tcp stream read error").
1344        /// Indistinguishable from `Kill` at the context level, which is
1345        /// exactly why `is_stopped()` alone is too coarse a guard.
1346        ConnectionReadError,
1347        /// The transport died with the client still attached and the context
1348        /// live — a genuine failure that must stay classified as an error.
1349        TransportOnly,
1350    }
1351
1352    /// Drive `pump_response_stream` through the client-teardown ordering:
1353    /// the engine emits `content_frames` chunks, then the upstream reader goes
1354    /// away (mirroring `handle_writer` exiting on `context.stopped()`) while the
1355    /// trailing `complete_final` frame is still unsent.
1356    ///
1357    /// Returns (publish_final count, publish_response count).
1358    async fn run_teardown_race(content_frames: usize, teardown: Teardown) -> (u64, u64) {
1359        let ingress = TestIngress::new();
1360        let metrics = Arc::new(test_metrics());
1361        ingress
1362            .metrics
1363            .set(metrics.clone())
1364            .expect("metrics already set");
1365
1366        // Capacity covers every content frame, so a send only fails once the
1367        // receiver is gone — never merely because the channel is full.
1368        let (tx, mut rx) = tokio::sync::mpsc::channel(content_frames + 8);
1369        let publisher = StreamSender { tx, prologue: None };
1370
1371        let ctx = Context::new(serde_json::json!({}));
1372        let engine_ctx = ctx.context();
1373
1374        // The stream yields its content, then parks until the test has torn the
1375        // receiver down. Ending after the gate (rather than on a timer) is what
1376        // makes the race deterministic.
1377        let (gate_tx, gate_rx) = tokio::sync::oneshot::channel::<()>();
1378        let content: Vec<TestResponse> = (0..content_frames)
1379            .map(|i| Annotated::from_data(serde_json::json!({ "token": i })))
1380            .collect();
1381        let tail = stream::unfold(Some(gate_rx), |state| async move {
1382            // Awaiting then yielding `None` ends the stream, so
1383            // `send_complete_final` stays true and the final frame is attempted.
1384            let gate = state?;
1385            let _ = gate.await;
1386            None
1387        });
1388        let response_stream: ManyOut<TestResponse> = ResponseStream::new(
1389            Box::pin(stream::iter(content).chain(tail)),
1390            engine_ctx.clone(),
1391        );
1392
1393        let pump = tokio::spawn({
1394            let ingress = ingress.clone();
1395            async move {
1396                ingress
1397                    .pump_response_stream(
1398                        response_stream,
1399                        &publisher,
1400                        RequestPlanePayloadCodec::Json,
1401                    )
1402                    .await;
1403            }
1404        });
1405
1406        // Drain the content frames so the pump is past the per-frame branch.
1407        for _ in 0..content_frames {
1408            rx.recv().await.expect("content frame");
1409        }
1410
1411        // Now reproduce the teardown: the frontend has everything it needs and
1412        // drops the request, which kills the worker's writer task.
1413        match teardown {
1414            Teardown::Stop => engine_ctx.stop(),
1415            Teardown::Kill | Teardown::ConnectionReadError => engine_ctx.kill(),
1416            Teardown::TransportOnly => {}
1417        }
1418        drop(rx);
1419        let _ = gate_tx.send(());
1420
1421        pump.await.expect("pump task panicked");
1422
1423        let errors = &metrics.error_counter;
1424        (
1425            errors
1426                .with_label_values(&[work_handler::error_types::PUBLISH_FINAL])
1427                .get(),
1428            errors
1429                .with_label_values(&[work_handler::error_types::PUBLISH_RESPONSE])
1430                .get(),
1431        )
1432    }
1433
1434    /// Losing the `complete_final` send to a client that has already
1435    /// torn down is not a worker error. The per-frame branch already makes this
1436    /// distinction; the final-marker branch must make it too.
1437    #[tokio::test]
1438    async fn test_publish_final_race_with_stopped_context_is_not_an_error() {
1439        let (publish_final, publish_response) = run_teardown_race(3, Teardown::Stop).await;
1440        assert_eq!(
1441            publish_final, 0,
1442            "complete_final lost to a stopped context must not count as an error"
1443        );
1444        assert_eq!(
1445            publish_response, 0,
1446            "content frames were all delivered before teardown"
1447        );
1448    }
1449
1450    /// A killed context must stay a counted error. `is_stopped()` is
1451    /// `state != Live`, so it is true after `kill()` as well — but the
1452    /// response-stream reader kills the context on a TCP read error, so
1453    /// suppressing on `is_stopped()` alone would hide real connection
1454    /// failures from the very counter meant to surface them.
1455    #[tokio::test]
1456    async fn test_publish_final_with_killed_context_is_still_an_error() {
1457        let (publish_final, _) = run_teardown_race(3, Teardown::Kill).await;
1458        assert_eq!(
1459            publish_final, 1,
1460            "a killed context is not a graceful teardown and must still be counted"
1461        );
1462    }
1463
1464    /// The concrete regression: `tcp/client.rs` calls `context.kill()` on a TCP
1465    /// read error ("tcp stream read error, closing connection"). That path must
1466    /// remain visible in `dynamo_component_errors_total`.
1467    #[tokio::test]
1468    async fn test_publish_final_after_connection_read_error_is_still_an_error() {
1469        let (publish_final, _) = run_teardown_race(3, Teardown::ConnectionReadError).await;
1470        assert_eq!(
1471            publish_final, 1,
1472            "a dropped connection must not be silently reclassified as a benign teardown"
1473        );
1474    }
1475
1476    /// Guards against suppressing too much: with the context still live, a failed
1477    /// `complete_final` is a real transport failure and must still be counted.
1478    #[tokio::test]
1479    async fn test_publish_final_failure_without_stop_is_still_an_error() {
1480        let (publish_final, _) = run_teardown_race(3, Teardown::TransportOnly).await;
1481        assert_eq!(
1482            publish_final, 1,
1483            "a genuine complete_final failure must still be counted"
1484        );
1485    }
1486
1487    /// The marker itself is the transport-level end-of-stream signal that
1488    /// non-chat consumers (KV-router worker index queries, disaggregated
1489    /// prefill→decode) rely on to tell a clean end from a truncated one. The
1490    /// classification change must not disturb the clean path: every content
1491    /// frame plus a `complete_final: true` frame still goes out, and nothing is
1492    /// counted as an error.
1493    #[tokio::test]
1494    async fn test_complete_final_marker_still_sent_on_clean_stream() {
1495        let ingress = TestIngress::new();
1496        let metrics = Arc::new(test_metrics());
1497        ingress.metrics.set(metrics.clone()).unwrap();
1498
1499        let content_frames = 3;
1500        let (tx, mut rx) = tokio::sync::mpsc::channel(content_frames + 8);
1501        let publisher = StreamSender { tx, prologue: None };
1502
1503        let ctx = Context::new(serde_json::json!({}));
1504        let content: Vec<TestResponse> = (0..content_frames)
1505            .map(|i| Annotated::from_data(serde_json::json!({ "token": i })))
1506            .collect();
1507        let response_stream: ManyOut<TestResponse> =
1508            ResponseStream::new(Box::pin(stream::iter(content)), ctx.context());
1509
1510        ingress
1511            .pump_response_stream(response_stream, &publisher, RequestPlanePayloadCodec::Json)
1512            .await;
1513        drop(publisher);
1514
1515        let mut frames = Vec::new();
1516        while let Some(msg) = rx.recv().await {
1517            let (_header, data) = msg.into_parts();
1518            frames.push(serde_json::from_slice::<serde_json::Value>(&data).unwrap());
1519        }
1520
1521        assert_eq!(
1522            frames.len(),
1523            content_frames + 1,
1524            "expected every content frame plus the trailing marker"
1525        );
1526        for (i, frame) in frames.iter().take(content_frames).enumerate() {
1527            assert_eq!(frame["complete_final"], false, "content frame {i}");
1528        }
1529        assert_eq!(
1530            frames[content_frames]["complete_final"], true,
1531            "trailing frame must carry the end-of-stream marker"
1532        );
1533
1534        let errors = &metrics.error_counter;
1535        assert_eq!(
1536            errors
1537                .with_label_values(&[work_handler::error_types::PUBLISH_FINAL])
1538                .get(),
1539            0
1540        );
1541        assert_eq!(
1542            errors
1543                .with_label_values(&[work_handler::error_types::PUBLISH_RESPONSE])
1544                .get(),
1545            0
1546        );
1547    }
1548}