Rebol2's "Hot Errors" (Abandoned by R3-Alpha, Red)

Rebol2 had a concept which had the inklings of definitional errors, in the sense that if you would RETURN an ERROR! value, it would escalate itself to an exception if not triaged with special functions at the callsite:

rebol2>> boom: func [] [return make error! "Boom"]

rebol2>> boom
** User Error: Boom
** Near: return make error! "Boom"

rebol2>> type? boom
== error!

rebol2>> (boom) print "No triage, this won't print"
** User Error: Boom
** Near: return make error! "Boom"

rebol2>> (disarm boom) print "Triaged, so this will print"
Triaged, so this will print

rebol2>> disarm boom

rebol2>> probe disarm boom
make object! [
    code: 800
    type: 'user
    id: 'message
    arg1: "Boom"
    arg2: none
    arg3: none
    near: [return make error! "Boom"]
    where: none
]

Why Was This Abandoned?

According to Carl:

"Prior versions of REBOL used 'hot' errors. That is, you had to treat error values in a special way or they would automatically trigger error processing. This behavior was originally implemented to keep errors from propagating too far from their origins (the principle was to preserve as much as possible the locality of the error)."

"These hot errors turned out to be overkill, and the benefit of error locality was offset by the difficulty of handling error values in general. (See the articles of Ladislav Mecir who wrote excellent notes on this subject). It could be quite tricky at times."

I'm not sure what articles he's referring to from Ladislav. The link he provides has an error section where Ladislav points out some bugs and says "any Rebol value should be obtainable as a result of an expression, a result of a parenthesized expression, a result of a block evaluation and a result of a function evaluation", which seems pretty uncontroversial.

So... What Was The Problem?

One big ergonomic problem is that TRY intercepted errors, but didn't disarm them. :frowning:

rebol2>> caught: try [boom]
** User Error: Boom
** Near: return make error! "Boom"

rebol2>> caught/id
** User Error: Boom
** Near: return make error! "Boom"

That's incredibly inconvenient. So why didn't TRY do a DISARM?

The problem is "hotness" was a property of all values of ERROR! type, and when you disarm them they'd become OBJECT!. If TRY were to DISARM the error it wouldn't be an ERROR! anymore, so you couldn't test to see if your expression had produced an error or not.

You get this because you're trying to pack too much information into one return result. It could have been addressed by having you write your TRY as arity-2 with an EXCEPT.

rebol2>> try-except: func [code handler /local e] [
             return either error? e: try code [handler disarm :e] [:e]
         ]

rebol2>> try-except [boom] func [e] [probe e/arg1]
"Boom"

rebol2>> try-except [1 + 2] func [e] [probe e/arg1]
== 3

Ren-C gives you this with infix, and you can use an arrow function, so it's more pleasing:

boom except e -> [probe e.arg1]

Hot Errors Would Have Been Only Half The Story

It seems Rebol2 was on the right track by having a state you could carry in a function's return result, that would promote to a divergent panic if not triaged at the callsite.

But for it to gel, you need to draw a sharp line between divergent panics and ERROR!, and not mix up their interception (in fact, you should practically never intercept divergent panics).

Rebol2's TRY was a one-size-fits-none construct, considering any typo or deeply nested errors to be on par with one that was "RETURN'd" directly from the function you were calling:

rebol2>> probe disarm try [read %nonexistent.txt]
make object! [
    code: 500
    type: 'access
    id: 'cannot-open
    arg1: "/C/Projects/rebol2/nonexistent.txt"
    arg2: none
    arg3: none
    near: [read %nonexistent.txt]
    where: 'halt-view
]

rebol2>> probe disarm try [rread %nonexistent.txt]
make object! [
    code: 300
    type: 'script
    id: 'no-value
    arg1: 'rread
    arg2: none
    arg3: none
    near: [rread %nonexistent.txt]
    where: 'halt-view
]

This makes it nigh impossible to act upon the information reliably.

The Wayward Drift To All-Errors-Are-Exceptions...

Despite Rebol2 being on the cusp of meaningful error handling, R3-Alpha went the way of the exception fallacy. Jumping across arbitrary levels of stack running arbitrary code in order to handle an error in stack levels above is something that the software industry has pretty much debunked.

For the most part, exceptions need to be reserved for things that should basically never happen. The rare systems that do handle them should be when it's required for mitigating damage or corruption that might occur if cleanup code doesn't get run. Exceptions are not what you want to use to deliver garden variety errors...you need direct contracts between caller and callee.

1 Like

I'll reiterate that I'm quite firm on the importance in discerning:

  • interceptable hot ERROR! by contract "multiplexed" in a function's return value (in the spirit of Rust Result, Haskell Either)

  • "non-interceptible" panics (exceptions)

...and that I think it was a major mistake in R3-Alpha and Red to fuse these into an "all-exceptions" model. That glommed typos and arbitrarily deep errors into the same handling.

But I've also stressed that "hot errors" raise some puzzling issues in handling, that need some care. For instance, just testing for them...

rebol2>> boom: func [] [return make error! "Boom"]

rebol2>> integer? boom
== false

rebol2>> even? boom
** Script Error: even? expected number argument of type: number char date money time

There you see that INTEGER? is the kind of function that's willing to gloss over the fact that a "hot" error happened, because its argument is "ANY-TYPE!"

But EVEN? didn't work because of a type error.

What if we wrote our own versions of INTEGER? and EVEN?

>> my-integer?: func [x] [integer! = type? x]

>> my-integer? boom
** User Error: Boom
** Near: return make error! "Boom"

>> my-even?: func [x] [0 = mod x 2]

>> my-even? boom
** User Error: Boom
** Near: return make error! "Boom"

It seems that if you don't have a type constraint, the ERROR! is promoted to a "panic". But if you have a type constraint that doesn't include ERROR!, it will behave like a typechecking error.

Let's try adjusting those with type constraints and see what happens:

>> my-integer?: func [x [any-type!]] [integer! = type? x]

>> my-integer? boom
== false

>> my-even?: func [x [integer!]] [0 = mod x 2]

>> my-even? boom
** Script Error: my-even? expected x argument of type: integer

Ren-C Concept: Type Checks Bubble ERROR!

I'm not that comfortable with:

rebol2>> integer? boom
== false

That seems much too likely to gloss over a misunderstanding of an error condition.

So I proposed type checks propagating ERROR! instead of returning logic on ERROR! input... (with the obvious exception of ERROR? giving a logic).

With Ren-C's definition of TRY (convert ERROR! to NULL, pass through other values) you can then say try integer? boom and that will defuse the error which propagates, getting you your false result. It looks better than integer?:relax boom or similar, and is strictly more powerful.

2 Likes

While I complain about Rye language's aesthetics, there is agreement on this point of "hot errors".


"Contrary to Null, Rye Failure can be rich with information. A failure can have a message (string), a type (word), even Failure code (integer). It can also hold details of exact failure. And it can reference a parent failure, so lower level failures can translate to higher level ones as they move up the stack."

failure 404           ; Constructs a failure with status code 404
fail "user not found" ; Fails with a failure with specific message
fail { 403 wrong-signin-data } ; Fails with failure w/ specific code and type

"Unlike Null, which can be silently passed around, Rye forces you to address Failures immediately."

"You can use functions like fix (to provide default values), check (to check for failure and add context to it if it happens), or disarm (to deactivate the Failure and access it's data) to handle Failures gracefully."

If failure is not handeled it elevates into an Error, our program enters an unpredicted state and generally needs to be stopped and Error fixed in code.


Is FAILURE! a Useful Name?

Rye uses the same term as Ren-C for the function that makes a "hot error", and that's fail.

I say this makes an ERROR!, he says it makes a "Failure". Then what I call being in a state of "PANIC" he calls an "ERROR".

So in the Ren-C World:

  • FAIL makes an ERROR! which if not triaged will escalate to a PANIC ("exception")

In the Rye World:

  • FAIL makes a FAILURE which if not triaged will escalate to an ERROR ("exception")

Both of us avoid using the word "exception". I don't know his reasoning but I know mine: I use EXCEPT as a postfix operator to triage ERROR! values, so I don't really want to mentally tie people to the particulars of things like C++ exceptions. I think PANIC is a good term for such a state that the console can recover from, and CRASH for something that forces termination of the whole system.

1 Like

So I think Rye might be in the right, on this one. "I got an error" tends to be the most natural way to refer to an exception-style "panic".

>> add 10 1 / 0
** Error: This seems like saying "error" is most natural

"I hit an error"... "I got an error"...

While "unusual", FAILURE! as the name for the antiform is seeming to feel more natural and more correlated with FAIL.

I think it turns out that the generality and familiarity of the term ERROR! for the antiform actually works against it--instead of for it.

Okay Rye, you win this one. :slight_smile:

But I think PANIC as the function for taking you direct to an "error state" is still good (I wouldn't run that as error "invalid type", error is too much of a noun... panic "invalid type" is better.)

2 Likes

One consequence of this is that I've been calling ERROR!s e all over the place:

blah blah except (e -> [
    ...
])

Now we'd be saying it's a FAILURE! (well, a disarmed failure). So e doesn't line up with the type name anymore...

I use f around generically for FRAME!.

This bothers me.

As I said: internally to the source, Error is the Stub type you find in a FAILURE! Cell, so it's kind of like "failures have an error inside them". We can think of it as "the error I would report, if you don't handle me".

This might suggest that ERROR! really is just the non-antiform state. Maybe less judgmental "I'm just an error object". This rolls back history a bit to when there was ERROR! => RAISED! antiform, except saying it's ERROR! => FAILURE! antiform.

"An error is not a failure. And a failure is not a PANIC...yet."

Does that track? It might. Maybe we keep PANIC and non-antiform-error-states go back to being instances of ERROR!.

This borrows something from everybody:

  • ERROR! as inert descriptive object for errors (from R3-Alpha/Red)
  • FAILURE! as name for the antiform/fail state (from Rye)
  • PANIC as the name for the escalated exception state (from Ren-C)