OTP 28.0-rc1 Released

OTP 28.0-rc1

Erlang/OTP 28.0-rc1 is the first release candidate of three before the OTP 28.0 release.

The intention with this release is to get feedback from our users.
All feedback is welcome, even if it is only to say that it works for you.

We encourage users to try it out and give us feedback either by creating an issue at https://github.com/erlang/otp/issues or by posting to Erlang Forums.

All artifacts for the release can be downloaded from the Erlang/OTP Github release and you can view the new documentation at https://erlang.org/documentation/doc-16-rc1/doc. You can also install the latest release using kerl like this:

kerl build 28.0-rc1 28.0-rc1.

Starting with this release, a source Software Bill of Materials (SBOM) will describe the release on the Github Releases page.
We welcome feedback on the SBOM.

Erlang/OTP 28 is a new major release with new features, improvements as well as a few incompatibilities. Some of the new features are highlighted below.

Many thanks to all contributors!

HIGHLIGHTS

New language features

  • Comprehensions have been extended with ā€œzip generatorsā€ allowing multiple generators to be run in parallel. For example, [A+B || A <- [1,2,3] && B <- [4,5,6]] will produce [5,7,9].

  • Generators in comprehensions can now be strict, meaning that if the generator pattern does not match, an exception will be raised instead of silently ignore the value that didnā€™t match.

  • It is now possible to use any base for floating point numbers as per EEP 75: Based Floating Point Literals.

Compiler and JIT improvements

  • For certain types of errors, the compiler can now suggest corrections. For example, when attempting to use variable A that is not defined but A0 is, the compiler could emit the following message: variable 'A' is unbound, did you mean 'A0'?

  • The size of an atom in the Erlang source code was limited to 255 bytes in previous releases, meaning that an atom containing only emojis could contain only 63 emojis. While atoms are still only allowed to contain 255 characters, the number of bytes is no longer limited.

  • The warn_deprecated_catch option enables warnings for use of old-style catch expressions of the form catch Expr instead of the modern try ā€¦ catch ā€¦ end.

  • Provided that the map argument for a maps:put/3 call is known to the compiler to be a map, the compiler will replace such calls with the corresponding update using the map syntax.

  • Some BIFs with side-effects (such as binary_to_atom/1) are optimized in try ā€¦ catch in the same way as guard BIFs in order to gain performance.

  • The compilerā€™s alias analysis pass is now both faster and less conservative, allowing optimizations of records and binary construction to be applied in more cases.

ERTS

  • The trace:system/3 function has been added. It has a similar interface as erlang:system_monitor/2` but it also supports trace sessions.

  • os:set_signal/2 now supports setting handlers for the SIGWINCH, SIGCONT, and SIGINFO signals.

  • The two new BIFs erlang:processes_iterator/0 and erlang:process_next/1 make it possible to iterate over the process table in a way that scales better than erlang:processes/0.

Shell and terminal

  • The erl -noshell mode has been updated to have two sub modes called raw and cooked, where cooked is the old default behaviour and raw can be used to bypass the line-editing support of the native terminal. Using raw mode it is possible to read keystrokes as they occur without the user having to press Enter. Also, the raw mode does not echo the typed characters to stdout.

  • The shell now prints a help message explaining how to interrupt a running command when stuck executing a command for longer than 5 seconds.

STDLIB

  • The join(Binaries, Separator) function that joins a list of binaries has been added to the binary module.

  • By default, sets created by module sets will now be represented as maps.

  • Module re has been updated to use the newer PCRE2 library instead of the PCRE library.

  • There is a new zstd module that does Zstandard compression.

Dialyzer

SSL

  • The data handling for tls-v1.3 has been optimized.

Emacs mode (in the Tools application)

  • The indent-region in Emacs command will now handle multiline strings better.

For more details about new features and potential incompatibilities see the README.

17 Likes

Please correct ā€œā€¦ on the form ā€¦ā€ to ā€œā€¦ of the form ā€¦ā€ in all documentation.
Use ā€œx on the form yā€ when y is a paper or electronic form and x is something on it.
Use ā€œx of the form yā€ when y is a shape or structure had x has that shape or structure.

ā€œYou may not write phrases of the form ā€˜ ā€™ on the tax form.ā€

1 Like

Thanks for pointing this out. Iā€™ve opened a pull request.

Huh? Why is this being deprecated? Itā€™s a very useful shorthand and can reduce boilerplate by a lot.

Itā€™s not even listed as deprecated in the documentation: Expressions ā€” Erlang System Documentation v28.0

It is very seldom needed and you can achieve everything catch does with try instead. The difference is that you have to be explicit about catching everything, which I think is good because catch can easily hide more errors than intended. Itā€™s a bit of a footgun in that sense, and an anti-pattern in my opinion.

As long as I still can call it in the shell Iā€™ll be happy. Thatā€™s the only place I use it.

Yes, and you can drill every screw that screwdriver does with power drill instead. That doesnā€™t mean I bring out the power drill every time I want to screw something.

I think it fits perfectly in ā€œlet it failā€ patterns where you donā€™t care what the error is.

Hereā€™s a code excerpt:

twitch_route_message(Req, Message) ->
    case cowboy_req:header(<<"twitch-eventsub-message-type">>, Req) of
      <<"notification">> ->
        #{ <<"subscription">> := #{ <<"type">> := Type } } = Message,
        twitch_handle_webhook(Req, Message, catch binary_to_existing_atom(Type, latin1));
      <<"webhook_callback_verification">> ->
        twitch_respond_to_challenge(Req, Message);
      <<"revocation">> ->
        twitch_subscription_revoked(Req, Message)
    end.

twitch_handle_webhook(Req, Message, Type) when Type == 'stream.online' orelse Type == 'stream.offline' ->
    ...

In this case, I want the type as an atom, but I obviously donā€™t want the user to pollute my atom namespace with arbitrary input, so I use binary_to_existing_atom, which throws if the atom does not already exist. Instead of wrapping this entire call in a try-catch expression I can just forward it down to the next function head which checks the input anyway.

Hereā€™s another example:

take(Key, Default) ->
    case catch gen_server:call(?MODULE, {take, Key}) of
        {ok, Value}           -> Value;
        error                 -> Default;
        {'EXIT', OtherError}  ->
            ?LOG_WARNING(#{ id => "eb7b37fe-9bf7-524d-b8fa-eb9f6998ba29",
                in => note_keeper, log => command, what => "take",
                result => "error", "error" => OtherError
            }),
            Default
    end.

In this module, I make it clear that there are 3 different states that I care about: A value being there (return the value), the value not being there (return the default) and some sort of rare error (return the default and log).
Itā€™s all listed perfectly in order, and if I was going to try-catch this I would have to pull the innards out and indent to high heaven, resulting in uglier code.

The beauty of catch in my view is that you donā€™t care what errors youā€™re hiding.
I really donā€™t understand the reasoning behind calling try-catch ā€œmodernā€ in comparison.

1 Like

I donā€™t think anyone objects to the short-hand of

_ = catch Expr,

The issue is when you have

X = catch Expr,
ā€¦ use X

In this scenario catch is completely broken because it conflates throws with normal returns, which can hide errors. Old rpc invokes the fun in a catch, and Iā€™ve personally seen an incident caused by that hiding an error, preventing the error from being addressed in time.

If you have a lot of catch usage you can always define a one-line macro to emulate it.

There is (or at least was) a performance/memory penalty when you use catch, the stacktrace needs to built for the errors. With a try you can choose if you want the stacktrace or not.

1 Like

It is not being deprecated, at least not now. The warning is disabled by default.

There is still the same performance penalty when the value returned from a catch is used in any way. If the value of the catch is ignored:

_ = catch Expr

the compiler internally rewrites the catch to a tryā€¦catch, which does not need to build the stacktrace.

And those are errors that Dialyzer cannot find. Because normal returns are conflated with exceptions, all Dialyzer can say is that a catch returns an Erlang term (term() / any()). In the same way, the compilerā€™s type analysis canā€™t figure out what a catch expression returns, which can potentially prevent some type-based optimizations from being applied.

The intention with this release is to get feedback from our users.
All feedback is welcome, even if it is only to say that it works for you.

I tried 28.0-rc1 on a project of mine, a CPU simulator, and hit an issue with dialyzer.

The changes to support nominal types (which I donā€™t use) affect code using opaque types.

My symbol table implementation had an optimization for empty symbol tables

-type symtab() :: false | ets:tid().

This works with OTP-27, but with 28.0-rc1 dialyzer complained

ā€¦/symtab.erl:25:1: Body yields the type
ā€˜falseā€™ which violates the opacity of the other clauses.

ā€¦/symtab.erl:26:1: Body yields the type
atom() | ets:tid() which violates the opacity of the other clauses.

The issue is that although ets:tid() is reference() itā€™s also opaque meaning I cannot put it as-is in a union type since dialyzer thinks I cannot distinguish it from the other union members. Iā€™d have to either tag it (say in a tuple) or eliminate the union. In the end I removed my empty symbol table optimization :frowning:

(*) Why ETS and not a map()? I need ordered_set semantics and ets:prev/2 to map an absent key to the closest preceding present key.

I would never ever do that in any production code, and wouldnā€™t approve of any reviews where that was done. That can hide so many bugs without you ever noticing.

Even using it is slightly better, and only if you only match on a specific set of of values (although I would still ban catch in production at all).

Havenā€™t heard of that pattern, but I have heard of the let it crash-pattern which this goes completely against. :wink:

Sure, the use case there is very real. But someday later someone will come and refactor this code and it will read twitch_handle_webhook(Req, Message, catch parse_type(Type)) and that function will later get a bug that is now completely hidden.

Are you aware that the try statement supports of clauses? I personally would write it as something like this:

take(Key, Default) ->
    try catch gen_server:call(?MODULE, {take, Key}) of
        {ok, Value}           -> Value;
        error                 -> Default;
    catch
        exit:{noproc, Reason}  = Reason ->
            ?LOG_WARNING(#{error => cache_not_available, reason => Reason}),
            Default
    end.

I think it is both clearer what possible results and events this code expects, and you avoid catching anything unexpected and thereby hiding bugs (present or future).

Sure, if you really need to catch everything (e.g. in case youā€™re building some server framework that handles all top-level requests) I would still do try ... catch Class:Reason -> handle_error(Class, Reason) end instead to make it clear that it is just not a normal response.

I think in the end the problem with e.g. catch gen_server:call(...) is that you can never determine if the error comes from that call, or if that gen_server process called some other process and did a catch internally just as you do here and responded with it (a very real chance if this style is used throughout the codebase).

1 Like

Canā€™t edit the post anymore but it should be try gen_server:call(...) of (no catch!).

(which reminds me of this)

2 Likes

Youā€™re not supposed to peek behind the veil of opaque, itā€™s intended to let libraries swap out their representations without affecting well-behaving user code. As ets:tid() was declared opaque, you cannot distinguish it from other union members because it could be something else next release.

The point of the warning is to discourage people from using these tricks, but weā€™ve ended up walking it back for RC2 because there are too many contracts (including in OTP itself) that mix structural constraints and opaques, leading to cryptic warnings we canā€™t get rid of without changing APIs (local suppressions donā€™t work).

1 Like

I did indeed mean the let it crash-pattern, and I see your point. What Iā€™m actually doing here is moving the error to the next function head instead, which is a minor thing that, I suppose, is a waste of effort when I could let it crash even earlier.

I was not aware that try ... of exists and that does indeed look better than what I feared. I also see your point of only catching the expected error and letting it otherwise propagate.

1 Like

Yeah, itā€™s always easy to make sweeping statements like ā€you should only return errors as terms if they can be handled immediately by the callerā€, but what if they can be handled three levels up in the call stack? Whatā€™s best then?

Relying too much on exceptions can also lead to a code base that is hard to read because they behave like upwardly stack local GOTOs (which is sometimes desirable as well!).

So in the end it has to be decided on a case by case basis, where guidelines help but cannot be set in stone.

1 Like

FYI the docs link is broken, Erlang/OTP v28.0 rc1ā€” D-ocumentation works though!

1 Like

Thanks, fixed!