Before we get started: yes, I’m well aware that the docs say not to do this. Yes, I’m aware that this is counter to the general shape of the GenServer API. I’m not interested in any of that. What I’m interested in is whether there are any footguns to this idea, in full awareness that it’s probably a lot dumber than it is clever. I’m trying to solve a very specific problem here with a very specific solution and I’m looking for input.
In a nutshell, what I want to do is to be able to call receive/1
within a GenServer.
My reasons for this are primarily to try and replicate within a GenServer the general shape of :gen_tcp
’s active: :once
pattern, whereby an incoming TCP packet can either be explciticly waited on via :gen_tcp.recv/2
, or optionally sent to the process as an Erlang message.
I’ve read through the gen_server.erl and gen.erl source code, and the only ‘special’ thing I can see with messages handling within GenServers are that calls/cast messages have a specific format to them ({:"$gen_call", {pid, ref}, msg}
for calls, and {:"$gen_cast", msg}
for casts). My hunch here is that if I’m careful about filtering these messages in my receive/1
call (and if I set aside any expectation of temporal ordering between inline received messages and call/cast messages), I should be OK.
Supposing the following proof of concept:
defmodule Test do
use GenServer
def start_link(arg), do: GenServer.start_link(__MODULE__, arg, name: __MODULE__)
def init(arg), do: {:ok, arg}
def handle_call(msg, _from, state) do
IO.puts("handle_call #{msg}")
{:reply, :ok, state}
end
def handle_cast(msg, state) do
IO.puts("handle_cast #{msg}")
{:noreply, state}
end
def handle_info(:wait, state) do
received = safe_receive(10_000)
IO.puts("Waited for #{received}")
{:noreply, state}
end
def handle_info(msg, state) do
IO.puts("handle_info #{msg}")
{:noreply, state}
end
defguardp is_gen_call_msg(msg)
when is_tuple(msg) and tuple_size(msg) == 3 and elem(msg, 0) == :"$gen_call"
defguardp is_gen_cast_msg(msg)
when is_tuple(msg) and tuple_size(msg) == 2 and elem(msg, 0) == :"$gen_cast"
defguardp is_gen_server_msg(msg) when is_gen_call_msg(msg) or is_gen_cast_msg(msg)
def safe_receive(timeout \\ :infinity) do
receive do
msg when not is_gen_server_msg(msg) -> msg
after
timeout -> nil
end
end
end
I get the following result:
iex(1)> Test.start_link :ok
{:ok, #PID<0.163.0>}
iex(2)> GenServer.cast(Test, "abc")
handle_cast abc
:ok
iex(3)> send(Test, "abc")
handle_info abc
"abc"
iex(4)> send(Test, :wait)
:wait
iex(5)> send(Test, "abc") # Within 10 seconds
Waited for abc
"abc"
iex(6)> send(Test, "abc")
handle_info abc
"abc"
iex(7)> send(Test, :wait)
:wait
iex(8)> GenServer.cast(Test, "abc") # Within 10 seconds
:ok
Waited for
handle_cast abc
Beyond assuming things about the shape of internal GenServer messages, I can’t see where I’m seriously tempting fate here. Can anyone see any obvious footguns with this? I’m hoping to wrap this general approach up into a library that provides :gen_tcp
-like sync/async recv semantics, but backed by Erlang messages instead of a socket (this is all to facilitate GenServer based processes for HTTP/2 streams in Bandit, to better allow for HTTP/2 WebSocket upgrade handling).