Skip to main content

asimov_runner/
input.rs

1// This is free and unencumbered software released into the public domain.
2
3//! Byte and JSONL batch sources for a child process's standard input.
4//!
5//! The content-specific aliases all refer to [`Input`]; they express a program
6//! pattern's expected payload without imposing an encoding or validating bytes.
7//! [`NoInput`] instead represents a pattern that has no input parameter.
8
9use alloc::boxed::Box;
10use derive_more::Debug;
11use tokio::io::AsyncRead;
12
13/// An input stream with no prescribed content type.
14pub type AnyInput = Input;
15/// JSONL graph input. With `std`, graph consumers adapt [`Input::AsyncRead`] into
16/// line batches; `Input::Jsonl` connects a graph producer's output directly to a consumer.
17pub type GraphInput = Input;
18/// The absence of an input value for a program pattern.
19pub type NoInput = ();
20/// An input stream intended to contain a SPARQL query.
21pub type QueryInput = Input;
22/// An input stream intended to contain text, without enforcing an encoding.
23pub type TextInput = Input;
24
25/// The source of bytes to supply to a child's stdin.
26///
27/// Program wrappers configure the child's stdin from this value and feed the
28/// resulting pipe during execution. Graph-output runners transfer ownership of
29/// their input into the returned stream. Buffered runners consume it in place.
30/// Repeated executions never replay bytes that have already been read. Early
31/// exit or cancellation can leave input partially consumed, including a partly
32/// written record; reusing it does not guarantee record-boundary resumption.
33///
34/// With `std` enabled, conversion to `Stdio` only selects null or piped stdin;
35/// it does not transfer bytes. The consuming conversion also drops any stored
36/// reader or batch stream. Use `Input::as_stdio` to preserve the source for execution.
37#[derive(Debug)]
38pub enum Input {
39    /// Supplies no bytes by configuring stdin to read from the null device.
40    Ignored,
41    /// Supplies bytes from an owned asynchronous reader through a stdin pipe.
42    ///
43    /// The reader must support use across asynchronous tasks (`Send + Sync`)
44    /// and unpinned I/O (`Unpin`). Its contents are omitted from debug output.
45    AsyncRead(#[debug(skip)] Box<dyn AsyncRead + Send + Sync + Unpin>),
46    /// Supplies JSONL batches, applying backpressure and propagating source errors.
47    /// Contiguous terminated batches are written directly; fragmented batches
48    /// use bounded vectored I/O when supported, or a reusable copy buffer. Batch
49    /// boundaries are not encoded on the wire. Empty batches are ignored.
50    /// Existing line endings are preserved; an LF is appended to any line that
51    /// does not end in LF so adjacent records cannot run together.
52    /// An empty line therefore writes a blank line. Line constructors enforce
53    /// framing; UTF-8, JSON, and RDF mapping profiles are not validated here.
54    #[cfg(feature = "std")]
55    Jsonl(#[debug(skip)] crate::JsonlStream),
56}
57
58impl Input {
59    /// Selects the child's stdin configuration without consuming this input.
60    ///
61    /// Returns null stdin for [`Ignored`](Self::Ignored) and a pipe for
62    /// [`AsyncRead`](Self::AsyncRead) and [`Jsonl`](Self::Jsonl). The caller must
63    /// still feed the child's pipe after spawning it.
64    #[cfg(feature = "std")]
65    pub fn as_stdio(&self) -> std::process::Stdio {
66        use std::process::Stdio;
67        match self {
68            Input::Ignored => Stdio::null(),
69            Input::AsyncRead(_) => Stdio::piped(),
70            Input::Jsonl(_) => Stdio::piped(),
71        }
72    }
73
74    /// Adapts byte input to batched JSONL input using default thresholds.
75    ///
76    /// Wraps [`AsyncRead`](Self::AsyncRead) using [`crate::jsonl_batches`]; other
77    /// variants are returned unchanged. Adaptation is lazy and performs no I/O.
78    /// When fed to a child, a final unterminated line gains an LF as described
79    /// by [`Jsonl`](Self::Jsonl).
80    #[cfg(feature = "std")]
81    pub fn into_jsonl(self) -> Self {
82        self.into_jsonl_with_batching(crate::BatchOptions::default())
83    }
84
85    /// Adapts an asynchronous reader into JSONL batches with the supplied policy.
86    /// Existing batch streams and ignored input are returned unchanged. To rebatch
87    /// a stream, combine [`crate::flatten_batches`] and [`crate::batch_lines`].
88    #[cfg(feature = "std")]
89    pub fn into_jsonl_with_batching(self, options: crate::BatchOptions) -> Self {
90        match self {
91            Self::AsyncRead(reader) => Self::Jsonl(crate::jsonl_batches(reader, options)),
92            input => input,
93        }
94    }
95
96    #[cfg(feature = "std")]
97    pub(crate) async fn write_to(
98        &mut self,
99        stdin: Option<tokio::process::ChildStdin>,
100    ) -> Result<(), crate::completion::InputFailure> {
101        use crate::completion::InputFailure;
102        use tokio::io::{AsyncReadExt, AsyncWriteExt};
103
104        if matches!(self, Self::Ignored) {
105            return Ok(());
106        }
107        let mut stdin = stdin.ok_or_else(|| {
108            std::io::Error::new(std::io::ErrorKind::InvalidInput, "stdin must be piped")
109        })?;
110        match self {
111            Self::Ignored => {},
112            Self::AsyncRead(reader) => {
113                let mut buffer = [0; 8192];
114                loop {
115                    let count = reader
116                        .read(&mut buffer)
117                        .await
118                        .map_err(|error| InputFailure::Source(error.into()))?;
119                    if count == 0 {
120                        break;
121                    }
122                    stdin.write_all(&buffer[..count]).await?;
123                }
124            },
125            Self::Jsonl(batches) => {
126                write_batches(batches, &mut stdin).await?;
127            },
128        }
129        stdin.shutdown().await?;
130        Ok(())
131    }
132}
133
134#[cfg(feature = "std")]
135async fn write_batches(
136    batches: &mut crate::JsonlStream,
137    writer: &mut (impl tokio::io::AsyncWrite + Unpin),
138) -> Result<(), crate::completion::InputFailure> {
139    use crate::StreamExt;
140    use crate::completion::InputFailure;
141    use tokio::io::AsyncWriteExt;
142
143    let mut buffer = alloc::vec::Vec::new();
144    while let Some(batch) = batches.next().await {
145        let batch = batch.map_err(InputFailure::Source)?;
146        if batch.is_empty() {
147            tokio::task::yield_now().await;
148            continue;
149        }
150        if let Some(bytes) = batch.as_contiguous_bytes() {
151            writer.write_all(bytes).await?;
152            continue;
153        }
154        if writer.is_write_vectored() {
155            // Sixteen is a conservative portable iovec bound. Coalescing shared
156            // spans usually needs far fewer; larger fragmented batches use the
157            // copy fallback rather than issuing many tiny vectored writes.
158            if let Some(mut slices) = batch.wire_slices(16) {
159                let mut remaining = slices.as_mut_slice();
160                while !remaining.is_empty() {
161                    let written = writer.write_vectored(remaining).await?;
162                    if written == 0 {
163                        return Err(std::io::Error::new(
164                            std::io::ErrorKind::WriteZero,
165                            "failed to write JSONL batch",
166                        )
167                        .into());
168                    }
169                    std::io::IoSlice::advance_slices(&mut remaining, written);
170                }
171                continue;
172            }
173        }
174        buffer.clear();
175        for line in batch.lines() {
176            buffer.extend_from_slice(line);
177            if !line.ends_with(b"\n") {
178                buffer.push(b'\n');
179            }
180        }
181        // write_all handles partial writes and backpressure. There is no
182        // per-line flush or additional batch framing on the wire.
183        writer.write_all(&buffer).await?;
184    }
185    Ok(())
186}
187
188#[cfg(all(test, feature = "std"))]
189mod tests {
190    use super::*;
191    use crate::{ExecutorError, JsonlBatch, JsonlStream, completion::InputFailure};
192    use alloc::{vec, vec::Vec};
193    use core::{
194        pin::Pin,
195        task::{Context, Poll},
196    };
197    use std::io;
198    use tokio::io::AsyncWrite;
199
200    struct Destination {
201        bytes: Vec<u8>,
202        writes: usize,
203        max_write: usize,
204        vectored: bool,
205        vectored_calls: usize,
206    }
207    impl AsyncWrite for Destination {
208        fn poll_write(
209            mut self: Pin<&mut Self>,
210            _: &mut Context<'_>,
211            bytes: &[u8],
212        ) -> Poll<io::Result<usize>> {
213            let count = bytes.len().min(self.max_write);
214            self.bytes.extend_from_slice(&bytes[..count]);
215            self.writes += 1;
216            Poll::Ready(Ok(count))
217        }
218        fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<io::Result<()>> {
219            panic!("batches must not flush per line");
220        }
221        fn is_write_vectored(&self) -> bool {
222            self.vectored
223        }
224        fn poll_write_vectored(
225            mut self: Pin<&mut Self>,
226            _: &mut Context<'_>,
227            slices: &[io::IoSlice<'_>],
228        ) -> Poll<io::Result<usize>> {
229            self.vectored_calls += 1;
230            let mut written = 0;
231            for slice in slices {
232                let count = slice.len().min(self.max_write - written);
233                self.bytes.extend_from_slice(&slice[..count]);
234                written += count;
235                if written == self.max_write {
236                    break;
237                }
238            }
239            Poll::Ready(Ok(written))
240        }
241        fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<io::Result<()>> {
242            Poll::Ready(Ok(()))
243        }
244    }
245
246    #[tokio::test]
247    async fn coalesces_batches_preserving_line_endings_and_handling_partial_writes() {
248        for max_write in [usize::MAX, 3] {
249            let mut batches: JsonlStream = Box::pin(crate::stream::iter([
250                Ok(JsonlBatch::default()),
251                Ok(
252                    JsonlBatch::try_from(vec![b"{}".to_vec(), b"[]\r\n".to_vec(), Vec::new()])
253                        .unwrap(),
254                ),
255                Ok(JsonlBatch::try_from(vec![b"last".to_vec()]).unwrap()),
256            ]));
257            let mut destination = Destination {
258                bytes: Vec::new(),
259                writes: 0,
260                max_write,
261                vectored: false,
262                vectored_calls: 0,
263            };
264            assert!(write_batches(&mut batches, &mut destination).await.is_ok());
265            assert_eq!(destination.bytes, b"{}\n[]\r\n\nlast\n");
266            if max_write == usize::MAX {
267                assert_eq!(destination.writes, 2);
268            }
269        }
270    }
271
272    #[tokio::test]
273    async fn batch_source_error_stops_writing_after_complete_batches() {
274        let mut batches: JsonlStream = Box::pin(crate::stream::iter([
275            Ok(JsonlBatch::try_from(vec![b"first".to_vec()]).unwrap()),
276            Err(ExecutorError::UnexpectedOther(io::Error::other(
277                "source failed",
278            ))),
279            Ok(JsonlBatch::try_from(vec![b"must not be written".to_vec()]).unwrap()),
280        ]));
281        let mut destination = Destination {
282            bytes: Vec::new(),
283            writes: 0,
284            max_write: usize::MAX,
285            vectored: false,
286            vectored_calls: 0,
287        };
288        assert!(matches!(
289            write_batches(&mut batches, &mut destination).await,
290            Err(InputFailure::Source(_))
291        ));
292        assert_eq!(destination.bytes, b"first\n");
293    }
294
295    #[tokio::test]
296    async fn vectored_writes_handle_partial_progress_and_insert_missing_lf() {
297        let mut batches: JsonlStream =
298            Box::pin(crate::stream::iter([Ok(JsonlBatch::try_from(vec![
299                b"ab\n".to_vec(),
300                Vec::new(),
301                b"cd".to_vec(),
302            ])
303            .unwrap())]));
304        let mut destination = Destination {
305            bytes: Vec::new(),
306            writes: 0,
307            max_write: 2,
308            vectored: true,
309            vectored_calls: 0,
310        };
311        assert!(write_batches(&mut batches, &mut destination).await.is_ok());
312        assert_eq!(destination.bytes, b"ab\n\ncd\n");
313        assert_eq!(destination.writes, 0);
314        assert!(destination.vectored_calls > 1);
315    }
316
317    #[tokio::test]
318    async fn contiguous_batches_and_fragmented_fallback_use_single_writes() {
319        use crate::Bytes;
320        let backing = Bytes::from_static(b"a\nb\n");
321        let shared = JsonlBatch::from_bytes(backing);
322        let fragmented = JsonlBatch::try_from(vec![b"x\n".to_vec(); 32]).unwrap();
323        let mut batches: JsonlStream = Box::pin(crate::stream::iter([Ok(shared), Ok(fragmented)]));
324        let mut destination = Destination {
325            bytes: Vec::new(),
326            writes: 0,
327            max_write: usize::MAX,
328            vectored: true,
329            vectored_calls: 0,
330        };
331        assert!(write_batches(&mut batches, &mut destination).await.is_ok());
332        assert_eq!(
333            destination.bytes,
334            [b"a\nb\n".to_vec(), b"x\n".repeat(32)].concat()
335        );
336        assert_eq!(destination.writes, 2);
337        assert_eq!(destination.vectored_calls, 0);
338    }
339
340    #[tokio::test]
341    async fn zero_vectored_progress_is_an_error() {
342        let mut batches: JsonlStream =
343            Box::pin(crate::stream::iter([Ok(JsonlBatch::try_from(vec![
344                b"a".to_vec(),
345                b"b".to_vec(),
346            ])
347            .unwrap())]));
348        let mut destination = Destination {
349            bytes: Vec::new(),
350            writes: 0,
351            max_write: 0,
352            vectored: true,
353            vectored_calls: 0,
354        };
355        assert!(
356            matches!(write_batches(&mut batches, &mut destination).await,
357            Err(InputFailure::Write(error)) if error.kind() == io::ErrorKind::WriteZero)
358        );
359    }
360}
361
362#[cfg(feature = "std")]
363impl Into<std::process::Stdio> for Input {
364    fn into(self) -> std::process::Stdio {
365        use std::process::Stdio;
366        match self {
367            Input::Ignored => Stdio::null(),
368            Input::AsyncRead(_) => Stdio::piped(),
369            Input::Jsonl(_) => Stdio::piped(),
370        }
371    }
372}