otp icon indicating copy to clipboard operation
otp copied to clipboard

Add documentation on returning via throw #5450

Open bucko909 opened this issue 2 years ago • 16 comments

This is a proposed documentation amendment to cover the undocumented behaviour whereby gen_server:try_dispatch will catch throw and treat the reason as the function's return value (used in global_group for example).

bucko909 avatar Jun 06 '22 09:06 bucko909

CT Test Results

       2 files       86 suites   30m 57s :stopwatch: 1 783 tests 1 735 :heavy_check_mark: 48 :zzz: 0 :x: 2 065 runs  2 015 :heavy_check_mark: 50 :zzz: 0 :x:

Results for commit 6a79459e.

:recycle: This comment has been updated with latest results.

To speed up review, make sure that you have read Contributing to Erlang/OTP and that all checks pass.

See the TESTING and DEVELOPMENT HowTo guides for details about how to run test locally.

Artifacts

// Erlang/OTP Github Action Bot

github-actions[bot] avatar Jun 06 '22 09:06 github-actions[bot]

Hello!

There are no tests that this actually works for gen_server, so if we want to extend the documentation to guarantee that this should work then there need to be new tests written. Since gen_statem allows this type of return, I think that we should add it to gen_server as well.

global_group (nor any other module that I'm aware of in Erlang/OTP) does not use this undocumented feature. It wraps all calls in a try catch that catches the throw and returns the correct value.

garazdawi avatar Jun 07 '22 07:06 garazdawi

@garazdawi you make some good points (I didn't actually check the modules I mentioned; I only searched for throw({noreply,). I propose that at least one of the following things should happen:

  • Change this patch to "letting exceptions of class throw bubble up to gen_server will result in undefined, undocumented behaviour, which may change in future versions."
  • If this feature is emulated in all standard modules, perhaps the better solution is simply to deprecate it and remove it from the behaviour modules. The only reason I'm submitting this patch is that the end of a 2 hour investigation into a system bug (getting a bad return value from a callback handler) discovered an uncaught throw which hit this clause: I'd prefer it gone, personally. If people need it, it's easy enough to add back in.
  • Some tests should be written in gen_server (and anything else that does the same thing) to verify the behaviour.

I don't know which of these is the way forwards, and the first might be a sensible stopgap for the next release if it's controversial. What do you think?

bucko909 avatar Jun 07 '22 08:06 bucko909

IIRC that's been discussed on the Erlang Forums (with no specific conclusion). I'm not sure if this behaviour is in fact desired - so far I know no other generic behaviour (gen_statem?) that does it. If this is desired design, then probably it should be coming with a set of other guidelines for using different exception classes (similar to what we have internally, when throw is not supposed to cross module boundary, and exit means that the current process state is no longer safe to use as it can have garbage in the process dictionary or unexpected messages in the message queue, - like it was before with gen_server late replies, before aliases were the thing).

max-au avatar Jun 08 '22 05:06 max-au

@max-au: gen_server, (the obsoleted gen_fsm), gen_event and gen_statem all implement this "feature" of equating a thrown value with a regular return. I suspect, as @mikpe, that this stems from using catch Mod:Callback() before try...catch existed.

When I wrote gen_statem I followed the pattern of the other generic behaviours, and saw reasonable use cases such as throwing {keep_state_and_data,[postpone]} from wherever you like, so I documented it in gen_statem.

RaimoNiskanen avatar Jun 09 '22 09:06 RaimoNiskanen

The throw-based returns have the added downside of making building abstractions on top of gen_server rather complicated - you need to guard against custom callbacks potentially throwing and handling those cases every time - otherwise it's easy to either break the abstraction (by throw-returning inner state, rather than the abstracted one), or by getting very confusing error messages on erroneous uncaught throws.

In practice few libraries handle this correctly.

michalmuskala avatar Jun 09 '22 10:06 michalmuskala

If the behaviour were to be preserved, I think the correct way to do it would be via a tagged exit reason, much like how gen_server:cast messages are sent as {'$gen_cast', Message}. The behaviour could then implement something like gen_server:throw_early_return(Value) to generate the correct shape of exit. This has the additional advantage that if something does accidentally catch the exit, the source of the exit will be clearer, because it'll be something like throw:{'$gen_early_return', Value}. It also uses the primary mechanism of alerting a developer to the existence of a feature (the existence of a function or callback), making it more discoverable.

I prefer the approach of forcing implementations to write their own exception handling, though, and I think we actually use this in our code-base, not having known about the throw bug/feature until finding it recently.

bucko909 avatar Jun 09 '22 11:06 bucko909

I think some tests should be added, that we simply keep this ancient behaviour behaviour, and document it.

RaimoNiskanen avatar Jun 09 '22 13:06 RaimoNiskanen

OK, so I was looking at what tests would be needed. For gen_statem the bug/feature seems to be tested by having a few of the callbacks in gen_statem_SUITE return via throw, though there is no test which explicitly does so. Would this be an acceptable approach for gen_event, gen_fsm, and gen_server?

(Searching implementations to verify these modules indeed have this bug/feature was somewhat confusing as it looks like, as @mikpe suggests, it's basically down to use of catch Mod:handle_call(...) which doesn't distinguish between returning and throwing, though I wasn't helped by some try ... catch handlers which don't explicitly write throw: in their clauses. I guess this is the original cause of the feature.)

bucko909 avatar Jul 14 '22 09:07 bucko909

A few callbacks that uses throw/1 in the test suites for gen_server, gen_event (and gen_fsm) would be sufficient, just to exercise the code path and to etch the feature into the test suites.

Handlers that do not use try ... catch throw:Result but instead try ... catch Result are not the origin of this feature; it has to be the use of catch Mod:Callback(...) from before try ... catch ... end existed. throw is the default exception class in try ... catch Class:Reason ... end and I have used that to mark when the code only handles a non-local return (throw/1) as opposed to exceptions of different classes. At least that has been my intention.

RaimoNiskanen avatar Jul 14 '22 10:07 RaimoNiskanen

Mmm, apologies, I conflated two things there -- the "default throw" shape made just grepping for "throw" inaccurate, but clearly isn't the original source. The catch F() shape was likely both the original source and made grepping inaccurate. My only point on the "default throw" thing is that things like this that "feel" nice can make macro understanding of the code harder. I suspect this, too, dates back to a time when the only catchable class was throw.

bucko909 avatar Jul 14 '22 14:07 bucko909

@bucko909: From what I remember, catch Expression has always caught all 3 classes of exceptions:

catch throw(4711). % -> 4711.
catch exit(4711).  % -> {'EXIT', 4711}.
catch error(4711). % -> {'EXIT', {4711, Stacktrace}}

So throw(X) passes X verbatim, and the others wrap the Reason in an {'EXIT', _} tuple. This conflates non-local returns, throw(X), with error handling.

When using this, unwrapping the EXIT tuple, to detect error you also get exit in the same bin and cannot distinguish a throw from a normal return value. It is also impossible to know if throw(X) or value return has been used to fake an EXIT tuple

When then try...catch...after...end construct was introduced, the fact that one of its uses was, like the old catch Expression, non-local returns, merited that the default class should be throw. Then you can write try Expression catch Thrown -> Code end if you want to handle only non-local returns. This also indicated that non-local returns are conceptually different from exceptions.

RaimoNiskanen avatar Aug 01 '22 10:08 RaimoNiskanen

I've pushed proposed tests for gen_server -- is this approach the right one? If so, I can do similar in the other two modules (and document them) and we can put this to bed.

Have rebased as the branch is oooooolde.

bucko909 avatar Sep 08 '22 09:09 bucko909

@garazdawi ?

bucko909 avatar Sep 29 '22 15:09 bucko909