macula_quic (macula v13.4.0)
View SourceMacula QUIC transport — Quinn-based Rust NIF.
Provides QUIC listener, connection, and stream operations backed by Quinn (Rust). Listeners bind to specific IP addresses, enabling per-identity IPv6 binding for virtual relay identities.
Active-mode messages delivered to owning process: {quic, Data, StreamRef, Flags} — stream data {quic, new_conn, ConnRef, Info} — new connection accepted {quic, new_stream, StreamRef, Props} — new stream accepted {quic, connected, Tag, ConnRef} a dial from async_connect/4 connected {quic, connect_failed, Tag, Reason} a dial from async_connect/4 failed {quic, peer_send_shutdown, StreamRef, undefined} {quic, stream_closed, StreamRef, Flags} Flags is {reset, ErrorCode} when the read failed because the peer called reset_stream/2 on their send side (a deliberate, peer-visible abort) — none for every other read failure (connection loss, zero-RTT rejection, ...). {quic, shutdown, Handle, Reason} {quic, send_ready, StreamRef, undefined} The stream takes data again after async_send/2 answered {error, busy} to this process; also sent when the stream stops taking data, so the retry sees the failure. {quic, send_failed, StreamRef, Reason} A write on the stream failed; later sends return the error. Handle it as a closed stream.
Sent to the process that called async_send/3, once per tagged send that returned ok: {quic, send_complete, StreamRef, Tag} All the data queued with Tag is written. {quic, send_incomplete, StreamRef, {Tag, Reason}} The stream was reset or closed, or its writes failed, before that data was written.
Summary
Functions
Start accepting connections on a listener. Delivers {quic, new_conn, ConnRef, Info} to the calling process.
Start accepting streams on a connection. Delivers {quic, new_stream, StreamRef, #{conn => ConnRef}} to the owning process.
Start a dial and return at once, instead of waiting as connect/4 does. The calling process owns the dial and later receives {quic, connected, Tag, ConnRef} or {quic, connect_failed, Tag, Reason}, where Tag is dial_tag(Dial). The dial ends early when cancel_connect/1 is called or when its owner exits. Opts and Timeout are those of connect/4; options that cannot be used return {error, Reason} at once.
Start opening a bidirectional stream and return at once, instead of waiting as open_stream/1 does. The calling process owns the open and later receives {quic, stream_opened, Tag, StreamRef}, and owns that stream, or {quic, stream_open_failed, Tag, Reason}, where Tag is stream_open_tag(Opening). The open waits for as long as the peer allows no further stream, and ends early when cancel_open_stream/1 is called or when its owner exits. On a closed connection it returns {error, already_closed} at once.
Queue data on a stream and return at once.
async_send/2 for data whose end the calling process hears about.
Async shutdown stream. Code now genuinely reaches the wire via reset_stream/2 — previously a stub that silently discarded both Flag and Code and always did a graceful close_stream/1. Flag is unused (reserved; no caller has ever needed it, kept for signature compatibility).
End a dial. Afterwards no result for it is in, or will reach, the caller's mailbox: a result sent before the cancel is taken out, and the connection it carried is closed. Call it from the dial's owner.
End a stream open. Afterwards no result for it is in, or will reach, the caller's mailbox: a result sent before the cancel is taken out, and the stream it carried is reset. Call it from the open's owner.
Generic close — tries stream, then connection, then listener.
Close a connection with application error code 0 and the reason closed.
Close a connection with an application error code and a reason, which the peer reads with close_reason/1. Code must fit a QUIC variable-length integer (below 2^62), and Reason is at most 256 bytes. The codes macula sends are named in include/macula_quic_error_codes.hrl.
Close a listener. Once it returns the owner receives no further {quic, new_conn, ...} from it: a connection whose handshake completes after the close is closed instead.
Why a connection closed, or open while it is open. A peer's application close comes back as {application_closed, Code, Reason}, with the code and reason the peer passed to close_connection/3; locally_closed means this side closed it.
Close a stream's sending side gracefully, and return at once.
Connect to a remote QUIC server. Host is a hostname or IP-string.
Transfer ownership of a handle to another process. Works with both stream and connection handles. Returns once no message for the handle is on its way to the former owner: from then on every data message and event of a stream, and every new_stream notice of a connection, goes to Pid.
The reference in this dial's result message.
Keying material exported from this connection's TLS 1.3 session (RFC 8446 section 7.5): Length bytes for Label and Context. Both ends of one connection export the same bytes; another connection, label or context exports different ones. Handshake v5 binds its session proofs to this value (plans/DESIGN_NEIGHBOUR_CHANNEL_BINDING.md). A closed connection returns {error, already_closed}.
A station's certificate: self-signed ML-DSA-87 on its TLS key (plan decision D12), from the key's 32-byte seed, naming Sans. The TLS key is ML-DSA-87 in both crypto profiles and is used for nothing but the TLS handshake: a node key of purpose tls, or for a distribution listener a seed from macula_crypto_nif:mldsa_generate/1. A SAN that parses as an IP address is an IP address SAN, any other a DNS name. Returns the certificate and the key, PKCS#8 in RFC 9881's seed form, both PEM-encoded for listen/3's cert and key files. The certificate is made in the NIF by macula-pqc, which signs it with macula-mldsa.
Get connection stats. NOT IMPLEMENTED — answers {error, not_implemented}.
Complete TLS handshake. With Quinn, handshake completes during accept — this is a no-op for compat.
Listen on a port or {Address, Port} tuple.
Listen on a specific bind address and port. BindAddr is a binary: "0.0.0.0", "192.168.1.1", "2600:3c0e::100", etc.
Packets this connection's congestion controller has declared lost.
Path MTU as discovered by Quinn's DPLPMTUD on this connection. Returns {ok, Bytes} once the path MTU has been established; {error, no_path_mtu} early in the connection lifecycle (before the first probe lands) or if the peer disabled datagrams. Phase 4.2.
Open a new bidirectional stream, owned by the calling process.
Open stream with options map (for macula_dist).
The leaf certificate the other side sent in this connection's TLS handshake, as DER, exactly as received. A dialed connection has the station's leaf. An accepted connection returns {error, no_peer_leaf}, since clients send no certificate.
Get remote address of a connection. The host is the IP address as TEXT, a binary such as <<"127.0.0.1">> or <<"::1">>: the NIF formats the address, it does not return an inet tuple.
The leaf certificate this side sent in this connection's TLS handshake, as DER. An accepted connection has the leaf its listener presented when it accepted the connection, also after reload_certificate/3. A dialed connection returns {error, no_presented_leaf}.
Make a new certificate the one a listener presents. CertFile and KeyFile are read like the cert and key options of listen/3, and the listener's other settings stay as they are. Connections accepted after this returns present the new leaf; a connection accepted earlier keeps the leaf it presented (see presented_leaf/1). A file that cannot be read, holds no certificate or key, or a key that does not match the certificate returns {error, Reason} and keeps the current certificate.
Abruptly reset a stream's send side with ErrorCode — a QUIC RESET_STREAM frame, genuinely peer-visible at the transport level: the peer's RecvStream::read fails with {quic, stream_closed, PeerStream, {reset, ErrorCode}} instead of the clean EOF close_stream/1 produces. Returns at once: data queued on the stream is dropped, and a send/2 waiting for its write returns {error, reset}. ErrorCode must fit a QUIC VarInt (< 2^62); out-of-range values answer {error, error_code_out_of_range}.
Send data on a stream, waiting in the calling process until the data is written or the write fails.
Set active mode on a stream handle.
The reference in this open's result message.
What the TLS configurations this NIF builds actually do: the key exchange groups the dialler and the listener offer, as IANA code points in order, whether either end offers or accepts 0-RTT or sends tickets, and whether a second in-memory handshake between them is full or resumed, and the same for the dialler against a listener that does issue tickets, which witnesses the dialler's own setting. macula_tls_posture checks it before peering starts.
Types
Functions
Start accepting connections on a listener. Delivers {quic, new_conn, ConnRef, Info} to the calling process.
Start accepting streams on a connection. Delivers {quic, new_stream, StreamRef, #{conn => ConnRef}} to the owning process.
-spec async_connect(Host, inet:port_number(), list(), timeout()) -> {ok, dial()} | {error, term()} when Host :: binary() | string().
Start a dial and return at once, instead of waiting as connect/4 does. The calling process owns the dial and later receives {quic, connected, Tag, ConnRef} or {quic, connect_failed, Tag, Reason}, where Tag is dial_tag(Dial). The dial ends early when cancel_connect/1 is called or when its owner exits. Opts and Timeout are those of connect/4; options that cannot be used return {error, Reason} at once.
-spec async_open_stream(reference()) -> {ok, stream_opening()} | {error, term()}.
Start opening a bidirectional stream and return at once, instead of waiting as open_stream/1 does. The calling process owns the open and later receives {quic, stream_opened, Tag, StreamRef}, and owns that stream, or {quic, stream_open_failed, Tag, Reason}, where Tag is stream_open_tag(Opening). The open waits for as long as the peer allows no further stream, and ends early when cancel_open_stream/1 is called or when its owner exits. On a closed connection it returns {error, already_closed} at once.
Queue data on a stream and return at once.
Returns ok when the data is queued for the stream's writer task. When the stream already has 1 MiB queued, queues nothing and returns {error, busy}; the calling process later gets one {quic, send_ready, Stream, undefined} message, meaning it may retry. That message also comes when the stream's writes stop meanwhile, and the retry then returns the reason. Returns {error, already_closed} after close_stream/1 or reset_stream/2, and the reason once the stream's writes have failed.
When a write fails, the stream's owner gets one {quic, send_failed, Stream, Reason} message.
async_send/2 for data whose end the calling process hears about.
Returns as async_send/2 does, and queues nothing unless it returns ok. For data it queued, the calling process gets exactly one message: {quic, send_complete, Stream, Tag} once all of the data is written, or {quic, send_incomplete, Stream, {Tag, Reason}} when the stream is reset, closed or fails first, Reason being reset, closed or why the write failed. Tag is the caller's own term, copied into that message, so keep it small.
Async shutdown stream. Code now genuinely reaches the wire via reset_stream/2 — previously a stub that silently discarded both Flag and Code and always did a graceful close_stream/1. Flag is unused (reserved; no caller has ever needed it, kept for signature compatibility).
-spec cancel_connect(dial()) -> ok.
End a dial. Afterwards no result for it is in, or will reach, the caller's mailbox: a result sent before the cancel is taken out, and the connection it carried is closed. Call it from the dial's owner.
-spec cancel_open_stream(stream_opening()) -> ok.
End a stream open. Afterwards no result for it is in, or will reach, the caller's mailbox: a result sent before the cancel is taken out, and the stream it carried is reset. Call it from the open's owner.
-spec close(reference()) -> ok.
Generic close — tries stream, then connection, then listener.
-spec close_connection(reference()) -> ok.
Close a connection with application error code 0 and the reason closed.
-spec close_connection(reference(), non_neg_integer(), binary()) -> ok | {error, error_code_out_of_range | reason_too_long}.
Close a connection with an application error code and a reason, which the peer reads with close_reason/1. Code must fit a QUIC variable-length integer (below 2^62), and Reason is at most 256 bytes. The codes macula sends are named in include/macula_quic_error_codes.hrl.
-spec close_listener(reference()) -> ok.
Close a listener. Once it returns the owner receives no further {quic, new_conn, ...} from it: a connection whose handshake completes after the close is closed instead.
-spec close_reason(reference()) -> open | locally_closed | reset | timed_out | version_mismatch | cids_exhausted | {application_closed | transport_closed | transport_error, non_neg_integer(), binary()}.
Why a connection closed, or open while it is open. A peer's application close comes back as {application_closed, Code, Reason}, with the code and reason the peer passed to close_connection/3; locally_closed means this side closed it.
-spec close_stream(reference()) -> ok.
Close a stream's sending side gracefully, and return at once.
Data queued before the close is still written, and then a QUIC FIN ends the stream: the peer's RecvStream::read resolves {ok, none}. When that data cannot be written within the linger bound, the stream is reset with application error code 1, ?QUIC_CODE_LINGER_EXPIRED (see the code table at reset_stream/2), and its unwritten data is dropped. The bound is the macula application env quic_close_linger_ms, 30000 by default, read when close_stream/1 is called. For an immediate, peer-visible abort see reset_stream/2.
-spec connect(Host, inet:port_number(), list(), timeout()) -> {ok, reference()} | {error, term()} when Host :: binary() | string().
Connect to a remote QUIC server. Host is a hostname or IP-string.
There is one way the server is verified (plan decisions D12 and D16): it presents one self-signed ML-DSA-87 certificate, and its TLS 1.3 handshake signature must verify under that certificate's key. That proves the server holds the key and nothing more: no certificate authority issues ML-DSA certificates, and none is consulted. Who the server is, the caller proves by checking the server identity's binding over that key against peer_leaf/1, as the connection handshake does.
The verify and verify_pubkey options are gone with the modes they chose, and a dial given either is refused with {error, {verify_option_removed, Name}} rather than run with a check the caller did not ask for.
The calling process waits for the result, and the dial ends if that process exits while it waits. Use async_connect/4 to wait elsewhere.
Transfer ownership of a handle to another process. Works with both stream and connection handles. Returns once no message for the handle is on its way to the former owner: from then on every data message and event of a stream, and every new_stream notice of a connection, goes to Pid.
The reference in this dial's result message.
-spec export_keying_material(reference(), binary(), binary(), pos_integer()) -> {ok, binary()} | {error, already_closed | export_failed}.
Keying material exported from this connection's TLS 1.3 session (RFC 8446 section 7.5): Length bytes for Label and Context. Both ends of one connection export the same bytes; another connection, label or context exports different ones. Handshake v5 binds its session proofs to this value (plans/DESIGN_NEIGHBOUR_CHANNEL_BINDING.md). A closed connection returns {error, already_closed}.
-spec generate_self_signed_cert(Seed :: <<_:256>>, Sans :: [binary() | string()]) -> {ok, {CertPem :: binary(), KeyPem :: binary()}} | {error, term()}.
A station's certificate: self-signed ML-DSA-87 on its TLS key (plan decision D12), from the key's 32-byte seed, naming Sans. The TLS key is ML-DSA-87 in both crypto profiles and is used for nothing but the TLS handshake: a node key of purpose tls, or for a distribution listener a seed from macula_crypto_nif:mldsa_generate/1. A SAN that parses as an IP address is an IP address SAN, any other a DNS name. Returns the certificate and the key, PKCS#8 in RFC 9881's seed form, both PEM-encoded for listen/3's cert and key files. The certificate is made in the NIF by macula-pqc, which signs it with macula-mldsa.
Get connection stats. NOT IMPLEMENTED — answers {error, not_implemented}.
⚠ This used to answer {ok, [{S, 0} || S <- Stats]} — plausible, well-formed, permanently zero — and excuse itself with "zeroed values are harmless (dist_util only uses these for liveness signals)". That is precisely the use a hardcoded zero destroys. A counter that always reads zero makes "nothing is moving" indistinguishable from "nobody implemented the counter", so any liveness check built on it is green forever and its author cannot tell.
That is not hypothetical. On 2026-08-13 station-it-milan received every packet sent to it, answered none for thirty hours, and every signal derived from the BEAM read healthy. Anyone reaching for a send-side counter to catch that would have found this one, and it would have lied. Failing loudly is the only honest answer until the NIF surfaces the real thing.
Quinn HAS the numbers: quinn::Connection::stats() carries udp_tx{datagrams,bytes}, udp_rx{...} and path{rtt,lost_packets,black_holes_detected}, and nif_max_datagram_size already calls stats() and discards all but path.current_mtu. Surfacing the rest is an extension of a working function — see macula-station plans/PLAN_WIRE_LIVENESS_TRIPWIRE.md commit 5.
The sole consumer, the getstat callback in macula_dist, already has an {error, _} -> {ok, 0, 0, 0} branch, so this changes no behaviour there. It changes what the next caller is told.
Complete TLS handshake. With Quinn, handshake completes during accept — this is a no-op for compat.
-spec listen(inet:port_number() | {string() | binary(), inet:port_number()}, list()) -> {ok, reference()} | {error, term()}.
Listen on a port or {Address, Port} tuple.
-spec listen(binary() | string(), inet:port_number(), list()) -> {ok, reference()} | {error, term()}.
Listen on a specific bind address and port. BindAddr is a binary: "0.0.0.0", "192.168.1.1", "2600:3c0e::100", etc.
stream_receive_window and receive_window are the credit, in bytes, a peer gets on one stream and across all of a connection's streams before this side reads: 16 MiB and 64 MiB unless set. A value that is not a positive integer returns {error, {invalid_receive_window, Value}}.
-spec lost_packets(reference()) -> {ok, non_neg_integer()} | {error, term()}.
Packets this connection's congestion controller has declared lost.
CUMULATIVE for the life of the connection and monotonically non-decreasing. It never resets. A caller wanting a rate reads it twice and subtracts; a single reading says nothing about WHEN the losses happened. A replaced connection starts a new count, so a delta is only meaningful within one Conn reference.
This makes a question answerable rather than answering one: an operation that stalls either coincides with a rise here or it does not, and both outcomes are informative.
⚠ This is ONE field, not a stats API, and deliberately NOT routed through getstat/2. That function refuses with not_implemented on purpose (see its comment): a counter that always reads zero makes "nothing is moving" indistinguishable from "nobody implemented the counter". Surfacing one real field here does not compromise that; filling the rest of getstat/2's shape with zeros would.
-spec max_datagram_size(reference()) -> {ok, pos_integer()} | {error, term()}.
Path MTU as discovered by Quinn's DPLPMTUD on this connection. Returns {ok, Bytes} once the path MTU has been established; {error, no_path_mtu} early in the connection lifecycle (before the first probe lands) or if the peer disabled datagrams. Phase 4.2.
Open a new bidirectional stream, owned by the calling process.
The calling process waits until the peer allows another stream or the connection ends, for as long as that takes, and the open ends if that process exits while it waits. Use async_open_stream/1 to wait elsewhere.
Open stream with options map (for macula_dist).
-spec peer_leaf(reference()) -> {ok, public_key:der_encoded()} | {error, no_peer_leaf}.
The leaf certificate the other side sent in this connection's TLS handshake, as DER, exactly as received. A dialed connection has the station's leaf. An accepted connection returns {error, no_peer_leaf}, since clients send no certificate.
-spec peername(reference()) -> {ok, {binary(), inet:port_number()}} | {error, term()}.
Get remote address of a connection. The host is the IP address as TEXT, a binary such as <<"127.0.0.1">> or <<"::1">>: the NIF formats the address, it does not return an inet tuple.
-spec presented_leaf(reference()) -> {ok, public_key:der_encoded()} | {error, no_presented_leaf}.
The leaf certificate this side sent in this connection's TLS handshake, as DER. An accepted connection has the leaf its listener presented when it accepted the connection, also after reload_certificate/3. A dialed connection returns {error, no_presented_leaf}.
-spec reload_certificate(reference(), binary() | string(), binary() | string()) -> ok | {error, term()}.
Make a new certificate the one a listener presents. CertFile and KeyFile are read like the cert and key options of listen/3, and the listener's other settings stay as they are. Connections accepted after this returns present the new leaf; a connection accepted earlier keeps the leaf it presented (see presented_leaf/1). A file that cannot be read, holds no certificate or key, or a key that does not match the certificate returns {error, Reason} and keeps the current certificate.
-spec reset_stream(reference(), non_neg_integer()) -> ok | {error, term()}.
Abruptly reset a stream's send side with ErrorCode — a QUIC RESET_STREAM frame, genuinely peer-visible at the transport level: the peer's RecvStream::read fails with {quic, stream_closed, PeerStream, {reset, ErrorCode}} instead of the clean EOF close_stream/1 produces. Returns at once: data queued on the stream is dropped, and a send/2 waiting for its write returns {error, reset}. ErrorCode must fit a QUIC VarInt (< 2^62); out-of-range values answer {error, error_code_out_of_range}.
The application error codes macula itself sends are defined once, by name, in include/macula_quic_error_codes.hrl:
- 0,
?QUIC_CODE_CANCELLED: the sender cancelled the stream, in a content transfer cancel or a stream open cancelled after the peer allowed it. - 1,
?QUIC_CODE_LINGER_EXPIRED: a closed stream's queued data could not be written within its linger bound.
Any other code is the caller's own.
Send data on a stream, waiting in the calling process until the data is written or the write fails.
A stream's writes run in a writer task on the QUIC runtime; the calling process waits in a receive, not in a NIF. It waits for as long as the peer withholds flow-control credit. For a bounded wait, use async_send/2 with its busy result and send_ready message, or reset_stream/2.
Returns ok; {error, already_closed} after close_stream/1 or reset_stream/2; {error, reset} when reset_stream/2 dropped the data; {error, closed} when the stream ended without writing it; or the reason the stream's writes failed.
Set active mode on a stream handle.
-spec stream_open_tag(stream_opening()) -> reference().
The reference in this open's result message.
-spec tls_posture() -> {ok, #{client_groups := [non_neg_integer()], server_groups := [non_neg_integer()], client_early_data := 0 | 1, server_max_early_data := non_neg_integer(), server_tickets := non_neg_integer(), second_handshake := full | resumed, dialler_second_handshake := full | resumed}} | {error, binary()}.
What the TLS configurations this NIF builds actually do: the key exchange groups the dialler and the listener offer, as IANA code points in order, whether either end offers or accepts 0-RTT or sends tickets, and whether a second in-memory handshake between them is full or resumed, and the same for the dialler against a listener that does issue tickets, which witnesses the dialler's own setting. macula_tls_posture checks it before peering starts.