Skip to main content

src/nquic_conn_migration.erl

-module(nquic_conn_migration).
-moduledoc """
Connection-migration glue for the handshake state machine.

The pure migration core lives in `m:nquic_protocol_migration`; this
module is the handshake-statem-side glue that opens/rebinds sockets,
rotates connection IDs, queues `PATH_CHALLENGE`, and tears down
listener dispatch routing. Functions take and return `#conn_state{}`
(or a classified result) and never produce gen_statem transitions or
action lists; `nquic_conn_statem` owns the FSM. `flush_and_send/1`
stays in the statem: callers here queue frames and the statem-side
adapter flushes.
""".

-include("nquic_conn.hrl").
-include("nquic_path.hrl").
-export([
    arm_recv_after_migration/1,
    attempt_server_migration/1,
    awaiting_per_conn_fd_cid/1,
    finalize_server_migration/1,
    initiate_client_migration/2,
    maybe_initiate_server_migration/1,
    rotate_or_close/2
]).

-export_type([server_migration_result/0]).

-type server_migration_result() :: #conn_state{}.

-spec arm_recv_after_migration(#conn_state{}) -> #conn_state{}.
arm_recv_after_migration(#conn_state{socket = Socket} = Data) ->
    case nquic_socket:recv_now(Socket) of
        {ok, _} ->
            Data;
        {select, SelectInfo} ->
            Data#conn_state{select_info = SelectInfo};
        {error, _} ->
            Data
    end.

-spec attempt_server_migration(#conn_state{}) -> {ok, #conn_state{}} | {error, term()}.
attempt_server_migration(#conn_state{peer = Peer, path = Path0, gso_size = GsoSize} = Data) ->
    OpenOpts =
        case GsoSize of
            undefined -> #{};
            Size when is_integer(Size) -> #{gso => Size}
        end,
    maybe
        {ok, NewSocket} ?= nquic_socket:open_ephemeral(Peer, OpenOpts),
        {ok, Data1} ?= rotate_or_close(NewSocket, Data),
        PS0 = (Path0)#conn_path_mgmt.path_state,
        {NewPS, Challenge} = nquic_path:initiate_validation(PS0, PS0#path_state.peer),
        NewPath = Path0#conn_path_mgmt{path_state = NewPS},
        Data2 = Data1#conn_state{path = NewPath},
        {ok, Data3} ?= nquic_protocol_send_queues:queue_app_frame(Challenge, Data2),
        Data4 = Data3#conn_state{
            socket = NewSocket,
            socket_connected = true,
            self_migration_pending = true,
            select_info = undefined
        },
        {ok, arm_recv_after_migration(Data4)}
    else
        {error, _} = Err -> Err
    end.

-spec awaiting_per_conn_fd_cid(#conn_state{}) -> boolean().
awaiting_per_conn_fd_cid(#conn_state{
    role = server,
    server_per_conn_fd = true,
    socket_connected = false
}) ->
    true;
awaiting_per_conn_fd_cid(_Data) ->
    false.

-spec finalize_server_migration(#conn_state{}) -> #conn_state{}.
finalize_server_migration(#conn_state{dispatch_table = undefined} = Data) ->
    Data;
finalize_server_migration(
    #conn_state{
        dispatch_table = Table,
        path = #conn_path_mgmt{local_cids = LocalCids},
        odcid = ODCID
    } = Data
) ->
    ok = maps:foreach(
        fun(_Seq, CID) ->
            _ = nquic_listener:dispatch_unregister(Table, CID),
            ok
        end,
        LocalCids
    ),
    case ODCID of
        undefined ->
            ok;
        <<>> ->
            ok;
        _ ->
            _ = nquic_listener:dispatch_unregister(Table, ODCID),
            ok
    end,
    Data#conn_state{dispatch_table = undefined}.

-doc """
Rebind the client socket and queue a `PATH_CHALLENGE` for the new
path. The challenge is left queued; the statem caller flushes.
""".
-spec initiate_client_migration(nquic_socket:sockaddr(), #conn_state{}) ->
    {ok, #conn_state{}} | {error, term()}.
initiate_client_migration(
    NewLocalAddr, #conn_state{socket = OldSocket, path = Path0} = Data
) ->
    PS = Path0#conn_path_mgmt.path_state,
    case nquic_socket:rebind(OldSocket, NewLocalAddr) of
        {ok, NewSocket} ->
            {NewPS, ChallengeFrame} = nquic_path:initiate_validation(PS, PS#path_state.peer),
            NewPath = Path0#conn_path_mgmt{path_state = NewPS},
            Data1 = Data#conn_state{socket = NewSocket, path = NewPath},
            nquic_protocol_send_queues:queue_app_frame(ChallengeFrame, Data1);
        {error, _} = Err ->
            Err
    end.

-doc """
Attempt the `server_per_conn_fd` self-migration, returning a
classified result. Logging is the statem's responsibility;
`noop` means the connection was not eligible.
""".
-spec maybe_initiate_server_migration(#conn_state{}) -> server_migration_result().
maybe_initiate_server_migration(
    #conn_state{
        role = server,
        server_per_conn_fd = true,
        socket_connected = false,
        self_migration_pending = false,
        peer = Peer
    } = Data
) when Peer =/= undefined ->
    case attempt_server_migration(Data) of
        {ok, Data1} ->
            Data1;
        {error, no_available_cids} ->
            Data;
        {error, Reason} ->
            logger:warning(
                "nquic: server_per_conn_fd migration aborted: ~p", [Reason]
            ),
            Data
    end;
maybe_initiate_server_migration(Data) ->
    Data.

-spec rotate_or_close(nquic_socket:t(), #conn_state{}) ->
    {ok, #conn_state{}} | {error, no_available_cids}.
rotate_or_close(NewSocket, Data) ->
    case nquic_protocol_cid:rotate_dcid(Data) of
        {ok, _} = Ok ->
            Ok;
        {error, no_available_cids} = Err ->
            _ = nquic_socket:close(NewSocket),
            Err
    end.