A door for user programs
Everything so far lived inside the network service. But an SSH daemon, or any server, is a separate program that just wants to listen on a port and read bytes. This chapter is the door it knocks on: six small syscalls, and not one raw pointer to a connection.
A reliable byte stream is only useful if something can hold one end of it. That something is a user program at EL0 — the future SSH server, a diagnostics daemon, whatever we write next. It must be able to open a socket without touching a frame, a ring, or the connection table. So we hand it handles, not pointers, and we never let it block.
Two capabilities, two roles
Recall the wall from Chapter 2: the protocol brain holds CAP_NET_SERVER and
is the only thing allowed to move frames. A program that merely wants a TCP socket is a
different, lesser role, and it gets a different key: CAP_NET_CLIENT. The
socket syscalls all begin the same way — check the capability, refuse if it's
missing. A program with neither key cannot touch the network at all; a program with the
client key can open sockets but can never inject a raw frame. Least privilege, enforced
at the door.
The six calls
The whole ABI (in src/network/dispatch.c, the SYS_NET_TCP_*
range) is six verbs:
listen(port)— claim a local port, return a small listener handle. Backlog is capped at the accept-queue depth (2).accept(listener, &peer)— take the next established connection off the queue, mark it owned by this program, and copy the peer's address out. Returns a connection handle.recv(conn, buf, len)— copy staged received bytes into the program's buffer.send(conn, buf, len)— hand up to 512 bytes to the TCP engine for reliable delivery.close(handle, kind)— tear down a connection, or a listener (and all its connections).poll(handle, mask)— ask what's ready: readable, writable, hung-up, errored.
Handles, not pointers
A program never receives the address of a struct net_tcp_conn. It receives a
small integer — the table index plus one — and every call translates it back
under supervision, refusing anything that doesn't map to a live, client-owned slot:
static struct net_tcp_conn *net_tcp_conn_from_handle(uint32_t handle)
{
uint32_t index = handle - 1u;
if (handle == 0u || index >= NET_TCP_CONN_MAX
|| g_net_tcp_conn_table[index].used == 0u
|| g_net_tcp_conn_table[index].client_owned == 0u) /* must be YOURS */
return 0;
return &g_net_tcp_conn_table[index];
}
This indirection is quiet but load-bearing. A forged or stale handle resolves to nothing and earns an error, never a wild pointer. The kernel's internal structures stay on the kernel's side of the wall; EL0 gets a claim ticket, and the cloakroom checks it every time.
Nobody blocks
This is the rule that shapes the whole ABI. Alquist's scheduler is cooperative and
single-core: if a syscall blocked waiting for data, the entire machine would
freeze until that data arrived. So nothing blocks. recv with no data returns
EAGAIN; accept with an empty queue returns EAGAIN;
send while a retransmit is still in flight returns EAGAIN. The
program's job is to try, and if the answer is "not yet," to yield and come back:
/* recv: drain the stage, or report EOF, or "come back later" */
if (conn->peer_reset != 0u) return NET_SOCK_ECONNRESET;
if (conn->rx_stage_len > 0u) { /* copy out min(len, staged); return count */ }
if (conn->peer_fin != 0u) return 0; /* clean end of stream */
return NET_SOCK_EAGAIN; /* nothing yet -- yield and retry */
The companion to non-blocking calls is poll, which reports readiness as a
mask — IN (data to read), OUT (room to send),
HUP (peer hung up), ERR (reset) — so a well-behaved server
polls its sockets, does a bounded slice of work on whichever are ready, and yields. Every
byte still crosses the wall as a validated copy: send stages the program's
buffer after net_ptr_ok approves it, recv and accept
copy outward the same way.
The shape of a server
Put together, an Alquist network server is a small, honest loop — and this is exactly the skeleton the SSH daemon in the next chapter is built on:
h = tcp_listen(22);
for (;;) {
conn = tcp_accept(h, &peer); /* EAGAIN? yield, retry */
if (conn >= 0) {
n = tcp_recv(conn, buf, sizeof buf); /* EAGAIN? yield */
if (n > 0) tcp_send(conn, reply, ...);
if (n == 0) tcp_close(conn, CONN); /* peer sent FIN */
}
yield(); /* never spin; let the box breathe */
}
Gotchas worth remembering
- EAGAIN is the normal answer, not an error. On a cooperative kernel,
"not yet, yield" is how a program waits. A server that treats
EAGAINas failure is a bug; one that busy-loops on it without yielding is a freeze. - Small, fixed limits. Four connections, listener backlog and accept queue of two, 512-byte send and receive staging. Everything an outsider could inflate is a fixed-size array by design (Chapter 6's lesson, applied at the ABI).
- Ownership is checked, not assumed. A connection must be
client_owned(handed over byaccept) before a program can touch it — a half-open connection still being handshaked by the service is not reachable through a handle. - The wall is still up. This ABI adds a door; it does not remove the copy-only boundary. A user program parses only bytes copied into its own buffers, exactly like the protocol brain does.
There is the door: listen, accept, read, write, close, poll — capability-gated, handle-based, never blocking. Now we walk a real program through it, one that turns a raw byte stream into something you would dare to type a password into. Knock, but encrypted.