Discussion:
[e-lang] The latest attempt to fix sync/async error handling: implicit ejector arguments.
Kevin Reid
2014-10-11 03:21:17 UTC
Permalink
This came out of Friam today. Hasty but complete writeup follows. BCC'd to the
friam list for archival/notification purposes but I want the discussion to
happen on e-lang because this is a very E thing.

Introduction
------------

The problem is reconciling the requirements of synchronous and asynchronous
error propagation/reporting/handling. We are concerned with those errors which
are _handled_ by application code in some way, as opposed to those which are
treated as 'Something unexpected happened, stop.'

-- Sealed Exceptions --
Years ago, I proposed that conventional thrown/caught exceptions, as E had at
the time, were an unacceptable leak: one component's descriptive error message
is another's secret leak, if the latter doesn't catch all exceptions from the
former.

The fix I proposed and prototyped, "sealed exceptions" said that when you
'throw(X)', the object you get from a (dynamically) enclosing 'catch' is not
X, but a sealed-box of X: nobody can unseal it except for someone with
debugging access to the vat. The box itself provides no information except the
implicit "something unspecified and unexpected happened".

-- Ejectors --
This means that you can't use exceptions to report errors, so what do you do
instead? Use ejectors. To recap, an ejector is an object created by the
'escape' syntax:
escape ej {
...
ej('oops')
...
}
The expression "escape ej { ... }" binds "ej" within its block to an ejector.
If the ejector is called before the block has been exited normally, then a
non-local exit occurs and the ejector returns the value passed to the ejector.
(There is an optional 'catch' block to distinguish this exit from normal
returns.)

This can be used to handle errors in this sort of way:

escape notFound {
value := things.lookup(key, notFound)
} catch _ {
value := makeNewThing()
}

-- Promises --
The problem with the above is that it doesn't help at all with eventual
(asynchronous) programming.

The original form of promise error handling was that when a message was
received and delivered to a near (local) object, the local call operation
would be effectively wrapped in a try-catch, something like:
def deliver(resolver, target, argument) {
try {
resolver.resolve(target(argument)) # obviously a more general
# call, actually
} catch aProblem {
resolver.smash(aProblem)
}
}

However, sealed exceptions says that aProblem is one of those uninformative
sealed objects, so this mechanism doesn't work for _handled_ errors any more.

We can't use ejectors in this case, either, because an ejector is always
invalid by the time a remote object receives it as a parameter, since the
block has long since been exited.

-- The bad idea --
However, we can imagine passing an ejector-like object. Verbosely in current
E:

def [valuePromise, valueResolver] := Ref.promise()
def notFound() {
valueResolver.smash('not found')
throw('this is just to abort execution')
}
when (def successfulValue := things <- lookup(key, notFound)) -> {
valueResolver.resolve(successfulValue)
}

The problem with this pattern is that it needs some syntactic sugar to make it
sanely usable. My older notion was to provide a special part of the
eventual-send syntax which did this:

def valuePromise := things <- lookup(key, <!>)

The <!> can occur only within an eventual send's arguments list and is
essentially bound to "resolver.smash" of the implicitly constructed promise.

The problem with _this_ is that it's just plain ugly -- particularly, it
introduces a new syntactic element which is associated with "the nearest
enclosing X" (where X is "eventual send" in this case), which is bad in
principle.

Today's idea
------------

Suppose that we're willing to admit named parameters as a basic part of the
language. (I want to do this anyway for reasons which I should write up
separately.)

In that case, we can say that there is a specific parameter name (it can be a
distinct object, not a string) set aside for "the failure handler". Let's call
this parameter "FAIL" for now. There are two special features:

1. Every eventual send has a "Miranda" FAIL argument bound to resolver.smash
as discussed above.

2. Every normal function call has a "Miranda" FAIL argument bound to the
'throw' function.

3. Every method has a "Miranda" FAIL parameter which is ignored.

(Miranda: "If you do not have one, one will be provided for you.")

Therefore, any method which wishes to report a handleable failure can use
FAIL:

def things {
to lookup(key, => FAIL) {
...
FAIL('not found')
...
}
}

(E syntax reminder: "=> foo" is shorthand for a named map-entry (or parameter)
whose key is "foo" and is bound to foo. We'd need to refine this if the FAIL
name is not a string...)

And in order to call it asynchronously and get the failure back, you don't
need to do anything at all, because the implicit FAIL handles it:

def valuePromise := things <- lookup(key)
# valuePromise will be broken with 'not found' if this fails

To do synchronous error handling, you don't have to do much more than
ejector-styled error handling requires:

escape notFound {
value := things.lookup(key, FAIL => notFound)
} ...

If you want to simply propagate an error synchronously, it's:

def wrapper(key, => FAIL) {
return things.lookup(key, => FAIL)
}

which is just like current ejectors except with a special name for the ejector
argument.

The asynchronous version is actually simpler, because we can just return the
promise:

def wrapper(key) {
return things <- lookup(key)
}

The fact that the two "just a wrapper" cases have different amounts of
explicitness is a bit alarming, I admit. But I think this is fundamental to
the fact that, if you think of synchronous operations as an _extension_ to the
eventual computation semantics, it comes about from the fact that synchronous
programming distinguishes failures _now_ (throw/eject) from failures that
already, or haven't yet, happened and you're merely passing around a broken
promise.

(And in E, don't we want to encourage asynchronous programming, anyway?)

As I said, I just had this idea today, but so far it seems less horrible than
the alternatives.
--
Kevin Reid <http://switchb.org/kpreid/>
Kevin Reid
2014-10-15 00:51:24 UTC
Permalink
[bcc friam: please send all further discussion to e-lang principally]
[...]
Post by Kevin Reid
The original form of promise error handling was that when a message was
received and delivered to a near (local) object, the local call operation
def deliver(resolver, target, argument) {
try {
resolver.resolve(target(argument)) # obviously a more general
# call, actually
} catch aProblem {
resolver.smash(aProblem)
}
}
However, sealed exceptions says that aProblem is one of those uninformative
sealed objects, so this mechanism doesn't work for _handled_ errors any more.
I think this is slightly misanalysing the problem.
Leakage of secrets from error descriptions is an issue whether the error is
handled synchronously or asynchronously.
The problem is therefore not with the above try/catch. It's with the fact
that the caller can't specify which authority is needed to unseal the error
description in the async case.
[bcc friam: please send all further discussion to e-lang principally]

I am not proposing an error-_handling_ mechanism which involves _sealers_. The sealed exceptions in the sealed-exceptions-scheme are precisely those which are _not available to any application code_ (only from the role of debuggers), because they have been thrown. I do not wish to try to rescue any information from them, because by being thrown they have participated in a dynamic-scope mechanism.

Instead, I am trying to establish a non-dynamically-scoped -- capability-structured -- path by which the errors may be communicated. If we get this right, no amplification is needed and therefore no sealers, in both the sync and async cases.
If the caller could specify this, then it would be possible to write asynchronous
error handling code exactly analogous to synchronous use of escapes. The error
description would still be implicitly passed to resolver.smash, but it could
only be unsealed (and tested for a particular error) by using [the unsealer
corresponding to] the sealer
passed to the original call. The code in the callee would be the same.
I see what you are proposing. It is indeed very similar to my scheme in what it requires of the programmer.

However, I don't like it aesthetically, because I would prefer to eliminate dynamic-scoped-value-passing entirely from the language, and in particular I am concerned that it could interact with other features to create a communication path which is, if not technically a capability violation, at least surprising. Because I don't like it, I'm going to throw some mud and we can see what sticks:

It is _not_ equivalent to ejectors, because ejections cannot be caught (though they can be aborted via finally) but throws can. (This is a valuable property of ejections -- it eliminates one of the arguments against using exceptions for flow control.) Thus code which e.g. wraps an entire error-prone block (that happens to contain calls to FAIL) with a try-catch sees different behavior in the two cases.

Here's an example of a bad habit it could enable: a program which _one_ exception-sealer, then using it for all of its eventual sends. This would allow the program to exhibit "sibling amplification" between them and end up attributing failures peculiarly if the callee is sufficiently twisty -- and this is also unlike ejectors.

Finally, if this is a good way to handle eventual errors, why not use it for immediate errors? That is, pass throw-sealers around where we currently use ejectors. (Maybe the answer is that we should! I'd be surprised.)

[bcc friam: please send all further discussion to e-lang principally]
--
Kevin Reid <http://switchb.org/kpreid/>
Loading...