There’s two basic ways of following the lifecycle of a process from outside of it.
- Linking to the process we want to follow
- Monitoring the process we want to follow
1 - Is useful when you want exits to be linked, so:
a) Parent Process Exits when Child Process Exits and vice-versa;
or
b) when you want to follow the child process exit without exiting (by trapping exits on the parent) but make the child process exit automatically if and when the parent dies (to not leave lingering child processes that only make sense in the context of the parent in case the parent itself exits for some reason - I don’t think the opposite in this case is useful or have ever run into a situation that would need it but you could certainly trap exits in the childs and not the parent to have the opposite)
2 - is useful when
a) you just want to monitor the child process and the child process is known to always exit (meaning you don’t need to worry about lingering child processes because they will inevitably run their course)
b) you want to follow the lifecycle but for some reason don’t want to enable trapping exits on the parent
Given your situation, the conceptual approach you took with linking seems the most reasonable, the problem seems to be that you’re linking the shell process to the gen_server (and probably not trapping exits in the shell itself) and then after linking issuing an exit to that gen_server (which will propagate to the shell due to the link), I wonder if you have a proper handle_info for the {:EXIT, pid, reason}
message that it will receive when trapping exits?
My approach would be as the MyGenserver module receives exit messages from the child processes to store them in its own state, with a proper handle for each exit case you want to distinguish. You need to keep track of all started processes as well, so that in the end you distinguish those that haven’t exited/or force them to exit from those that did during the running cycle.
So basically:
State Data Form: %{running: %{}, finished: %{}}
handles:
init, handle_info for the exit messages, a handle/logic to decide when it's finished?
Step1: init -> trap_exits,
Step2: start child processes -> store the pid in a map in the state of the parent MyGenserver, in the running
map, it can be for instance in the form of pid -> identifier
key-value
Step3: wait for exit messages -> They come as {:EXIT, pid, reason}
, As they exit use the pid to remove the pid from the running
map (with Map.pop/2
you get access to the value of the popped key), and store their exit reason with that popped identifier in the finished
map
Step4: When finished -> You now have a map of all early exits in finished
, and you can for instance
a) iterate the remaining keys in the running
map to add them as normal
(meaning the stayed alive until the end) to the finished
map.
b) iterate the remaining keys in the running
map and send an exit signal with :normal
as the reason to each pid still alive, which in turn will make them exit normally, further delivering the regular exit message (due to trapping), which will the follow the same logic, adding it to the finished
map.
You can now write this to a file, to a database, or whatever you want and then exit the MyGenserver (forcing the still alive child processes to exit due to the linking in case you didn’t exit them prior).
An example would be (with the caveat that here all processes exit so step4 isn’t shown):
defmodule Game.Server do
use GenServer
require Logger
defstruct [running: %{}, finished: %{}]
def start_link() do
# naming it the name of the module only allows on game server
# probably not what you want, but just for the example
GenServer.start_link(__MODULE__, %__MODULE__{}, [name: __MODULE__])
end
def init(state) do
Process.flag(:trap_exit, true)
{:ok, state}
end
def start_players(list_of_players) do
Enum.each(list_of_players, fn(identifier) -> start_player(identifier) end)
end
def start_player(identifier) do
GenServer.call(__MODULE__, {:start_player, identifier})
end
def handle_call({:start_player, identifier}, _from, %{running: running} = state) do
spawned_pid = Process.spawn(fn ->
Process.sleep(5000)
case Enum.random([true, false]) do
true -> Process.exit(self(), :normal)
false -> Process.exit(self(), :disqualified)
end
end, [:link])
n_running = Map.put(running, spawned_pid, identifier)
{:reply, :ok, %{state | running: n_running}}
end
def handle_info({:EXIT, pid, reason}, %{running: running, finished: finished} = state) when :erlang.is_map_key(pid, running) do
{identifier, n_running} = Map.pop(running, pid)
n_finished = Map.put(finished, identifier, {reason, DateTime.utc_now()})
{:noreply, %{state | running: n_running, finished: n_finished}, {:continue, :maybe_finished}}
end
def handle_info({:EXIT, pid, reason}, state) do
Logger.warn("#{inspect pid} exited with reason #{reason} but wasn't in the running map")
{:noreply, state}
end
def handle_continue(:maybe_finished, %{running: running, finished: finished} = state) when running == %{} do
Logger.info("Finished all player processes:\n #{inspect finished}")
{:stop, :normal, state}
end
def handle_continue(:maybe_finished, state), do: {:noreply, state}
end
iex(5)> {:ok, pid} = Game.Server.start_link()
{:ok, #PID<0.1316.0>}
iex(6)> Game.Server.start_players([:a, :b, :c, :d])
:ok
iex(7)> [info] Finished all player processes:
%{a: {:disqualified, ~U[2020-04-08 08:40:06.033692Z]}, b: {:normal, ~U[2020-04-08 08:40:06.033699Z]}, c: {:disqualified, ~U[2020-04-08 08:40:06.033663Z]}, d: {:disqualified, ~U[2020-04-08 08:40:06.033703Z]}}