Sockets across platforms¶
lrvx/net/socket.h is the socket surface the venue perimeter and the FIX
initiator use, with the platforms' disagreements in one place instead of at
sixty call sites. Nothing under venue/ or include/lrvx/connector/ reaches
a POSIX network header any more; the layer is the only thing that does.
It is deliberately small. Anything the standard library already does portably is not here, and neither is any operation nothing calls: a layer that wraps what nobody uses is a layer nobody maintains.
Differences that do not announce themselves¶
There were three when this layer was written and a fourth turned up when the
perimeter moved onto it. Most of what separates Berkeley sockets from Winsock
is loud — a type that
is not int, a close that is not close, errors that are not in errno.
Those fail to compile and get fixed. Three do not.
The receive timeout. Both platforms spell the option SO_RCVTIMEO. POSIX
wants a timeval; Windows wants a count of milliseconds in a DWORD. Pass
one where the other is expected and setsockopt succeeds, returning zero,
having set a timeout nobody asked for. Nothing downstream can tell. The
perimeter leans on these timeouts for idle disconnects and for bounding how
long a connection's lock is held, so the failure is not a crash — it is
sessions that stop being reaped, or a poll loop that spins.
setReceiveTimeout(handle, milliseconds) and setSendTimeout take the unit
in their name and produce whatever the platform wants.
Suppressing SIGPIPE. Writing to a peer that has gone away raises SIGPIPE
and, unhandled, ends the process. macOS answers with a socket option
(SO_NOSIGPIPE), Linux with a send flag (MSG_NOSIGNAL), Windows with
nothing, because it has no such signal. Three answers to one question, and
the wrong one on macOS is fatal rather than wrong.
suppressSigPipe(handle) at setup and sendNoSignal at the send: together
they cover all three, and each does nothing where nothing is needed.
Non-blocking mode. POSIX reaches it through the file descriptor
(fcntl), Windows through the socket handle (ioctlsocket). The two APIs
share no argument. setNonBlocking(handle, bool).
Where an error is kept. (The fourth, found during the move.) A read that comes back empty is either a timeout
or a dead peer, and the perimeter decides between them by asking what went
wrong. POSIX keeps the answer in errno. Windows keeps socket errors in a
place errno never sees, so errno == EAGAIN there compiles and is always
false: every idle tick would read as a healthy socket, and every timeout as
one, until a session that should have been reaped never was.
lastError() reads from the right place; clearLastError() clears the right
place, which a caller needs before a read whose failure may set nothing at
all -- a clean EOF, for instance, would otherwise be judged by whatever the
previous failing call left behind.
Names¶
openSocket and closeSocket, not open and close. A caller who writes
using namespace lrvx::net and then close(fd) would otherwise get an
ambiguity against the POSIX functions of those names, and the error would
land in their file rather than in this header. The first version used the
short names and the first test written against it did not compile.
Errors¶
wouldBlock(e) and interrupted(e) separate "the socket had nothing to give" from "a signal
arrived, try again" — and they must stay separate, because a retry loop that
treats EINTR as emptiness turns into a spin. On Windows interrupted is
always false, which is the honest answer rather than an oversight.
Connections¶
bindTo, listenOn, acceptOne, connectTo and boundPort take an
address as a string and a port as a number, so the one place that knows how
an address is laid out stays parseAddress. acceptOne fills in the peer
only if asked; the length argument it needs is an int on one platform and a
socklen_t on the other, and it is in/out, so it cannot be a sizeof at the
call site.
boundPort exists because bindTo(handle, ip, 0) asks the system to pick a
free port, and that is the only way the caller learns which one it got. Every
ephemeral listener in the tree is those two calls.
sendTo and receiveFrom are the datagram pair. The destination is a parsed
address rather than a string: a publisher resolves its group once at open and
sends to it per message, and re-parsing text per send would put a conversion
on the hot path.
resolveIPv4 is the name lookup, kept separate from parseAddress so that a
dotted quad never pays for a resolver.
IPv4 only, and what that rules out¶
The layer carries one address family. openSocket passes AF_INET, every
address in the header is a sockaddr_in, parseAddress runs inet_pton with
AF_INET, and resolveIPv4 pins hints.ai_family to it, so a name that
resolves only to an AAAA record does not resolve here at all. A perimeter
built on this reaches v4 hosts and no others; an IPv6 literal (::1,
fe80::1) is refused by parseAddress, and therefore by bindTo,
connectTo and sendTo, rather than being turned into some other address.
That refusal is the part to rely on: the limit is visible at the call, not
later.
This is a deliberate limit rather than an omission. Lifting it is not a flag
on openSocket: every address-carrying signature here -- parseAddress,
acceptOne, connectAddress, sendTo, receiveFrom, resolveIPv4 -- names
sockaddr_in in its type, and carrying both families means sockaddr_storage
through all of them and a family argument at every open, with the venue
perimeter and the FIX initiator moved over in the same change. Nothing in the
tree has needed it. A deployment that has to reach a v6 host puts a v4 address
in front of it today; when the layer does grow a family, it will be an
enum class Family { IPv4, IPv6 } on openSocket and sockaddr_storage
underneath, not a second set of functions.
What is tested¶
tests/test_socket_portability.cpp exercises the layer over loopback rather
than asserting that it compiles: a receive timeout must elapse in the
milliseconds it was given, a non-blocking receive must return at once and say
why, a peek must not consume, a send to a dead peer must fail rather than end
the process, pollRead must see readable and must time out, address parsing
must refuse what is not an address (an IPv6 literal included), a multicast
join must refuse a unicast one, a datagram must carry its sender back to the receiver, and a signal
arriving mid-wait must be reported as interrupted rather than as a broken
socket -- that last one is arranged on purpose, with a handler installed
without SA_RESTART and a thread that signals the waiter.
The timeout test is the one that matters most: mutating setReceiveTimeout
to pass milliseconds where a timeval belongs turns it red. Without a test
that measures elapsed time, that mutation compiles, runs, and is wrong.
Every operation added for the perimeter was checked the same way -- each one
mutated in turn, each mutation required to turn a named test red. Three of
them first turned the suite hung rather than red, because a test whose
connect had silently not happened sat in accept forever. A hang is not a red
test: the accepts in the suite now wait on pollRead first, the same bounded
wait the perimeter itself does, so a mutation fails a line instead of parking.
One mutation is not caught, and it is worth saying which rather than leaving
the impression that all of them are. Handing setMulticastLoop an eight-byte
value where the option wants one byte passes on macOS: the platform accepts
both and behaves identically, so no test running there can tell them apart.
The shape matters on Windows, where this test does not yet run. What is
covered is the option's effect -- a mutation that stops it taking effect at
all turns red, because the test asks whether a sender hears its own multicast
rather than whether setsockopt returned zero.
That effect check itself has a platform boundary worth stating. Turning the option off is asserted on macOS only: the test selects the loopback interface for egress, and Linux and Windows both deliver a packet sent out of it regardless, because that is what a loopback interface is. The option governs the kernel's extra copy, not delivery over an interface that is already a loop. Observing it would need a real NIC address, which a CI runner cannot be counted on to have, so the assertion runs where it means something -- the macOS job, which builds these tests.