eventual-send could wrap errors when target resolution throws
See original GitHub issueI propose that eventual-send
should wrap errors when the target resolution results in a throw. It would raise a new TypeError
with the appropriate message (e.g. Cannot send to rejected promise
), and use the thrown value as cause
for this new error.
Conversation from https://github.com/Agoric/agoric-sdk/issues/5185:
I actually hadn’t noticed this contagion through
send
/. Any reason for the contagion to exist in the first place?then
_Originally posted by @mhofman in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111565227_
OMG contagion is critical and pervasive across our kind of promises. It is one of the main things that make them usable. In any case, JS’s
.then
,await
, andPromise.all
already implement literal contagion, which is why I withdrew my original suggestion. If we introduced a special value representing disconnection,.then
,await
, andPromise.all
would all implement literal contagion, so we must as well. I forgot this when I wrote the “If so, we should still ask”. Our hand is forced. In this case, I don’t mind having this decision be forced in this direction. I think net, it is more right than any alternative._Originally posted by @erights in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111568390_
Sorry I shouldn’t have included
then
, but I am confused aboutsend
contagion.The same way I do not expect
undefined.foo()
to result inundefined
, I wouldn’t expectPromise.reject('err')~.foo()
to result in an'err'
rejection, but in aTypeError: cannot send 'foo' to rejected promise
with a propertycause: 'err'
on that new error._Originally posted by @mhofman in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111572170_
The analogy of
reject
is notreturn
, it isthrow
. The analogy of contagion is unhindered propagation of a thrown value through non-interfering intermediaries.The semantics of
await
depend on this analogy._Originally posted by @erights in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111573563_
> async function foo() { "use strict"; (await Promise.reject('err')).foo(); } undefined > foo() Promise { <pending>, } > Uncaught 'err'
It does result in an
'err'
rejection._Originally posted by @erights in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111575369_
I really don’t understand. When the user does
E(resultP)
they make the assumption that the eventual resolution ofresultP
will be a remotable. If they performE(resultP).foo()
and their assumption breaks down, they should be told that their assumption broke (new error), and why it broke (the initial rejection ofresultP
as cause).We have an opportunity with eventual send to not propagate the
await
contagion blindly.In the case of remotable result, awaiting the result promise will in most cases not yield you a local object, but only a remote presence, on which you need to use eventual send anyway.
_Originally posted by @mhofman in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111577702_
Today if a user does
const t = Promise.resolve(harden({someData: 42})); E(t).foo()
, an error is generated, right? Why not similarly generate an error in the case wheret = Promise.reject(harden({someData: 42}))
?_Originally posted by @mhofman in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111579433_
@michaelfig and I have discussed raising the abstraction level of
E
to be more marshal-aware. This question is undecided. We’ll likely leave it at the eventual-send level of abstraction, where it is now. It doesreturn harden((...args) => HandledPromise.applyMethod(x, p, args));
which uses only concepts from SES (
harden
) and from the eventual-send proposal (currently spelledHandledPromise.applyMethod
).You can thus
E
any JS value._Originally posted by @erights in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111581358_
E(t).foo()
does not generate an error because
harden({someData: 42})
is not remotable. It generates an error only later, when it tries to call itsfoo
method.@michaelfig please correct me if I’m not properly representing the semantics or plans for
E
._Originally posted by @erights in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111582394_
SwingSet does blindly propagate the rejection, but I maintain this a decision in the design of eventual-send, and not one which I’m convinced about right now.
_Originally posted by @mhofman in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111583186_
Because
harden({someData: 42})
is local, the semantics ofE(t).foo()
is identical toPromise.resolve(t).then(r => r.foo());
is identical to
(await t).foo()
_Originally posted by @erights in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111584066_
Yes, it is a SwingSet choice whether to implement a semantics consistent with the local JS semantics, or to implement distributed semantics which differs from local semantics in hard to explain ways. I just don’t like the second choice 😉 .
_Originally posted by @erights in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111585009_
But it is the proposed eventual-send semantics to propagate a rejection of
x
in the case ofHandledPromise.applyMethod(x, p, args)
instead of raising a newTypeError
with cause which I’m suggesting. I still don’t see why pure JS semantics are allegedly involved here._Originally posted by @mhofman in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111586447_
The proposed eventual-send semantics were designed to produce the above equivalences when
t
is anything other than a handled promise (or a promise forwarded to a handled promise). If I saw such a proposal that did not preserve this semantics for the local case, I would likely reject it. I don’t believe any other choice for the eventual-send proposal is reasonable.It is then a choice, only of the provider of the handler of a handled promise. In this case, liveSlots, whether to implement a similar semantics. I’ve already acked that this is a SwingSet choice. I just think that SwingSet should preserve the semantics. But we can argue about that.
_Originally posted by @erights in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111589700_
If I saw such a proposal that did not preserve this semantics for the local case, I would likely reject it.
Given that the language now has a native concept of error
cause
I believe we should consider when an operation is complex enough and has a clear boundary that it does not blindly bubble underlying errors but instead creates its own errors and chains them appropriately (aka catch and throw with cause). I believe eventual-send is such a case where chaining is appropriate instead of pass-through for the target._Originally posted by @mhofman in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111595149_
One constraint on the eventual-send proposal that I should emphasize: It motivates handled promises from the use-case of distributed objects, enabling a great variety of distributed object systems such as ours to be built. If we made the proposal too specific to our choices of distributed object semantics, I would likely object. For the proposal to be accepted into the language, it should have broad applicability, enabling the creation of many different systems.
_Originally posted by @erights in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111595190_
That’s a good point! I can imagine that handled promise operations add a new outer layer to the
cause
chain even if.then
,await
, andPromise.all
do not. You got me on the fence with this observation.What stack would you associate with this auto-generated outer error? (The answer I’d love is, sadly, probably too expensive to get most engine makers to agree to.)
@michaelfig , eventual-send is our proposal. What do you think of adding such automatic error wrapping?
_Originally posted by @erights in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111601776_
What stack would you associate with this auto-generated outer error?
I would expect the stack at the point where
applyMethod
is called, but I feel this may be a trick question 😉_Originally posted by @mhofman in https://github.com/Agoric/agoric-sdk/issues/5185#issuecomment-1111617806_
Issue Analytics
- State:
- Created a year ago
- Comments:10 (8 by maintainers)
Still waiting for @dtribble to clarify what async tail recursion problem he’s worried about. But until then, I’m going to argue that none of this is a problem. I’ll start by dividing into two cases.
Non-recursive deep send-chains by composition.
When debugging conventional sequential programs, I know I’m often surprised by the stack depth we get in the absence of recursion, just through composition across multiple layers of abstraction. These are often deep enough to make understanding hard, and hence a burden in debugging. But these are almost never deep enough to have a performance penalty, even in the absence of any tail optimization. I’m pretty sure this same observation applies to non-recursive async composition of send-chains. Indeed, Causeway was all about bringing the same kind of debugger’s view to send-chains that conventional debuggers bring to call stacks. I think the analogy holds.
The distributed async case has a further reason to consider tail optimization, which is shortening unresolved promise chains to cut out unnecessary intermediary vats. This is nice to reduce future message latency along those paths, but not crucial. It also makes the availability of those paths stop depending on the continued reachability of those intermediary machines. In any case, while we should and will need to pay attention to this, we would not currently be able to do this shortening anyway, so let’s set this aside.
Knowingly recursive tail send-chains
By “knowingly”, I mean that the entire tail recursion is co-designed, and therefore subject to a refactoring that changes how the recursion is done. For example, the refactoring at https://github.com/Agoric/agoric-sdk/pull/5214 changes async send-chain tail recursion into async iteration using a for/await/of loop. However, this is an invasive refactoring that takes some work, and can reduce the quality of the code. (More often it probably improves the quality of the code, but nevertheless…)
Even if eventual-sends are properly tail in their direct storage obligations, there is still a storage leak that would motivate this refactoring: they would leave behind promise forwarding chains that an implementation might not collapse before the end of the chain is settled (fulfilled or rejected). An implementation could, and could even guarantee it if its GC did the collapsing. But this isn’t the path of least resistance, so we should not expect such guarantees in general.
Fortunately, there’s a universally applicable small refactoring. Split the outer function into a non-recursive wrapper which allocates a promise/resolver pair and an inner recursive function with an extra argument which is the resolver. The outer function returns the promise and then calls the inner function with the resolver argument. Where the original function would have returned a promise, it instead returns void put passes the same resolver forward through the recursion. No new promises created. No promise forwarding chains. When we reach the recursion base case, it simply fulfills that one resolver to the tail answer.
Example coming up when I have time to come back to it.
Thus, if all async tail sends of interest fall into one of the above two cases, I don’t believe there is any remaining async tail recursion optimization needed. Then, this would no longer stand in the way of @mhofman 's suggested rejection wrapping.
@mhofman @kriskowal and I moved this into the Product Backlog. Please let us know if you disagree with that prioritization.