macula_quic (macula v11.4.0)

View Source

Macula 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; validation depends on verify / verify_pubkey opts.

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.

Generate a self-signed X.509 cert from an Ed25519 keypair. Returns {ok, {CertPem, KeyPem}} as PEM-encoded binaries suitable for handing to macula_quic:listen/3 via cert / key opts (after writing to disk). The cert wraps the identity's macula pubkey; no CA chain required. Used by station listeners running pubkey-anchored peering.

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.

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 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.

Types

dial/0

-opaque dial()

stream_opening/0

-opaque stream_opening()

Functions

async_accept(Listener)

-spec async_accept(reference()) -> ok | {error, term()}.

Start accepting connections on a listener. Delivers {quic, new_conn, ConnRef, Info} to the calling process.

async_accept(Listener, Opts)

-spec async_accept(reference(), map()) -> ok | {error, term()}.

async_accept_stream(Conn)

-spec async_accept_stream(reference()) -> ok | {error, term()}.

Start accepting streams on a connection. Delivers {quic, new_stream, StreamRef, #{conn => ConnRef}} to the owning process.

async_accept_stream(Conn, Opts)

-spec async_accept_stream(reference(), map()) -> ok | {error, term()}.

async_connect(Host, Port, Opts, Timeout)

-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.

async_open_stream(Conn)

-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.

async_send(Stream, Data)

-spec async_send(reference(), iodata()) -> ok | {error, term()}.

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(Stream, Data, Tag)

-spec async_send(reference(), iodata(), term()) -> ok | {error, term()}.

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(Stream, Flag, Code)

-spec async_shutdown_stream(reference(), integer(), integer()) -> ok | {error, term()}.

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).

cancel_connect(_)

-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.

cancel_open_stream(_)

-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.

close(Ref)

-spec close(reference()) -> ok.

Generic close — tries stream, then connection, then listener.

close_connection(Conn)

-spec close_connection(reference()) -> ok.

Close a connection with application error code 0 and the reason closed.

close_connection(Conn, Code, Reason)

-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.

close_listener(Listener)

-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.

close_reason(Conn)

-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.

close_stream(Stream)

-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.

connect(Host, Port, Opts, Timeout)

-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; validation depends on verify / verify_pubkey opts.

Trust modes (most to least authenticated):

  • {verify_pubkey, Pin} — pin the leaf cert's Ed25519 SPKI to Pin (32 bytes). No CA chain. Overrides verify.
  • {verify, webpki} — webpki roots + hostname check (Let's Encrypt-anchored station certs). THE DEFAULT since 5.0.0; before that the default was none.
  • {verify, none} — skip all server-cert verification. Development / self-signed labs only; a network MITM can impersonate the peer. Must now be opted into explicitly, and every such dial logs a warning.

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.

controlling_process(Handle, Pid)

-spec controlling_process(reference(), pid()) -> ok | {error, term()}.

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.

dial_tag(_)

-spec dial_tag(dial()) -> reference().

The reference in this dial's result message.

generate_self_signed_cert(Pubkey, Privkey, Sans)

-spec generate_self_signed_cert(Pubkey :: binary(), Privkey :: binary(), Sans :: [binary() | string()]) ->
                                   {ok, {CertPem :: binary(), KeyPem :: binary()}} | {error, term()}.

Generate a self-signed X.509 cert from an Ed25519 keypair. Returns {ok, {CertPem, KeyPem}} as PEM-encoded binaries suitable for handing to macula_quic:listen/3 via cert / key opts (after writing to disk). The cert wraps the identity's macula pubkey; no CA chain required. Used by station listeners running pubkey-anchored peering.

getstat(Conn, Stats)

-spec getstat(reference(), [atom()]) -> {error, not_implemented}.

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.

handshake(Conn)

-spec handshake(reference()) -> ok | {ok, reference()} | {error, term()}.

Complete TLS handshake. With Quinn, handshake completes during accept — this is a no-op for compat.

listen(Port, Opts)

-spec listen(inet:port_number() | {string() | binary(), inet:port_number()}, list()) ->
                {ok, reference()} | {error, term()}.

Listen on a port or {Address, Port} tuple.

listen(BindAddr, Port, Opts)

-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}}.

max_datagram_size(Conn)

-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_stream(Conn)

-spec open_stream(reference()) -> {ok, reference()} | {error, term()}.

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(Conn, Opts)

-spec open_stream(reference(), map()) -> {ok, reference()} | {error, term()}.

Open stream with options map (for macula_dist).

peer_leaf(Conn)

-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.

peername(Conn)

-spec peername(reference()) -> {ok, {string(), inet:port_number()}} | {error, term()}.

Get remote address of a connection.

presented_leaf(Conn)

-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}.

reload_certificate(Listener, CertFile, KeyFile)

-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.

reset_stream(Stream, ErrorCode)

-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 (&lt; 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(Stream, Data)

-spec send(reference(), iodata()) -> ok | {error, term()}.

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.

setopt(Stream, _, Value)

-spec setopt(reference(), active, boolean()) -> ok | {error, term()}.

Set active mode on a stream handle.

stream_open_tag(_)

-spec stream_open_tag(stream_opening()) -> reference().

The reference in this open's result message.