Skip to content

std/io ​

Stdin/stdout and owned file handles. (Read-by-path helpers — readFile, readLines, splitLines — live in std/fs.)

milo
from "std/io" import { File, readLine, writeStdout }

Example ​

Count the lines on stdin, then write to a file:

milo
var n = 0
while let Some(_) = readLine() {
    n += 1
}
var f = File.openWrite("count.txt")!
let _ = f.writeAll($"{n}\n")

API reference ​

AsFd ​

milo
pub trait AsFd

A handle that owns (or, for the stdio streams, outlives) an OS descriptor. Generic std calls that only need the number for the duration of the call take &T with T: AsFd (Select.onRead, waitReadable, isTerminal, OwnedFd.dup), so the borrow checker, not the caller, guarantees the owner is alive while they use it.

rawFd is the escape hatch and every std impl declares it @unsafe: the integer it returns outlives the borrow it came from, and once the owner drops it names whatever the kernel hands that number to next. Implement it on your own owning type to make that type usable with the generic calls.

AsFd.rawFd ​

milo
fn AsFd.rawFd(self: &AsFd): i32

The descriptor number, still owned by this handle: valid only while it lives.

BufReader ​

milo
pub struct BufReader<R: Reader>

Buffered reads over any Reader, and the reason the Reader trait exists: without it readByte is one read(2) per byte.

It OWNS the source. BufReader<File>.new(f) consumes the File and closes it on drop; BufReader<FdStream>.new(sock.stream()) borrows the descriptor instead and leaves the socket writable. Spell the type argument — a bare BufReader.new(f) does not resolve on a generic struct today.

Fields: inner: R, buf: string, pos: i64, cap: i64, ended: bool.

BufReader.new ​

milo
fn BufReader.new(inner: R): BufReader<R>

BufReader.readAll ​

milo
fn BufReader.readAll(self: &mut BufReader): Result<string, IoError>

Everything left, to end of stream.

BufReader.readByte ​

milo
fn BufReader.readByte(self: &mut BufReader): Result<i64, IoError>

Next byte as 0..255, or -1 at end of stream.

BufReader.readExact ​

milo
fn BufReader.readExact(self: &mut BufReader, n: i64): Result<string, IoError>

Exactly n bytes, or an error naming how many the stream actually had.

BufReader.readLine ​

milo
fn BufReader.readLine(self: &mut BufReader): Result<Option<string>, IoError>

One line without its terminator. Strips a trailing CR so CRLF input reads the same as LF input. None at end of stream — the while let loop condition.

BufReader.readUntil ​

milo
fn BufReader.readUntil(self: &mut BufReader, delim: u8): Result<Option<string>, IoError>

Bytes up to (and consuming) delim, with the delimiter itself stripped. None means the stream was already at its end; a final unterminated run comes back as Some, so no input is ever silently discarded.

BufReader.withCapacity ​

milo
fn BufReader.withCapacity(inner: R, cap: i64): BufReader<R>

BufWriter ​

milo
pub struct BufWriter<W: Writer>

Buffered writes over any Writer, collapsing many small writes into one.

Unflushed bytes are not lost if you forget to flush: drop flushes. If that last flush fails there is nowhere to return the error to, so it goes to stderr rather than vanishing. Call flush() explicitly wherever a write error is something your program can act on — that is the only way to handle one.

There is deliberately no intoInner(): a struct with a Drop impl cannot have a field moved out of it, and giving up flush-on-drop to buy one back would be the wrong trade.

Fields: inner: W, buf: string, cap: i64.

BufWriter.new ​

milo
fn BufWriter.new(inner: W): BufWriter<W>

BufWriter.pending ​

milo
fn BufWriter.pending(self: &BufWriter): i64

Buffered bytes not yet handed down.

BufWriter.withCapacity ​

milo
fn BufWriter.withCapacity(inner: W, cap: i64): BufWriter<W>

BufWriter.writeByte ​

milo
fn BufWriter.writeByte(self: &mut BufWriter, b: u8): Result<Unit, IoError>

BufWriter.writeLine ​

milo
fn BufWriter.writeLine(self: &mut BufWriter, line: &string): Result<Unit, IoError>

BytesReader ​

milo
pub struct BytesReader

A Reader over bytes already in hand. Owns its buffer — there is no borrowing variant, because a struct cannot hold a &string.

Fields: data: string, pos: i64.

BytesReader.new ​

milo
fn BytesReader.new(data: string): BytesReader

BytesReader.remaining ​

milo
fn BytesReader.remaining(self: &BytesReader): i64

BytesWriter ​

milo
pub struct BytesWriter

A Writer that accumulates into memory — the sink for "render this to a string", and the test double for anything that takes a Writer.

Fields: buf: string.

BytesWriter.len ​

milo
fn BytesWriter.len(self: &BytesWriter): i64

BytesWriter.new ​

milo
fn BytesWriter.new(): BytesWriter

BytesWriter.take ​

milo
fn BytesWriter.take(self: &mut BytesWriter): string

Take the accumulated bytes out, leaving the writer empty and reusable.

FdReader ​

milo
pub struct FdReader

A read cursor over a raw fd whose read strategy is fixed at construction: under the green scheduler it parks the task on the event loop at EAGAIN; on a plain OS thread it blocks in the kernel. attach() decides once from runtime truth and welds that choice to the fd's O_NONBLOCK state, so no caller ever threads an "in green?" flag — the sharp edge where a wrong bool either stalls the whole cooperative runtime (a blocking read inside a green task) or spins treating EAGAIN as EOF (parking with no scheduler). Illegal states are unrepresentable: a Green reader implies a non-blocking fd, a Blocking one a blocking read path.

The reader borrows the fd; it does not own or close it. Keep the owning source (File / Pty / Child / socket) alive for the reader's lifetime. The fields are private so the only ways to aim one at a descriptor are FdReader.attach on a handle and the raw fdReaderAttach.

FdReader.attach ​

milo
fn FdReader.attach<T: AsFd>(h: &T): FdReader

A reader over h's descriptor: readFrameWith(FdReader.attach(pipe)) reads a framed protocol off a pipe, socket or pty without its number ever reaching the caller. The same view rule as OwnedFd.stream: h must outlive the reader. The reader copies h's number and does not keep h alive: build it where it is used (as readFrameWith(FdReader.attach(h)) does), not stored past h. It never closes, so a stale reader misreads rather than double-closes. Closing this gap needs a reader that borrows its handle.

FdReader.readByte ​

milo
fn FdReader.readByte(self: &FdReader): i64

Read one byte, returned as 0..255. Returns -1 at EOF or on error.

FdReader.readExact ​

milo
fn FdReader.readExact(self: &FdReader, n: i64): Result<string>

Read exactly n bytes into a string. Err if the stream ends first.

FdStream ​

milo
pub struct FdStream

A borrowed byte stream over an OS handle — the adapter that turns anything with a descriptor (file, socket, pipe, pty, child stdio) into a Reader and a Writer.

It does NOT own the handle and will never close it. Keep the owning value (File, TcpStream, Pty, Child) alive and open for as long as the stream is in use; this is the same rule FdReader and fdChannel already carry, and it is what makes a simultaneous BufReader and BufWriter on one socket expressible at all.

The compiler cannot check that rule — a descriptor number is an integer, not a reference — so this is the one sharp edge in the streams API:

BufReader&lt;FdStream>.new(File.openRead(p)!.stream())   // WRONG: the File is a
                                                      // temporary and closes
                                                      // at the end of the line
var f = File.openRead(p)!                             // right: the handle has
BufReader&lt;FdStream>.new(f.stream())                   // a name and outlives it

When you do not need the handle back, do not use a view at all: File implements Reader and Writer itself, so BufReader<File>.new(f) takes ownership and the question cannot arise.

FdStream.onFd ​

milo
fn FdStream.onFd(fd: i32): FdStream

A CRT file descriptor: file, pipe, tty, pty. @unsafe: the view outlives nothing, so the caller vouches the owner of fd outlives the stream. The safe spellings are the owners' own stream() and the stdio constructors below.

FdStream.onSocket ​

milo
fn FdStream.onSocket(fd: i32): FdStream

A socket handle. Not interchangeable with onFd on Windows.

FdStream.stderr ​

milo
fn FdStream.stderr(): FdStream

FdStream.stdin ​

milo
fn FdStream.stdin(): FdStream

FdStream.stdout ​

milo
fn FdStream.stdout(): FdStream

Writes here go straight at fd 1, bypassing the stdio buffer print uses, so the two interleave in the wrong order. Pick one per output session — that is why this does not fflush on your behalf: doing so per write would defeat the buffering you wrapped it in a BufWriter to get.

File ​

milo
pub struct File

Owned file handle. Automatically closes the fd when dropped.

File.close ​

milo
fn File.close(self: &mut File): Result<Unit, IoError>

Close now instead of at drop, and report failure. Drop closes silently, which is fine for reads but hides a write error that only surfaces at close (NFS, full filesystems). After this the File is inert and its drop is a no-op.

File.openAppend ​

milo
fn File.openAppend(path: &string): Result<File, IoError>

Open a file for appending, creating it if missing (mode 0644).

File.openRead ​

milo
fn File.openRead(path: &string): Result<File, IoError>

Open a file for reading.

File.openWrite ​

milo
fn File.openWrite(path: &string): Result<File, IoError>

Open a file for writing, creating it or truncating it (mode 0644).

File.readAll ​

milo
fn File.readAll(self: &File): Result<string, IoError>

File.seek ​

milo
fn File.seek(self: &File, pos: i64): Result<i64, IoError>

Move the read/write cursor to an absolute byte offset. Returns the new offset. A BufReader over this file caches bytes past its own cursor, so seeking underneath one desynchronizes it — seek first, then wrap.

File.size ​

milo
fn File.size(self: &File): i64

Size of the open file in bytes. The read position is left where it was.

File.stream ​

milo
fn File.stream(self: &File): FdStream

A non-owning Reader+Writer view of this file, for when the File itself must stay put (e.g. you want a BufReader and a BufWriter on the same handle). The File must outlive the view and the compiler does not check that — see FdStream. Prefer BufReader<File>.new(f), which owns the handle outright.

File.tell ​

milo
fn File.tell(self: &File): i64

Current cursor offset, or -1 for a stream that cannot report one (pipe, tty).

File.writeAll ​

milo
fn File.writeAll(self: &File, data: &string): Result<i64, IoError>

IoError ​

milo
pub enum IoError

Why an I/O operation failed. The four named variants carry the path; Other carries a message (from a path operation, the path plus the OS reason).

Variants:

  • NotFound(string)
  • PermissionDenied(string)
  • IsDirectory(string)
  • AlreadyExists(string)
  • Other(string)

OwnedFd ​

milo
pub struct OwnedFd

Sole owner of a descriptor that is neither a File nor a socket: a pipe end taken from a Child, a dup of a pty master. Closes it on drop, so the move checker allows one closer per descriptor, and a task that owns one is the last user of the number by construction. There is no safe way to build one from a bare integer: adopting a number something else will also close is the double close (or the close of a reused number) this type exists to rule out. Get one from an owner, or by dup.

OwnedFd.channel ​

milo
fn OwnedFd.channel(self: &OwnedFd): Channel<string>

Stream the descriptor's bytes as a channel (see fdChannel). Keep this handle alive until the channel closes: dropping it closes the fd under the pump.

OwnedFd.dup ​

milo
fn OwnedFd.dup<T: AsFd>(h: &T): Result<OwnedFd, IoError>

A new descriptor for the same open file as h's, owned by the result. h keeps its own: closing one of the two leaves the other open. This is how a task gets a descriptor it can outlive its owner with (a pump over a pty, a writer on a socket).

OwnedFd.isOpen ​

milo
fn OwnedFd.isOpen(self: &OwnedFd): bool

Whether this handle holds a descriptor at all: Child.takeStderr hands back an empty one when stderr was not a pipe. > 0 matches drop, for which 0 is the moved-from value.

OwnedFd.stream ​

milo
fn OwnedFd.stream(self: &OwnedFd): FdStream

A non-owning Reader+Writer view; the same rule as File.stream.

OwnedFd.writeOnce ​

milo
fn OwnedFd.writeOnce(self: &OwnedFd, s: &string): i64

One write(2) of s, never retried and never parked: the bytes the descriptor took (possibly fewer than s.len), or -1. Unlike stream().write, which parks a green task until every byte is out, nothing else runs between the first byte and the last, so two green tasks writing whole messages to one blocking pipe (a protocol's requests from two senders) cannot interleave them. Same contract as Pty.writeOnce.

Reader ​

milo
pub trait Reader

The two byte-stream interfaces every source and sink in std composes against.

They are traits, not interfaces, and that is forced by the language rather than chosen for speed: an interface value is a fat pointer that has to live somewhere, and Milo references are second-class, so a stored &Reader cannot exist. A trait is monomorphized into the holder — BufReader<File> is a real struct containing a real File — which means the wrapper owns its source and no lifetime ever escapes. The cost is that a Vec<Reader> of mixed sources is not expressible; wrap the mixed case in an enum of concrete sources instead.

Both use IoError. That is not a claim that every module's errors are unified (they are not) — it is that a read or a write on a byte stream fails for kernel reasons, and IoError is the enum that already carries them.

Reader.read ​

milo
fn Reader.read(self: &mut Reader, max: i64): Result<string, IoError>

Stderr ​

milo
pub struct Stderr

Stdin ​

milo
pub struct Stdin

The process's standard streams as handles. They live as long as the process, so lending their number can never outlive an owner: sel.onRead(Stdin {}), isTerminal(Stdout {}).

Stdout ​

milo
pub struct Stdout

Writer ​

milo
pub trait Writer

Writer.flush ​

milo
fn Writer.flush(self: &mut Writer): Result<Unit, IoError>

No-op: this layer holds no buffer. Use syncFile from std/fs to force the kernel's page cache out to the device.

Writer.write ​

milo
fn Writer.write(self: &mut Writer, data: &string): Result<Unit, IoError>

Functions ​

adoptFd ​

milo
pub fn adoptFd(fd: i32): OwnedFd

Take ownership of fd. The caller vouches that nothing else will close it; this is how an owner elsewhere in std (Child.takeStdout) hands its descriptor over.

copyStream ​

milo
pub fn copyStream<R: Reader, W: Writer>(src: &mut R, dst: &mut W): Result<i64, IoError>

Drain src into dst, returning the byte count. The pipe that makes any source usable with any sink. Does NOT flush dst — a caller stacking more onto the same writer should not pay for a flush per copy.

defaultBufferSize ​

milo
pub fn defaultBufferSize(): i64

Default buffer size for BufReader/BufWriter. Big enough that per-byte and per-line work amortizes to roughly one syscall per 64 KiB.

fdChannel ​

milo
pub fn fdChannel(fd: i32): Channel<string>

Stream a file descriptor's bytes on a background green task, returned as a channel of raw chunks. This is the single pump behind every async byte source (pty, sockets, child stdio, pipes) — the caller just recvs or iterates (for chunk in fdChannel(fd)), never touching read/EAGAIN. The channel closes at EOF (read returns <= 0). Milo's answer to a node.js Readable, minus the fd.

LIFETIME: the detached pump holds the raw fd. Keep the owning source (Pty / TcpStream / Child) alive and open for as long as you consume the channel — closing or dropping it out from under the pump strands the pump (parks forever), and for a TLS source would read freed SSL state. That is why it is @unsafe: the safe spellings are OwnedFd.channel, stdinChannel and the owning types' own channel methods, which tie the number to a live owner.

fdReaderAttach ​

milo
pub fn fdReaderAttach(fd: i32): FdReader

Capture the read strategy from the current runtime context, flipping the fd non-blocking iff we will park on it so the two never drift apart.

@unsafe: the caller vouches that fd stays open while the reader is used. Safe code builds one from a handle with FdReader.attach.

ioError ​

milo
pub fn ioError(path: &string): IoError

map errno to IoError variant with path context

putChar ​

milo
pub fn putChar(ch: u8): void

Write a single byte to stdout.

Goes onto the same stdio buffer print writes to — one syscall per byte would be absurd for a per-character API, and a raw fd write would also land ahead of any buffered print output.

readLine ​

milo
pub fn readLine(): Option<string>

Read a single line from stdin. Returns None at EOF.

readStdin ​

milo
pub fn readStdin(): string

Read all of stdin into a string (blocks to EOF). Prefer stdinChannel() for streaming/incremental consumption.

stdinChannel ​

milo
pub fn stdinChannel(): Channel<string>

Stream stdin as an iterable channel of chunks — the async counterpart to the blocking readStdin/readLine. for chunk in stdinChannel() { ... }. NOTE: this puts fd 0 into O_NONBLOCK and does not restore it, so afterward the blocking readLine/readStdin on the same tty may return early. Pick one style per stdin session; don't mix streaming and blocking reads of the same fd.

writeStdout ​

milo
pub fn writeStdout(s: &string): void

Write a string to stdout without appending a newline.

print goes through stdio's buffer; this goes straight at the fd. Drain the buffer first or the two interleave in the wrong order. fflush on an already-empty buffer is not a syscall, so a program that only ever uses this pays nothing.