A gen_* process using send_request/4 receives an info result message rather than receive_response

I’m trying out the new(ish) gen_{server,statem}:send_request, which looks very nice. It looked like I could replace a bunch of boiler plate that I usually write to manage concurrent request/replies within some gen_* services.

My try out code (in a gen_server), is calling another process (a gen_statem in this case) that is doing some difficult work (2 + 2) which might take a while. An ideal candidate for an async send_request, {timeout, idle} followed by a receive_response.

handle_call(do_work, From, #{requests := Requests} = Data) ->
    {noreply,
     Data#{requests :=  gen_statem:send_request(
                          worker,
                          {add, 2, 2},
                          From,
                          Requests)}};

The worker is a gen_statem doing the heavy lifting which looks like:

handle_event({call, From}, {add, X, Y}, _, _) ->
    {keep_state_and_data, {reply, From, X + Y}}.

At a convenient future point I would call receive_response and get the asynchronous result. Instead I get a crash:

=WARNING REPORT==== 16-Jul-2022::15:47:56.846636 === <0.590.0> gen_server:try_dispatch/4:1127
** Undefined handle_info in client_gen_server
** Unhandled message: {[alias|#Ref<0.2932551881.3440967681.24357>],4}

The reply is coming back as a vanilla info message (via gen:reply/2).

reply({_To, [alias|Alias] = Tag}, Reply) when is_reference(Alias) ->
    Alias ! {Tag, Reply}, ok;

It doesn’t look like I can use send_request and receive_response within a gen_* process?

Is this expected behaviour, or am I doing something daft?

Erlang/OTP v25.0.2.

Thanks!
Peter.

1 Like

This is expected behaviour. You typically do not want to use receive_response() in a gen_server, but instead use check_response() from handle_info(). receive_response() is only useful if you want to receive the response or block until such a message arrive.

When a message comes in to a gen_server it will be dispatched to one of the handle_*() callbacks. For example, a call request message will be dispatched to handle_call(). When the incoming message is not associated with any specific handle_*() callback, it will be dispatched to the handle_info() callback.

The response message corresponding to a call request is not associated with any specific gen_server handle_*() callback. That is, the response message will be dispatched to handle_info(). Therefor, in order to take care of the response message in a gen_server, you want to check if a message passed to handle_info() is a response to your call request or not by passing the message to check_response(). Note that you cannot assume anything about the format of the response message, so this is typically the first thing you want to do in handle_info().

global_group (which is a gen_server) has recently (in the maint branch) been modified to use erpc:send_request()/erpc:check_response() which are similar to send_request()/check_response() in the gen_* behaviours. It can perhaps be useful to look at that code.

3 Likes

Awesome! Thank you very much @rickard :smile: Makes a lot of sense. So the fragment of my gen_server would look something like this:

handle_call(do_work, From, #{requests := Requests} = Data) ->
    {noreply,
     Data#{requests :=  gen_statem:send_request(
                          worker,
                          {add, 2, 2},
                          From,
                          Requests)}}.

handle_info(Msg, #{requests := ExistingRequests} = Data) ->
    case gen_server:check_response(Msg, ExistingRequests, true) of
        {{reply, Reply}, From, UpdatedRequests} ->
            gen_server:reply(From, Reply),
            {noreply, Data#{requests := UpdatedRequests}};

        {{error, {Reason, _}}, _From, UpdatedRequests} ->
                {stop, Reason, Data#{requests := UpdatedRequests}}
    end.

I’m stopping on {{error, {Reason, _}}, ... - which I’m not sure about, but is safer for now.

I’m crashing on no_request to catch cases where Msg isn’t a reply, so that I can add more selective handle_info clauses prior to handle DOWN, EXIT, etc.

So the message flow looks like:

3> client_gen_server:do_work().
*DBG* client_gen_server got call do_work from <0.84.0>
*DBG* client_gen_server new state #{requests =>
                                        #{#Ref<0.2247983184.3079733252.175921> =>
                                              {<0.84.0>,
                                               [alias|
                                                #Ref<0.2247983184.3079733252.175918>]}}}
*DBG* worker receive call {add,2,2} from <0.278.0> in state ready
*DBG* worker send 4 to <0.278.0>
*DBG* client_gen_server got {[alias|#Ref<0.2247983184.3079733252.175921>],4}
*DBG* worker consume call {add,2,2} from <0.278.0> in state ready
*DBG* client_gen_server new state #{requests => #{}}

Can I make any assumptions on the “shape” of Msg?

From gen:reply/2:

reply({_To, [alias|Alias] = Tag}, Reply) when is_reference(Alias) ->
    Alias ! {Tag, Reply}, ok;
reply({_To, [[alias|Alias] | _] = Tag}, Reply) when is_reference(Alias) ->
    Alias ! {Tag, Reply}, ok;
reply({To, Tag}, Reply) ->
    try To ! {Tag, Reply}, ok catch _:_ -> ok end.

First two clauses could allow a handle_info([alias|Alias], ....) when is_reference(Alias), but the last clause would prevent that. So I guess having handle_info(Msg, #{requests := ...}) is as selective as it can be? Trying to keep to the crash early, crash often…

I know the type is opaque but it suggests that it is a reference for the third clause.

-opaque reply_tag() :: % As accepted by reply/2
          reference()
        | nonempty_improper_list('alias', reference())
        | nonempty_improper_list(
            nonempty_improper_list('alias', reference()), term()).

The send_request → info reply looks really good, and chops out a bunch of boiler plate handling these kind of concurrent request/replies in a gen_*.

Thanks very much!
Regards,
Peter.

1 Like

You cannot have anymore handle_info() clauses. If you do not handle no_request and no_reply, you will just crash in such scenarios. If there can come other messages, you need to match on no_request and no_reply as well and handle those messages either inline in a nested case or by calling another function (like global_group does which calls continue_handle_info() in those cases).

No, this is a message protocol that is internal to the gen_* modules which can change (and in the past has changed) at any time without prior notice. If you assume anything about the format of this message, your code may suddenly stop working if we make such changes.

If we look into the internals (again; note that your code should not utilize any knowledge about the internals), this is also only the successful case. In the failure case, the response will be a 'DOWN' message.

2 Likes