Unsupervised gen_server doesn't call terminate when it receives exit signal

Short answer:

If you call the exit/2 function from inside the gen_server actor, it behaves as expected based on the documentation and the terminate/2 callback will be called.

Long answer:

When you send the exit message from the shell, the Parent value of exit tuple is set to the shell process id, on the other hand when you start the gen_server process form shell its Parent value is set to its own process id, not the shell process id, therefore when it gets the exit message it doesn't match the second clause of the receive block in decode_msg/8 function so the terminate/6 function is not called and finally the next clause is matched which is calling handle_msg/5 function.

Recommendation:

For getting the terminate/3 callback called even by sending an exit message to the gen_server process, you can trap the exit message in handle_info/2 and then return with stop tuple as follows:

init([]) ->
    process_flag(trap_exit, true),
    {ok, #state{}}.

handle_info({'EXIT', _From, Reason}, State) ->
    io:format("Got exit message: ~p~n", []),
    {stop, Reason, State}.

terminate(Reason, State) ->
    io:format("Terminating with reason: ~p~n", [Reason]),
    %% do cleanup ...
    ok.