net

tape
import net from "net";
import io from "io";
import time from "time";

// Connect to a host by name (IPv6 and IPv4 raced), send a request, read the reply
var s: net.TcpStream = net.connect_host("example.com", 80, time.seconds(10)) or { return 1; };
defer s.close();
s.set_read_timeout(time.seconds(10)) or { return 1; };
s.write_all("HEAD / HTTP/1.0\r\nHost: example.com\r\n\r\n") or { return 1; };
var r: io.BufReader = io.buf_reader(s.reader());
defer r.close();
let status: ?string = r.read_line() or { return 1; };
if (status != null) { io.println(status!); }

net is sockets for programs: TCP streams and listeners, UDP sockets, name resolution, a readiness poll, and connect_host, which resolves a name and races its addresses. It is a capability module, a thin layer over the operating system’s sockets (BSD sockets on Linux and macOS, Winsock on Windows). Every failure is an io.IoError; the network ones are listed under Errors.

Sockets are blocking by default. Timeouts are per call and opt-in, and so are nonblocking mode and poll. A socket is a plain value holding the runtime’s handle: copying it copies the handle, not the socket, and you close it yourself (defer s.close();), like an fs.File.

TLS (tls_connect, tls_wrap) runs the tls engine over a TCP stream, checking the server against tape’s bundled roots or a trust set you build.

Endpoints

An Endpoint is an IP address and a port:

tape
pub tagged Endpoint {
    V4 { addr: [4]u8; port: u16; }
    V6 { addr: [16]u8; port: u16; scope_id: u32; }
}

IPv4 bytes are in network order (10.0.0.1 is [10, 0, 0, 1]); an IPv6 address has its zone in scope_id (0 for none).

FunctionDoes
v4(a, b, c, d, port)a.b.c.d:port
v6(addr, port, scope_id = 0)An IPv6 endpoint from 16 address bytes (traps for another length)
localhost_v4(port) / localhost_v6(port)127.0.0.1 / ::1
any_v4(port) / any_v6(port)0.0.0.0 / ::, every address, for listen and bind_udp
parse_ip(text, port) -> ?EndpointAn address literal, or null when text is not one (a name to resolve)
parse_endpoint(text) -> ?Endpoint"1.2.3.4:80" or "[::1]:443" (brackets required for IPv6)
e.port(), e.with_port(p), e.is_v6(), e.equals(&other)
e.format() -> string"10.0.0.1:80", "[2001:db8::1]:443", "[fe80::1%3]:22"
e.format_ip() -> stringThe address alone: "fe80::1%3"

parse_ip reads IPv4 as exactly four decimal parts 0-255 without leading zeros (010.0.0.1 is refused rather than guessed as octal), and IPv6 as RFC 4291 writes it: :: for a run of zero groups, an IPv4 tail (::ffff:1.2.3.4), hex in either case, an optional numeric %zone. format writes IPv6 in the RFC 5952 form (lowercase, the longest run of zero groups as ::, no leading zeros, an IPv4-mapped address with its IPv4 tail), so parsing and formatting round-trip to one spelling.

tape
let e: net.Endpoint = net.parse_endpoint("[2001:DB8:0:0:0:0:0:1]:443")!;
io.println(e.format());                       // [2001:db8::1]:443

TCP

FunctionDoes
connect(ep, timeout = null) -> TcpStreamA blocking connection to ep; with a timeout, TimedOut after it
connect_host(host, port, timeout = null) -> TcpStreamResolve host and connect to one of its addresses, racing them (below); the timeout covers the whole call
connect_nonblocking(ep) -> TcpStreamStart a connection and return at once, the stream nonblocking
listen(ep, backlog = 128) -> TcpListenerBind and listen; port 0 picks a free port
l.accept() -> AcceptedThe next connection: .stream and .peer

A TcpStream has:

MethodDoes
read(buf) -> i64Bytes read; 0 when the peer closed its side
read_exact(buf)Fill buf (UnexpectedEof if the peer closes first)
write(data) -> i64Bytes the system took, possibly fewer than data.len
write_all(data)Write everything
shutdown_write()Send FIN: the peer reads the end of the stream; this side can still read
set_read_timeout(t), set_write_timeout(t)Each later read or write gives up after t (TimedOut); null waits forever
set_nonblocking(on)A read or write that would wait is WouldBlock instead
set_nodelay(on)Turn Nagle’s algorithm off
local_endpoint(), peer_endpoint()
take_error()The pending error, cleared: how a connect_nonblocking ended
poll_item(want) -> PollItemFor poll
reader() -> io.Reader, writer() -> io.WriterThe stream as an io stream
close()Close it (twice is fine)

A TcpListener has accept, set_timeout(t) (each later accept gives up after t), set_nonblocking(on), local_endpoint, poll_item and close. A stream from accept starts blocking whatever the listener’s mode.

A timeout of zero or less is InvalidInput. connect refuses port 0 and the any_* addresses (InvalidInput). Every method on a closed socket is InvalidInput. Writing to a peer that has gone away is an error (BrokenPipe, ConnectionReset or ConnectionAborted), never a signal.

A listener on an IPv6 address accepts IPv6 only: to serve both families, listen twice. On Linux and macOS a restarted server can take its port back at once (SO_REUSEADDR); on Windows the port is exclusive to its listener.

tape
// A server on loopback, one connection
var l: net.TcpListener = net.listen(net.localhost_v4(8080)) or { return 1; };
defer l.close();
var a: net.Accepted = l.accept() or { return 1; };
defer a.stream.close();
io.println("connection from {a.peer.format()}");
a.stream.write_all("hello\n") or { return 1; };

Happy Eyeballs

connect_host connects the way RFC 8305 describes, in a fixed, predictable form:

  1. The resolver’s addresses, duplicates removed, alternate between the families starting with the first address’s (IPv6, IPv4, IPv6, …); at most 16 are tried.
  2. The first attempt starts at once. Each next one starts 250 ms after the previous one if nothing has connected yet, or at once when every attempt in flight has failed.
  3. The first attempt to connect wins (the earliest started, if several connect together); the others are closed. The stream comes back blocking.
  4. When every address fails, the error of the first attempt is returned; when the timeout passes first, TimedOut.

An address literal (connect_host("127.0.0.1", 80)) is not resolved, and a name with a single address is a plain connect.

Streams

s.reader() and s.writer() are an io.Reader and io.Writer over the socket, so io.BufReader, read_line, read_all, io.BufWriter and anything written against io streams works over TCP. They don’t own the socket; close the TcpStream as usual. The stream’s timeouts and nonblocking mode apply to them (TimedOut, WouldBlock).

tape
var w: io.BufWriter = io.buf_writer(s.writer());
w.write_all("one\ntwo\n") or { return 1; };
w.close() or { return 1; };              // flushes; the socket stays open

UDP

FunctionDoes
bind_udp(ep) -> UdpSocketA UDP socket bound to ep (port 0 picks one)
u.send_to(data, to) -> i64Send one datagram
u.recv_from(buf) -> DatagramReceive one: .len, .peer (the sender), .truncated
u.connect(peer)Fix the peer: send and recv use it
u.send(data), u.recv(buf)To and from the connected peer
u.set_broadcast(on), u.set_read_timeout(t), u.set_nonblocking(on)
u.local_endpoint(), u.poll_item(want), u.close()

A datagram is sent whole or not at all (one too large is InvalidInput). A received datagram longer than buf is cut to fit and truncated is set. Sending to a broadcast address needs set_broadcast(true). On a connected socket, a peer port that is closed shows up as ConnectionRefused on a later call (on Windows only where the firewall lets the ICMP message through; otherwise the read times out).

Resolution

resolve(host, port) -> Vec_Endpoint asks the system resolver (the hosts file and the configured DNS) for host’s addresses, in the resolver’s order, each with port. An address literal comes back as it is. Errors: HostNotFound (no such name, or no addresses), TimedOut (no DNS server answered), InvalidInput (empty, over 253 bytes, a NUL byte). Names are ASCII; an internationalized name is passed in its xn-- form.

tape
let addrs: net.Vec_Endpoint = net.resolve("localhost", 80) or { return 1; };
for (i in 0..addrs.len()) { io.println(addrs.get(i).format()); }

Polling

poll(items, timeout = null) -> i64 waits until at least one item is ready or the timeout passes (null: no limit; zero: just look), sets each item’s got and returns how many are ready (0 on timeout). At most POLL_MAX (1024) items.

tape
pub struct PollItem { h: i64; want: u8; got: u8; }
pub const READABLE: u8 = 1;   // data, or a connection to accept
pub const WRITABLE: u8 = 2;   // room to write, or a connection finished
pub const HANGUP: u8 = 4;     // the peer closed (reported whether wanted or not)
pub const ERROR: u8 = 8;      // take_error has something to say (likewise)

With poll and nonblocking sockets, one thread can serve several connections:

tape
var items: [2]net.PollItem;
items[0] = l.poll_item(net.READABLE);
items[1] = s.poll_item(net.READABLE);
let n: i64 = net.poll(items[0..2], time.seconds(1)) or { return 1; };
if ((items[0].got & net.READABLE) != 0) { /* accept */ }

connect_nonblocking plus poll for WRITABLE, then take_error, is a connection that doesn’t block; read_exact and write_all on a nonblocking stream wait with poll for as long as it takes.

TLS

tape
var s: net.TlsStream = net.tls_connect("example.com", 443, net.tls_options()) or {
    io.eprintln("TLS: {err.desc()}");
    return 1;
};
defer s.close();
s.write_all("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n") or { return 1; };
var buf: [4096]u8;
while (true) {
    let n: i64 = s.read(buf[0..4096]) or { return 1; };
    if (n == 0) { break; }           // the server's close_notify
    io.print(buf[0..n]);
}
FunctionDoes
tls_options() -> TlsOptionsThe bundled roots, no timeouts, no ALPN, no pin
tls_connect(host, port, opts) -> TlsStreamconnect_host, then tls_wrap with host as the server name
tls_wrap(tcp, server_name, opts) -> TlsStreamTLS 1.3 over a connected TcpStream it takes over (closed on failure)
s.read(buf) -> i64Decrypted bytes; 0 after the server’s close_notify
s.write(data) -> i64, s.write_all(data)Encrypt and send (up to 16 KiB a record)
s.update_keys(request_peer)A KeyUpdate: new send keys, and with request_peer new ones from the server too
s.suite(), s.alpn()The cipher suite; the ALPN protocol the server chose (null for none)
s.peer_closed(), s.last_error()Whether close_notify came; the error the stream last returned
s.buffered() -> boolWhether a read can return without the socket (data already received): an event loop polls the socket only when this is false
s.reader(), s.writer()The stream as an io.Reader/io.Writer
s.tcp()The TCP stream underneath (its endpoints)
s.close()close_notify, the socket closed, the keys wiped, the memory freed
tape
pub struct TlsOptions {
    trust: ?*const x509.TrustStore = null;     // null: tape's bundled roots
    connect_timeout: ?time.Duration = null;    // tls_connect's connecting
    handshake_timeout: ?time.Duration = null;  // the whole handshake
    io_timeout: ?time.Duration = null;         // each socket read and write
    alpn: []const []const u8 = [];             // protocols to offer, e.g. ["h2", "http/1.1"]
    pin_leaf_sha256: []const u8 = [];          // the SHA-256 of the one server certificate accepted
    pin_only: bool = false;                    // the pin instead of the chain
}

The server’s certificate must chain to a root in trust, be valid now (the wall clock) and name server_name. There is no option to skip that check. A pin (pin_leaf_sha256) is checked too; with pin_only it is the whole check, for a known server with a self-signed certificate.

Errors are SecureError: Io { e: io.IoError } from the socket (a timeout is TimedOut) or Tls { e: tls.TlsError } from TLS (UnknownCa, NameMismatch, CertificateExpired, Truncated when the connection ends without close_notify, …); err.desc() gives the text. Through reader() and writer() a TLS error reads as UnexpectedEof (Truncated) or Other, and last_error() says which.

A TlsStream holds about 70 KiB on the heap: close it (defer s.close();). Copying one copies a reference to the same connection.

Trust

tape
// The bundled roots plus the operating system's (a corporate root)
var t: net.Trust = net.trust();
defer t.free();
t.add_bundled();
t.add_system() or { io.eprintln("no system roots: {err.desc()}"); };
var opts: net.TlsOptions = net.tls_options();
opts.trust = t.store();
FunctionDoes
bundled_trust() -> *const x509.TrustStoretape’s bundled roots (ca_roots), loaded once: the default. Safe from any thread, so a thread or @pool task can connect with the default trust
trust() -> TrustAn empty set
trust_from_pem(pem), trust_from_der(certs)A set of the certificates of a PEM bundle, or of DER certificates
t.add_bundled()Add the bundled roots
t.add_system()Add the operating system’s roots
t.add_pem(pem), t.add_der(cert)Add a PEM bundle’s certificates, one DER certificate
t.store()The store to put in TlsOptions.trust or http.Options.trust
t.len(), t.skipped()Anchors in the set; certificates add_system could not use
t.free()Free the set, after the connections using it have closed

The operating system’s store is used only when you add it. add_system reads $SSL_CERT_FILE or the distribution’s bundle on Linux (/etc/ssl/certs/ca-certificates.crt and the other usual places), the ROOT certificate store on Windows, the Security framework’s anchors on macOS. A certificate already in the set is not added again. Errors are TrustError: BadPem, BadCertificate, Empty, and Io when the system store could not be read; on an error nothing from that input is added.

Errors

The network variants of io.IoError:

VariantMeans
ConnectionRefusedNothing listens there (or a UDP peer’s port is closed)
ConnectionResetThe peer reset the connection
ConnectionAbortedThe connection was dropped on this side, or reset before accept
NotConnectedThe socket has no connection
AddrInUseAnother socket has the address and port
AddrNotAvailableThe address is not one of this machine’s
NetworkUnreachable, HostUnreachableNo route there
TimedOutA timeout passed; or no DNS server answered
WouldBlockNonblocking, and the call would have to wait
HostNotFoundThe name does not exist or has no addresses

Besides these: BrokenPipe (writing after the peer closed), PermissionDenied (a port below 1024 on Unix, a firewall), InvalidInput, Interrupted, Unsupported (no sockets on this platform) and Other.

Platforms

Windows, Linux and macOS, and leivos (the Uusi kernel), where the sockets are netd’s. On leivos: IPv4 only (IPv6 is Unsupported), a datagram of at most 200 bytes sent with send_to (224 with a connected send) and 192 received with recv_from (longer ones are cut, and truncated is set), at most 9 addresses from resolve, and eight sockets in all across the system’s programs. Bytes move to and from netd in its 256-byte messages, a couple of hundred bytes a round trip. A netd that shares a memory window with the program instead moves up to 64 KiB a request, and a datagram can be that long. The runtime asks netd on the program’s first send or receive and uses the window when netd offers one, and the messages otherwise. Nothing changes in your code. While netd is not running yet (early in boot, or before the network card is up), every socket call fails with NetworkUnreachable, a transient error: the next call asks again.

Last modified: