Current section

Files

Jump to
quic src quic_token_cache.erl
Raw

src/quic_token_cache.erl

%%% -*- erlang -*-
%%%
%%% Client-side cache for RFC 9000 §8.1.3 NEW_TOKEN frames.
%%%
%%% Servers send NEW_TOKEN to signal that a given client address has
%%% already completed address validation. A subsequent connect by the
%%% same client can include that opaque token in the Initial packet's
%%% Token field and skip the retry round-trip.
%%%
%%% This cache is in-memory only; tokens do not survive VM restart.
%%% The table is bounded to avoid unbounded growth from talking to
%%% many distinct servers — on overflow the oldest entry is dropped.
-module(quic_token_cache).
-behaviour(gen_server).
-export([start_link/0, put/2, take/1, clear/0]).
-export([init/1, handle_call/3, handle_cast/2]).
-define(TABLE, ?MODULE).
-define(MAX_ENTRIES, 256).
-type endpoint() :: {binary() | inet:ip_address(), inet:port_number()}.
%%====================================================================
%% API
%%====================================================================
-spec start_link() -> {ok, pid()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
%% @doc Store a token for the given endpoint. Overwrites any previous
%% entry for the same endpoint; when the cache is at its size limit,
%% the oldest entry is dropped.
-spec put(endpoint(), binary()) -> ok.
put(Endpoint, Token) when is_binary(Token) ->
case whereis(?MODULE) of
undefined ->
ok;
_ ->
Ts = erlang:monotonic_time(millisecond),
true = ets:insert(?TABLE, {Endpoint, Token, Ts}),
gen_server:cast(?MODULE, maybe_evict),
ok
end.
%% @doc Atomically return and remove a cached token for the endpoint,
%% or `empty' if none is cached. Returning the token consumes it —
%% per RFC 9000 §8.1.3 each NEW_TOKEN is meant to be used at most once.
-spec take(endpoint()) -> {ok, binary()} | empty.
take(Endpoint) ->
case whereis(?MODULE) of
undefined ->
empty;
_ ->
case ets:lookup(?TABLE, Endpoint) of
[] ->
empty;
[{_, Token, _Ts}] ->
ets:delete(?TABLE, Endpoint),
{ok, Token}
end
end.
%% @doc Drop all cached tokens. Useful for tests.
-spec clear() -> ok.
clear() ->
case whereis(?MODULE) of
undefined -> ok;
_ -> gen_server:call(?MODULE, clear)
end.
%%====================================================================
%% gen_server callbacks
%%====================================================================
init([]) ->
Tab = ets:new(?TABLE, [named_table, public, set, {read_concurrency, true}]),
{ok, Tab}.
handle_call(clear, _From, Tab) ->
ets:delete_all_objects(Tab),
{reply, ok, Tab}.
handle_cast(maybe_evict, Tab) ->
maybe_evict_oldest(Tab),
{noreply, Tab}.
%%====================================================================
%% Internal
%%====================================================================
maybe_evict_oldest(Tab) ->
case ets:info(Tab, size) > ?MAX_ENTRIES of
false ->
ok;
true ->
%% Linear scan for the oldest entry; acceptable at the
%% default cap (256 entries).
Oldest = ets:foldl(
fun
({Key, _, Ts}, undefined) ->
{Key, Ts};
({Key, _, Ts}, {_, OldestTs}) when Ts < OldestTs ->
{Key, Ts};
(_, Acc) ->
Acc
end,
undefined,
Tab
),
case Oldest of
undefined -> ok;
{Key, _} -> ets:delete(Tab, Key)
end
end.