A File Descriptor Is a Number
Open a file, accept a connection, create a pipe. Each one gives you back a small non-negative integer, and that integer is the whole of what you hold. There is no object on your side. The kernel keeps a table per process, the number is an index into it, and every later call names the thing you opened by passing the number back.
Press Run.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
var buf: [1024]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writerStreaming(io, &buf);
const out = &stdout_writer.interface;
// A `std.Io.File` is a handle and two flag bits. The handle is the number,
// and its type is whatever the platform calls a descriptor.
try out.print("File.Handle = {s}\n", .{@typeName(std.Io.File.Handle)});
try out.print("a File is {d} bytes\n\n", .{@sizeOf(std.Io.File)});
// The three every process starts with. Nothing opened them; they were
// already there when main was entered.
try out.print("stdin {d}\n", .{std.Io.File.stdin().handle});
try out.print("stdout {d}\n", .{std.Io.File.stdout().handle});
try out.print("stderr {d}\n\n", .{std.Io.File.stderr().handle});
try out.flush();
// Nothing is special about the File that `stdout()` returns. Build one out
// of the number 1 and it addresses the same stream.
const by_hand: std.Io.File = .{ .handle = 1, .flags = .{ .nonblocking = false } };
var hand_buf: [64]u8 = undefined;
var hand_writer = by_hand.writerStreaming(io, &hand_buf);
try hand_writer.interface.writeAll("written through a hand-built File\n");
try hand_writer.interface.flush();
try out.print("same handle: {}\n", .{by_hand.handle == std.Io.File.stdout().handle});
try out.flush();
}What a File is
handle: Handle,
flags: Flags,
pub const Handle = std.posix.fd_t;
That is the entire declaration of std.Io.File in the standard library, minus
its methods. A descriptor and one bit saying whether it was opened
non-blocking. fd_t is i32 on every POSIX target and on WASI; on Windows it
is a HANDLE, which is why the type is named rather than written out.
Because the struct holds nothing else, building one out of a raw number is not a trick and needs no cast:
const by_hand: std.Io.File = .{ .handle = 1, .flags = .{ .nonblocking = false } };
That value and std.Io.File.stdout() are the same eight bytes, and writing
through either sends bytes to the same place.
The three you are given
A process starts with descriptors 0, 1 and 2 already open, and nothing in your program opened them. The shell, or the parent process, arranged them before your first instruction ran:
| Number | Name | Convention |
|---|---|---|
| 0 | standard input | where the program reads from |
| 1 | standard output | where its results go |
| 2 | standard error | where its diagnostics go |
The numbering is convention with one exception: it is convention that the
kernel and every program agree on, so it is as binding as a rule. zig build,
your shell’s 2>&1, and the redirect a CI system applies to your test run all
assume it.
Nothing else about the numbers is promised. The next descriptor you open takes the lowest free number, which is 3 in a fresh process and something else the moment anything has been opened or closed. Code that hardcodes 4 works until the day a library opens a log file before your function runs.
The sandbox keeps the numbering
This chapter runs in your browser, which has no kernel, no descriptor table and no processes. The numbers still come out 0, 1 and 2, because WASI copied the interface: a module is handed descriptors by its host, refers to them by number, and reads and writes through calls that take that number as their first argument.
Which is the useful thing to notice about descriptors. The number is not an implementation detail of Linux that leaked into the API. It is the API, thin enough that a wasm runtime in a browser tab can implement it and a program written against a kernel cannot tell.
Where they come from next
The rest of this section is descriptors doing the four things worth doing to them: writing to the ones you were given, passing modified copies to a child, holding one end of a pipe, and closing one to say you are finished. Sockets in Network Programming are the same integers again, with a different call to create them.