Packages

macula

0.7.21
7.0.0 6.0.0 5.2.2 5.2.1 5.2.0 5.1.0 5.0.0 4.8.0 4.7.1 4.7.0 4.6.0 4.5.0 4.4.10 4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.1 4.3.0 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.1 4.1.0 4.0.0 3.16.0 3.15.3 3.15.2 3.15.1 3.14.0 3.13.0 3.12.1 3.12.0 3.11.1 3.11.0 3.10.3 3.10.2 3.10.1 3.9.0 3.8.0 3.7.0 3.5.0 3.4.0 3.3.0 3.2.0 3.1.0 3.0.0 2.1.1 2.1.0 2.0.0 1.5.2 1.5.1 1.4.30 1.4.29 1.4.28 1.4.27 1.4.26 1.4.25 1.4.24 1.4.23 1.4.22 1.4.21 1.4.20 1.4.19 1.4.18 1.4.17 1.4.16 1.4.15 1.4.14 1.4.13 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.1 1.3.0 1.2.0 1.1.0 1.0.10 1.0.9 1.0.8 1.0.7 1.0.6 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 1.0.0 0.48.6 0.48.5 0.48.4 0.48.3 0.48.2 0.48.1 0.48.0 0.47.1 0.47.0 0.46.3 0.46.1 0.46.0 0.45.3 0.45.2 0.45.1 0.45.0 0.44.2 0.44.1 0.44.0 0.43.3 0.43.2 0.43.1 0.43.0 0.42.9 0.42.8 0.42.7 0.42.6 0.42.5 0.42.4 0.42.3 0.42.2 0.42.1 0.42.0 0.41.1 0.41.0 0.40.1 0.40.0 0.39.9 0.39.8 0.39.7 0.39.6 0.39.5 0.39.4 0.39.3 0.39.2 0.39.1 0.39.0 0.38.8 0.38.7 0.38.6 0.38.5 0.38.4 0.38.3 0.38.2 0.38.1 0.38.0 0.37.7 0.37.6 0.37.5 0.37.4 0.37.3 0.37.2 0.37.1 0.37.0 0.36.6 0.36.5 0.36.4 0.36.3 0.36.2 0.36.1 0.36.0 0.35.4 0.35.3 0.35.2 0.35.1 0.35.0 0.34.1 0.34.0 0.33.1 0.33.0 0.32.5 0.32.4 0.32.3 0.32.2 0.32.1 0.32.0 0.31.9 0.31.8 0.31.7 0.31.6 0.31.5 0.31.4 0.31.3 0.31.2 0.31.1 0.31.0 0.30.10 0.30.9 0.30.8 0.30.7 0.30.6 0.30.5 0.30.4 0.30.3 0.30.2 0.30.1 0.30.0 0.29.0 0.28.3 0.28.2 0.28.1 0.28.0 0.27.1 0.27.0 0.26.1 0.26.0 0.25.6 0.25.5 0.25.4 0.25.3 0.25.2 0.25.1 0.25.0 0.24.6 0.24.5 0.24.4 0.24.3 0.24.2 0.24.1 0.24.0 0.23.3 0.23.2 0.23.1 0.23.0 0.22.12 0.22.11 0.22.10 0.22.9 0.22.8 0.22.7 0.22.6 0.22.5 0.22.4 0.22.3 0.22.2 0.22.1 0.22.0 0.21.7 0.21.6 0.21.5 0.21.4 0.21.2 0.21.1 0.21.0 0.20.25 0.20.24 0.20.23 0.20.22 0.20.21 0.20.20 0.20.19 0.20.18 0.20.17 0.20.16 0.20.15 0.20.14 0.20.13 0.20.12 0.20.11 0.20.10 0.20.9 0.20.8 0.20.7 0.20.6 0.20.5 0.20.3 0.20.2 0.20.1 0.20.0 0.19.2 0.19.1 0.19.0 0.18.1 0.18.0 0.17.4 0.17.3 0.17.2 0.17.1 0.17.0 0.16.6 0.16.5 0.16.4 0.16.3 0.16.2 0.16.1 0.16.0 0.15.1 0.15.0 0.14.3 0.14.2 0.14.1 0.14.0 0.12.6 0.12.5 0.12.3 0.11.3 0.10.2 0.10.1 0.10.0 0.9.2 0.9.1 0.9.0 0.8.25 0.8.24 0.8.23 0.8.22 0.8.21 0.8.20 0.8.19 0.8.18 0.8.17 0.8.16 0.8.15 0.8.14 0.8.13 0.8.12 0.8.11 0.8.10 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.3 0.8.2 0.8.1 0.8.0 0.7.30 0.7.29 0.7.28 0.7.27 0.7.26 0.7.25 0.7.24 0.7.23 0.7.22 0.7.21 0.7.20 0.7.19 0.7.18 0.7.17 0.7.16 0.7.15 0.7.14 0.7.13 0.7.12 0.7.11 0.7.10 0.7.9 0.7.8 0.7.7 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.7 0.6.6 0.6.5 0.6.4 0.6.3 0.6.2 0.6.1 0.6.0 0.5.0 0.4.4 0.4.3 0.4.2 0.4.1 0.4.0 0.3.4 0.3.3 0.3.2 0.3.1

Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise

Current section

Files

Jump to
macula src macula_connection.erl
Raw

src/macula_connection.erl

%%%-------------------------------------------------------------------
%%% @doc
%%% Macula Connection - QUIC Transport Layer (v0.7.0+).
%%%
%%% This module manages the low-level QUIC connection lifecycle and
%%% message transport for mesh participants.
%%%
%%% Responsibilities:
%%% - Establish and maintain QUIC connection
%%% - Send messages via QUIC stream
%%% - Receive and route incoming messages to handlers
%%% - Handle connection errors and reconnection
%%% - Message encoding/decoding and buffering
%%%
%%% Renamed from macula_connection in v0.7.0 for clarity:
%%% - macula_connection = QUIC transport (this module - low-level)
%%% - macula_peer = mesh participant (high-level API)
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(macula_connection).
-behaviour(gen_server).
-include_lib("kernel/include/logger.hrl").
-include("macula_config.hrl").
%% API
-export([start_link/2, send_message/3, get_status/1, default_config/0]).
%% API for testing
-export([decode_messages/2]).
%% Internal exports
-export([start_keepalive_timer/1]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
-define(SERVER, ?MODULE).
-define(CONNECT_RETRY_DELAY, 5000).
-record(state, {
url :: binary(),
opts :: map(),
node_id :: binary(),
realm :: binary(),
connection :: pid() | undefined,
stream :: pid() | undefined,
status = connecting :: connecting | connected | disconnected | error,
recv_buffer = <<>> :: binary(),
keepalive_timer :: reference() | undefined
}).
%%%===================================================================
%%% API
%%%===================================================================
-spec start_link(binary(), map()) -> {ok, pid()} | {error, term()}.
start_link(Url, Opts) ->
gen_server:start_link(?MODULE, {Url, Opts}, []).
-spec default_config() -> map().
default_config() ->
#{
keepalive_enabled => true,
keepalive_interval => 30000 %% 30 seconds
}.
-spec send_message(pid(), atom(), map()) -> ok | {error, term()}.
send_message(Pid, Type, Msg) ->
gen_server:call(Pid, {send_message, Type, Msg}, 5000).
-spec get_status(pid()) -> connecting | connected | disconnected | error.
get_status(Pid) ->
gen_server:call(Pid, get_status, 5000).
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
init({Url, Opts}) ->
?LOG_INFO("Connection manager starting for ~s", [Url]),
%% Parse URL to extract host and port
{Host, Port} = parse_url(Url),
%% Get realm (required)
Realm = get_realm_from_opts(Opts),
%% Generate or get node ID
NodeId = maps:get(node_id, Opts, generate_node_id()),
State = #state{
url = Url,
opts = Opts#{host => Host, port => Port},
node_id = NodeId,
realm = Realm,
connection = undefined,
stream = undefined,
status = connecting,
recv_buffer = <<>>,
keepalive_timer = undefined
},
%% Initiate connection asynchronously
self() ! connect,
{ok, State}.
handle_call({send_message, Type, Msg}, _From, #state{status = connected, stream = Stream} = State) ->
case send_message_raw(Type, Msg, Stream) of
ok ->
{reply, ok, State};
{error, Reason} ->
{reply, {error, Reason}, State}
end;
handle_call({send_message, _Type, _Msg}, _From, State) ->
{reply, {error, not_connected}, State};
handle_call(get_status, _From, State) ->
{reply, State#state.status, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(connect, State) ->
case do_connect(State) of
{ok, State2} ->
{noreply, State2};
{error, Reason} ->
?LOG_ERROR("Connection failed: ~p, retrying in ~p ms",
[Reason, ?CONNECT_RETRY_DELAY]),
erlang:send_after(?CONNECT_RETRY_DELAY, self(), connect),
{noreply, State#state{status = error}}
end;
handle_info({quic, Data, Stream, _Props}, State) when is_binary(Data) ->
%% Received data from QUIC stream
MainStream = State#state.stream,
handle_stream_data(Stream =:= MainStream, Data, Stream, State);
%% Handle QUIC control messages (non-binary Data)
handle_info({quic, ControlMsg, _Stream, _Props}, State) when is_atom(ControlMsg) ->
%% QUIC control message (send_shutdown_complete, etc.) - ignore
?LOG_DEBUG("Ignoring QUIC control message: ~p", [ControlMsg]),
{noreply, State};
%% Handle keep-alive tick - send PING message
handle_info(keepalive_tick, #state{status = connected, stream = Stream} = State) ->
%% Send PING message
PingMsg = #{timestamp => erlang:system_time(millisecond)},
case send_message_raw(ping, PingMsg, Stream) of
ok ->
?LOG_DEBUG("Keep-alive PING sent"),
%% Restart timer for next keep-alive
StateWithTimer = start_keepalive_timer(State),
{noreply, StateWithTimer};
{error, Reason} ->
?LOG_WARNING("Failed to send keep-alive PING: ~p", [Reason]),
%% Connection might be dead - let it retry
{noreply, State}
end;
%% Ignore keep-alive tick if not connected
handle_info(keepalive_tick, State) ->
{noreply, State};
handle_info(_Info, State) ->
%% Ignore unexpected messages
?LOG_DEBUG("Unhandled handle_info message: ~p", [_Info]),
{noreply, State}.
terminate(_Reason, #state{stream = Stream, connection = Conn}) ->
?LOG_INFO("Connection manager terminating"),
%% Clean up QUIC resources
catch macula_quic:close(Stream),
catch macula_quic:close(Conn),
ok.
%%%===================================================================
%%% Internal functions
%%%===================================================================
%% @doc Dispatch stream data based on validity (pattern matching on boolean).
handle_stream_data(true, Data, _Stream, State) ->
handle_received_data(Data, State);
handle_stream_data(false, _Data, Stream, State) ->
?LOG_WARNING("Received data from unknown stream: ~p", [Stream]),
{noreply, State}.
%% @doc Establish QUIC connection and perform handshake.
-spec do_connect(#state{}) -> {ok, #state{}} | {error, term()}.
do_connect(State) ->
#{host := Host, port := Port} = State#state.opts,
QuicOpts = build_quic_opts(),
case attempt_quic_connection(Host, Port, QuicOpts) of
{ok, Conn, Stream} ->
complete_connection_setup(Conn, Stream, Host, Port, State);
{error, _Reason} = Error ->
Error
end.
%% @doc Build QUIC connection options
-spec build_quic_opts() -> list().
build_quic_opts() ->
[
{alpn, ["macula"]},
{verify, none} %% TODO: Add proper TLS verification
].
%% @doc Attempt QUIC connection and stream setup
-spec attempt_quic_connection(string(), integer(), list()) ->
{ok, pid(), pid()} | {error, term()}.
attempt_quic_connection(Host, Port, QuicOpts) ->
case safe_quic_connect(Host, Port, QuicOpts) of
{ok, Conn} ->
setup_bidirectional_stream(Conn);
{error, _Reason} = Error ->
Error;
{error, _Type, _Details} = Error ->
{error, {connection_failed, Error}};
Other ->
{error, {connection_failed, Other}}
end.
%% @doc Safe QUIC connect with error handling.
%% NIF boundary - quicer can throw exceptions, convert to tagged tuples.
-spec safe_quic_connect(string(), integer(), list()) ->
{ok, pid()} | {error, term()}.
safe_quic_connect(Host, Port, QuicOpts) ->
try
macula_quic:connect(Host, Port, QuicOpts, ?DEFAULT_TIMEOUT)
catch
_:Error ->
{error, {connection_failed, Error}}
end.
%% @doc Open and configure bidirectional stream
-spec setup_bidirectional_stream(pid()) ->
{ok, pid(), pid()} | {error, term()}.
setup_bidirectional_stream(Conn) ->
case macula_quic:open_stream(Conn) of
{ok, Stream} ->
configure_stream_active_mode(Conn, Stream);
{error, Reason} ->
macula_quic:close(Conn),
{error, {stream_open_failed, Reason}}
end.
%% @doc Set stream to active mode for receiving messages
-spec configure_stream_active_mode(pid(), pid()) ->
{ok, pid(), pid()} | {error, term()}.
configure_stream_active_mode(Conn, Stream) ->
case quicer:setopt(Stream, active, true) of
ok ->
?LOG_DEBUG("Stream set to active mode"),
{ok, Conn, Stream};
{error, SetOptErr} ->
?LOG_WARNING("Failed to set stream active: ~p", [SetOptErr]),
macula_quic:close(Stream),
macula_quic:close(Conn),
{error, {stream_setopt_failed, SetOptErr}}
end.
%% @doc Complete connection setup with handshake and DHT registration
-spec complete_connection_setup(pid(), pid(), string(), integer(), #state{}) ->
{ok, #state{}} | {error, term()}.
complete_connection_setup(Conn, Stream, Host, Port, State) ->
ConnectMsg = build_connect_message(State),
case send_message_raw(connect, ConnectMsg, Stream) of
ok ->
?LOG_INFO("Connected to Macula mesh: ~s:~p", [Host, Port]),
register_server_in_dht(State),
%% Start keep-alive timer if enabled
ConnectedState = State#state{
connection = Conn,
stream = Stream,
status = connected
},
StateWithKeepalive = start_keepalive_timer(ConnectedState),
{ok, StateWithKeepalive};
{error, Reason} ->
macula_quic:close(Stream),
macula_quic:close(Conn),
{error, {handshake_failed, Reason}}
end.
%% @doc Build CONNECT protocol message
-spec build_connect_message(#state{}) -> map().
build_connect_message(State) ->
LocalEndpoint = get_advertise_endpoint(),
#{
version => <<"1.0">>,
node_id => State#state.node_id,
realm_id => State#state.realm,
capabilities => [pubsub, rpc],
endpoint => LocalEndpoint
}.
%% @doc Get endpoint to advertise for peer-to-peer connections
-spec get_advertise_endpoint() -> binary().
get_advertise_endpoint() ->
case application:get_env(macula, advertise_endpoint) of
{ok, Endpoint} when is_binary(Endpoint) ->
Endpoint;
_ ->
construct_default_endpoint()
end.
%% @doc Construct default endpoint from NODE_HOST environment variable
-spec construct_default_endpoint() -> binary().
construct_default_endpoint() ->
NodeHost = list_to_binary(os:getenv("NODE_HOST", "localhost")),
<<"https://", NodeHost/binary, ":9443">>.
%% @doc Register connected server in DHT routing table (best effort).
%% If DHT is not running, logs warning but continues.
%% Unexpected errors (bad URL, crypto failure) will crash - this is intentional.
-spec register_server_in_dht(#state{}) -> ok.
register_server_in_dht(State) ->
ServerAddress = parse_server_endpoint(State#state.url),
ServerNodeInfo = #{
node_id => crypto:strong_rand_bytes(32),
address => ServerAddress
},
case macula_routing_server:add_node(macula_routing_server, ServerNodeInfo) of
ok ->
?LOG_DEBUG("Added server node to DHT routing table with address: ~p", [ServerAddress]),
ok;
{error, Reason} ->
?LOG_WARNING("DHT registration failed (expected if DHT not running): ~p", [Reason]),
ok
end.
%% @doc Send a protocol message through a stream (raw).
%% Crashes if message is invalid - this indicates a bug in the caller.
-spec send_message_raw(atom(), map(), pid()) -> ok | {error, term()}.
send_message_raw(Type, Msg, Stream) ->
io:format("[send_message_raw] Type=~p, Msg=~p~n", [Type, Msg]),
Binary = macula_protocol_encoder:encode(Type, Msg),
macula_quic:async_send(Stream, Binary).
%% @doc Handle received data from the stream.
-spec handle_received_data(binary(), #state{}) -> {noreply, #state{}}.
handle_received_data(Data, State) ->
%% Append to receive buffer
Buffer = <<(State#state.recv_buffer)/binary, Data/binary>>,
%% Try to decode messages
{Messages, RemainingBuffer} = decode_messages(Buffer, []),
%% Process each message
State2 = lists:foldl(fun process_message/2, State, Messages),
{noreply, State2#state{recv_buffer = RemainingBuffer}}.
%% @doc Decode all complete messages from buffer.
-spec decode_messages(binary(), list()) -> {list(), binary()}.
decode_messages(Buffer, Acc) when byte_size(Buffer) < 8 ->
%% Not enough for header
{lists:reverse(Acc), Buffer};
decode_messages(<<_Version:8, _TypeId:8, _Flags:8, _Reserved:8,
PayloadLen:32/big-unsigned, Rest/binary>> = Buffer, Acc) ->
case byte_size(Rest) of
ActualLen when ActualLen >= PayloadLen ->
%% We have a complete message
case macula_protocol_decoder:decode(Buffer) of
{ok, {Type, Msg}} ->
%% Skip this message and continue
<<_:8/binary, _Payload:PayloadLen/binary, Remaining/binary>> = Buffer,
decode_messages(Remaining, [{Type, Msg} | Acc]);
{error, _Reason} ->
%% Decode error, skip this message
<<_:8/binary, _Payload:PayloadLen/binary, Remaining/binary>> = Buffer,
decode_messages(Remaining, Acc)
end;
_ ->
%% Incomplete message, wait for more data
{lists:reverse(Acc), Buffer}
end.
%% @doc Process a received message - route to appropriate handler.
-spec process_message({atom(), map()}, #state{}) -> #state{}.
%% Route PUBLISH messages to pub/sub handler
process_message({publish, Msg}, State) ->
?LOG_INFO("Connection manager routing message type: ~p", [publish]),
macula_pubsub_handler:handle_incoming_publish(Msg),
?LOG_DEBUG("Routed PUBLISH to pubsub_handler"),
State;
%% Route REPLY messages to RPC handler
process_message({reply, Msg}, State) ->
?LOG_INFO("Connection manager routing message type: ~p", [reply]),
macula_rpc_handler:handle_incoming_reply(Msg),
?LOG_DEBUG("Routed REPLY to rpc_handler"),
State;
%% Handle CONNECTED acknowledgment
process_message({connected, Msg}, State) ->
?LOG_INFO("Received CONNECTED acknowledgment from server: ~p", [Msg]),
State;
%% Handle PING message - respond with PONG
process_message({ping, Msg}, #state{stream = Stream} = State) ->
?LOG_DEBUG("Received PING, responding with PONG"),
PongMsg = #{timestamp => maps:get(timestamp, Msg, 0)},
_ = send_message_raw(pong, PongMsg, Stream),
State;
%% Handle PONG message - keep-alive acknowledgment
process_message({pong, _Msg}, State) ->
?LOG_DEBUG("Received PONG - connection alive"),
State;
%% Handle unknown message types
process_message({Type, _Msg}, State) ->
?LOG_INFO("Connection manager routing message type: ~p", [Type]),
?LOG_WARNING("Unknown message type: ~p", [Type]),
State.
%% @doc Parse URL to extract host and port.
-spec parse_url(binary()) -> {string(), integer()}.
parse_url(Url) when is_binary(Url) ->
parse_url(binary_to_list(Url));
parse_url("https://" ++ Rest) ->
parse_host_port(Rest, 443);
parse_url("http://" ++ Rest) ->
parse_host_port(Rest, 80);
parse_url(Url) ->
error({invalid_url, Url}).
%% @doc Parse host and port from URL remainder, using default port if not specified.
-spec parse_host_port(string(), integer()) -> {string(), integer()}.
parse_host_port(HostPort, DefaultPort) ->
case string:split(HostPort, ":") of
[Host, PortStr] -> {Host, list_to_integer(PortStr)};
[Host] -> {Host, DefaultPort}
end.
%% @doc Parse server endpoint to extract address for DHT.
-spec parse_server_endpoint(binary()) -> binary().
parse_server_endpoint(Url) ->
%% Extract host from URL for DHT address
case parse_url(Url) of
{Host, Port} ->
list_to_binary(Host ++ ":" ++ integer_to_list(Port));
_ ->
Url
end.
%% @doc Generate a random node ID.
-spec generate_node_id() -> binary().
generate_node_id() ->
macula_utils:generate_node_id().
%% @doc Get realm from options and normalize.
-spec get_realm_from_opts(map()) -> binary().
get_realm_from_opts(Opts) ->
normalize_realm(maps:get(realm, Opts, undefined)).
%% @doc Normalize realm to binary.
-spec normalize_realm(undefined | binary() | list() | atom()) -> binary().
normalize_realm(undefined) ->
error({missing_required_option, realm});
normalize_realm(Realm) when is_binary(Realm) ->
Realm;
normalize_realm(Realm) when is_list(Realm) ->
list_to_binary(Realm);
normalize_realm(Realm) when is_atom(Realm) ->
atom_to_binary(Realm).
%% @doc Start keep-alive timer if enabled in options.
-spec start_keepalive_timer(#state{}) -> #state{}.
start_keepalive_timer(#state{opts = Opts, keepalive_timer = OldTimer} = State) ->
%% Cancel existing timer if present
case OldTimer of
undefined -> ok;
_ -> erlang:cancel_timer(OldTimer)
end,
%% Check if keep-alive is enabled
Enabled = maps:get(keepalive_enabled, Opts, true),
start_keepalive_timer(Enabled, State).
%% Keep-alive disabled
start_keepalive_timer(false, State) ->
State#state{keepalive_timer = undefined};
%% Keep-alive enabled - start timer
start_keepalive_timer(true, #state{opts = Opts} = State) ->
Interval = maps:get(keepalive_interval, Opts, 30000),
TimerRef = erlang:send_after(Interval, self(), keepalive_tick),
State#state{keepalive_timer = TimerRef}.