std/io
Stdin/stdout and owned file handles. (Read-by-path helpers — readFile, readLines, splitLines — live in std/fs.)
from "std/io" import { File, readLine, writeStdout }Example
Count the lines on stdin, then write to a file:
var n = 0
while let Some(_) = readLine() {
n += 1
}
var f = File.openWrite("count.txt")!
let _ = f.writeAll($"{n}\n")API reference
AsFd
pub trait AsFdA 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
fn AsFd.rawFd(self: &AsFd): i32The descriptor number, still owned by this handle: valid only while it lives.
BufReader
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
fn BufReader.new(inner: R): BufReader<R>BufReader.readAll
fn BufReader.readAll(self: &mut BufReader): Result<string, IoError>Everything left, to end of stream.
BufReader.readByte
fn BufReader.readByte(self: &mut BufReader): Result<i64, IoError>Next byte as 0..255, or -1 at end of stream.
BufReader.readExact
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
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
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
fn BufReader.withCapacity(inner: R, cap: i64): BufReader<R>BufWriter
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
fn BufWriter.new(inner: W): BufWriter<W>BufWriter.pending
fn BufWriter.pending(self: &BufWriter): i64Buffered bytes not yet handed down.
BufWriter.withCapacity
fn BufWriter.withCapacity(inner: W, cap: i64): BufWriter<W>BufWriter.writeByte
fn BufWriter.writeByte(self: &mut BufWriter, b: u8): Result<Unit, IoError>BufWriter.writeLine
fn BufWriter.writeLine(self: &mut BufWriter, line: &string): Result<Unit, IoError>BytesReader
pub struct BytesReaderA 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
fn BytesReader.new(data: string): BytesReaderBytesReader.remaining
fn BytesReader.remaining(self: &BytesReader): i64BytesWriter
pub struct BytesWriterA 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
fn BytesWriter.len(self: &BytesWriter): i64BytesWriter.new
fn BytesWriter.new(): BytesWriterBytesWriter.take
fn BytesWriter.take(self: &mut BytesWriter): stringTake the accumulated bytes out, leaving the writer empty and reusable.
FdReader
pub struct FdReaderA 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
fn FdReader.attach<T: AsFd>(h: &T): FdReaderA 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
fn FdReader.readByte(self: &FdReader): i64Read one byte, returned as 0..255. Returns -1 at EOF or on error.
FdReader.readExact
fn FdReader.readExact(self: &FdReader, n: i64): Result<string>Read exactly n bytes into a string. Err if the stream ends first.
FdStream
pub struct FdStreamA 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<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<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
fn FdStream.onFd(fd: i32): FdStreamA 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
fn FdStream.onSocket(fd: i32): FdStreamA socket handle. Not interchangeable with onFd on Windows.
FdStream.stderr
fn FdStream.stderr(): FdStreamFdStream.stdin
fn FdStream.stdin(): FdStreamFdStream.stdout
fn FdStream.stdout(): FdStreamWrites 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
pub struct FileOwned file handle. Automatically closes the fd when dropped.
File.close
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
fn File.openAppend(path: &string): Result<File, IoError>Open a file for appending, creating it if missing (mode 0644).
File.openRead
fn File.openRead(path: &string): Result<File, IoError>Open a file for reading.
File.openWrite
fn File.openWrite(path: &string): Result<File, IoError>Open a file for writing, creating it or truncating it (mode 0644).
File.readAll
fn File.readAll(self: &File): Result<string, IoError>File.seek
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
fn File.size(self: &File): i64Size of the open file in bytes. The read position is left where it was.
File.stream
fn File.stream(self: &File): FdStreamA 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
fn File.tell(self: &File): i64Current cursor offset, or -1 for a stream that cannot report one (pipe, tty).
File.writeAll
fn File.writeAll(self: &File, data: &string): Result<i64, IoError>IoError
pub enum IoErrorWhy 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
pub struct OwnedFdSole 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
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
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
fn OwnedFd.isOpen(self: &OwnedFd): boolWhether 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
fn OwnedFd.stream(self: &OwnedFd): FdStreamA non-owning Reader+Writer view; the same rule as File.stream.
OwnedFd.writeOnce
fn OwnedFd.writeOnce(self: &OwnedFd, s: &string): i64One 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
pub trait ReaderThe 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
fn Reader.read(self: &mut Reader, max: i64): Result<string, IoError>Stderr
pub struct StderrStdin
pub struct StdinThe 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
pub struct StdoutWriter
pub trait WriterWriter.flush
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
fn Writer.write(self: &mut Writer, data: &string): Result<Unit, IoError>Functions
adoptFd
pub fn adoptFd(fd: i32): OwnedFdTake 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
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
pub fn defaultBufferSize(): i64Default buffer size for BufReader/BufWriter. Big enough that per-byte and per-line work amortizes to roughly one syscall per 64 KiB.
fdChannel
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
pub fn fdReaderAttach(fd: i32): FdReaderCapture 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
pub fn ioError(path: &string): IoErrormap errno to IoError variant with path context
putChar
pub fn putChar(ch: u8): voidWrite 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
pub fn readLine(): Option<string>Read a single line from stdin. Returns None at EOF.
readStdin
pub fn readStdin(): stringRead all of stdin into a string (blocks to EOF). Prefer stdinChannel() for streaming/incremental consumption.
stdinChannel
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
pub fn writeStdout(s: &string): voidWrite 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.