Discouraging The Interception of Divergent Panics

With definitional errors, the landscape changes considerably for how we think about error handling.

In this world, there are not a lot of good reasons to use what I call "RECOVER".

By its design, RECOVER will intercept any error in code at any depth. This is what Rebol2's ATTEMPT and TRY did as well, but this made them not the right tool for hardly any code:

rebol2>> attempt [print "Attempting to read file" read %nonexistent-file.txt]
Attempting to read file
== none

rebol2>> attempt [print "Attempting but made typos" rread %nonexistent-file.txt]
== none

The problem isn't just about typos. It's about the illusion that there's something you can do to react to a panic coming form a completely arbitrary stack level beneath you...when the constructs you were using didn't even understand it well enough to pipe it through to their output.

In almost all cases, an error intercepted by "RECOVER" cannot be reacted to sanely...it has to be reported. Such use cases should only be things like consoles and test suites.

It's for this reason that languages like Rust pretty much enforce a panic when a function call produces an error the caller doesn't immediately and explicitly handle.

And our case is even more compelling. For example: How many places is it ever a good idea to sweep a typo under the rug, and just run some other code?

Might We Make It Look More "Special" To Discourage Use?

I thought at minimum we should move it to a place that shows it's more of a "system utility" than a "language feature".

So calling it SYS.UTIL/RECOVER would be a step in that direction.

Things to think about. Anyway, I've made some progress on definitional errors in the scanner and with TO and MAKE operations, so some of the things people like to intercept (like conversions) should work correctly with attempt now.

For instance, in this finite-integer world... an out of range error:

>> try to integer! "10483143873258978444434343"
== \~null~\  ; antiform

>> try to intgeer! "10483143873258978444434343"
** Script Error: intgeer! word is attached to a context, but unassigned

>> to integer! "10483143873258978444434343" except e -> [print ["Error:" mold e]]
Error: make warning! [
    type: 'Script
    id: 'bad-make-arg
    message: ["cannot MAKE/TO" :arg1 "from:" :arg2]
    near: [to integer! "10483143873258978444434343" ** except e -> ***]
    where: [to args]
    file: '
    line: 1
    arg1: #[datatype! integer!]
    arg2: "10483143873258978444434343"
]

Should be a more specific error, now that I look at that. But I guess it just wasn't.

2 Likes

Speaking of discouragement of using RECOVER-like-things... (!)

I'm doing some triage of various "unimportant" things that broke. One of which was SAVE's compression behavior.

R3-Alpha made it so that when you'd SAVE a script you could ask it to be compressed. The compression could be:

  • false - no compression at all, a normal looking script

  • true - the script would have a header, and right after the closing bracket of the header the compressed data bytes would begin.

    • This kind of script would not be loadable in a text editor, since it would be (likely) invalid UTF-8 bytes
  • script - there would be a header followed by a Base64-encoded BINARY! literal of the compressed data

But the header only says "compress" or not. How would LOAD know whether to look for a BINARY! literal or raw bytes? Since all bytes are legal in raw compressed data, it couldn't know by matching 64#{...} (Actually it probably could, since there are magic numbers that start most compressed data, but this was using a kind of black box compression.)

So how did it do it? Using ATTEMPT, and it was bad:

; R3-alpha
unless rest: any [ ; automatic detection of compression type
    attempt [decompress/part rest end] ; binary compression
    attempt [decompress first transcode/next rest] ; script encoded
] [return 'bad-compress]

If you have script encoding, the first thing it's doing is trying to decompress it as a binary format. That's just decompressing garbage.

But decompressing garbage can do all sorts of insane things, like interpret noise as a memory request size... and then actually request that amount of memory.

It's an epicycle of why Rebol2 TRY and ATTEMPT were so bad, and shows the kind of bad practice we shouldn't be sweeping under the rug by overuse of RECOVER (!)

2 Likes

Recent experiences has only confirmed what I already know: intercepting arbitrary errors--of the non-definitional sort--is ALMOST NEVER a good idea. Definitional errors are pretty much the only kind you can react to.

Modern RESCUE has a nice interface for use with definitional errors, when you don't want to use the infix EXCEPT.

Its interface is reversed from Rebol2 ATTEMPT--instead of returning NULL on failure, it returns NULL on success... and on failure returns the disarmed error. There's a secondary multi-return for the return product if you want it.

Internally to the system in API calls, unpacking multi-returns from a C API call is tricky. So I usually use something I've called ENRESCUE. This gives back a lifted return result overlaid with an WARNING! (non-antiform error). So you get:

  • a plain WARNING! on failure

  • A QUASI! item like ~null~ if the result was an antiform

  • A QUOTED! if it was a normal evaluation

It's easy enough to test for errors.

result: enrescue [
    ...your code being trapped goes here...
]

if warning? result [
    ...code responding to the result (was an ERROR!)...
] else [
    ...process result (it's LIFT-ed)
]

You can't use it sensibly with THEN and ELSE (it would always run THEN). Since it's a special construct we could pick a state to return NULL for... e.g. instead of quoted null, which might be convenient... but probably only if we were permissive and said UNLIFT would turn NULL into NULL. :-/ Probably not a good idea.

RECOVER => SYS.UTIL.RECOVER

I did the change so that the function for intercepting abrupt failures is poked into SYS.UTIL to make it clearer that you shouldn't be using it casually.

sys.util/recover [
    .. dangerous code
] then warning -> [
   ... "then" implies "if we recovered, then run this code w/error"
] else [  ; was null
   ... it succeeded
]

That seems all right. But I made an ENRECOVER as well, that lets you get the evaluation product LIFT-ed on success, and the plain warning on failure.

Perhaps the lifting behavior on these functions should just be controlled by a refinement?

>> rescue [1 / 0]
== &[warning! [...]]

>> rescue [1 + 2]
== \~null~\  ; antiform

>> rescue:lift [1 / 0]
== &[warning! [...]]

>> rescue:lift [1 + 2]
== '3 

Then the same refinement could be used with RECOVER.

The warning won't be LIFT-ed either way, so calling the refinement LIFT is a little confusing...but maybe not confusing enough to outweigh the benefit of reminding you that the result is LIFT-ed in there.

3 Likes

I feel like I should mention that there is a trick that could get you the error or the value if there was no error, without using ENRECOVER...

The RECOVER operation could convert an abrupt failure into a definitional one. Then you could use EXCEPT to handle the error case, while getting your value out the top of the expression in the non-abrupt-failure case:

result: sys.util/recover [
    if condition [panic "This is a divergent panic"]
    10 + 20
] except e -> [
    ; handle the divergent panic
]

For a construct named RECOVER, it doesn't seem to make sense.

  • recover [...] then x -> [...] naturally implies "this is code I want you to run if you recovered something, pass me the error you caught in a defused way."

  • recover...except makes it sound like the recovery didn't succeed.

Considering that you're not really supposed to be using SYS.UTIL/RECOVER hardly ever, it doesn't seem like too much of a burden to just move your assignment inside the body:

sys.util/recover [
    if condition [panic "This is a divergent panic"]
    result: 10 + 20
] then e -> [
    ; handle the divergent panic
]

But Maybe It Just Needs Another Name?

Multiplexing the intercepted divergent error onto the body result might make more sense if it were called something like SANDBOX:

result: sys.util/sandbox [
    if condition [panic "This is a divergent panic"]
    10 + 20
] except e -> [
    ; handle the divergent panic
]

Though that sounds like a noun more than a verb. ISOLATE... though that sounds more like a context thing, for isolating variables. QUARANTINE, I dunno.

:thinking:

Getting rid of ENRECOVER seems desirable, and the more natural form of multiplexing is to use ERROR! itself with the body return value, not this weird lifting idea.

Another aspect that makes me open to a new name and interface is that RECOVER and RESCUE are two rather similar words that start with R. This would help eliminate confusion between a construct that picks out definitional errors from sequential steps in an expression vs. one that does the dangerous deed of intercepting divergent errors.