sysl

The net module

sysl.posix.net — blocking TCP and the names it resolves: socket, bind, listen, accept, connect, send, recv, shutdown, close, and why it mirrors POSIX rather than inventing a stream type.

Every declaration in sysl.posix.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.posix.net is blocking TCP: a socket, an address to give it, and the four calls that move bytes. It requires posix.

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

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))
true
127.0.0.1 8080 Ipv4
127.0.0.1:8080

Why it is sysl.posix.net and not sysl.net

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 keeps the underlying API’s own vocabulary.

So this is socket, bind, listen, accept, connect, send, recv, shutdown and close, with those meanings and in that order, and it is deliberately not a TcpStream.

A portable sysl.net comes later, over this, and that order is the point rather than a compromise. POSIX made every decision this module needs forty years ago, so mirroring it cannot guess wrong — and a module that cannot guess wrong is one that can be frozen. What a portable address is, what a portable error is, how a timeout is spelled, whether a listener is a type or a function: those are guesses, and they want real consumers before anything is fixed. Adding a module after 0.1.0 is free; only removing one is forbidden. So the safe half goes before the freeze and the risky half after it.

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. So sysl’s standard library having no asynchronous tier stops being an omission and becomes a decision with a precedent behind it.

A client

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

import sysl.posix.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.posix.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.posix.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 blocking call reports EAGAIN, 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.

An address carries the platform’s bytes

Address holds a sockaddr and its length and sysl never looks inside it. A sockaddr_in and a sockaddr_in6 are different layouts, and Darwin’s carry a length byte glibc’s do not, so a struct written in sysl would be a transcription of a layout — the thing that compiles everywhere and is wrong somewhere. What crosses is the bytes; everything a program wants to know about an address it asks for.

Family is Ipv4 and Ipv6, named by the version rather than by AF_INET‘s number — which is what a program must not be handed, since AF_INET6 is 30 on Darwin and 10 under glibc. 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, and is a thing an attacker controls the answer to.

Printing one brackets a v6 address — [::1]:8080 — because a v6 address has colons in it and ::1:8080 cannot be read apart. It is the form the URL syntax settled on.

What is deliberately absent

UDP, multicast, 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. That is the same reasoning that puts a portable sysl.net after the freeze rather than before it.

Search

Esc
to navigate to open Esc to close