sys::io
sys::io is where the io capabilities live. A stream is not one type
with a tag saying what it is — it is a type of its own (sys::fs::File,
sys::tcp::TcpStream, sys::tls::TlsStream, sys::process::Pipe,
sys::io::Stdio), and the traits it implements say what you can do
with it. Anything else that implements them — including a stream
written in Graphix — works with the same code.
use sys::io::{Read, Write, Close};
let f = sys::fs::open(`Read, "/etc/hostname")?;
let text = buffer::to_string(Read::read_all(f)?)?;
Close::close(text ~ f)?
Read is the one to implement if you are writing a stream of your own:
read is the only required method, and read_exact and read_all are
written in terms of it, so they come for free. The system streams
override read_exact with a single call into the operating system,
which reads under one lock instead of looping.
/// A handle to one of the process's standard streams, from [stdin],
/// [stdout] or [stderr].
type Stdio;
/// A source of bytes.
///
/// `read` is the only method an implementation must supply — the
/// others are written in terms of it, so a stream written in Graphix
/// (a decoder, a framer, a mock for a test) gets them for free. The
/// system streams override `read_exact` with a single call into the
/// operating system, which reads under one lock instead of looping.
trait Read {
/// Read up to n bytes from the stream. May return fewer bytes than
/// requested if fewer are available, and no bytes at the end of
/// the stream.
val read: fn(self, n: u64) -> Result<bytes, `IOError(string)>;
/// Read exactly n bytes from the stream. Returns fewer bytes only
/// if the stream ends before n bytes have been read.
val read_exact: fn(self, n: u64) -> Result<bytes, `IOError(string)> = |s, n| {
// a new request, a new stream or a new n, starts over
let want: u64 = (s, n) ~ n;
let acc: bytes = (s, n) ~ buffer::from_string("");
let chunk = read(s, want);
select chunk {
error as _ => filter_err(chunk),
// done is decided when a chunk arrives, never when acc moves
bytes as b => select b ~ (buffer::len(b) == u64:0
|| buffer::len(acc) + buffer::len(b) >= n) {
true => b ~ buffer::concat(acc, b),
false => {
// `b ~` is load-bearing: a connect fires when its
// RHS fires, and an ungated `acc <- f(acc, b)`
// would re-fire on its own write — the documented
// counter idiom, an accumulator by accident.
acc <- b ~ buffer::concat(acc, b);
want <- b ~ want - buffer::len(b);
never()
}
}
}
};
// CR claude for eric: [perf/low] Every chunk does `buffer::concat(acc, b)`, which
// copies everything read so far, so a Graphix-defined stream reads in time
// quadratic in its length: 64 MB took 3.8 s against 1.3 s for 32 MB. read_exact's
// default does the same. Push the chunks onto an Array<bytes> and `buffer::concat`
// it once at the end. For read_exact, keep a running length beside the array.
// probe: design/review-2026-10-19/repro/gx-stdlib-read-all-quadratic.sh
// (gx-stdlib-07)
// 2026-10-19 claude: Not done: the Graphix default bodies in sys's io.gxi reach
// only core (sys depends on no array or list package), and core has no array or
// list append, so the chunks cannot be pushed onto an Array<bytes> here. A fix
// needs one of: sys depending on graphix-package-array for array::push, a core
// builtin that appends to an Array, or a recursive chunk list flattened at the end.
// Each is a dependency or surface choice.
/// Read the stream to its end and return everything it produced as
/// one value. Reads re-arm themselves until the stream ends, so a
/// stream that never ends (a socket the peer holds open) never
/// produces.
val read_all: fn(self) -> Result<bytes, `IOError(string)> = |s| {
let want: u64 = u64:65536;
let acc: bytes = s ~ buffer::from_string("");
let chunk = read(s, want);
select chunk {
error as _ => filter_err(chunk),
bytes as b => select b ~ (buffer::len(b) == u64:0) {
true => b ~ acc,
false => {
// gated on the chunk: see `read_exact`
acc <- b ~ buffer::concat(acc, b);
want <- b ~ u64:65536;
never()
}
}
}
}
};
/// A source of bytes that frames itself into lines.
///
/// Framing is at the BYTE level, which is why this is a capability of
/// its own and not a loop over [Read::read]: a multi-byte character
/// split across a read boundary is destroyed by decoding each chunk on
/// its own, and nothing the caller does controls where those
/// boundaries fall. Only complete lines are decoded, and lossily, so
/// one line of invalid UTF-8 cannot take down the stream. A trailing
/// `\r` is stripped, and a last line the stream ends without a newline
/// is still a line. A line longer than 16 MiB is an error.
///
/// Reads re-arm themselves, so either method is the whole "follow a
/// stream" idiom in one call. Both stop at the end of the stream. A
/// read error is returned, which also stops it — a stream that has
/// failed once will not recover, and re-arming into the same error
/// would spin. The error is a VALUE, like every other operation in
/// this module: the caller decides whether to propagate it with `?`,
/// log and drop it with `$`, or match on it.
trait Lines {
/// One event per complete line, with the newline stripped. A chunk
/// carrying several lines produces several events, and a chunk
/// that ends mid-line holds the remainder until the rest arrives,
/// so a line is never split across two events.
val lines: fn(self) -> Result<string, `IOError(string)>;
/// As [Lines::lines], but each event carries every line the read
/// made available, instead of one line per event.
///
/// This is the cheaper form and the one to reach for when the
/// consumer works in batches anyway — a busy stream delivers one
/// event per read rather than one per line. Framing is identical.
val lines_batched: fn(self) -> Result<Array<string>, `IOError(string)>;
};
/// A sink for bytes.
///
/// `write` and `flush` are the required methods; `write_exact` loops
/// over `write` and the system streams override it with a single call.
trait Write {
/// Write bytes to the stream. Returns the number of bytes written,
/// which may be less than the full length of data.
val write: fn(self, data: bytes) -> Result<u64, `IOError(string)>;
/// Write all the bytes, looping until complete.
val write_exact: fn(self, data: bytes) -> Result<null, `IOError(string)> = |s, data| {
let rest: bytes = (s, data) ~ data;
let written = write(s, rest);
select written {
error as _ => filter_err(written),
u64 as n => select n ~ (n >= buffer::len(rest)) {
true => null,
false => select n {
u64:0 => error(`IOError("write returned 0 bytes")),
_ => { rest <- n ~ rest[n..]$; never() }
}
}
}
};
/// Flush any buffered writes.
val flush: fn(self) -> Result<null, `IOError(string)>;
};
/// A handle that holds an operating system resource until it is
/// released.
trait Close {
/// Close the stream, releasing the underlying resource. For
/// writable streams pending data is flushed and end-of-stream is
/// signaled — a piped child stdin delivers EOF, so a
/// read-until-EOF child can run to completion. Closing an
/// already-closed stream is a no-op; subsequent operations on a
/// closed stream return an error.
val close: fn(self) -> Result<null, `IOError(string)>;
};
impl Read for Stdio;
impl Lines for Stdio;
impl Write for Stdio;
impl Close for Stdio;
/// Return a handle to standard input.
val stdin: fn(trigger: Any) -> Stdio;
/// Return a handle to standard output.
val stdout: fn(trigger: Any) -> Stdio;
/// Return a handle to standard error.
val stderr: fn(trigger: Any) -> Stdio;
Which stream implements what
| type | Read | Lines | Write | Close | other |
|---|---|---|---|---|---|
sys::fs::File | ✓ | ✓ | ✓ | ✓ | sys::fs::Seek |
sys::tcp::TcpStream | ✓ | ✓ | ✓ | ✓ | sys::tcp::Socket |
sys::tls::TlsStream | ✓ | ✓ | ✓ | ✓ | sys::tcp::Socket |
sys::process::Pipe | ✓ | ✓ | ✓ | ✓ | |
sys::io::Stdio | ✓ | ✓ | ✓ | ✓ |
A handle whose direction is wrong for the call — writing to stdin,
reading from a child’s stdin pipe — returns an IOError rather than
failing to compile: the trait says the operation exists, the operating
system says which end of the pipe you are holding.
Parsing and writing formats
json and toml parse from bytes or a string and serialize to
either; pack reads and writes bytes; xls reads bytes and has no
writer. Reading a document from a stream is therefore just
reading the stream:
use sys::io::{Read, Write};
let f = sys::fs::open(`Read, path)?;
let config: Config = toml::read(Read::read_all(f)?)?;
let out = sys::fs::open(`Create, out_path)?;
Write::write_exact(out, json::write_bytes(#pretty: true, config)?)?