-module(nquic_socket).
-moduledoc """
UDP socket abstraction using OTP socket module.
Provides a high-level interface for UDP socket operations optimized for QUIC.
Uses completion-based async I/O for efficient packet reception.
## Example Usage
```erlang
{ok, Socket} = nquic_socket:open(4433, #{}),
{select, SelectInfo} = nquic_socket:recv_start(Socket),
handle_info({'$socket', Socket, select, _Info}, State) ->
case nquic_socket:recv_now(Socket) of
{ok, {Source, Data}} ->
{noreply, State};
{select, NewSelectInfo} ->
{noreply, State#state{select_info = NewSelectInfo}}
end.
```
""".
-include("nquic_frame.hrl").
-export([open/1, open/2]).
-export([send/3, send_connected/2]).
-export([recv_cancel/2, recv_now/1, recv_start/1]).
-export([recv_msg_now/1, recv_msg_start/1]).
-export([open_connected/2, open_ephemeral/2]).
-export([port/1, sockname/1]).
-export([controlling_process/2]).
-export([close/1]).
-export([rebind/2]).
-export([make_sockaddr/2, sockaddr_to_tuple/1]).
-export([get_ecn_from_cmsg/1, set_ecn/2, set_egress_ecn/2]).
-export([send_connected_with_ecn/3, send_with_ecn/4]).
-export([capabilities/0, get_gso_size_from_cmsg/1, set_gro/2, set_gso_size/2]).
-export_type([capabilities/0, ecn_mark/0, open_opts/0, select_info/0, sockaddr/0, t/0]).
-type capabilities() :: #{gso := boolean(), gro := boolean()}.
-type ecn_mark() :: not_ect | ect0 | ect1 | ce.
-type open_opts() :: #{
port => inet:port_number(),
ip => inet:ip_address() | any,
recbuf => pos_integer(),
sndbuf => pos_integer(),
reuseaddr => boolean(),
reuseport => boolean(),
ipv6_v6only => boolean(),
ecn => boolean(),
gso => boolean() | pos_integer(),
gro => boolean()
}.
-type select_info() :: socket:select_info().
-type sockaddr() :: socket:sockaddr_in() | socket:sockaddr_in6().
-type t() :: socket:socket().
-define(DEFAULT_RECBUF, 2 * 1024 * 1024).
-define(DEFAULT_SNDBUF, 2 * 1024 * 1024).
-define(SOL_UDP, 17).
-define(UDP_SEGMENT, 103).
-define(UDP_GRO, 104).
-define(DEFAULT_GSO_SIZE, 1200).
-define(CAPABILITIES_KEY, {?MODULE, capabilities}).
%%%-----------------------------------------------------------------------------
%% API FUNCTIONS
%%%-----------------------------------------------------------------------------
-doc "Close the socket.".
-spec close(t()) -> ok | {error, nquic_error:any_reason()}.
close(Socket) ->
socket:close(Socket).
-doc "Transfer socket ownership to another process.".
-spec controlling_process(t(), pid()) -> ok | {error, nquic_error:any_reason()}.
controlling_process(Socket, Pid) ->
socket:setopt(Socket, otp, controlling_process, Pid).
-doc "Create a sockaddr from IP and port.".
-spec make_sockaddr(inet:ip_address(), inet:port_number()) -> sockaddr().
make_sockaddr({A, B, C, D}, Port) when
is_integer(A),
is_integer(B),
is_integer(C),
is_integer(D),
is_integer(Port),
Port >= 0,
Port =< 65535
->
#{family => inet, addr => {A, B, C, D}, port => Port};
make_sockaddr({A, B, C, D, E, F, G, H}, Port) when
is_integer(A),
is_integer(B),
is_integer(C),
is_integer(D),
is_integer(E),
is_integer(F),
is_integer(G),
is_integer(H),
is_integer(Port),
Port >= 0,
Port =< 65535
->
#{family => inet6, addr => {A, B, C, D, E, F, G, H}, port => Port}.
-doc "Open a UDP socket on an ephemeral port.".
-spec open(open_opts()) -> {ok, t()} | {error, nquic_error:any_reason()}.
open(Opts) ->
open(0, Opts).
-doc "Open a UDP socket on a specific port.".
-spec open(inet:port_number(), open_opts()) -> {ok, t()} | {error, nquic_error:any_reason()}.
open(Port, Opts) ->
Family = determine_family(Opts),
maybe
{ok, Socket} ?= socket:open(Family, dgram, udp),
ok ?= set_socket_opts(Socket, Opts),
ok ?= bind_socket(Socket, Port, Family, Opts),
{ok, Socket}
else
{error, _} = Err ->
Err
end.
-doc """
Open a connected UDP socket bound to the same port as the listener.
Creates a new UDP socket on `ListenerPort`, then calls `socket:connect/2`
to bind it to `Peer`. The kernel will route datagrams from `Peer` to this
socket (higher priority than the unconnected listener socket). This enables
direct recv on the connection owner process, bypassing the receiver dispatch.
GRO is left off here; callers that expect bursty replies enable it
adaptively via `set_gro/2` once the reply pattern warrants it (a coalesced
datagram is then split per `get_gso_size_from_cmsg/1`).
""".
-spec open_connected(inet:port_number(), sockaddr()) ->
{ok, t()} | {error, nquic_error:any_reason()}.
open_connected(ListenerPort, Peer) ->
Opts = #{reuseaddr => true, reuseport => true},
maybe
{ok, Socket} ?= open(ListenerPort, Opts),
ok ?= socket:connect(Socket, Peer),
{ok, Socket}
else
{error, _} = Err ->
Err
end.
-doc """
Open an ephemeral connected UDP socket for server-side per-conn FDs.
Binds to a kernel-chosen local port (no SO_REUSEPORT) on the same family
as the peer, then `connect(2)`s the socket to `Peer`. The kernel then
delivers any datagram whose 4-tuple matches (peer addr/port, local
addr/port) directly to this socket, bypassing the listener's
SO_REUSEPORT group entirely. Used post-handshake to migrate a server
connection off the shared listener FD onto its own 4-tuple
(RFC 9000 ยง9).
`Opts` lets the caller inherit socket-level features (ECN, GSO, GRO,
rcvbuf, sndbuf) from the listener configuration; the function forces
`reuseaddr => false` and `reuseport => false` so the new socket owns
its 4-tuple exclusively.
""".
-spec open_ephemeral(sockaddr(), open_opts()) ->
{ok, t()} | {error, nquic_error:any_reason()}.
open_ephemeral(#{family := Family} = Peer, Opts) ->
BindIP =
case Family of
inet -> {0, 0, 0, 0};
inet6 -> {0, 0, 0, 0, 0, 0, 0, 0}
end,
SockOpts = (maps:without([reuseaddr, reuseport, ip], Opts))#{
reuseaddr => false,
reuseport => false,
ip => BindIP
},
maybe
{ok, Socket} ?= open(0, SockOpts),
ok ?= socket:connect(Socket, Peer),
{ok, Socket}
else
{error, _} = Err ->
Err
end.
-doc "Get the local port number of the socket.".
-spec port(t()) -> {ok, inet:port_number()} | {error, nquic_error:any_reason()}.
port(Socket) ->
case socket:sockname(Socket) of
{ok, #{port := Port}} ->
{ok, Port};
{error, _} = Err ->
Err
end.
-doc """
Rebind a socket to a new local address for connection migration.
Opens a new socket on the new address, closes the old one, returns the new socket.
""".
-spec rebind(t(), sockaddr()) -> {ok, t()} | {error, nquic_error:any_reason()}.
rebind(OldSocket, NewAddr) ->
Port = maps:get(port, NewAddr, 0),
Addr = maps:get(addr, NewAddr, any),
Opts = #{ip => Addr},
maybe
{ok, NewSocket} ?= open(Port, Opts),
ok ?= close(OldSocket),
{ok, NewSocket}
end.
-doc "Cancel a pending async receive.".
-spec recv_cancel(t(), select_info()) -> ok | {error, nquic_error:any_reason()}.
recv_cancel(Socket, SelectInfo) ->
case socket:cancel(Socket, SelectInfo) of
ok -> ok;
{error, closed} -> ok;
{error, _} = Err -> Err
end.
-doc "Receive data with ancillary data without blocking. Same as `recv_msg_start/1`.".
-spec recv_msg_now(t()) ->
{ok, {sockaddr(), binary(), list()}}
| {select, select_info()}
| {error, nquic_error:any_reason()}.
recv_msg_now(Socket) ->
recv_msg_start(Socket).
-doc """
Start async receive with ancillary data (for ECN marks).
Uses `socket:recvmsg` to receive control messages alongside the packet data.
When IP_RECVTOS is set (via `set_ecn/2`), the control messages include the
TOS byte. Use `get_ecn_from_cmsg/1` to extract the ECN codepoint.
""".
-spec recv_msg_start(t()) ->
{ok, {sockaddr(), binary(), list()}}
| {select, select_info()}
| {error, nquic_error:any_reason()}.
recv_msg_start(Socket) ->
case socket:recvmsg(Socket, ?NQUIC_MAX_DATAGRAM, 256, [], nowait) of
{ok, #{addr := Source, iov := IOV, ctrl := Ctrl}} ->
Data = iolist_to_binary(IOV),
{ok, {Source, Data, Ctrl}};
{select, _} = Select ->
Select;
{error, _} = Err ->
Err
end.
-doc """
Receive data without blocking. Call this after receiving a select message.
Returns:
- `{ok, {Source, Data}}` - Packet received
- `{select, SelectInfo}` - No data ready, wait for next select message
- `{error, Reason}` - Error occurred
""".
-spec recv_now(t()) ->
{ok, {sockaddr(), binary()}}
| {select, select_info()}
| {select_read, {select_info(), {sockaddr(), binary()}}}
| {completion, socket:completion_info()}
| {error, nquic_error:any_reason()}.
recv_now(Socket) ->
socket:recvfrom(Socket, ?NQUIC_MAX_DATAGRAM, nowait).
-doc """
Start async receive. Returns {select, Info} when waiting for data.
After calling this, the process will receive a message of the form:
`{'$socket', Socket, select, SelectInfo}` when data is available.
Then call `recv_now/1` to get the actual data.
""".
-spec recv_start(t()) ->
{ok, {sockaddr(), binary()}}
| {select, select_info()}
| {select_read, {select_info(), {sockaddr(), binary()}}}
| {completion, socket:completion_info()}
| {error, nquic_error:any_reason()}.
recv_start(Socket) ->
socket:recvfrom(Socket, ?NQUIC_MAX_DATAGRAM, nowait).
-doc "Send data to a destination address.".
-spec send(t(), sockaddr(), iodata()) -> ok | {error, nquic_error:any_reason()}.
send(Socket, Dest, Data) ->
case socket:sendto(Socket, Data, Dest) of
ok ->
ok;
{ok, _RestData} ->
{error, partial_send};
{error, _} = Err ->
Err
end.
-doc "Send data on a connected socket (no destination needed).".
-spec send_connected(t(), iodata()) -> ok | {error, nquic_error:any_reason()}.
send_connected(Socket, Data) ->
case socket:send(Socket, Data) of
ok -> ok;
{ok, _RestData} -> {error, partial_send};
{error, _} = Err -> Err
end.
-doc "Convert a sockaddr to {IP, Port} tuple for compatibility.".
-spec sockaddr_to_tuple(sockaddr()) -> {inet:ip_address(), inet:port_number()}.
sockaddr_to_tuple(#{addr := Addr, port := Port}) ->
{Addr, Port}.
%%%-----------------------------------------------------------------------------
%% ECN SUPPORT (RFC 9000 S13 4)
%%%-----------------------------------------------------------------------------
-spec ecn_from_tos(non_neg_integer() | atom() | binary()) -> not_ect | ect0 | ect1 | ce.
ecn_from_tos(TOS) when is_integer(TOS) ->
case TOS band 16#03 of
0 -> not_ect;
1 -> ect1;
2 -> ect0;
3 -> ce
end;
ecn_from_tos(default) ->
not_ect;
ecn_from_tos(lowdelay) ->
not_ect;
ecn_from_tos(throughput) ->
not_ect;
ecn_from_tos(reliability) ->
not_ect;
ecn_from_tos(mincost) ->
ect0;
ecn_from_tos(<<TOS:8, _/binary>>) ->
ecn_from_tos(TOS);
ecn_from_tos(_) ->
not_ect.
-spec ecn_to_tos(not_ect | ect0 | ect1 | ce) -> non_neg_integer().
ecn_to_tos(not_ect) -> 0;
ecn_to_tos(ect1) -> 1;
ecn_to_tos(ect0) -> 2;
ecn_to_tos(ce) -> 3.
%%%-----------------------------------------------------------------------------
%% INTERNAL FUNCTIONS
%%%-----------------------------------------------------------------------------
-spec bind_socket(t(), inet:port_number(), inet | inet6, open_opts()) ->
ok | {error, nquic_error:any_reason()}.
bind_socket(Socket, Port, Family, Opts) ->
IP = maps:get(ip, Opts, any),
SockAddr = make_bind_addr(Family, IP, Port),
socket:bind(Socket, SockAddr).
-spec determine_family(open_opts()) -> inet | inet6.
determine_family(Opts) ->
case maps:get(ip, Opts, any) of
{_, _, _, _, _, _, _, _} -> inet6;
_ -> inet
end.
-spec ecn_ctrl_for_dest(sockaddr(), non_neg_integer()) -> [map()].
ecn_ctrl_for_dest(#{family := inet6}, TOS) ->
[#{level => ipv6, type => tclass, value => TOS}];
ecn_ctrl_for_dest(_, TOS) ->
[#{level => ip, type => tos, value => TOS}].
-spec ecn_ctrl_for_socket(t(), non_neg_integer()) -> [map()].
ecn_ctrl_for_socket(Socket, TOS) ->
case socket:sockname(Socket) of
{ok, #{family := inet6}} ->
[#{level => ipv6, type => tclass, value => TOS}];
_ ->
[#{level => ip, type => tos, value => TOS}]
end.
-doc """
Extract the ECN codepoint from recvmsg control messages.
Returns `not_ect` (0), `ect1` (1), `ect0` (2), or `ce` (3).
""".
-spec get_ecn_from_cmsg(list() | undefined) -> not_ect | ect0 | ect1 | ce.
get_ecn_from_cmsg(undefined) ->
not_ect;
get_ecn_from_cmsg([]) ->
not_ect;
get_ecn_from_cmsg([#{level := ip, type := tos, value := TOS} | _]) ->
ecn_from_tos(TOS);
get_ecn_from_cmsg([#{level := ipv6, type := tclass, value := TC} | _]) ->
ecn_from_tos(TC);
get_ecn_from_cmsg([_ | Rest]) ->
get_ecn_from_cmsg(Rest).
-spec make_bind_addr(inet | inet6, inet:ip_address() | any, inet:port_number()) ->
sockaddr().
make_bind_addr(inet, any, Port) ->
#{family => inet, addr => any, port => Port};
make_bind_addr(inet, {_, _, _, _} = Addr, Port) ->
#{family => inet, addr => Addr, port => Port};
make_bind_addr(inet6, any, Port) ->
#{family => inet6, addr => any, port => Port};
make_bind_addr(inet6, {_, _, _, _, _, _, _, _} = Addr, Port) ->
#{family => inet6, addr => Addr, port => Port}.
-spec maybe_set_ecn(t(), open_opts()) -> ok | {error, nquic_error:any_reason()}.
maybe_set_ecn(Socket, Opts) ->
case maps:get(ecn, Opts, false) of
true -> set_ecn(Socket, true);
false -> ok
end.
-spec maybe_set_gro(t(), open_opts()) -> ok.
maybe_set_gro(Socket, Opts) ->
case maps:get(gro, Opts, false) of
true -> set_gro(Socket, true);
false -> ok
end.
-spec maybe_set_gso(t(), open_opts()) -> ok.
maybe_set_gso(Socket, Opts) ->
case maps:get(gso, Opts, false) of
false -> ok;
true -> set_gso_size(Socket, ?DEFAULT_GSO_SIZE);
Size when is_integer(Size), Size > 0 -> set_gso_size(Socket, Size);
_ -> ok
end.
-spec maybe_set_reuseport(t(), open_opts()) -> ok | {error, nquic_error:any_reason()}.
maybe_set_reuseport(Socket, Opts) ->
case maps:get(reuseport, Opts, false) of
true -> socket:setopt(Socket, socket, reuseport, true);
false -> ok
end.
-doc """
Send data on a connected socket with a specific ECN codepoint.
Uses sendmsg with an `IP_TOS` / `IPV6_TCLASS` cmsg. Slower than
`send_connected/2` because of the extra cmsg processing, so the hot path
relies on socket-level TOS pre-stamping (see `set_ecn/2`) and only falls
back to this primitive when per-packet control is required. Future work
(GSO batching, pacing) is the expected caller.
`iolist_to_iovec/1` flattens the iolist into a list of binaries without
materialising a single concatenated binary, preserving the zero-copy
property of the encrypt path.
""".
-spec send_connected_with_ecn(t(), iodata(), ecn_mark()) ->
ok | {error, nquic_error:any_reason()}.
send_connected_with_ecn(Socket, Data, ECN) ->
TOS = ecn_to_tos(ECN),
Ctrl = ecn_ctrl_for_socket(Socket, TOS),
Msg = #{iov => erlang:iolist_to_iovec(Data), ctrl => Ctrl},
case socket:sendmsg(Socket, Msg) of
ok -> ok;
{ok, _} -> ok;
{error, _} = Err -> Err
end.
-doc "Send data with a specific ECN codepoint using sendmsg. See `send_connected_with_ecn/3`.".
-spec send_with_ecn(t(), sockaddr(), iodata(), ecn_mark()) ->
ok | {error, nquic_error:any_reason()}.
send_with_ecn(Socket, Dest, Data, ECN) ->
TOS = ecn_to_tos(ECN),
Ctrl = ecn_ctrl_for_dest(Dest, TOS),
Msg = #{
addr => Dest,
iov => erlang:iolist_to_iovec(Data),
ctrl => Ctrl
},
case socket:sendmsg(Socket, Msg) of
ok -> ok;
{ok, _} -> ok;
{error, _} = Err -> Err
end.
-doc """
Enable ECN on a socket.
Configures both directions:
- Inbound: `IP_RECVTOS` / `IPV6_RECVTCLASS` so `recvmsg` returns the TOS
/ traffic-class byte in ancillary data. The receiver decodes it via
`get_ecn_from_cmsg/1` and feeds the per-packet ECN counts into the
protocol layer.
- Outbound: `IP_TOS = 2` / `IPV6_TCLASS = 2` so the kernel stamps every
outgoing datagram as ECT(0) without per-packet `sendmsg` overhead.
Validation failure flips this back to 0 via `set_egress_ecn/2`.
Errors from setopt are tolerated per-option when the family is not
present (a v4-only socket cannot accept `ipv6/*` and vice versa).
""".
-spec set_ecn(t(), boolean()) -> ok | {error, nquic_error:any_reason()}.
set_ecn(Socket, true) ->
_ = socket:setopt(Socket, ip, recvtos, true),
_ = socket:setopt(Socket, ipv6, recvtclass, true),
ok = set_egress_ecn(Socket, ect0),
ok;
set_ecn(Socket, false) ->
_ = socket:setopt(Socket, ip, recvtos, false),
_ = socket:setopt(Socket, ipv6, recvtclass, false),
ok = set_egress_ecn(Socket, not_ect),
ok.
-doc """
Flip the socket-level egress ECN mark.
Use after a path validation failure (RFC 9000 ยง13.4.2.1) to stop
emitting ECT-marked packets on this path. Best-effort: errors from the
non-matching family are ignored.
""".
-spec set_egress_ecn(t(), ecn_mark()) -> ok.
set_egress_ecn(Socket, Mark) ->
TOS = ecn_to_tos(Mark),
_ = socket:setopt(Socket, ip, tos, TOS),
_ = socket:setopt(Socket, ipv6, tclass, TOS),
ok.
-spec set_ipv6_opts(t(), open_opts()) -> ok | {error, nquic_error:any_reason()}.
set_ipv6_opts(Socket, Opts) ->
case maps:get(ipv6_v6only, Opts, undefined) of
undefined ->
ok;
V6Only ->
socket:setopt(Socket, ipv6, v6only, V6Only)
end.
-spec set_socket_opts(t(), open_opts()) -> ok | {error, nquic_error:any_reason()}.
set_socket_opts(Socket, Opts) ->
RecBuf = maps:get(recbuf, Opts, ?DEFAULT_RECBUF),
SndBuf = maps:get(sndbuf, Opts, ?DEFAULT_SNDBUF),
ReuseAddr = maps:get(reuseaddr, Opts, true),
maybe
ok ?= socket:setopt(Socket, socket, reuseaddr, ReuseAddr),
ok ?= socket:setopt(Socket, socket, rcvbuf, RecBuf),
ok ?= socket:setopt(Socket, socket, sndbuf, SndBuf),
ok ?= maybe_set_reuseport(Socket, Opts),
ok ?= set_ipv6_opts(Socket, Opts),
ok ?= maybe_set_ecn(Socket, Opts),
ok = maybe_set_gso(Socket, Opts),
ok = maybe_set_gro(Socket, Opts),
ok
end.
-doc "Get the local address of the socket.".
-spec sockname(t()) -> {ok, sockaddr()} | {error, nquic_error:any_reason()}.
sockname(Socket) ->
socket:sockname(Socket).
%%%-----------------------------------------------------------------------------
%% UDP BATCHING (GSO / GRO, RFC-NA, LINUX-SPECIFIC KERNEL OFFLOAD)
%%%-----------------------------------------------------------------------------
-doc """
Probe the running kernel for UDP_SEGMENT (GSO) and UDP_GRO support.
Result is cached in `persistent_term`; the probe runs at most once per
node. On non-Linux platforms (or older kernels missing one or both
features) the corresponding capability comes back `false`. Callers can
treat the result as opaque and pass `gso => true` / `gro => true` only
when the matching capability is set; the open path silently no-ops if
the kernel rejects the setsockopt.
""".
-spec capabilities() -> capabilities().
capabilities() ->
case persistent_term:get(?CAPABILITIES_KEY, undefined) of
undefined ->
Caps = probe_capabilities(),
persistent_term:put(?CAPABILITIES_KEY, Caps),
Caps;
Caps ->
Caps
end.
-doc """
Extract the GRO segment size from a `recvmsg` control-message list.
Returns the segment size in bytes when the kernel coalesced the recv,
`undefined` otherwise. The 16-bit segment-size value sits in the lower
two bytes of the cmsg payload, which the kernel pads to a 4-byte
multiple, so the trailing bytes are matched as a wildcard.
""".
-spec get_gso_size_from_cmsg(list() | undefined) -> undefined | pos_integer().
get_gso_size_from_cmsg(undefined) ->
undefined;
get_gso_size_from_cmsg([]) ->
undefined;
get_gso_size_from_cmsg([
#{level := udp, type := ?UDP_GRO, data := <<Size:16/native, _/binary>>} | _
]) when Size > 0 ->
Size;
get_gso_size_from_cmsg([_ | Rest]) ->
get_gso_size_from_cmsg(Rest).
-spec probe_capabilities() -> capabilities().
probe_capabilities() ->
case socket:open(inet, dgram, udp) of
{ok, S} ->
GSO =
case socket:setopt_native(S, {?SOL_UDP, ?UDP_SEGMENT}, <<1200:32/native>>) of
ok -> true;
_ -> false
end,
GRO =
case socket:setopt_native(S, {?SOL_UDP, ?UDP_GRO}, <<1:32/native>>) of
ok -> true;
_ -> false
end,
_ = socket:close(S),
#{gso => GSO, gro => GRO};
{error, _} ->
#{gso => false, gro => false}
end.
-doc """
Enable UDP_GRO on a socket.
Once GRO is on, the kernel coalesces consecutive equal-size datagrams of
the same flow into a single buffer; `socket:recvmsg/5` then returns a
control message with `#{level => udp, type => 104, data => <<Size:16/native, _/binary>>}`
that the caller must use to split the buffer back into per-packet
chunks. See `get_gso_size_from_cmsg/1`.
""".
-spec set_gro(t(), boolean()) -> ok.
set_gro(Socket, true) ->
_ = socket:setopt_native(Socket, {?SOL_UDP, ?UDP_GRO}, <<1:32/native>>),
ok;
set_gro(Socket, false) ->
_ = socket:setopt_native(Socket, {?SOL_UDP, ?UDP_GRO}, <<0:32/native>>),
ok.
-doc """
Configure sticky UDP_SEGMENT (GSO) on a socket.
After this call, any `socket:send/sendto` whose payload exceeds `Size`
will be split by the kernel into segments of `Size` bytes each (the
final segment may be shorter). `Size = 0` disables segmentation.
Returns `ok` even when the kernel rejects the option, mirroring
`set_ecn/2`'s best-effort policy: callers who care must check
`capabilities/0` first.
Pair with `set_gro/2` on the peer's receive socket: without GRO,
the coalesced segments arrive as individual datagrams that overrun
the UDP receive buffer on loopback / fast paths, causing 2-3x
retransmissions and a net throughput regression versus the
un-offloaded send path.
""".
-spec set_gso_size(t(), non_neg_integer()) -> ok.
set_gso_size(Socket, Size) when is_integer(Size), Size >= 0 ->
_ = socket:setopt_native(Socket, {?SOL_UDP, ?UDP_SEGMENT}, <<Size:32/native>>),
ok.