%% Copyright (C) 2026 Fluxer Contributors %% %% This file is part of Fluxer. %% %% Fluxer is free software: you can redistribute it and/or modify %% it under the terms of the GNU Affero General Public License as published by %% the Free Software Foundation, either version 3 of the License, or %% (at your option) any later version. %% %% Fluxer is distributed in the hope that it will be useful, %% but WITHOUT ANY WARRANTY; without even the implied warranty of %% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the %% GNU Affero General Public License for more details. %% %% You should have received a copy of the GNU Affero General Public License %% along with Fluxer. If not, see . -module(session_connection). -export([ handle_presence_connect/2, handle_guild_connect/3, handle_guild_connect_result/4, handle_guild_connect_timeout/3, handle_call_reconnect/3 ]). -define(GUILD_CONNECT_MAX_INFLIGHT, 8). -define(MAX_RETRY_ATTEMPTS, 25). -define(MAX_CALL_RETRY_ATTEMPTS, 15). -define(GUILD_CONNECT_ASYNC_TIMEOUT_MS, 30000). -define(MAX_GUILD_UNAVAILABLE_RETRY_DELAY_MS, 30000). -define(MAX_GUILD_UNAVAILABLE_BACKOFF_ATTEMPT, 5). -define(GUILD_UNAVAILABLE_JITTER_DIVISOR, 5). -type session_state() :: session:session_state(). -type guild_id() :: session:guild_id(). -type channel_id() :: session:channel_id(). -type attempt() :: non_neg_integer(). -type guild_connect_result() :: {ok, pid(), map()} | {ok_unavailable, pid(), map()} | {ok_cached_unavailable, map()} | {error, term()}. -spec handle_presence_connect(attempt(), session_state()) -> {noreply, session_state()}. handle_presence_connect(Attempt, State) -> UserId = maps:get(user_id, State), UserData = maps:get(user_data, State), Guilds = maps:get(guilds, State), Status = maps:get(status, State), SessionId = maps:get(id, State), Afk = maps:get(afk, State), Mobile = maps:get(mobile, State), SocketPid = maps:get(socket_pid, State, undefined), FriendIds = presence_targets:friend_ids_from_state(State), GroupDmRecipients = presence_targets:group_dm_recipients_from_state(State), Message = {start_or_lookup, #{ user_id => UserId, user_data => UserData, guild_ids => maps:keys(Guilds), status => Status, friend_ids => FriendIds, group_dm_recipients => GroupDmRecipients, custom_status => maps:get(custom_status, State, null) }}, case gen_server:call(presence_manager, Message, 5000) of {ok, Pid} -> try_presence_session_connect( Pid, SessionId, Status, Afk, Mobile, SocketPid, FriendIds, GroupDmRecipients, Attempt, State ); _ -> schedule_presence_retry(Attempt, State) end. -spec try_presence_session_connect( pid(), binary(), atom(), boolean(), boolean(), pid() | undefined, [integer()], map(), attempt(), session_state() ) -> {noreply, session_state()}. try_presence_session_connect( Pid, SessionId, Status, Afk, Mobile, SocketPid, FriendIds, GroupDmRecipients, Attempt, State ) -> try case gen_server:call( Pid, {session_connect, #{ session_id => SessionId, status => Status, afk => Afk, mobile => Mobile, socket_pid => SocketPid }}, 10000 ) of {ok, Sessions} -> gen_server:cast(Pid, {sync_friends, FriendIds}), gen_server:cast(Pid, {sync_group_dm_recipients, GroupDmRecipients}), NewState = maps:merge(State, #{ presence_pid => Pid, presence_mref => monitor(process, Pid), collected_sessions => Sessions }), session_ready:check_readiness(NewState); _ -> schedule_presence_retry(Attempt, State) end catch exit:{noproc, _} -> schedule_presence_retry(Attempt, State); exit:{normal, _} -> schedule_presence_retry(Attempt, State); _:_ -> {noreply, State} end. -spec schedule_presence_retry(attempt(), session_state()) -> {noreply, session_state()}. schedule_presence_retry(Attempt, State) when Attempt < ?MAX_RETRY_ATTEMPTS -> erlang:send_after(backoff_utils:calculate(Attempt), self(), {presence_connect, Attempt + 1}), {noreply, State}; schedule_presence_retry(_Attempt, State) -> {noreply, State}. -spec handle_guild_connect(guild_id(), attempt(), session_state()) -> {noreply, session_state()}. handle_guild_connect(GuildId, Attempt, State) -> Guilds = maps:get(guilds, State), SessionId = maps:get(id, State), UserId = maps:get(user_id, State), case maps:get(GuildId, Guilds, undefined) of {_Pid, _Ref} -> {noreply, State}; cached_unavailable -> maybe_handle_cached_unavailability(GuildId, Attempt, SessionId, UserId, State); _ -> maybe_spawn_guild_connect(GuildId, Attempt, SessionId, UserId, State) end. -spec maybe_handle_cached_unavailability( guild_id(), attempt(), binary(), integer(), session_state() ) -> {noreply, session_state()}. maybe_handle_cached_unavailability(GuildId, Attempt, SessionId, UserId, State) -> UserData = maps:get(user_data, State, #{}), case guild_availability:is_guild_unavailable_for_user_from_cache(GuildId, UserData) of true -> mark_cached_guild_unavailable_and_retry(GuildId, Attempt, State); false -> Guilds = maps:get(guilds, State, #{}), ResetGuilds = maps:put(GuildId, undefined, Guilds), ResetState = maps:put(guilds, ResetGuilds, State), maybe_spawn_guild_connect(GuildId, 0, SessionId, UserId, ResetState) end. -spec mark_cached_guild_unavailable(guild_id(), session_state()) -> {noreply, session_state()}. mark_cached_guild_unavailable(GuildId, State) -> Guilds = maps:get(guilds, State, #{}), case maps:get(GuildId, Guilds, undefined) of cached_unavailable -> {noreply, State}; _ -> UpdatedGuilds = maps:put(GuildId, cached_unavailable, Guilds), StateWithGuild = maps:put(guilds, UpdatedGuilds, State), {noreply, MarkedState} = session_ready:mark_guild_unavailable(GuildId, StateWithGuild), session_ready:check_readiness(MarkedState) end. -spec mark_cached_guild_unavailable_and_retry(guild_id(), attempt(), session_state()) -> {noreply, session_state()}. mark_cached_guild_unavailable_and_retry(GuildId, Attempt, State) -> {noreply, MarkedState} = mark_cached_guild_unavailable(GuildId, State), schedule_cached_unavailable_retry(GuildId, Attempt, MarkedState). -spec handle_guild_connect_result(guild_id(), attempt(), guild_connect_result(), session_state()) -> {noreply, session_state()}. handle_guild_connect_result(GuildId, Attempt, Result, State) -> Inflight = maps:get(guild_connect_inflight, State, #{}), case maps:get(GuildId, Inflight, undefined) of Attempt -> NewInflight = maps:remove(GuildId, Inflight), State1 = maps:put(guild_connect_inflight, NewInflight, State), handle_guild_connect_result_internal(GuildId, Attempt, Result, State1); _ -> {noreply, State} end. -spec handle_guild_connect_timeout(guild_id(), attempt(), session_state()) -> {noreply, session_state()}. handle_guild_connect_timeout(GuildId, Attempt, State) -> Inflight0 = maps:get(guild_connect_inflight, State, #{}), case maps:get(GuildId, Inflight0, undefined) of Attempt -> Inflight = maps:remove(GuildId, Inflight0), State1 = maps:put(guild_connect_inflight, Inflight, State), retry_or_fail(GuildId, Attempt, State1, fun(GId, St) -> session_ready:mark_guild_unavailable(GId, St) end); _ -> {noreply, State} end. -spec handle_call_reconnect(channel_id(), attempt(), session_state()) -> {noreply, session_state()}. handle_call_reconnect(ChannelId, Attempt, State) -> Calls = maps:get(calls, State, #{}), SessionId = maps:get(id, State), case maps:get(ChannelId, Calls, undefined) of {_Pid, _Ref} -> {noreply, State}; _ -> attempt_call_reconnect(ChannelId, Attempt, SessionId, State) end. -spec maybe_spawn_guild_connect(guild_id(), attempt(), binary(), integer(), session_state()) -> {noreply, session_state()}. maybe_spawn_guild_connect(GuildId, Attempt, SessionId, UserId, State) -> Inflight0 = maps:get(guild_connect_inflight, State, #{}), AlreadyInflight = maps:is_key(GuildId, Inflight0), TooManyInflight = map_size(Inflight0) >= ?GUILD_CONNECT_MAX_INFLIGHT, Bot = maps:get(bot, State, false), case {AlreadyInflight, TooManyInflight} of {true, _} -> {noreply, State}; {false, true} -> erlang:send_after(50, self(), {guild_connect, GuildId, Attempt}), {noreply, State}; {false, false} -> Inflight = maps:put(GuildId, Attempt, Inflight0), State1 = maps:put(guild_connect_inflight, Inflight, State), SessionPid = self(), InitialGuildId = maps:get(initial_guild_id, State, undefined), UserData = maps:get(user_data, State, #{}), spawn(fun() -> do_guild_connect( SessionPid, GuildId, Attempt, SessionId, UserId, Bot, InitialGuildId, UserData ) end), {noreply, State1} end. -spec do_guild_connect( pid(), guild_id(), attempt(), binary(), integer(), boolean(), guild_id() | undefined, map() ) -> ok. do_guild_connect(SessionPid, GuildId, Attempt, SessionId, UserId, Bot, InitialGuildId, UserData) -> Result = try case gen_server:call(guild_manager, {start_or_lookup, GuildId}, 5000) of {ok, GuildPid} -> case maybe_build_unavailable_response_from_cache(GuildId, UserData) of {ok, UnavailableResponse} -> {ok_cached_unavailable, UnavailableResponse}; not_unavailable -> ActiveGuilds = build_initial_active_guilds(InitialGuildId, GuildId), IsStaff = maps:get(<<"is_staff">>, UserData, false), Request = #{ session_id => SessionId, user_id => UserId, session_pid => SessionPid, bot => Bot, is_staff => IsStaff, initial_guild_id => InitialGuildId, active_guilds => ActiveGuilds }, gen_server:cast(GuildPid, {session_connect_async, #{ guild_id => GuildId, attempt => Attempt, request => Request }}), _ = erlang:send_after( ?GUILD_CONNECT_ASYNC_TIMEOUT_MS, SessionPid, {guild_connect_timeout, GuildId, Attempt} ), pending end; Error -> {error, {guild_manager_failed, Error}} end catch exit:{noproc, _} -> {error, {guild_died, noproc}}; exit:{normal, _} -> {error, {guild_died, normal}}; _:Reason -> {error, {exception, Reason}} end, case Result of pending -> ok; _ -> SessionPid ! {guild_connect_result, GuildId, Attempt, Result} end, ok. -spec maybe_build_unavailable_response_from_cache(guild_id(), map()) -> {ok, map()} | not_unavailable. maybe_build_unavailable_response_from_cache(GuildId, UserData) -> case guild_availability:is_guild_unavailable_for_user_from_cache(GuildId, UserData) of true -> {ok, #{ <<"id">> => integer_to_binary(GuildId), <<"unavailable">> => true }}; false -> not_unavailable end. -spec handle_guild_connect_result_internal( guild_id(), attempt(), guild_connect_result(), session_state() ) -> {noreply, session_state()}. handle_guild_connect_result_internal( GuildId, _Attempt, {ok_unavailable, GuildPid, UnavailableResponse}, State ) -> finalize_guild_connection(GuildId, GuildPid, State, fun(St) -> session_ready:process_guild_state(UnavailableResponse, St) end); handle_guild_connect_result_internal( GuildId, Attempt, {ok_cached_unavailable, _UnavailableResponse}, State ) -> mark_cached_guild_unavailable_and_retry(GuildId, Attempt, State); handle_guild_connect_result_internal(GuildId, _Attempt, {ok, GuildPid, GuildState}, State) -> finalize_guild_connection(GuildId, GuildPid, State, fun(St) -> session_ready:process_guild_state(GuildState, St) end); handle_guild_connect_result_internal(GuildId, Attempt, {error, {session_connect_failed, _}}, State) -> retry_or_fail(GuildId, Attempt, State, fun(_GId, St) -> {noreply, St} end); handle_guild_connect_result_internal(GuildId, Attempt, {error, _Reason}, State) -> retry_or_fail(GuildId, Attempt, State, fun(GId, St) -> session_ready:mark_guild_unavailable(GId, St) end). -spec finalize_guild_connection(guild_id(), pid(), session_state(), fun( (session_state()) -> {noreply, session_state()} )) -> {noreply, session_state()}. finalize_guild_connection(GuildId, GuildPid, State, ReadyFun) -> Guilds0 = maps:get(guilds, State), case maps:get(GuildId, Guilds0, undefined) of {Pid, _Ref} when is_pid(Pid) -> {noreply, State}; _ -> MonitorRef = monitor(process, GuildPid), Guilds = maps:put(GuildId, {GuildPid, MonitorRef}, Guilds0), State1 = maps:put(guilds, Guilds, State), ReadyFun(State1) end. -spec retry_or_fail(guild_id(), attempt(), session_state(), fun( (guild_id(), session_state()) -> {noreply, session_state()} )) -> {noreply, session_state()}. retry_or_fail(GuildId, Attempt, State, _FailureFun) when Attempt < ?MAX_RETRY_ATTEMPTS -> BackoffMs = backoff_utils:calculate(Attempt), erlang:send_after(BackoffMs, self(), {guild_connect, GuildId, Attempt + 1}), {noreply, State}; retry_or_fail(GuildId, _Attempt, State, FailureFun) -> FailureFun(GuildId, State). -spec schedule_cached_unavailable_retry(guild_id(), attempt(), session_state()) -> {noreply, session_state()}. schedule_cached_unavailable_retry(GuildId, Attempt, State) -> SessionId = maps:get(id, State, <<>>), DelayMs = cached_unavailable_retry_delay_ms(GuildId, SessionId, Attempt), NextAttempt = Attempt + 1, erlang:send_after(DelayMs, self(), {guild_connect, GuildId, NextAttempt}), {noreply, State}. -spec cached_unavailable_retry_delay_ms(guild_id(), binary(), attempt()) -> non_neg_integer(). cached_unavailable_retry_delay_ms(GuildId, SessionId, Attempt) -> CappedAttempt = min(Attempt, ?MAX_GUILD_UNAVAILABLE_BACKOFF_ATTEMPT), BaseDelay = backoff_utils:calculate(CappedAttempt, ?MAX_GUILD_UNAVAILABLE_RETRY_DELAY_MS), case BaseDelay >= ?MAX_GUILD_UNAVAILABLE_RETRY_DELAY_MS of true -> ?MAX_GUILD_UNAVAILABLE_RETRY_DELAY_MS; false -> MaxJitter = max(1, BaseDelay div ?GUILD_UNAVAILABLE_JITTER_DIVISOR), Jitter = erlang:phash2({GuildId, SessionId, Attempt}, MaxJitter + 1), min(?MAX_GUILD_UNAVAILABLE_RETRY_DELAY_MS, BaseDelay + Jitter) end. -spec attempt_call_reconnect(channel_id(), attempt(), binary(), session_state()) -> {noreply, session_state()}. attempt_call_reconnect(ChannelId, Attempt, _SessionId, State) -> case gen_server:call(call_manager, {lookup, ChannelId}, 5000) of {ok, CallPid} -> connect_to_call_process(CallPid, ChannelId, State); not_found -> retry_call_or_remove(ChannelId, Attempt, State); _Error -> retry_call_or_remove(ChannelId, Attempt, State) end. -spec connect_to_call_process(pid(), channel_id(), session_state()) -> {noreply, session_state()}. connect_to_call_process(CallPid, ChannelId, State) -> Calls = maps:get(calls, State, #{}), MonitorRef = monitor(process, CallPid), NewCalls = maps:put(ChannelId, {CallPid, MonitorRef}, Calls), StateWithCall = maps:put(calls, NewCalls, State), case gen_server:call(CallPid, {get_state}, 5000) of {ok, CallData} -> session_dispatch:handle_dispatch(call_create, CallData, StateWithCall); _Error -> demonitor(MonitorRef, [flush]), {noreply, State} end. -spec retry_call_or_remove(channel_id(), attempt(), session_state()) -> {noreply, session_state()}. retry_call_or_remove(ChannelId, Attempt, State) when Attempt < ?MAX_CALL_RETRY_ATTEMPTS -> erlang:send_after( backoff_utils:calculate(Attempt), self(), {call_reconnect, ChannelId, Attempt + 1} ), {noreply, State}; retry_call_or_remove(ChannelId, _Attempt, State) -> Calls = maps:get(calls, State, #{}), NewCalls = maps:remove(ChannelId, Calls), {noreply, maps:put(calls, NewCalls, State)}. -spec build_initial_active_guilds(guild_id() | undefined, guild_id()) -> sets:set(guild_id()). build_initial_active_guilds(undefined, _GuildId) -> sets:new(); build_initial_active_guilds(GuildId, GuildId) -> sets:from_list([GuildId]); build_initial_active_guilds(_, _) -> sets:new(). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). build_initial_active_guilds_test() -> ?assertEqual(sets:new(), build_initial_active_guilds(undefined, 123)), ?assertEqual(sets:from_list([123]), build_initial_active_guilds(123, 123)), ?assertEqual(sets:new(), build_initial_active_guilds(456, 123)), ok. mark_cached_guild_unavailable_test() -> GuildId = 2001, CacheState = #{ id => GuildId, data => #{ <<"guild">> => #{ <<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>] } } }, _ = guild_availability:update_unavailability_cache_for_state(CacheState), State0 = #{ id => <<"session-1">>, user_id => 55, user_data => #{<<"flags">> => <<"0">>}, guilds => #{GuildId => undefined}, collected_guild_states => [], ready => undefined }, {noreply, State1} = mark_cached_guild_unavailable(GuildId, State0), Guilds = maps:get(guilds, State1), ?assertEqual(cached_unavailable, maps:get(GuildId, Guilds)), Collected = maps:get(collected_guild_states, State1, []), ?assertEqual(1, length(Collected)), ?assertMatch( #{<<"id">> := _, <<"unavailable">> := true}, hd(Collected) ), {noreply, State2} = mark_cached_guild_unavailable(GuildId, State1), ?assertEqual(1, length(maps:get(collected_guild_states, State2, []))), CacheCleanupState = #{ id => GuildId, data => #{ <<"guild">> => #{ <<"features">> => [] } } }, _ = guild_availability:update_unavailability_cache_for_state(CacheCleanupState), ok. do_guild_connect_skips_session_connect_when_cached_unavailable_test() -> GuildId = 2002, Attempt = 3, SessionId = <<"session-2">>, UserId = 77, CacheState = #{ id => GuildId, data => #{ <<"guild">> => #{ <<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>] } } }, _ = guild_availability:update_unavailability_cache_for_state(CacheState), Parent = self(), TestRef = make_ref(), GuildPid = spawn(fun() -> guild_stub_loop(Parent, TestRef) end), ManagerPid = spawn(fun() -> manager_stub_loop(GuildId, GuildPid) end), ?assertEqual(undefined, whereis(guild_manager)), true = register(guild_manager, ManagerPid), try ok = do_guild_connect( Parent, GuildId, Attempt, SessionId, UserId, false, undefined, #{<<"flags">> => <<"0">>} ), case await_guild_connect_unavailable_result(GuildId, Attempt) of {ok, Response} -> ?assertEqual(integer_to_binary(GuildId), maps:get(<<"id">>, Response)), ?assertEqual(true, maps:get(<<"unavailable">>, Response)); timeout -> ?assert(false, guild_connect_result_not_received) end, case saw_guild_stub_call(TestRef, 200) of true -> ?assert(false, should_not_call_session_connect_when_cache_unavailable); false -> ok end after case whereis(guild_manager) of ManagerPid -> unregister(guild_manager); _ -> ok end, ManagerPid ! stop, GuildPid ! stop, CacheCleanupState = #{ id => GuildId, data => #{ <<"guild">> => #{ <<"features">> => [] } } }, _ = guild_availability:update_unavailability_cache_for_state(CacheCleanupState), ok end. do_guild_connect_uses_session_connect_async_cast_test() -> GuildId = 2004, Attempt = 0, SessionId = <<"session-async-1">>, UserId = 77, Parent = self(), TestRef = make_ref(), GuildPid = spawn(fun() -> guild_stub_loop(Parent, TestRef) end), ManagerPid = spawn(fun() -> manager_stub_loop(GuildId, GuildPid) end), ?assertEqual(undefined, whereis(guild_manager)), true = register(guild_manager, ManagerPid), SessionPid = spawn(fun() -> session_capture_loop() end), try ok = do_guild_connect( SessionPid, GuildId, Attempt, SessionId, UserId, false, undefined, #{<<"flags">> => <<"0">>, <<"is_staff">> => false} ), ?assertMatch({session_connect_async, _}, await_guild_stub_cast(TestRef, 1000)) after SessionPid ! stop, case whereis(guild_manager) of ManagerPid -> unregister(guild_manager); _ -> ok end, ManagerPid ! stop, GuildPid ! stop end. session_capture_loop() -> receive stop -> ok; _ -> session_capture_loop() end. await_guild_stub_cast(TestRef, TimeoutMs) -> receive {guild_stub_cast, TestRef, Msg} -> Msg; _Other -> await_guild_stub_cast(TestRef, TimeoutMs) after TimeoutMs -> timeout end. -spec await_guild_connect_unavailable_result(guild_id(), attempt()) -> {ok, map()} | timeout. await_guild_connect_unavailable_result(GuildId, Attempt) -> receive {guild_connect_result, GuildId, Attempt, {ok_cached_unavailable, Response}} -> {ok, Response}; _Other -> await_guild_connect_unavailable_result(GuildId, Attempt) after 1000 -> timeout end. cached_unavailable_retry_delay_ms_cap_test() -> Delay = cached_unavailable_retry_delay_ms(123, <<"session-cap">>, 500), ?assertEqual(30000, Delay). cached_unavailable_retry_delay_ms_uses_jitter_test() -> Delay = cached_unavailable_retry_delay_ms(123, <<"session-jitter">>, 0), ?assert(Delay >= 1000), ?assert(Delay =< 1200). maybe_handle_cached_unavailability_retries_when_cache_available_again_test() -> GuildId = 2003, CacheState = #{ id => GuildId, data => #{ <<"guild">> => #{ <<"features">> => [] } } }, _ = guild_availability:update_unavailability_cache_for_state(CacheState), Inflight = maps:from_list([{N, N} || N <- lists:seq(3000, 3007)]), State0 = #{ id => <<"session-3">>, user_id => 88, user_data => #{<<"flags">> => <<"0">>}, guilds => #{GuildId => cached_unavailable}, guild_connect_inflight => Inflight }, {noreply, State1} = maybe_handle_cached_unavailability( GuildId, 42, <<"session-3">>, 88, State0 ), Guilds = maps:get(guilds, State1), ?assertEqual(undefined, maps:get(GuildId, Guilds)), receive {guild_connect, GuildId, 0} -> ok after 300 -> ?assert(false, guild_connect_retry_not_scheduled_with_reset_attempt) end. guild_connect_timeout_exhaustion_marks_unavailable_test() -> GuildId = 9001, Attempt = ?MAX_RETRY_ATTEMPTS, State0 = #{ id => <<"session-timeout-1">>, user_id => 100, guilds => #{GuildId => undefined}, guild_connect_inflight => #{GuildId => Attempt}, collected_guild_states => [], ready => undefined }, {noreply, State1} = handle_guild_connect_timeout(GuildId, Attempt, State0), Collected = maps:get(collected_guild_states, State1, []), ?assertEqual(1, length(Collected)), [UnavailableEntry] = Collected, ?assertEqual(integer_to_binary(GuildId), maps:get(<<"id">>, UnavailableEntry)), ?assertEqual(true, maps:get(<<"unavailable">>, UnavailableEntry)), Inflight = maps:get(guild_connect_inflight, State1, #{}), ?assertEqual(false, maps:is_key(GuildId, Inflight)). -spec saw_guild_stub_call(reference(), non_neg_integer()) -> boolean. saw_guild_stub_call(TestRef, TimeoutMs) -> receive {guild_stub_called, TestRef, _Request} -> true; _Other -> saw_guild_stub_call(TestRef, TimeoutMs) after TimeoutMs -> false end. -spec manager_stub_loop(guild_id(), pid()) -> ok. manager_stub_loop(GuildId, GuildPid) -> receive stop -> ok; {'$gen_call', From, {start_or_lookup, GuildId}} -> gen_server:reply(From, {ok, GuildPid}), manager_stub_loop(GuildId, GuildPid); {'$gen_call', From, _Request} -> gen_server:reply(From, {error, unsupported}), manager_stub_loop(GuildId, GuildPid); _Other -> manager_stub_loop(GuildId, GuildPid) end. -spec guild_stub_loop(pid(), reference()) -> ok. guild_stub_loop(Parent, TestRef) -> receive stop -> ok; {'$gen_cast', Msg} -> Parent ! {guild_stub_cast, TestRef, Msg}, guild_stub_loop(Parent, TestRef); {'$gen_call', From, Request} -> Parent ! {guild_stub_called, TestRef, Request}, gen_server:reply(From, {ok, #{}}), guild_stub_loop(Parent, TestRef); _Other -> guild_stub_loop(Parent, TestRef) end. -endif.