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.htm... [2] https://promisesaplus.com/
Kind regards, Jakob