Replying to a process that has died costs nothing and tells you nothing.
GenServer.reply/2 puts a message in a mailbox that will never be read,
hands you back :ok, and lets you carry on. That is correct Erlang, and it
is almost always harmless.
It cost me a browser page every single time it happened.
The pool it happened in is unremarkable: one GenServer per provider that lends out pages, caps how many can be open at once, and closes the browser when nobody has wanted one for a while. It had two bugs in it that took me longer to spot than I would like, because they share a shape. In both of them, a message arrives about a world that has already moved on.
The pool
The first version is about as plain as a pool gets. A free list, a counter, a ceiling, and a queue for callers who arrive when everything is lent out:
def handle_call(:checkout, from, state) do
state = state |> ensure_started() |> cancel_idle_timer()
cond do
state.free != [] ->
[page | rest] = state.free
{:reply, {:ok, page}, %{state | free: rest, in_use: state.in_use + 1}}
state.in_use < state.max_pages ->
case Page.open(state.provider) do
{:ok, page} -> {:reply, {:ok, page}, %{state | in_use: state.in_use + 1}}
{:error, error} -> {:reply, {:error, error}, state}
end
true ->
{:noreply, %{state | waiting: :queue.in(from, state.waiting)}}
end
end
That last branch is the interesting one. Returning {:noreply, ...} while
keeping from in a queue is how you park a caller: it stays blocked in its
GenServer.call until somebody replies on its behalf.
Somebody being the next checkin.
The caller that went away
def handle_cast({:checkin, page}, state) do
case :queue.out(state.waiting) do
{{:value, waiter}, rest} ->
GenServer.reply(waiter, {:ok, page})
{:noreply, %{state | waiting: rest}}
{:empty, _rest} ->
state = %{state | free: [page | state.free], in_use: state.in_use - 1}
{:noreply, schedule_idle_timer(state)}
end
end
Read the first branch as if the waiter were dead, and the whole bug is in
those three lines. The page is handed straight to the waiter, so it is not put
back on the free list and in_use is not decremented. Both of those are
correct when somebody is actually there to receive it.
Nothing checks that anybody is.
A caller can be gone for perfectly boring reasons. It hit its own timeout, or
the request that wanted a page was cancelled, or the process crashed while
waiting. None of that reaches the pool, and the reply it sends into the void
returns :ok like every other reply it has ever sent.
So the page is in nobody’s hands, absent from the free list, and still counted
as in use. It is gone. Do that twice with max_pages: 2 and the pool is empty
for good, every caller blocking for the full 60 second checkout timeout.
I ran the current tests against that old code to be sure I was not telling myself a story:
1) test a waiter that dies while queued does not shrink the pool
Assertion with == failed
code: assert Session.free_count("test") == 1
left: 0
right: 1
Zero free pages where there should be one. The page went to a dead process.
Ask to be told
The fix is the boring one, which is usually the right one. Monitor the caller when you park it, and you get a message when it dies:
true ->
{pid, _tag} = from
ref = Process.monitor(pid)
{:noreply, %{state | waiting: :queue.in({ref, from}, state.waiting)}}
Then take the dead one out of the queue when that message lands:
def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do
{:noreply, %{state | waiting: drop_waiter(state.waiting, ref)}}
end
That alone would mostly work, and mostly is doing a lot of work in that
sentence. A DOWN is a message like any other, so it sits in the mailbox
behind whatever arrived first. A checkin that got there ahead of it would
still pop a corpse off the queue. So popping checks too, and keeps going until
it finds someone alive:
defp pop_live_waiter(queue) do
case :queue.out(queue) do
{:empty, rest} ->
{:empty, rest}
{{:value, {ref, {pid, _tag}} = entry}, rest} ->
if Process.alive?(pid) do
{:ok, entry, rest}
else
Process.demonitor(ref, [:flush])
pop_live_waiter(rest)
end
end
end
Two mechanisms for one problem looks redundant until you notice they cover
different windows. The monitor cleans up the queue in the background. The
check at the pop covers the gap between a process dying and its DOWN
reaching the front of the mailbox.
The browser that went away
The second bug lived in a line I would have sworn was fine:
def handle_call(:shutdown, _from, state), do: {:reply, :ok, close_browser(state)}
close_browser/1 closes the browser and resets the counters. Under no load at
all, that is correct. Under load it wrecks two things at once.
Queued waiters are simply left in the queue. Nobody is going to reply to them now, so they hang until their own 60 second timeout expires, which the test shows in the least ambiguous way available:
3) test shutdown rejects queued waiters instead of abandoning them
** (exit) exited in: Task.await(..., 1000)
** (EXIT) time out
The other half is worse, because it is silent. A caller holding a page when
the shutdown lands will eventually give it back. That checkin arrives at a
pool whose counters have been zeroed, so in_use goes to -1, and the page
handle goes onto the free list. It points at a page in a browser that no
longer exists. The next caller gets handed that, and finds out the hard way:
2) test a late checkin after a shutdown does not resurrect a dead page or corrupt state
Assertion with == failed
code: assert Session.free_count("test") == 0
left: 1
right: 0
Stamp what you lend
I cannot stop a late checkin from arriving. The caller is a separate process, it holds the page, and it will hand it back whenever it is done, including long after I decided the browser was going away. What I can do is make the lateness visible.
Every checkout gets stamped with the generation of the browser it came from:
defp lend(state, page) do
%{
state
| in_use: state.in_use + 1,
checked_out: Map.put(state.checked_out, page.page_id, state.generation)
}
end
Closing the browser bumps the generation, and the check on the way in is one line:
defp live_checkout?(state, page) do
Map.get(state.checked_out, page.page_id) == state.generation
end
A checkin whose stamp does not match is dropped. Not an error, not a log to
scroll past at three in the morning, just ignored, because that is exactly
what it deserves. Shutdown also replies to every queued waiter with
{:error, :shutdown} rather than leaving them to time out.
Generation counters are an old trick, and they turn up under other names, epochs and fencing tokens among them. The reason they keep coming back is that they turn an unanswerable question, is this handle still valid, into an integer comparison.
What the tests hold down
Three tests, and the names carry the whole specification:
test "a waiter that dies while queued does not shrink the pool" do
test "shutdown rejects queued waiters instead of abandoning them" do
test "a late checkin after a shutdown does not resurrect a dead page or corrupt state" do
All three fail against the old code, which is the only reason I trust them. The last one ends on the assertion I would keep if I could keep only one:
{:ok, fresh_page} = Session.checkout("test")
refute fresh_page.page_id == stale_page.page_id
Where I landed
Neither of these was a concurrency bug in the frightening sense. There was no race inside the GenServer, no shared mutable state, nothing the BEAM failed to protect me from. The mailbox did its job perfectly and delivered every message in order.
What the pool lacked was any way to tell whether what a message referred to still existed. A monitor covers one half of that by having the runtime tell me. A stamp covers the other by making the answer checkable on arrival. I now go looking for that gap whenever a GenServer hands something out and expects to get it back.
Comments, questions, or just a reaction?
Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.