Skip to main content

objectiveai_cli/
executor.rs

1//! In-process [`CommandExecutor`] implementor backed by the CLI's own
2//! leaf handlers. Lets programmatic consumers drive the SDK's per-leaf
3//! `execute<E>` entry points against the CLI's local logic without
4//! spawning a subprocess.
5//!
6//! `run.rs` also delegates here for non-`instance` argv — the
7//! `objectiveai_sdk::cli::command::execute(&executor, request)` call
8//! flows back through this executor down to each leaf.
9
10use std::any::{Any, TypeId};
11use std::future::Future;
12use std::marker::PhantomData;
13use std::pin::Pin;
14use std::sync::Arc;
15use std::task::{Context as TaskContext, Poll};
16
17use futures::{Stream, StreamExt, TryStreamExt};
18use objectiveai_sdk::cli::command::{
19    AgentArguments, CommandExecutor, CommandRequest, CommandResponse, ResponseItem, Transform,
20    parse_request,
21};
22use serde_json::Value;
23
24use crate::context::Context;
25use crate::error::Error;
26
27/// In-process executor. Owns a [`Context`] and dispatches each
28/// `CommandRequest` through the CLI's local root dispatcher.
29pub struct CliCommandExecutor {
30    ctx: Context,
31    /// Broadcast tee: every PRE-transform response item is serialized
32    /// and sent here (unbounded — a send never blocks stream
33    /// yielding); the writer task `run()` spawned drains it onto the
34    /// daemon's producer socket, and `run()` awaits that writer's
35    /// completion before its returned stream finishes. `None` = this
36    /// execution is not broadcast (nested/internal executors).
37    tee: Option<tokio::sync::mpsc::UnboundedSender<Value>>,
38}
39
40impl CliCommandExecutor {
41    pub fn new(
42        ctx: Context,
43        tee: Option<tokio::sync::mpsc::UnboundedSender<Value>>,
44    ) -> Self {
45        Self { ctx, tee }
46    }
47}
48
49/// Walk down externally-tagged enum wrappers (`{"<Variant>": <inner>}`)
50/// until `from_value::<T>` succeeds, or no more single-key-object layers
51/// remain. The aggregator's variant keys are PascalCase and leaf fields
52/// are snake_case across the SDK, so the first level whose shape matches
53/// `T` is the leaf.
54fn extract_leaf<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, serde_json::Error> {
55    let mut current = value;
56    loop {
57        match serde_json::from_value::<T>(current.clone()) {
58            Ok(t) => return Ok(t),
59            Err(_) => match current {
60                Value::Object(map) if map.len() == 1 => {
61                    current = map.into_iter().next().unwrap().1;
62                }
63                other => return serde_json::from_value::<T>(other),
64            },
65        }
66    }
67}
68
69/// Build a per-call [`Context`] from `base`. When `agent_arguments` is
70/// `Some`, clone the base ctx and overwrite the seven per-request
71/// identity fields on its `Config`: `agent_id`, `agent_full_id`,
72/// `agent_remote`, `response_id`, `response_ids`, `mcp_session_id` are
73/// set verbatim (including `None`, which clears the slot), and
74/// `agent_instance_hierarchy` falls back to `"UNKNOWN"` when missing
75/// because it's a non-nullable String on the cli's `Config`. The
76/// clone's API-client cell is detached afterwards so the memoized
77/// `HttpClient` rebuilds with the overridden identity headers. When
78/// `agent_arguments` is `None`, `base` is borrowed unchanged.
79///
80/// Shared by [`CliCommandExecutor`] and the daemon's `/execute`
81/// WebSocket route (`crate::websockets::daemon_execute`), which applies
82/// the same override to its own resident ctx per request.
83pub(crate) fn apply_agent_arguments<'a>(
84    base: &'a Context,
85    agent_arguments: Option<&AgentArguments>,
86) -> std::borrow::Cow<'a, Context> {
87    match agent_arguments {
88        None => std::borrow::Cow::Borrowed(base),
89        Some(args) => {
90            let mut ctx = base.clone();
91            ctx.config.agent_instance_hierarchy = args
92                .agent_instance_hierarchy
93                .clone()
94                .unwrap_or_else(|| "UNKNOWN".to_string());
95            ctx.config.agent_id = args.agent_id.clone();
96            ctx.config.agent_full_id = args.agent_full_id.clone();
97            ctx.config.agent_remote = args.agent_remote.clone();
98            ctx.config.response_id = args.response_id.clone();
99            ctx.config.response_ids = args.response_ids.clone();
100            ctx.config.mcp_session_id = args.mcp_session_id.clone();
101            ctx.reset_api_client();
102            std::borrow::Cow::Owned(ctx)
103        }
104    }
105}
106
107impl CliCommandExecutor {
108    /// Build the per-call [`Context`] for this execute — see
109    /// [`apply_agent_arguments`].
110    fn resolve_ctx<'a>(
111        &'a self,
112        agent_arguments: Option<&AgentArguments>,
113    ) -> std::borrow::Cow<'a, Context> {
114        apply_agent_arguments(&self.ctx, agent_arguments)
115    }
116}
117
118impl CommandExecutor for CliCommandExecutor {
119    type Error = Error;
120    type Stream<T>
121        = Pin<Box<dyn Stream<Item = Result<T, Self::Error>> + Send>>
122    where
123        T: Send + 'static;
124
125    async fn execute<R, T>(
126        &self,
127        request: R,
128        agent_arguments: Option<&AgentArguments>,
129    ) -> Result<Self::Stream<T>, Self::Error>
130    where
131        R: CommandRequest + Send + serde::Serialize,
132        T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
133    {
134        // Pull the envelope controls off the request up front, before
135        // the stream is built — upcoming conditional transforms consume
136        // these: the output transform (owned: jq / python, python wins),
137        // the timeout cap, and the max-tokens cap.
138        let base = request.request_base();
139        let transform = base.transform();
140        let timeout = base.timeout_seconds;
141        let max_tokens = base.max_tokens;
142
143        // Round-trip the typed request through the cli's `--request` JSON
144        // entry — request -> argv lowering no longer exists. `parse_request`
145        // deserializes the JSON straight back into the aggregate `Request`.
146        let argv = vec![
147            "--request".to_string(),
148            serde_json::to_string(&request).map_err(Error::InlineJson)?,
149        ];
150        let sdk_request = parse_request(&argv).map_err(|e| match e {
151            objectiveai_sdk::cli::command::ParseError::Clap(e) => Error::ClapParse(e),
152            objectiveai_sdk::cli::command::ParseError::FromArgs(e) => Error::FromArgs(e),
153        })?;
154        // Own the ctx so the deferred dispatch future can hold it —
155        // the returned stream is `'static` and outlives this call.
156        let ctx = self.resolve_ctx(agent_arguments).into_owned();
157        // The Python transform adapter needs its own ctx handle (for
158        // the python runtime); clone one before `ctx` is moved into the
159        // dispatch future, but only for a Python transform (jq needs no
160        // ctx).
161        let transform_ctx =
162            matches!(transform, Some(Transform::Python(_))).then(|| ctx.clone());
163
164        // Base stream ("the first one"): don't await the dispatcher
165        // here. Wrap the `Future<Result<Stream<Result<ResponseItem>>>>`
166        // as a stream that, on first poll, drives the future and
167        // flattens into its inner stream — so `execute` returns
168        // instantly and a dispatch setup error surfaces as the stream's
169        // first item rather than an eager `Err`. Box it so the wrappers
170        // below can hold it `Unpin`.
171        let source = futures::stream::once(async move {
172            crate::command::command::execute(&ctx, sdk_request).await
173        })
174        .try_flatten();
175        let source: Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>> =
176            Box::pin(source);
177
178        // Layer the wrapper adapters on top of the base, conditionally.
179        //
180        // Always: convert root `ResponseItem` items to the caller's `T`
181        // ([`ConvertStream`] — identity fast-path when `T ==
182        // ResponseItem`, else a serde round-trip).
183        let mut stream: Self::Stream<T> = Box::pin(ConvertStream::new(source));
184
185        // Broadcast tee: mirror the typed PRE-transform items onto the
186        // daemon's broadcast — the `/listen` wire always carries the
187        // leaf-typed items, never transformed output. Sits below the
188        // transform/token/timeout adapters, so the caps still bound
189        // the broadcast (a stopped downstream stops pulling through
190        // the tee).
191        if let Some(tee) = self.tee.clone() {
192            stream = Box::pin(TeeStream::new(stream, tee));
193        }
194
195        // Conditional: an output transform — each item is run through
196        // the transform and its (non-null) output replaces it; a null
197        // output skips the item. Comes BEFORE the token budget so the
198        // budget counts the transformed output. python overrides jq
199        // (resolved upstream by `RequestBase::transform`).
200        if let Some(transform) = transform {
201            match transform {
202                Transform::Python(code) => {
203                    // A per-stream-item transform must not make nested host
204                    // calls — disable `objectiveai.execute` for it automatically.
205                    let ctx = transform_ctx
206                        .expect("cloned whenever a python transform is present")
207                        .with_no_objectiveai(true);
208                    stream = Box::pin(PythonTransformStream::new(stream, ctx, code));
209                }
210                Transform::Jq(filter) => {
211                    stream = Box::pin(JqTransformStream::new(stream, filter));
212                }
213            }
214        }
215
216        // Conditional: a running token budget over the serialized output
217        // ([`TokenCountStream`]). Sits BEFORE the timeout so the deadline
218        // still bounds it.
219        if let Some(max_tokens) = max_tokens {
220            stream = Box::pin(TokenCountStream::new(stream, max_tokens));
221        }
222
223        // Conditional: a single whole-stream deadline ([`TimeoutStream`]
224        // — anchored at first poll; the stream never outlives it). Last,
225        // so it's the outermost cap over every other adapter.
226        if let Some(timeout_seconds) = timeout {
227            stream = Box::pin(TimeoutStream::new(stream, timeout_seconds));
228        }
229
230        Ok(stream)
231    }
232
233    async fn execute_one<R, T>(
234        &self,
235        request: R,
236        agent_arguments: Option<&AgentArguments>,
237    ) -> Result<T, Self::Error>
238    where
239        R: CommandRequest + Send + serde::Serialize,
240        T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
241    {
242        let mut stream: Self::Stream<T> =
243            self.execute::<R, T>(request, agent_arguments).await?;
244        match stream.next().await {
245            Some(item) => item,
246            None => Err(Error::EmptyStream),
247        }
248    }
249}
250
251/// Pass-through adapter that mirrors every item onto the broadcast
252/// tee. `Ok` items serialize as their JSON; `Err` items serialize as
253/// the structured `{type:"error",...}` line (the same payload
254/// `main.rs::write_error_line` prints). Sends are unbounded and never
255/// block; a send failure (the writer task is gone — daemon down)
256/// permanently disables the tee for this execution, never the
257/// command.
258struct TeeStream<T> {
259    inner: Pin<Box<dyn Stream<Item = Result<T, Error>> + Send>>,
260    tee: Option<tokio::sync::mpsc::UnboundedSender<Value>>,
261}
262
263impl<T> TeeStream<T> {
264    fn new(
265        inner: Pin<Box<dyn Stream<Item = Result<T, Error>> + Send>>,
266        tee: tokio::sync::mpsc::UnboundedSender<Value>,
267    ) -> Self {
268        Self {
269            inner,
270            tee: Some(tee),
271        }
272    }
273}
274
275impl<T> Stream for TeeStream<T>
276where
277    T: serde::Serialize,
278{
279    type Item = Result<T, Error>;
280
281    fn poll_next(
282        self: Pin<&mut Self>,
283        cx: &mut std::task::Context<'_>,
284    ) -> std::task::Poll<Option<Self::Item>> {
285        let this = self.get_mut();
286        let poll = this.inner.as_mut().poll_next(cx);
287        if let std::task::Poll::Ready(Some(item)) = &poll {
288            if let Some(tee) = &this.tee {
289                let json = match item {
290                    Ok(value) => serde_json::to_value(value).ok(),
291                    Err(e) => serde_json::to_value(objectiveai_sdk::cli::Error {
292                        r#type: objectiveai_sdk::cli::ErrorType::Error,
293                        level: Some(objectiveai_sdk::cli::Level::Error),
294                        fatal: None,
295                        message: e.output_message(),
296                    })
297                    .ok(),
298                };
299                if let Some(json) = json {
300                    if tee.send(json).is_err() {
301                        this.tee = None;
302                    }
303                }
304            }
305        }
306        poll
307    }
308}
309
310/// Stream adapter: convert the dispatcher's root [`ResponseItem`] items
311/// into the caller's `T`.
312///
313/// Mode is decided once at construction by a `TypeId` compare:
314/// - `T == ResponseItem`: identity — move each item straight through
315///   with no serialization (the alloc-free `Option` downcast).
316/// - otherwise: serialize to `serde_json::Value` and walk the
317///   externally-tagged wrappers down to `T` via [`extract_leaf`].
318struct ConvertStream<S, T> {
319    inner: S,
320    /// `true` ⇔ `T` is the root `ResponseItem` (take the identity path).
321    identity: bool,
322    _marker: PhantomData<fn() -> T>,
323}
324
325impl<S, T: 'static> ConvertStream<S, T> {
326    fn new(inner: S) -> Self {
327        Self {
328            inner,
329            identity: TypeId::of::<T>() == TypeId::of::<ResponseItem>(),
330            _marker: PhantomData,
331        }
332    }
333}
334
335impl<S, T> Stream for ConvertStream<S, T>
336where
337    S: Stream<Item = Result<ResponseItem, Error>> + Unpin,
338    T: serde::de::DeserializeOwned + 'static,
339{
340    type Item = Result<T, Error>;
341
342    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
343        let this = self.get_mut();
344        match Pin::new(&mut this.inner).poll_next(cx) {
345            Poll::Ready(Some(Ok(item))) => Poll::Ready(Some(convert_item::<T>(item, this.identity))),
346            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
347            Poll::Ready(None) => Poll::Ready(None),
348            Poll::Pending => Poll::Pending,
349        }
350    }
351}
352
353/// Convert one root item to `T`, per the mode [`ConvertStream`] chose.
354fn convert_item<T: serde::de::DeserializeOwned + 'static>(
355    item: ResponseItem,
356    identity: bool,
357) -> Result<T, Error> {
358    if identity {
359        let mut slot = Some(item);
360        Ok((&mut slot as &mut dyn Any)
361            .downcast_mut::<Option<T>>()
362            .and_then(Option::take)
363            .expect("identity flag guarantees T == ResponseItem"))
364    } else {
365        let value = serde_json::to_value(item).map_err(Error::InlineJson)?;
366        extract_leaf::<T>(value).map_err(Error::InlineJson)
367    }
368}
369
370/// Stream adapter: a Python output transform with null-skip.
371///
372/// Each upstream item is fed to the script as the global `input`; the
373/// script's output is taken back. NO output (a null result / nothing
374/// printed) SKIPS the item (nothing is yielded); any other output is
375/// deserialized back into `T` and yielded. Upstream errors pass
376/// through. The runtime call is async, so the in-flight future is held
377/// across polls (one item transformed at a time).
378struct PythonTransformStream<S, T> {
379    inner: S,
380    ctx: Context,
381    code: Arc<str>,
382    pending: Option<Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, Error>> + Send>>>,
383    _marker: PhantomData<fn() -> T>,
384}
385
386impl<S, T> PythonTransformStream<S, T> {
387    fn new(inner: S, ctx: Context, code: String) -> Self {
388        Self {
389            inner,
390            ctx,
391            code: Arc::from(code),
392            pending: None,
393            _marker: PhantomData,
394        }
395    }
396}
397
398impl<S, T> Stream for PythonTransformStream<S, T>
399where
400    S: Stream<Item = Result<T, Error>> + Unpin,
401    T: serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
402{
403    type Item = Result<T, Error>;
404
405    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
406        let this = self.get_mut();
407        loop {
408            // Drive an in-flight transform to completion first. The
409            // script's output arrives as a raw `Value`: a null result
410            // (or no output) skips the item; only a non-null `Value` is
411            // deserialized into `T` and yielded.
412            if let Some(fut) = this.pending.as_mut() {
413                match fut.as_mut().poll(cx) {
414                    Poll::Ready(Ok(Some(value))) => {
415                        this.pending = None;
416                        if !value.is_null() {
417                            return Poll::Ready(Some(
418                                serde_json::from_value::<T>(value).map_err(Error::InlineJson),
419                            ));
420                        }
421                        // null output → skip; fall through to the next item.
422                    }
423                    // no output → skip; fall through to pull the next item.
424                    Poll::Ready(Ok(None)) => this.pending = None,
425                    Poll::Ready(Err(e)) => {
426                        this.pending = None;
427                        return Poll::Ready(Some(Err(e)));
428                    }
429                    Poll::Pending => return Poll::Pending,
430                }
431            }
432            // No in-flight transform — pull the next upstream item.
433            match Pin::new(&mut this.inner).poll_next(cx) {
434                Poll::Ready(Some(Ok(item))) => {
435                    let ctx = this.ctx.clone();
436                    let code = Arc::clone(&this.code);
437                    this.pending = Some(Box::pin(async move {
438                        transform_item::<T>(&ctx, &code, item).await
439                    }));
440                    // loop to poll the freshly-built future
441                }
442                Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
443                Poll::Ready(None) => return Poll::Ready(None),
444                Poll::Pending => return Poll::Pending,
445            }
446        }
447    }
448}
449
450/// Run the Python transform for one item: feed it as `input`, take the
451/// output as a raw `serde_json::Value` (NOT yet `T`). `None` ⇔ the
452/// script produced no output. The caller decides skip-vs-yield: a null
453/// `Value` (or `None`) skips, and only a non-null `Value` is
454/// deserialized into `T` — so a non-nullable `T` never errors on a
455/// "skip" result.
456async fn transform_item<I: serde::Serialize>(
457    ctx: &Context,
458    code: &str,
459    item: I,
460) -> Result<Option<serde_json::Value>, Error> {
461    ctx.python().await?.exec_code(ctx, code, Some(item)).await
462}
463
464/// Stream adapter: a jq output transform with null-skip.
465///
466/// Each upstream item is run through the jq `filter` (jaq, via
467/// [`crate::filesystem::run_jq`]); the multi-result vector is collapsed
468/// the historical way — 0 results → null, exactly 1 → that result
469/// AS-IS, >1 → an array. A null collapsed result SKIPS the item (so a
470/// jq `select(...)` that yields nothing filters it out); any other
471/// result is deserialized into `T` and yielded. Upstream errors pass
472/// through. jaq is a synchronous CPU call, so the transform runs inline
473/// in `poll_next` — no held future.
474struct JqTransformStream<S, T> {
475    inner: S,
476    filter: Arc<str>,
477    _marker: PhantomData<fn() -> T>,
478}
479
480impl<S, T> JqTransformStream<S, T> {
481    fn new(inner: S, filter: String) -> Self {
482        Self {
483            inner,
484            filter: Arc::from(filter),
485            _marker: PhantomData,
486        }
487    }
488}
489
490impl<S, T> Stream for JqTransformStream<S, T>
491where
492    S: Stream<Item = Result<T, Error>> + Unpin,
493    T: serde::Serialize + serde::de::DeserializeOwned,
494{
495    type Item = Result<T, Error>;
496
497    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
498        let this = self.get_mut();
499        loop {
500            match Pin::new(&mut this.inner).poll_next(cx) {
501                Poll::Ready(Some(Ok(item))) => match jq_apply::<T>(&item, &this.filter) {
502                    Ok(Some(t)) => return Poll::Ready(Some(Ok(t))),
503                    // null collapsed result → skip; loop to the next item.
504                    Ok(None) => {}
505                    Err(e) => return Poll::Ready(Some(Err(e))),
506                },
507                Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
508                Poll::Ready(None) => return Poll::Ready(None),
509                Poll::Pending => return Poll::Pending,
510            }
511        }
512    }
513}
514
515/// Run the jq filter for one item and collapse its results the
516/// historical way (length-1 → as-is). `None` ⇔ the collapsed result is
517/// null (skip the item); otherwise the result deserialized into `T`.
518fn jq_apply<T: serde::Serialize + serde::de::DeserializeOwned>(
519    item: &T,
520    filter: &str,
521) -> Result<Option<T>, Error> {
522    let mut results = crate::filesystem::run_jq(item, filter).map_err(Error::Filesystem)?;
523    let value = match results.len() {
524        0 => serde_json::Value::Null,
525        1 => results.remove(0),
526        _ => serde_json::Value::Array(results),
527    };
528    if value.is_null() {
529        Ok(None)
530    } else {
531        serde_json::from_value::<T>(value)
532            .map(Some)
533            .map_err(Error::InlineJson)
534    }
535}
536
537/// Stream adapter: a running token budget over the serialized output.
538///
539/// Each item is `serde_json::to_string`'d and tokenized (tiktoken
540/// `o200k_base`, the same encoding the old `db query` budget used); the
541/// token count is added to a running total. The moment the total
542/// exceeds `max_tokens`, the adapter yields one final
543/// `Err(Error::TokenBudgetExceeded)` IN PLACE OF the item that pushed
544/// it over, and then ends. The encoder is built once at construction.
545struct TokenCountStream<S> {
546    inner: S,
547    max_tokens: u64,
548    total: u64,
549    encoder: tiktoken_rs::CoreBPE,
550    /// Set once the budget was blown or the inner stream ended.
551    finished: bool,
552}
553
554impl<S> TokenCountStream<S> {
555    fn new(inner: S, max_tokens: u64) -> Self {
556        Self {
557            inner,
558            max_tokens,
559            total: 0,
560            // Embedded BPE data — loading it is effectively infallible.
561            encoder: tiktoken_rs::o200k_base()
562                .expect("o200k_base BPE data is embedded and always loads"),
563            finished: false,
564        }
565    }
566}
567
568impl<S, I> Stream for TokenCountStream<S>
569where
570    S: Stream<Item = Result<I, Error>> + Unpin,
571    I: serde::Serialize,
572{
573    type Item = Result<I, Error>;
574
575    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
576        let this = self.get_mut();
577        if this.finished {
578            return Poll::Ready(None);
579        }
580        match Pin::new(&mut this.inner).poll_next(cx) {
581            Poll::Ready(Some(Ok(item))) => {
582                // Tally before yielding: serialize, tokenize, accumulate.
583                let json = match serde_json::to_string(&item) {
584                    Ok(s) => s,
585                    Err(e) => {
586                        this.finished = true;
587                        return Poll::Ready(Some(Err(Error::InlineJson(e))));
588                    }
589                };
590                this.total += this.encoder.encode_with_special_tokens(&json).len() as u64;
591                if this.total > this.max_tokens {
592                    this.finished = true;
593                    return Poll::Ready(Some(Err(Error::TokenBudgetExceeded {
594                        limit: this.max_tokens,
595                        actual: this.total,
596                    })));
597                }
598                Poll::Ready(Some(Ok(item)))
599            }
600            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
601            Poll::Ready(None) => {
602                this.finished = true;
603                Poll::Ready(None)
604            }
605            Poll::Pending => Poll::Pending,
606        }
607    }
608}
609
610/// Stream adapter: a single whole-stream deadline.
611///
612/// The `tokio::time::sleep` is created on the FIRST `poll_next`, so the
613/// deadline anchors at first poll, not at construction. Every poll
614/// races the deadline against the inner stream; once it elapses — even
615/// mid-wait on a slow item — the adapter yields one final
616/// `Err(Error::Timeout)` and then ends. The stream can never outlive
617/// `timeout_seconds`.
618struct TimeoutStream<S> {
619    inner: S,
620    timeout_seconds: u64,
621    /// Created lazily on first poll so the deadline anchors there.
622    deadline: Option<Pin<Box<tokio::time::Sleep>>>,
623    /// Set once the deadline fired or the inner stream ended.
624    finished: bool,
625}
626
627impl<S> TimeoutStream<S> {
628    fn new(inner: S, timeout_seconds: u64) -> Self {
629        Self {
630            inner,
631            timeout_seconds,
632            deadline: None,
633            finished: false,
634        }
635    }
636}
637
638impl<S, I> Stream for TimeoutStream<S>
639where
640    S: Stream<Item = Result<I, Error>> + Unpin,
641{
642    type Item = Result<I, Error>;
643
644    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
645        let this = self.get_mut();
646        if this.finished {
647            return Poll::Ready(None);
648        }
649        let secs = this.timeout_seconds;
650        let deadline = this.deadline.get_or_insert_with(|| {
651            Box::pin(tokio::time::sleep(std::time::Duration::from_secs(secs)))
652        });
653        // Deadline first: it's a hard cap, so it wins ties.
654        if deadline.as_mut().poll(cx).is_ready() {
655            this.finished = true;
656            return Poll::Ready(Some(Err(Error::Timeout {
657                timeout_seconds: secs,
658            })));
659        }
660        match Pin::new(&mut this.inner).poll_next(cx) {
661            Poll::Ready(Some(item)) => Poll::Ready(Some(item)),
662            Poll::Ready(None) => {
663                this.finished = true;
664                Poll::Ready(None)
665            }
666            Poll::Pending => Poll::Pending,
667        }
668    }
669}