sysl

The net module

sysl.net — blocking TCP and UDP and the names they resolve: socket, bind, listen, accept, connect, send, recv, send_to, recv_from, shutdown, close; addresses as sysl values; and the hooks a target without a C library answers.

Every declaration in sysl.net, with its signature: the generated API page. This page is the argument — what the module is for, and how its pieces fit; that one is the list.

sysl.net is blocking sockets: a TCP stream, a UDP socket, an address to give either, and the calls that move bytes. It requires os.

import sysl.net.{ipv4, resolve}

val addrs = resolve("127.0.0.1", 8080).unwrap()

print(addrs.len() > 0)
print(addrs.at(0).text(), addrs.at(0).port(), addrs.at(0).family())
print(addrs.at(0))
print(addrs.at(0) == ipv4(127, 0, 0, 1, 8080))
true
127.0.0.1 8080 Ipv4
127.0.0.1:8080
true

Why it is sysl.net, and still speaks sockets

The library has two conventions, and the rule that separates them is visible in one pair: sysl.term is the portable surface sysl invented over a terminal, and sysl.posix.tty is termios presented as termios. Everything under sysl.posix is a POSIX API presented as itself, and this module is not one any more: every call it makes goes through the hooks of sysl.net.sys, which a hosted target’s sockets answer and a kernel with a network stack of its own answers just as well, and an address is a sysl value rather than a sockaddr. So it is sysl.net. It was sysl.posix.net until 0.1.0-alpha.6; the names inside are the same, and moving a program is its import line.

The vocabulary stays the sockets one — socket, bind, listen, accept, connect, send, recv, shutdown, close, with those meanings — because forty years of programs and documentation are written in it, and a reader who knows it should not have to translate. It is deliberately not a TcpStream and a TcpListener: the sockets API has one type for both, and so does this.

Blocking, and only blocking

Every call here returns when its work is done. There is no event loop, no non-blocking mode and no readiness notification.

That is the line Rust draws too, and for the same reason: std::net is blocking and every async runtime, tokio included, is a package outside the standard library. sysl-lang/libuv is where the event-loop story lives, and the two compose — a program that wants a loop uses the package, and one that wants a straight line uses this.

A client

Resolve, make a socket of the address’s family, connect.

import sysl.net.{resolve, socket, Socket}

// A listener of our own, so the page has something to connect to. Port 0 asks the machine for one
// it has free, and `local` is what says which it gave.
val here = resolve("127.0.0.1", 0).unwrap().at(0)
var server = socket(here).unwrap()

server.reuse_address().unwrap()
server.bind(here).unwrap()
server.listen().unwrap()

val at = server.local().unwrap()

var client = socket(at).unwrap()

client.connect(at).unwrap()

var (accepted, from) = server.accept().unwrap()

client.send_all([104, 105]).unwrap()

var into: []u8 = [0; 8]
val got = accepted.recv(into).unwrap()

print(got, into[0] == u8('h'), into[1] == u8('i'))
print(from.family())

accepted.close().unwrap()
client.close().unwrap()
server.close().unwrap()
2 true true
Ipv4

resolve answers every address, in the order the machine ranked them, and both protocol versions come back. That is what makes a program work on a v6-only network without knowing it is on one, and the order is the system’s answer to which to prefer — RFC 6724 says how a machine sorts them, and second-guessing it here would be this module inventing policy that is not its to have. So the ordinary client is a loop over what came back, stopping at the first address that connects.

resolve_passive is the other question. A listener binds to the wildcard — every address the machine answers on — and nothing about the arguments alone says whether that or the loopback was meant. It is a separate call because a caller who got the wrong one binds to the loopback and wonders why nobody outside can reach it.

The bytes

send writes some of what it was given and says how many. A short write is not an error: a stream moves what it can and the caller comes back for the rest. send_all is that loop, and is what almost every caller wants — it also retries an interrupted call rather than reporting it, since a signal arriving mid-write is not a failure of the write.

recv reads into a slice and says how many bytes arrived. Zero means the peer has finished writing — end of stream, not an error and not a timeout. That is the one answer every reader has to handle, and it is why the count is a plain usize: a stream that is over is an ordinary outcome, and a loop that reads until zero is the whole protocol.

shutdown is not close

shutdown gives up one direction while the descriptor stays open, and the interesting case is Write: it sends the peer an end of stream, so a program that has finished asking can say so and then go on reading the answer. Closing would have said nothing and thrown the answer away.

import sysl.net.{resolve, socket, Shutdown}

val here = resolve("127.0.0.1", 0).unwrap().at(0)
var server = socket(here).unwrap()

server.bind(here).unwrap()
server.listen().unwrap()

val at = server.local().unwrap()
var client = socket(at).unwrap()

client.connect(at).unwrap()

var (accepted, _) = server.accept().unwrap()

client.shutdown(Shutdown.Write).unwrap()

var into: []u8 = [0; 4]

// The client has finished writing, so the server sees end of stream -- and can still answer.
print(accepted.recv(into).unwrap())

accepted.send_all([121]).unwrap()

print(client.recv(into).unwrap(), into[0] == u8('y'))

accepted.close().unwrap()
client.close().unwrap()
server.close().unwrap()
0
1 true

A socket is not closed by going out of scope. close is a call, exactly as it is in C, and a program that drops one without closing leaks a descriptor until it exits — which is C’s own rule rather than something this binding introduced. defer s.close() is the idiom.

The timeout, which a blocking call cannot do without

Otherwise “returns when the work is done” can mean never. read_timeout and write_timeout bound it in milliseconds, and zero — where a socket starts — means no limit.

import sysl.net.{resolve, socket, timed_out}

val here = resolve("127.0.0.1", 0).unwrap().at(0)
var server = socket(here).unwrap()

server.bind(here).unwrap()
server.listen().unwrap()

val at = server.local().unwrap()
var client = socket(at).unwrap()

client.connect(at).unwrap()

var (accepted, _) = server.accept().unwrap()

accepted.read_timeout(50).unwrap()

var into: []u8 = [0; 8]

// Nothing was ever sent, so without the timeout this call would not return at all.
print(timed_out(accepted.recv(into).unwrap_err()))

accepted.close().unwrap()
client.close().unwrap()
server.close().unwrap()
true

timed_out is a function rather than a case of IoError. A timed-out call reports ETIMEDOUT, which is a number and not one of that enum’s named cases — and adding a variant to an error type every module in the library shares, for a condition only this one can produce, is a larger change than the question deserves.

The rest of the error half is sysl.fs‘s IoError, from the same errno numbers as everywhere else. A name that will not resolve is NotFound, whatever the resolver’s own reason was: the EAI_* codes are a numbering of their own and overlap errno‘s — EAI_AGAIN is 2 on Darwin, which is ENOENT — so passing them through would produce an error that means something else entirely.

Datagrams: UdpSocket

udp_socket(address) makes a datagram socket of that address’s family; bind gives it a port to receive on, and then each send_to is one datagram and each recv_from takes one whole, with who sent it — which is the whole of an echo server:

import sysl.net.{ipv4, udp_socket}

val here = ipv4(127, 0, 0, 1, 0)
var server = udp_socket(here).unwrap()

server.bind(here).unwrap()

val at = server.local().unwrap()
var client = udp_socket(at).unwrap()

client.bind(here).unwrap()
client.send_to([112, 105, 110, 103], at).unwrap()

var into: []u8 = [0; 64]
val (n, from) = server.recv_from(into).unwrap()

// Back to whoever asked.
server.send_to(into[0..<n], from).unwrap()

val (m, back) = client.recv_from(into).unwrap()

print(n, m, into[0] == u8('p'), back == at, from == client.local().unwrap())

server.close().unwrap()
client.close().unwrap()
4 4 true true true

A datagram is not a stream. One longer than the buffer it is received into keeps what fits and loses the rest; one sent may never arrive; two may arrive in either order. UDP is for the exchanges that can live with that — a query and its answer, a heartbeat, a sample — and the calls say nothing more than that. An empty datagram is a datagram, and recv_from answers zero for it.

connect on a UdpSocket fixes where it talks to: send then goes there without naming it, and recv takes datagrams from there and from nowhere else. A lost datagram is waited for forever unless read_timeout says otherwise, which is the reason a UDP client sets one.

An address is a sysl value

Address is an IP address and a port: four bytes or sixteen, a port, and — for a link-local IPv6 address — the interface it is scoped to. It compares with == and hashes like any other value, so a server can key a table on whoever sent it something. resolve answers addresses, and ipv4 and ipv6 build one with no resolver at all, which is what a program on a machine without one writes:

import sysl.net.{ipv4, ipv6}

val a = ipv4(10, 0, 2, 15, 8007)

print(a, a.family(), a.octets()[3], a.with_port(7))
print(ipv6([0x2001, 0xdb8, 0, 0, 0, 0, 0, 1], 443))
print(ipv6([0xfe80, 0, 0, 0, 0, 0, 0, 1], 0, 4).text())
print(ipv6([0, 0, 0, 0, 0, 0xffff, 0x7f00, 1], 0).text())
10.0.2.15:8007 Ipv4 15 10.0.2.15:7
[2001:db8::1]:443
fe80::1%4
::ffff:127.0.0.1

text() is the numeric form and never a name: turning an address back into a hostname is a second lookup over the network, which is not what a program printing what it just connected to is asking for. An IPv6 address is written as RFC 5952 says every program should — lower-case hex, no leading zeros, the longest run of two or more zero groups as :: — and printing one brackets it, [::1]:8080, because a v6 address has colons in it and ::1:8080 cannot be read apart.

Family is Ipv4 and Ipv6, named by the version rather than by AF_INET‘s number, which is 30 on Darwin and 10 under glibc and is the hosted answer’s business. The sockaddr layouts — Darwin’s carry a length byte glibc’s do not — stay in that answer’s C, where the header decides them.

Answering the network on a target with no C library

Everything above reaches the network through sysl.net.sys and through nothing else: fourteen hooks, each an extern no module of the library defines.

hookwhat it answers
sysl_net_socket(family, kind) -> inta descriptor; family 4 or 6, kind stream (0) or datagram (1)
sysl_net_bind(fd, at) -> int, sysl_net_connect(fd, to) -> intzero
sysl_net_listen(fd, backlog) -> intzero
sysl_net_accept(fd, peer) -> intthe new connection’s descriptor, the peer written into peer
sysl_net_local(fd, at) -> intzero, the bound address written into at
sysl_net_send(fd, from, len) -> isize, sysl_net_recv(fd, into, room) -> isizea count of bytes
sysl_net_send_to(fd, from, len, to) -> isize, sysl_net_recv_from(fd, into, room, from) -> isizea count of bytes, the sender written into from
sysl_net_shutdown(fd, how) -> int, sysl_net_close(fd) -> intzero
sysl_net_option(fd, which, value) -> intzero; read_timeout, write_timeout (milliseconds) or reuse_address
sysl_net_resolve(host, host_len, port, passive, out, room) -> inthow many addresses it wrote

Each answers zero or more for success and the IoError code negated for a failure — the contract of every *.sys module (sysl.fs.sys, sysl.io.sys, sysl.process.sys) — and sysl.sys.UNSUPPORTED (-38 on every platform, not the host’s ENOSYS) for a call the target cannot make at all, which reaches the caller as not supported on this target. A call its timeout stopped answers ETIMEDOUT negated (60 on a BSD, 110 everywhere else), which is what timed_out asks. An address crosses as sysl.net.sys.Endpoint: sixteen bytes in network order (an IPv4 address in the first four), a scope, a port as the number itself, and the family — 24 bytes laid out as C lays out the same four fields, so a supplier reads it without knowing anybody’s sockaddr.

On a hosted target the library answers them itself, under weak exports (a module may supply another module’s extern). A freestanding program answers each hook it reaches with an @export of its own — a kernel’s UDP echo server needs six of them:

import sysl.net.sys.Endpoint

@export("sysl_net_recv_from")
k_recv_from(fd: int, into: *u8, room: usize, from: *Endpoint) -> isize = -110

A hook the program reaches and leaves unanswered is refused when it is compiled, all of them in one sentence, rather than surfacing at the link as a symbol no line of the program names:

import sysl.net.{ipv4, udp_socket}

val here = ipv4(10, 0, 2, 15, 8007)

udp_socket(here) match
    Ok(s) ->
        var u = s
        val _ = u.bind(here)
    Err(_) -> ()
this program reaches 'sysl.net', and 'aarch64-freestanding' has no operating system under it for the standard library to answer a network with, so the program answers it: define 'sysl_net_bind', 'sysl_net_socket' with '@export', each taking what its 'extern' in 'sysl.net.sys' declares and answering a descriptor, a count or zero, or the code of an 'IoError' negated

The question is asked only of what the program reaches, so a freestanding program that builds an Address and never makes a socket is asked nothing.

What is deliberately absent

Multicast, broadcast, unix domain sockets, non-blocking mode, and every socket option beyond the two timeouts and reuse_address, which is here because a listener that has just been stopped cannot otherwise be started again until TIME_WAIT runs out — a minute or two, during which the program looks broken.

All of them are additive, so leaving them out costs a later release nothing, and guessing at them now would fix a shape before anybody has used one. Each would be a hook in sysl.net.sys and a call here, which is the shape everything above already has.

Search

Esc
↑↓ to navigate ↵ to open Esc to close