Networking and signals
I/O and native services · Previous: Local I/O · Next: Databases
TCP sockets: TCPSocket.connect/TCPServer.listen/.accept
server = TCPServer.listen(8080)
conn = server.accept() # blocks until a client connects
line = conn.gets()
conn.write("echo: #{line}\n")
conn.close()
client = TCPSocket.connect("example.com", 8080)
client.write("hello\n")
client.gets()
client.close()
TCPSocket.connect(host, port) and TCPServer.listen(port) both resolve
addresses via getaddrinfo (protocol-agnostic — IPv4 and IPv6 both work,
nothing hardcodes sockaddr_in), trying each candidate address in turn
until one succeeds. port is an Int in both cases (converted to the
string getaddrinfo expects internally); host is a String hostname
or address. A connect/bind/listen failure raises a rescuable IOError
with strerror(errno) from the actual failing attempt, even when
multiple addresses were tried.
TCPServer.listen(port) also takes keyword options after the port, in any
order:
server = TCPServer.listen(0, host: "127.0.0.1", backlog: 16)
server.port() # => the port the kernel chose
host:— bind one address ("127.0.0.1","::1", a hostname) instead of every interface.backlog:— thelisten(2)queue length, a positiveInt(defaultSOMAXCONN).reuse_port:—SO_REUSEPORT, as above.
Listener#port() returns the bound port, which is how a listener opened
on port 0 is found. listen_nonblocking takes the same options.
A connected socket (from TCPSocket.connect, accept, or a
Socket) has shutdown_write(): it flushes what was written and half-closes
the connection, so the peer reads to EOF while this side can still read the
reply. Calling it on something that is not a socket raises IOError.
peer_address() returns the remote end of a connected socket as text: "203.0.113.7" or
"2001:db8::1" (an IPv4-mapped IPv6 peer is reported as plain IPv4), the same on a client socket and an
accepted one. It is nil when there is no such address (a socket still connecting, or a Unix-domain
peer) and raises IOError on something that is not a socket. A server that sits behind a proxy sees the
proxy's address here; use the proxy's forwarding header for the client.
TCPSocket.connect(host, port, options) also takes an optional third
options Hash (defaulting to nil, meaning none of this applies —
existing calls are unaffected):
client = TCPSocket.connect("example.com", 8080,
{"connect_timeout_ms": 5000, "read_timeout_ms": 10000})
recv_buffer_bytes/send_buffer_bytes—SO_RCVBUF/SO_SNDBUF, set before connecting so the window is negotiated from them. The kernel clamps the value (and Linux doubles it).connect_timeout_ms— bounds only theconnect(2)step. Applies per candidate address, not as one aggregate deadline across every addressgetaddrinforeturns (an ordinary connection refusal already moves on to the next candidate; a timeout does the same). Implemented by making the socket non-blocking before connecting andpolling for writability, then restoring blocking mode for every operation after — everything past this point behaves exactly as if the socket had always been an ordinary blocking one.read_timeout_ms/write_timeout_ms—SO_RCVTIMEO/SO_SNDTIMEOon the connected socket, applying to every subsequent.read()/.gets()/.write()on it. A timed-out read/write raises the same rescuableIOErroran ordinary failed read/write already does (strerror(EAGAIN), i.e. "Resource temporarily unavailable") — no new error path, just a new way for the existing one to trigger.
Omitting a key leaves the corresponding OS default (block forever)
untouched. An unrecognized key, or a value of the wrong type, raises a
TypeError rather than being silently ignored — the same "an
unrecognized name is a mistake, not a no-op" stance Signal.trap already
takes.
The key design decision: a connected socket — whether from
TCPSocket.connect or from .accept() — is fdopen()'d and wrapped in
the exact same DiamondFileHandle a File uses. This means
.read()/.gets()/.write(value)/.close() on a connected socket are
the identical File dispatch code already described above; no new
read/write logic exists for sockets at all. Only connection
establishment is new:
TCPServer.listen(port)produces a different, new heap object kind,DIAMOND_OBJECT_LISTENER(DiamondListenerHandle, wrapping a raw listening-socket file descriptor rather than aFILE *, since a listening socket is never read from or written to). LikeFile, nothing inside it references another d6 value, so it needs nomark_objectbranch; sweeping an unreached handle whose fd is still open closes it, same safety-net role asFile/Fiber..accept()is nativeDIAMOND_OP_INVOKEdispatch on aDIAMOND_OBJECT_LISTENERreceiver (alongside.close()) — the only genuinely new I/O operation in this slice. It blocks inaccept(2), then hands the resulting connection to the samefdopen-and-wrap pathTCPSocket.connectuses.
TCPSocket.connect/TCPServer.listen are recognized in the compiler the
same way File.open/Fiber.new are.
Both TCPServer.listen/listen_nonblocking also accept an optional
trailing reuse_port: <expr> keyword, defaulting to false — sets
SO_REUSEPORT on the listening socket, letting more than one independent
listener bind the same port (the kernel load-balances new connections
across them), instead of the default exclusive-ownership behavior where a
second listen on an already-bound port fails with IOError. Off by
default so every existing single-listener server keeps today's stricter
behavior unless it explicitly opts in — see docs/threads.md's
"Crossing the heap boundary" section and packages/gremlin's
gremlin_worker for why a caller would want this: each thread in a
multi-threaded server opens its own listener (a Listener can never cross
a Thread boundary), all bound to the same port via this flag.
Non-blocking sockets: TCPServer.listen_nonblocking, Socket, IO.poll
listener = TCPServer.listen_nonblocking(8080)
conn = listener.accept() # nil immediately if nothing's pending -- never blocks
if conn != nil
begin
data = conn.read(4096) # nil at EOF, or the bytes actually available right now
rescue error: WouldBlockError
# nothing to read yet -- not an error, just not ready
end
conn.write("hi") # returns the byte count actually written (may be partial)
conn.close()
end
ready = IO.poll([listener], [], -1) # -1 blocks until something's ready, 0 polls once
ready["readable"][0] # true/false, one entry per readables input, same order
Exists specifically to make genuine fiber-driven concurrency possible
(see packages/gremlin, docs/fibers.md) — a blocking .accept()/
.read()/.write() on the ordinary TCPServer/TCPSocket/File
primitives above blocks the entire process, not just one logical
connection, so a Fiber-per-connection design gets no real concurrency
out of them no matter how many fibers exist. These are the primitives
that let a fiber's own I/O yield back to a scheduler instead:
TCPServer.listen_nonblocking(port)— identical toTCPServer.listen(port)(samegetaddrinfo/socket/bind/listendance, factored into one sharedtcp_listen_helperinsrc/vm.c) but the resulting fd is also setO_NONBLOCKviafcntl, and theDiamondListenerHandlereturned is flagged accordingly..accept()on such a listener returnsnilinstead of blocking when nothing is pending (an ordinaryTCPServer.listenlistener's.accept()is unaffected either way — still blocks, same as always).Socket— a new object kind (DIAMOND_OBJECT_SOCKET,DiamondSocketHandle), returned by.accept()on aTCPServer.listen_nonblockinglistener orTCPSocket.connect_nonblocking. Deliberately not the bufferedFILE*File/blocking-socket path reuses — libc stdio buffering andEAGAINdon't mix cleanly, since a short buffered read can silently swallow the "nothing available yet" signal a poll-driven caller needs to see on every call, not just the first..read(n)/.write(value)are rawread(2)/write(2)against the fd directly:.read(n)returns aStringof however many bytes were actually available (up ton),nilat true EOF (the peer closed), or raisesWouldBlockError(a newStandardErrorsubclass) when nothing's ready right now — a real, meaningful distinctionFile#readnever needed, since a blocking read can't tell "not yet" from "never." Also accepted on Linux fd never inheritsO_NONBLOCKfrom the listener it came from, so.accept()sets it explicitly on each accepted fd too..write(value)returns the actual byte count written rather than either succeeding fully or raising — a partial write is a normal, expected outcome on a non-blocking socket whose send buffer filled up mid-write, and the caller (packages/gremlin'sNonblockingConnection#write) is expected to retry the remainder.IO.poll(readables, writables, timeout_ms)— apoll(2)wrapper accepting two Arrays (ofTCPServer.listen_nonblockinglisteners/Sockets,UDPSockets (readable once a datagram is queued, so the following.receive()cannot block — this is how a UDP server or client gets a receive timeout), or aProcess.spawnhandle'sProcess::Stream(see the Process section below) — an ordinary blocking listener/File is rejected, since polling a blocking-mode fd is meaningless: nothing in this VM ever puts one in non-blocking mode, so it would always appear either always-ready or never-ready depending on kernel buffering, never the genuine signal a caller needs) and a millisecond timeout (-1blocks indefinitely,0returns immediately). Returns aHash:{"readable": [...], "writable": [...]}, each an Array ofBools the same length and order as the corresponding input —readable[i]answers "isreadables[i]ready," not "which are ready" — deliberately avoiding a design needingArray#include?/object-identity comparison on the d6 side, neither of which exists. The same fd can appear in both lists (or twice within one, from two different d6 objects wrapping the same underlying connection) — internally deduplicated by fd and OR'd together into onepollfdentry, sincepoll(2)itself keys purely by fd.POLLHUP/POLLERR/POLLNVALcount toward both readiness directions: a peer that closed its connection is exactly the condition a caller's next.read()/.write()needs to be woken up to observe, not a state that would otherwise never produce aPOLLIN/POLLOUTagain.
Building this Hash result safely took real care: every allocate_* call
in src/vm.c can trigger a GC collection, and this VM's collector only
marks reachable values from rooted locations (registers, the exception
slot, fiber frame chains) — a freshly allocated object sitting in a
plain C local between two further allocate_* calls is invisible to it.
An earlier version of DIAMOND_OP_IO_POLL's result-construction code
allocated all four pieces (two result Arrays, two String keys) before
ever touching registers[dest], and a real heap-use-after-free in
hash_set/hash_find — caught by make test-sanitize under
DIAMOND_STRESS_GC=1, not by inspection — was the result. The fix: root
the Hash in registers[dest] immediately after allocating it, then for
each entry, root its key first with a DIAMOND_NIL placeholder value
(nil needs no protection, so this step is always safe) before allocating
the real value and overwriting the placeholder — hash_set already
updates an existing key's value in place. Nothing is ever more than one
allocation away from reachability through registers[dest].
TCPServer.listen_nonblocking/IO.poll are recognized in the compiler
the same way TCPServer.listen/TCPSocket.connect are (IO.poll
mirroring TCPSocket.connect's 3-argument shape, since neither is a
class with real dispatch — see above).
Cancellation and absolute deadlines
IO.poll(readables, writables, {"cancellations": channels, "deadline": deadline})
uses the same readiness result as the integer-timeout form. channels is a
required Array of Channels; closing any channel wakes the wait. Duplicates and
an empty array are allowed. deadline is a finite Float absolute
Time.monotonic() timestamp, or nil (also the default) for no deadline.
Unknown options and invalid types raise TypeError.
This form registers a notification pipe alongside the supplied descriptors.
It returns on readiness, cancellation, deadline expiry, or a spurious wakeup;
it does not raise a cancellation exception or reserve/consume socket data.
Readiness arrays may contain only false after a wakeup. Recheck cancellation
and retry nonblocking operations. An expired deadline or already closed
cancellation source returns immediately. Signals return to the VM dispatcher
after the waiter has unregistered; VMs with signal handlers retain bounded
10 ms returns. Other VMs have no periodic polling timer.
The cancellation cut wraps this as
token.poll(readables, writables), checking the token before and after each
wait. For accepted nonblocking TCP Sockets, use token.read(socket, count)
and token.write(socket, string) to retry readiness waits automatically.
Read returns up to count bytes or nil at EOF; write returns the byte count
after writing the entire string. Cancellation during a write may leave a
prefix on the wire. The caller still owns the socket and should close it in
ensure. Blocking TCP/TLS handles and SQL calls are outside this contract.
Nonblocking outbound TCP
TCPSocket.connect_nonblocking(address, port) starts a TCP connection and
immediately returns a nonblocking Socket. The address must be a numeric IPv4
or IPv6 string (for example "127.0.0.1" or "::1"), and the port an Int from
1 through 65535. Invalid inputs raise TypeError; hostnames are rejected so
DNS cannot block this operation. IPv4 uses dotted decimal; IPv6 literals use no URL brackets or scope suffix.
The existing TCPSocket.connect API continues to support hostnames and blocking
buffered I/O.
Call socket.finish_connect() to check completion. It returns nil on success,
raises WouldBlockError while pending, or closes the socket and raises
IOError on terminal connection failure. Wait for writable readiness with
IO.poll before retrying; readiness alone does not prove connection success.
The completion check is idempotent on connected or accepted sockets. Reads and
writes also check pending connection state. close() cancels an outstanding
connection and remains idempotent. All these sockets stay in their owning VM.
For cancellable connections, load the cancellation cut and use its 0.5.0 helper:
source = Cancellation::Source.new(nil, 2.0)
token = source.token()
socket = token.connect("localhost", 8080)
begin
token.write(socket, "hello")
response = token.read(socket, 4096)
ensure
socket.close()
end
token.connect accepts hostnames and numeric IP literals. It resolves the host,
tries unique IPv4/IPv6 addresses in resolver order after connection failures,
checks cancellation again after completion, and closes failed or cancelled
attempts. The earliest inherited deadline covers DNS and all connection attempts
without restarting. A pending connection uses the remaining deadline; addresses
are tried sequentially, without Happy Eyeballs racing. On success the caller owns
the socket. Use token.connect_tls to extend the same deadline through TLS negotiation.
Cancellable system DNS
token.resolve(host) exposes the same resolution separately. The lower-level
DNS.resolve(host, cancellations, deadline) takes a hostname String, an Array of
Channels, and a finite Float absolute Time.monotonic() deadline or nil. It
returns unique numeric address strings in system resolver order, or nil if any
channel closes or the deadline expires. DNS failures and exhausted worker
capacity raise IOError; invalid inputs raise TypeError. Empty hosts, embedded
NULs, URL brackets, and IPv6 scope suffixes are rejected. Network sandbox policy
also applies to DNS, including numeric literals.
Resolution runs in native workers while the calling VM waits with cancellation notifications and dispatches its signal handlers. Cancellation abandons the wait; it cannot forcibly interrupt libc's resolver. Workers retain only their own C buffers and descriptors, never the VM or its heap, and are not joined during VM cleanup. The process-wide limit is eight outstanding lookups, including abandoned ones. Further hostname requests fail promptly until a worker finishes; numeric literals bypass the pool. No new DNS cache is introduced, and system hosts/NSS configuration remains in effect.
Cancellable client TLS handshakes
Cancellation 0.6.0 adds token.connect_tls(host, port, options = nil). It resolves
and connects using the token, then negotiates TLS using that same deadline and
cancellation channels. Certificate verification is mandatory, using the original
hostname (or IP subject alternative name for numeric hosts), not a resolved IP
substituted for the hostname. TLS trust, client-certificate, ALPN, session, and
application read/write timeout options match TLSSocket.connect.
connect_timeout_ms is rejected: the token owns the connection deadline.
TLSSocket.start_handshake(socket, host, options = nil) is the native building
block. It takes a connected nonblocking Socket and transfers descriptor ownership
once input validation passes. Subsequent setup failure closes that descriptor;
validation failure leaves it owned by the original socket. The returned TLS socket
allows finish_handshake(), close(), and abort() while pending. Each finish call
returns "read", "write", or nil (complete). Poll only the requested direction,
then retry. Terminal errors close the connection immediately; completed calls are
idempotent. IO.poll accepts TLS sockets only during a pending handshake.
The token checks cancellation before each attempt and after completion. Its failure
cleanup calls abort(), an idempotent close without any TLS shutdown exchange.
Successful handshakes return a regular blocking TLS socket, owned by the caller;
application reads/writes remain outside the cancellation contract. Local trust-store
and certificate loading also remain synchronous filesystem work.
UDP sockets: UDPSocket.bind/UDPSocket.open, .send/.receive
server = UDPSocket.bind(9999)
result = server.receive(1024) # blocks until a datagram arrives
puts(result["data"]) # the bytes, as a String
puts(result["host"]) # sender's address, e.g. "127.0.0.1"
server.send("ack", result["host"], result["port"])
server.close()
client = UDPSocket.open()
client.send("hello", "example.com", 9999)
reply = client.receive(1024)
client.close()
UDP is connectionless — one socket sends and receives datagrams to/from
whatever address each individual call names, with no handshake and no
ordering/delivery guarantee, so the API shape is necessarily different
from the stream-oriented TCPSocket/TCPServer above:
UDPSocket.bind(port)— resolves and binds to a specific local port (the samegetaddrinfo/AI_PASSIVE/try-each-candidate danceTCPServer.listenuses,SOCK_DGRAMinstead ofSOCK_STREAM, nolisten(2)call — UDP has no equivalent, there's nothing to listen for, just a socket ready to send and receive immediately). The "server" side: binds to a port callers already know to reach it at. Every interface by default;UDPSocket.bind(port, host: "127.0.0.1")binds one address or hostname instead, likeTCPServer.listen'shost:. Ahost:that is not a String raisesTypeError..port()— the local port the socket is bound to, so a server bound to port0(the kernel picks a free one) can say where it is listening. On aUDPSocket.open()socket it is the ephemeral port once the first.sendhas assigned one, and0before that.UDPSocket.open()— opens a socket with no local address, letting the OS assign an ephemeral port on first use. The "client" side: don't care what port this sends from, just need to reach someone else's. A real scope cut, not an oversight:open()creates a plainAF_INETsocket rather than resolving anything (there's no destination yet to resolveAF_UNSPEC/getaddrinfoagainst), so it can only reach IPv4 destinations later —UDPSocket.bindhas no such limit, sinceAI_PASSIVEresolution naturally produces whichever families the local machine actually supports.- Both return the same new object kind,
DIAMOND_OBJECT_UDP_SOCKET(DiamondUdpSocketHandle) — a raw fd, like the non-blockingSocketabove, notFile's bufferedFILE*:sendto(2)/recvfrom(2)need the peer address on every call, which buffered stdio read/write has no way to carry. .send(data, host, port)— resolveshost/portviagetaddrinfo(AF_UNSPEC, so both IPv4 and IPv6 destinations resolve) and triessendtoagainst each candidate in turn on this same existing socket until one succeeds — the send-time equivalent ofTCPSocket.connect's own multi-candidate resilience, needed here specifically because a socket opened viaUDPSocket.open()might not share a family with whateverhostresolves to first. Returns theIntbyte count actually sent..receive(n)— blocks until a datagram arrives, then returns aHash:{"data": ..., "host": ..., "port": ...}— the payload (up tonbytes; a UDP datagram larger than the buffer is silently truncated by the kernel, standardrecvfrombehavior, not something d6 adds handling for) plus the sender's own address, extracted viagetnameinfo(..., NI_NUMERICHOST|NI_NUMERICSERV)sohostis always a plain address string, never a reverse-DNS lookup that could block or fail independently of the receive itself..close()— idempotent, same as every other native handle here.
Building .receive's three-key Hash result needed the exact same care
IO.poll's own result did (see above): the Hash is rooted in
registers[dest] immediately after allocating it, and each String-
valued entry (data, host) roots its key with a DIAMOND_NIL
placeholder before allocating the real value — port is a scalar Int
with no allocation of its own, so its key and value go in with one
hash_set call, no placeholder needed, since nothing can trigger a
collection between allocating the key and calling hash_set on the very
next line.
UDPSocket.bind/UDPSocket.open are recognized in the compiler the same
way, one parser handling both forms (bind takes the port and an optional
host: keyword, open takes none) and erroring on anything else.
Signals: Signal.trap
def run()
def handler()
puts("shutting down")
end
Signal.trap("INT", handler)
# ... normal program, including blocking calls ...
end
run()
Signal.trap(name, handler) registers a Callable[0] to run when the
named signal arrives — deliberately a small, fixed set of names rather
than every signal POSIX knows about: "INT" (Ctrl+C), "TERM" (kill),
"HUP" (terminal/controlling-process hangup). These three cover "someone
asked this process to stop," the motivating use case (a server closing
its listening socket and finishing in-flight work before exiting, rather
than just dying mid-request). SIGKILL/SIGSTOP can't be caught at the
OS level regardless; everything else (SIGSEGV, SIGCHLD, real-time
signals, ...) is out of scope for this first slice. name must be a
String, handler a Callable (a nested def, per the usual
top-level-def-isn't-a-value rule), or a rescuable TypeError; an
unrecognized name is also a TypeError rather than silently doing
nothing.
Why a genuinely blocked native call needs separate handling from
CPU-bound code. The actual OS signal handler installed by Signal.trap
only does what POSIX guarantees is async-signal-safe: set a
volatile sig_atomic_t and return (diamond_signal_handler, src/vm.c)
— resolving which d6 closure to call and actually calling it happens
later, synchronously. For CPU-bound d6 code, "later" means within a
few instructions: run_chunk checks a single cheap flag (almost always
false, negligible steady-state cost) on every backward jump, every call
and every pass through its dispatch loop's top, so any loop or recursion
reaches a check promptly, and, if set, invokes the pending handler(s) via the
same nested-run_chunk mechanism DIAMOND_OP_CALL_CLOSURE itself uses.
But a program genuinely blocked in a native call — TCPServer#accept,
IO.poll, UDPSocket#receive — might not reach "the next instruction"
for an arbitrarily long time (an idle server with nothing connecting,
by design, waits forever). So Signal.trap's own sigaction call
deliberately omits SA_RESTART: the signal actually interrupts the
blocking syscall (EINTR) instead of the kernel silently resuming it as
if nothing happened, and each of those three call sites has its own
small retry loop that dispatches any pending signal(s) — running the
d6 handler — before transparently retrying the syscall. From the
caller's own perspective, .accept() still either blocks until a real
connection or raises a real error; it's just no longer unresponsive
while doing so. TCPSocket.connect and buffered File/stdin reads are
not hardened this way — a real, documented scope cut for this first
slice, not an oversight; a signal arriving while blocked in one of those
won't be handled until the call completes on its own.
A trapped handler that raises an uncaught exception propagates exactly
like any other mid-dispatch failure (through the same catch_exception/
VM_RETURN machinery every other opcode uses) — rescuable at whatever
enclosing rescue was active when the interrupted instruction was about
to run, same as an ordinary exception from that point in the program
would be.
When testing Signal.trap from a shell script, remember that bash starts
asynchronous commands with SIGINT and SIGQUIT ignored. That disposition
survives exec, so kill -INT will not reach a d6 handler in a process
started with a plain ... &. Reset the signal before executing the program:
(trap - INT; exec ./build/d6 signal_test.d6) &
TLS: TLSSocket.connect/TLSServer.listen/.accept
listener = TLSServer.listen(8443, "cert.pem", "key.pem")
conn = listener.accept()
puts(conn.gets())
conn.write("hello over TLS\n")
conn.close()
listener.close()
conn = TLSSocket.connect("example.com", 443)
conn.write("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
puts(conn.read())
conn.close()
Built on OpenSSL (the project's one non-vendored external dependency — see the README's build prerequisites) rather than hand-rolled, unlike most of the rest of this codebase: TLS is exactly the kind of thing where "implemented from scratch" is a real security liability, not just extra work, so this is a deliberate exception to the project's usual zero-external-dependencies stance.
TLSSocket.connect(host, port, options)— resolves and connects likeTCPSocket.connect(reuses the samegetaddrinfo/try-each- candidate helper, and accepts the identicalconnect_timeout_ms/read_timeout_ms/write_timeout_msinoptions), then performs a client-side TLS handshake. Certificate verification is on unconditionally — the peer's certificate must chain to a trusted CA and be valid forhost(SSL_set1_host, SNI viaSSL_set_tlsext_host_name). There is noverify: false-style escape hatch in this API — a connection to an untrusted or misnamed certificate raises a rescuableIOErrorrather than silently succeeding.The trust anchor itself is a real option, though:
conn = TLSSocket.connect("internal.example.com", 443, {"ca_file": "internal-ca.pem"}) conn = TLSSocket.connect("example.com", 443, {"cert": "client-cert.pem", "key": "client-key.pem"})ca_file/ca_path— load a caller-supplied CA bundle file and/or directory (SSL_CTX_load_verify_locations) as the trust anchor instead of the system trust store, for an internal CA or a hermetic test server. Neither given keeps the original default (SSL_CTX_set_default_verify_paths, honoring$SSL_CERT_FILE/$SSL_CERT_DIR— howtests/run.sh's own TLS tests point this at a hermetic test CA instead of the real system store). Verification itself is never optional either way — this only changes which trust store is consulted, not whether one is.cert/key— a client certificate chain and private key (both PEM files) for mutual TLS, presented only if the server actually requests one during the handshake (ordinary TLS negotiation — this never forces client-cert auth on a server that doesn't ask). Must be given together; a lonecertorkeyis aTypeError, checked before any network activity. A missing or unreadable file, or a key that doesn't match the certificate, raises a rescuableIOError.alpn— an Array of protocol name Strings (RFC 7301, 1-255 bytes each), offered to the server in preference order for Application- Layer Protocol Negotiation:conn = TLSSocket.connect("example.com", 443, {"alpn": ["h2", "http/1.1"]}) conn.alpn_protocol() # => "h2", or nil if nothing was negotiatedValidated (non-empty, every element a 1-255-byte String) before any network activity, same as the
cert/keypairing check. Which protocol actually gets picked is the server's call (SSL_select_next_proto, the standard algorithm both this client andTLSServer.listenbelow use): the first entry in the server's own list that the client also offered, not the client's own preference order. A client and server with no protocol in common is a fatal handshake failure (IOError), not a silent "no protocol negotiated" — there's nothing left to fall back to once neither side will accept what the other proposed.session— a previously-serialized session blob (see#sessionbelow) to attempt resumption with. Always best-effort: a blob that fails to parse (corrupt, or from an incompatible OpenSSL build or a since-rotated server ticket key) is silently ignored rather than raising — TLS itself transparently falls back to an ordinary full handshake when resumption doesn't happen, exactly the same outcome as if this option had never been given.#session_reused?(below) tells the caller which actually happened.
As with
TCPSocket.connect, an unrecognizedoptionskey or a value of the wrong type is aTypeError, andoptionsitself may benil(the default) for the original, unchanged behavior.TLSSocket#alpn_protocol()— the negotiated ALPN protocol name as aString, ornilif ALPN wasn't offered or nothing was negotiated. Works the same way on both a client connection and aTLSServer.listenlistener's accepted connection.TLSSocket#session()— serializes the connection's current session (DER-encoded via OpenSSL'si2d_SSL_SESSION, an opaqueStringblob) for later resumption via a futureTLSSocket.connect's ownsessionoption, ornilif no session is available yet. Call this only after the connection has done at least one read (.gets()/.read()) — a TLS 1.3 session ticket normally arrives as a post-handshake message that OpenSSL only actually processes (via a callback registered internally) during a later read, not synchronously insideSSL_connect/SSL_acceptitself, so#sessioncalled immediately after connecting, with no read in between, can legitimately still returnnileven though a ticket is about to arrive.TLSSocket#peer_subject()/#peer_fingerprint()— who is on the other end, from the certificate the peer presented:peer_subjectis its subject in RFC 2253 form (most specific name first, e.g.O=Acme,CN=alice),peer_fingerprintits SHA-256 fingerprint as 64 lowercase hex characters (whatopenssl x509 -fingerprint -sha256prints, minus the colons and upcasing), a stable identity to pin or look up. On a connection accepted from aTLSServer.listenlistener withclient_cathis is the client's verified certificate; on aTLSSocket.connectconnection it is the server's. Both returnnilwhen the peer presented no certificate (an accepted connection on a listener withoutclient_ca). On a closedTLSSocketboth raiseIOError, like the other methods.TLSSocket#server_name()— the host name from the SNI extension: on an accepted connection, the name the client asked for (nilwhen it sent none, as it does not for an IP address); on a client connection, the name it requested.TLSSocket#session_reused?()—Bool, whether this connection actually resumed a previous session (SSL_session_reused) rather than performing a full handshake. The one reliable way to confirm resumption worked, since asessionoption that fails to resume (an expired ticket, say) doesn't raise — the connection just silently falls back to a full handshake instead.TLSServer.listen(port, cert_path, key_path, options)— binds and listens likeTCPServer.listen, then loads a certificate chain and private key (both PEM files) into oneSSL_CTXreused across every.accept()on this listener, so a broken cert/key pair fails loudly at.listen()time rather than on whichever connection happens to arrive first, and the (potentially expensive) cert/key parsing happens once, not per connection.options(optional, aHashornil) supports two keys:alpn— an Array of protocol name Strings this server is willing to negotiate, in this server's own preference order (see#alpn_protocolabove for how a mismatch is handled).client_ca— a String path to a PEM file of the certificate authorities client certificates must chain to. Giving it turns on mutual TLS: the server requests a client certificate in every handshake and refuses the connection unless one arrives that verifies against this CA (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT; there is no "optional" mode). A refusal makes the server's.accept()raise a rescuableIOError("peer did not return a certificate" / "certificate verify failed"), which a server loop should rescue and carry on from. The client sees it too, but under TLS 1.3 only on its first read after connecting (its own handshake completes before the server has judged the certificate), as anIOErrorsuch as "certificate required" or "unknown ca". The CA file is loaded at.listen()time, so a bad path fails there (cannot load client CA '...'), and a non-String value is aTypeError. Withoutclient_caa server never asks for a client certificate, as before. Session resumption needs no server-side option at all: reusing oneSSL_CTX/session-ticket key across every.accept()on a listener, whichTLSServer.listenalready does, is the entire server-side requirement — a returning client'sTLSSocket.connect(..., {"session": ...})just works against it.
.accept()on aTLSServer.listenlistener does the same blockingaccept()a plainTCPServer.listenlistener does (including the signal-interruptible retry loop from the Signals section above — a server genuinely idle with nothing connecting still responds promptly to a trapped signal), then a server-side TLS handshake. NoTLSServer.listen_nonblockingis not available. Client-side cancellable negotiation is provided separately byTLSSocket.start_handshake.Both return the same new object kind,
DIAMOND_OBJECT_TLS_SOCKET—.read(n)/.read()/.gets()/.write(value)/.close(), the exact same method surface and semantics asFile(bounded/unbounded read, line read, EOF-as-nil), notSocket's raw-fd/non-blocking shape — a completed TLS connection uses blocking application I/O, sopackages/http's ownconn.gets()/conn.read()/conn.write()calls work unchanged against either kind of connection. Internally a raw fd plus an OpenSSLSSL *rather than a bufferedFILE *:SSL_read/SSL_writeneed to own the fd's I/O directly, not share it with libc stdio buffering underneath..close()callsSSL_shutdown(best-effort, one call, sends this side's ownclose_notifywithout blocking on the peer's) thenSSL_freethenclose(fd)— both steps are required;SSL_freealone neither sends aclose_notifynor closes the fd on its own.A write to a connection whose peer has already reset it (not just cleanly closed) raises
SIGPIPEby default, which would otherwise kill the whole process outright — this is what actually surfaced needing a fix here, not something specific to TLS: OpenSSL servers send a post-handshakeNewSessionTicketmessage automatically for TLS 1.3, and a peer that disconnects without ever reading it (any short-lived connection that errors out or drops right after the handshake — the first realistic case in this codebase's own tests, from a TLS error test that deliberately raises immediately post-handshake) leaves that message unread in the kernel receive buffer at close time, which is Linux's own trigger for sendingRSTinstead of a plainFIN.diamond_vm_initnow ignoresSIGPIPEprocess-wide (once, idempotent) so a write like this returns a plainEPIPE/IOErrorinstead — every write-capable I/O path already turns a failed write into a catchable error viaerrno/SSL_get_error, so this was a straightforward latent bug fix, not a new behavior choice; it applies to the plain TCP/UDP write paths too, which had the same exposure but no test that happened to trigger it before now.
TLSSocket/TLSServer are recognized in the compiler the same way
TCPSocket/TCPServer/UDPSocket already are (gated on the identifier
not already being a local/function, so a variable or function actually
named TLSSocket shadows the builtin entirely). TLSSocket.connect
compiles to DIAMOND_OP_TLS_CONNECT; TLSServer.listen to
DIAMOND_OP_TLS_LISTEN.