ADR-019: Standard Input Is a Stream, and It Is Read Like Everything Else

Status: Accepted Related: ADR-283 (views and what tethers them), ADR-010 (provenance decides the hasher), ADR-296 D19 (a switch chooses the executor and nothing else)


1. Context

Part III 17.1 specifies std::fs down to seek and Cleanup, and says nothing about standard input. That is not a small omission: the Computer Language Benchmarks Game feeds every one of its text programs on stdin, wc-shaped tools read it by definition, and a program in a pipeline has nothing else. Nikaia could open a file by name and could not read what was piped into it.

The question is not whether to add it. It is what a stream may promise, in a language whose file API is built on the fact that a file is not one — and what “looks blocking, is not” means for an input that is empty until somebody at the other end writes.


2. Decisions

D1 — Standard input is a stream, and the surface says so.

No map, no seek, no len, and no second read of the same bytes.

std::fs’s zero-copy story rests on pages that exist before the program asks for them: fs::map hands back a buffer it did not fill, and every view a parser cuts is a view of the file (ADR-283). Stdin’s bytes do not exist until they are read. A program that wants views into its input must therefore own the buffer first, and the API should make that the obvious thing rather than a surprise:

pub fn read_to_string() -> String throws   // all of it, UTF-8 validated
pub fn read() -> Bytes throws              // all of it, as bytes
pub fn lines() -> Lines throws             // one line at a time
pub fn bytes() -> ByteStream throws        // chunks as they arrive

That is std::fs’s shape minus everything a stream cannot keep. The omissions are the specification: a seek that worked while the input fit in a buffer and failed when it did not would be worse than none.

D2 — It looks blocking, it is not, and it is not sync.

The rule is std::fs’s and is stated once for the module: no async on the signature, no await at the call, and the compiler turns the call into a suspension point. On a single-threaded runtime the event loop runs another task while the pipe is empty; with threads the read may resume on a different one. Neither is visible in the source, and the same source compiles under both (ADR-296 D19).

What is visible is the one thing that must be: a sync function cannot call it (Part II, 12.1). That is not a restriction invented here — it is the existing rule doing the job it exists for. A par_iter body is sync precisely so that no core can end up waiting on a pipe, and stdin is the most tempting thing to wait on.

D3 — lines() yields owned text, where a file’s lines are views.

A file’s lines are fs::map(path) and .lines() (ADR-025 D3), two calls because one owns the buffer and the other borrows views of it.

A file’s line can be a view because the file is still there to point at. A stream’s bytes are gone once they have been consumed, so a view would need the whole stream kept — which is read_to_string with extra steps and a worse name.

The types say which one was asked for, and the asymmetry is the honest one: it is a property of streams, not a shortcoming of the implementation. A program that wants both streaming and views is asking for a memory map, and should be reading a file.

D4 — There is one standard input, so the module is functions and not a handle.

io::read_to_string(), not io::stdin().read_to_string(). A handle is worth having when there is something to configure on it or several of the thing; here there is neither, and a handle that can be held invites two tasks to hold it. Two readers of one pipe get interleaved halves of lines, which is a bug with no error message.

Reading it a second time yields what the operating system says: nothing.

D5 — Provenance follows the rule files follow: Trusted, with trusted: false at the entry.

The operator chose what to connect to the pipe exactly as they chose which path to open (ADR-010). ./report < yesterday.log is the operator’s own file arriving by a different route, and it should not pay a keyed hash for that.

The case that argues the other way — a CGI request body on stdin — is the uploaded-file case, and it already has an answer in the same place and the same words:

let body = io::read_to_string(trusted: false)

One rule for where bytes come from, one escape hatch, one line in nikaia.contracts. Splitting the rule so that stdin is Untrusted “because it feels outside” would make the common case pay and would leave two rules to remember.

D6 — Output stays print and println.

std::io is here because there was no way to read standard input. There has been a way to write standard output since the first example, and adding a second one would be a choice where none is needed. Binary output is the open half, not decided here.


3. Consequences

For the benchmarks. k-nucleotide and reverse-complement read stdin; fasta writes stdout.

For the zero-copy argument. A stdin program allocates its input where 1BRC does not, and that is not a regression — it is the difference between a file and a pipe, stated where a reader will meet it. The views a parser cuts out of the owned buffer cost exactly what they cost over a mapping; only the first copy is different.


4. What was rejected

This site is open source. Improve this page.