[squeak-dev] On the rejection of Promises due to errors

Jakob Reschke forums.jakob at resfarm.de
Sun Jun 21 20:57:10 UTC 2020


Hi all,

Tony has recently fixed our Promise implementation to make it more
Promises/A+ compliant, thank you!

In the discussion that lead to this fix [1], I already pointed out a
difference between exceptions in Smalltalk and in JavaScript, where
the Promises/A+ specification originates: Smalltalk exceptions can be
resumed, while JavaScript exceptions cannot be resumed and always
unroll the stack.

The spec [2] says that if the onFulfilled or onRejected callback of a
#then call on a promise throws an exception, then the promise returned
by the #then call shall be rejected with the exception thrown.

Our current Promise implementation matches this for the blocks
supplied to #then:, #ifRejected: or #then:ifRejected, by catching all
Errors in the blocks and rejecting the promise. But this does not
allow a Squeak user to deal with exceptions in a debugger if they are
signalled in the callbacks, because they are caught. The same also
applies to #future promises. The latter are not really covered by the
Promises/A+ spec (because it does not force the resolution or
rejection of a promise that is not the result of a #then, and there is
no #future in JavaScript), but futures exhibit the same problem of not
being resumable in the debugger. Example:

promise := 1 future / 0. "<= inspect it => promise is rejected,
regardless of your actions in the debugger"
Number compile: 'methodWithTypo  ^ self asstring'.
promise := 1 future methodWithTypo. "<= inspect it => promise is
rejected, no chance to fix the misspelling of asString in the debugger
and proceed"

I could imagine instead letting all exceptions pass during the future
or callback block evaluation, and only reject the promise if the
evaluation is eventually curtailed due to the exception (be it an
Error or not, think of Warning or ModificationForbidden). Example
expectations:

promise := 1 future / 0. "<= inspect it, press Proceed in the
debugger, => promise is resolved"
promise := 1 future / 0. "<= inspect it, press Abandon in the
debugger, => promise is rejected"
promise := 1 future methodWithTypo. "<= inspect it, fix the typo of
asString in the debugger, proceed, => promise is resolved with '1'"

It could be done by fulfilling a Promise about aBlock similar to this:

[ self resolveWith: aBlock value ]
   on: Exception
   do: [ :ex | | resumed |
      resumed := false.
      [ | result |
      result := ex outer.
      resumed := true.
      ex resume: result]
         ifCurtailed: [resumed ifFalse: [self future rejectWith: ex]]]

(Find the current implementations here:
Promise>>#fulfillWith:passErrors: and Promise>>#then:ifRejected:)

Note that the #outer send would only trigger handlers in the
Project/World loop, or the defaultAction of the exception. The #future
in front of #rejectWith: is there to avoid curtailing the unwind block
context of ifCurtailed: itself if there are further errors in the
rejection callbacks of the promise. The behavior of non-local exits
from unwind contexts is undefined in the Smalltalk ANSI standard (just
like resume: or return: in a defaultAction, or not sending resume: or
return: in an on:do: exception handler at all -- VA Smalltalk
interprets that as resume, while Squeak does return, for example).

This implementation would also allow all deferred Notifications to
pass and not reject the promise. That is because true notifications
just resume silently if they are not handled.

promise := [Notification signal: 'hi there'. 42] future value. "<=
inspect it => Expected: resolved with 42. Actual (today): it is
needlessly rejected with Notification 'hi there'"

Pressing Proceed in the debugger on officially non-resumable errors
(which is possible) would also not reject the promise. But further
errors/debuggers are likely to appear, of which one may eventually be
used to abort the execution. If the execution finishes after
repeatedly pressing Proceed, then fine, resolve the promise with
whatever the outcome was.

promise := [self error: 'Fatal error'. 42] future value. "<= inspect
it, proceed after the so-called fatal error, => Expected: resolved
with 42. Actual: there is no debugger, the promise is immediately
rejected."

promise := [1 / 0 + 3] future value. "<= Cannot be resumed/proceeded
because if ZeroDivide is resumed, it will return the exception, and
ZeroDivide does not understand +, which cannot be resumed without
changing the code. So you'd have to curtail the block execution =>
Expected: rejected with ZeroDivide or MessageNotUnderstood (depending
on when you press Abandon or recompile the DoIt)."

promise := [1 / 0 + 3] future value. "... or instead of changing the
code or aborting, you could choose 'return entered value' in one of
the debuggers, and thereby complete the evaluation of the block =>
Expected: resolved with whatever you entered to return in the
debugger"

Promises with whenRejected:/ifRejected: callbacks would no longer
swallow errors, and would only be rejected when the user aborts in the
debuggers, or if the future execution catches errors by itself and
converts them to rejected promises, so the future promise will also be
rejected. This could pose a compatibility problem for existing code.

promise := (1 future / 0) then: [:result | result + 3] ifRejected:
[:reason | #cancelled]. "<= inspect it => Actual: resolved with
#cancelled immediately. Expected with my proposed changes: it would
first show the ZeroDivide debugger, which you can abandon to resolve
with #cancelled, or proceed to a MessageNotUnderstood +. If you
abandon the MNU, the promise would be rejected with the MNU, not
#cancelled, in accordance with the Promises/A+ spec."

How to get back a catch-all->reject-immediately future under these
circumstances:

promise := [[1 / 0] on: Error do: [:e | e return: (Promise new
rejectWith: e)]] future value.
promise := [1 future + 1 then: [:n | [n / 0] on: Error do: [:e | e
return: (Promise new rejectWith: e)]] future value.

We could also introduce a convenience constructor for
immediately-rejected promises like in JavaScript: Promise rejected: e.
Or a convenience exception handler: [...] rejectOn: Error.  Or [...]
rejectIfCurtailed (the fullfill/then methods would probably use this
as well).

What do you think?

As Tom Beckmann has already suggested in the last thread on the topic
[1], I could also use a custom class of Promise to get just the
behavior I want. But then I cannot solve it for the use of #future. At
least not without patching something about the compiler in my package
preamble... ;-)

[1] http://lists.squeakfoundation.org/pipermail/squeak-dev/2020-April/208546.html
[2] https://promisesaplus.com/

Kind regards,
Jakob


More information about the Squeak-dev mailing list