The stdlib system-call layer¶
Status: DESCRIPTIVE. This page records how the stdlib generators under
sushi_lang/sushi_stdlib/src/ reach the operating system, and why. The code is the
authority on WHAT is emitted; this page is the authority on WHY.
One route to a descriptor¶
Every File method goes to a descriptor through read(2), write(2), lseek(2) and
their positional twins. Nothing goes through libc stdio. fopen and fgets buffer, and a
File.write() through write(2) beside a buffered printf puts the console output out of
order (ruling R12). Thus there is one route to a descriptor.
The descriptor layer of <io/files> has two halves:
io/files/sequential.py:read,write,readln,seek. These calls MOVE the position of the descriptor, so aFilehandle is written on them.io/files/positional.py:pread,pwrite. These calls take the offset as an argument and do not move the position, so they are safe on a shared descriptor.
The line reader (fd_readln)¶
sushi_io_files_fd_readln(i32 fd) answers Result@(Maybe@(string), FileError): one line,
with the newline removed. A blank line and the end of file are different answers: a blank
line is Some("") and the end is None, so a file with a blank line in it does not stop
there. The end of file after some bytes is a last line with no newline
after it, and that is a line.
Two paths, chosen by whether the descriptor can seek¶
The reader first asks lseek(fd, 0, SEEK_CUR). A pipe, a socket and a terminal answer -1
(ESPIPE).
- By byte (the descriptor cannot seek). One
read(2)per byte. This is the only correct shape on such a descriptor: it cannot give back an over-read, so a chunked read would take bytes that the next reader owns.stdinis frequently a pipe. - Chunked (the descriptor can seek).
preadreads a chunk of 128 bytes at a computed offset, the reader finds the newline in the bytes that arrived, and onelseekmoves the position to the byte after the newline.
The by-byte path is slow. Measured on 2026-08-31 at 200 000 lines: 4.49s, against 0.47s
for a buffered fgets, with 3.3s of that in the kernel across 9.3 million
system calls. That is why a seekable descriptor takes the chunked path.
The chunked path uses ABSOLUTE positions¶
The start of the line is read one time. Each pread asks for start + len, and the final
lseek is a SEEK_SET to start + consumed. A relative seek back is the same idea, but
one sign error in it reads the wrong bytes with no error, and that is the worst failure.
At the end of file the position moves to just after what was consumed, so the next
readln answers None and does not read the tail again.
The shared state¶
Both paths write one buffer, one length, one capacity and one end-of-file flag, and both
leave through the same three blocks (the finish, the failure, the allocation failure). The
generator keeps this state in one frozen dataclass, _ReadlnFrame, and each path takes the
frame plus what is its own: its entry block, and the functions only it calls. The buffer
grows by 128 bytes and always keeps one byte for the NUL terminator, so the line is also a
C string.
fd_readln is under File.readln(), the UNBUFFERED read. To read a whole file line by
line, use BufReader@(R) and its lines() in <io/buf> (docs/stdlib/io/buf.md).
errno to an error tag¶
A failed libc call answers -1 (or a null pointer) and puts the cause in errno. The
generators turn that cause into the tag of a unit-variant error type -- FileError for
<io/files>, NetError for <net/socket> -- and build a Result.Err with it.
- One emitter.
emit_errno_tag(builder, module, table, default)insushi_lang/sushi_stdlib/src/errno_tags.pyreadserrnoand maps it throughtable, and an unmapped value maps todefault.emit_errno_err_resultadds theResult.Err. The module is undersrc/and not underio/ornet/, because neither of those may import the other. - The tables. There are two tables, one for each family.
errno_to_file_error_tableandERRNO_DEFAULT_FILE_ERRORare insushi_lang/sushi_stdlib/src/io/files/errno.py, because the file table reads the platform values ofENAMETOOLONGandELOOPfrom the platform module.errno_to_net_error_tableandERRNO_DEFAULT_NET_ERRORare insushi_lang/backend/runtime/constants.py, beside the sharedERRNO_*constants.io/files/errno.pyandnet/errno.pyeach give the emitter their table and their default. - The layout. The
Resultbytes are built insrc/results.pyand nowhere else, anderrnois read throughdeclare_errno_locationinsrc/libc_declarations.py, so the two error families read oneerrnothrough one declaration. NetError.ResolveFailedis the oneNetErrortag that noerrnoreaches. Agetaddrinfofailure answers its own code, and onlyEAI_SYSTEMmeans "readerrno"; every other code isResolveFailed.
The order of the calls¶
close(), free() and freeaddrinfo() can all overwrite errno. This is the largest
single cause of a wrong variant. So every failure edge reads the tag FIRST, directly after
the failed call, and keeps it; then it cleans up; then it builds the Err.
The rule is the same for a wrapper written in Sushi over errno() (docs/ffi.md,
"Reading errno"): let i32 code = errno() is the first statement on the failure edge.
The built-in reads errno through the same two platform symbols as
declare_errno_location.
EINTR¶
A read or a write that a signal interrupts before a byte moves answers -1 with EINTR,
and the correct response is to ask again. emit_is_eintr (io/files/errno.py) is the
test, and the read and write loops go back to the call when it is true.