Need help understanding strange GenServer calling behavior

I have two dynamically supervised GenServers that interact with each other.

I have a handle_info callback:

  def handle_info({:event, payload}, state) do
    model = PS.get_process_model(state.model_name)

    if model.events do
      [event] = model.events

      if event.message_selector.(payload) do
        exit_task(event.exit_task, state)  # <========
      else
        state
      end
    else
      state
    end

    {:noreply, state}
  end

The code for exit_task/2 is:

  defp exit_task(task_name, state) do
    task = Enum.find(Map.values(state.open_tasks), fn t -> t.name == task_name end)

    if task.type == :sub_process do
      complete_on_task_exit_event(task.sub_process_pid)   # <=======
    end

    Map.put(state, :completed_tasks, [task | state.completed_tasks])
    |> Map.put(:open_tasks, Map.delete(state.open_tasks, task.uid))
    |> execute_process()
  end

Initially, I had complete_on_task_exit_event/1 as a GenServer call function.

Strangely, with the GenServer.call implementation, the code in the exit_task/2 after the call to complete_on_task_exit/1 did not get executed, i.e. this code:

 Map.put(state, :completed_tasks, [task | state.completed_tasks])
    |> Map.put(:open_tasks, Map.delete(state.open_tasks, task.uid))
    |> execute_process()

I don’t mean that execution hung. What it seems like is that the function completed execution and just skipped the code above.

When I changed complete_on_task_exit_event/1 to a GenServer cast function, the previously unexecuted code did get executed.

Note that this isn’t a case recursively calling the same GenServer. The call to complete_on_task_exit_event/1 was on a separate GenServer instance.

I hope I’ve explained this sufficiently.

Can anyone offer an explanation?

Thanks

The issue is in that code - calling Process.exit(self(), :shutdown) while not trapping exits shuts the process down immediately. It never gets a chance to reply.

The process that is waiting for the result of call will also exit with :shutdown, causing the “skipped code” you observed.

Here’s a demo of the effect:

defmodule BarManager do
  use GenServer

  def poke_worker do
    GenServer.cast(__MODULE__, :poke_worker)
  end

  def start_link do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def init(_) do
    {:ok, nil}
  end

  def handle_cast(:poke_worker, _) do
    result = BarWorker.do_stuff()

    IO.inspect(result, label: "poke_worker")

    {:noreply, nil}
  end
end


defmodule BarWorker do
  use GenServer

  def do_stuff do
    GenServer.call(__MODULE__, :do_stuff)
  end

  def start_link do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def init(_) do
    {:ok, nil}
  end

  def handle_call(:do_stuff, _from, _state) do
    Process.exit(self(), :shutdown)

    {:reply, :wat, nil}
  end
end

{:ok, _} = BarManager.start_link()
{:ok, _} = BarWorker.start_link()

BarManager.poke_worker()

Process.sleep(500)

Process.whereis(BarWorker) |> IO.inspect()
Process.whereis(BarManager) |> IO.inspect()

running it prints:

** (EXIT from #PID<0.95.0>) shutdown

and nothing else.

If you want a process to exit after sending a final reply, consider returning a {:stop, reason, reply, new_state} tuple from the handle_call rather than calling Process.exit.

3 Likes

Thanks for the thoughtful analysis. I’m glad to have that mystery behind me. Before I got your reply, I switched over to the GenServer.cast implementation. I think I’ll experiment a bit with what you’ve suggested.

This project is the first time I’ve programmed GenServers. Thanks for helping me fill in some of the gaps.

Another error in your code is in the first handle_event. You’re never assigning the result of the if expression to state so the state you are returning is the one you received. If that happens to be intended, i.e. you only want the if for side effects, then you don’t need those else clauses.

2 Likes

Are you referring to this function?

def handle_info({:event, payload}, state) do
    model = PS.get_process_model(state.model_name)

    if model.events do
      [event] = model.events

      if event.message_selector.(payload) do
        exit_task(event.exit_task, state)
      else
        state
      end
    else
      state
    end

    {:noreply, state}
  end

If so, since the if function call is the last one in the function, the result of it should be returned. All my unit tests are broke at the moment. As soon as I get them all running again, I’ll double check. Maybe I’m missing something.

Further, that function is just roughed in at the moment. I’m working on implementing on what are called boundary events in BPMN2. I should have that fully functional by the end of the day.

it is returning something, but You don’t use it…

state = if...

I know you’re new to Elixir, but {:noreply, state} is the last expression in your function, therefore that is what is returned.

Hmmm. Well, I’m not that new to Elixir. I was just being dim, I’m afraid. It definitely looks like a bug, but so far it’s not breaking any of my tests. I just fixed it, and the tests run exactly the same. Maybe this is what @kokolegorille was referring to. I’ll look into this. Thanks, all!

Sidenote: if/2 is [arguably] an alien construct to functional languages and there is always a better way to handle it.

the above might be instead written as

    with [event] <- model.events,
         true <- event.message_selector.(payload),
      do: exit_task(event.exit_task, state),
      else: (_ -> state)
2 Likes

I should probably make an effort to use “with” more as it is often more concise. However, for me at least, and for many others I suspect, the “if” construct is more intuitive due to its prevalence in natural language. I’m not sure that there is a natural language analog to the “with” statement. Is there?

My first exposure to functional programming languages was Lisp many years back. We all used the “if” special form quite liberally. I don’t know if there was anything corresponding to Elixir’s with.

Thanks for the gentle nudge.

Sure, and in assembly we used JMP instruction and we were all good too :slight_smile:

One if (as in a natural language,) looks fine to me. But I rarely if never have heard the construct like “if I were a cat then if I had four paws then I would eat fish else I would eat chicken else I would eat bread.”

The matter of with is a chain of several conditions, like in “with a weather like yesterday, with/and an intent to go to the beach, with/and five euros we might rent three lounge chairs and chill there for a day.”

I believe once we have a success path, it’s easier to follow it, compared to a bunch of nested conditionals, but it might be just me.

1 Like

One if (as in a natural language,) looks fine to me. But I rarely if never have heard the construct like “if I were a cat then if I had four paws then I would eat fish else I would eat chicken else I would eat bread.”

The matter of with is a chain of several conditions, like in “with a weather like yesterday, with/and an intent to go to the beach, with/and five euros we might rent three lounge chairs and chill there for a day.”

Nicely put!

1 Like

@mudasobwa

Overcoming my inertia, I’m playing around with “with”. I definitely see its value now and I’ve made the corresponding change in my code. I’m sure I’ll be using it from now on.

I do see some syntax oddities that are confusing me though. The Elixir compiler likes this fine:

state =
      with [event] <- model.events,
           true <- event.message_selector.(payload) do
        exit_task(event.exit_task, state)
      else
        _ -> state
      end

but it doesn’t like this:

 state =
      with
          [event] <- model.events,
          true <- event.message_selector.(payload) do
        exit_task(event.exit_task, state)
      else
        _ -> state
      end

it gives the compile error:

== Compilation error in file lib/mozart/process_engine.ex ==
** (SyntaxError) invalid syntax found on lib/mozart/process_engine.ex:264:53:
     error: syntax error before: do
     │
 264 │            true <- event.message_selector.(payload) do
     │                                                     ^
     │
     └─ lib/mozart/process_engine.ex:264:53
    (elixir 1.16.2) lib/kernel/parallel_compiler.ex:428: anonymous fn/5 in Kernel.ParallelCompiler.spawn_workers/8

A simple added line-feed and you get a very cryptic error message.

Yeah, with/1 is a special form, which is inlined by a compiler, and therefore expects a stricter syntax compared to “generic” code.

I am not sure if there are plans to deal with it, I bet no, but you can always use parentheses with multiline special forms.

      with (
          [event] <- model.events,
          true <- event.message_selector.(payload)
      ) do
        exit_task(event.exit_task, state)
      else
        _ -> state
      end

Another small tip when You want to pipe, but not as the first argument… is to use Kernel.then

with [event] <- model.events,
  true <- event.message_selector.(payload) do
  exit_task(event.exit_task, state)
else
  _ -> state
end
|> Kernel.then(& {:noreply, &1})

but it does not make it more readable