tc39 / proposal-eventual-send

TC39 Eventual Send proposal
44 stars 6 forks source link

Eventual send redux #31

Open dead-claudia opened 2 years ago

dead-claudia commented 2 years ago

Continuing down my work on #22, I feel it could be simplified even further while retaining all the same power. Proxies already have traps, and it'd be much easier to reuse what's already there and just add in what's needed to fill in the gaps.

Really, this all just boils down to this: why are we reimplementing proxies a second time when we could just extend and build on top of them?

Concretely, I'm suggesting three things:

  1. Add a general-purpose "send" proxy trap
  2. Split delegation from traps and eliminate the idea of a presence
  3. Change wavy dot to simply act as a delegation piercing operator

This is also meant as an alternative to #22/#26, and it'd offer a resolution to the question I had in #29*. My goal is to divide each of these into standalone features, independent of each other as best as they can, while still providing a way for them to integrate cleanly.

* TL;DR: the delegation object of Promise.createDelegation below.

1. Add a general-purpose "send" proxy trap

Add a "send" proxy trap that hooks into the syntactic construct foo.method(bar). The default behavior being Reflect.apply(foo.method, foo, args) as it currently is.

From what I've seen, it's probably the most common use case for proxies that isn't covered by an existing proxy trap, and it'd also provide for a nice perf boost for those using it. A decent number of the results in this search (warning: very noisy and I couldn't get much better by filtering get properties) are actually wrapping method calls (example), so it'd be fairly useful to expose it. Also, the spec already special-cases this - it's not mere sugar for let method = foo.method; return method(bar).

Also, there's additional value to be gained by allowing getters and method calls to not do the same thing, even independent of this proposal. For instance, one could use it to expose a GraphQL like gql.hero to fetch (potentially cached) properties and gql.mutation() to perform mutations, each without having to worry about the fact that GraphQL query and mutation names live in different namespaces. This cannot currently be modeled at all without splitting methods and properties into entirely different objects (thus generating significant boilerplate), as you can't use primitives as proxy targets and, even if you could, they wouldn't === their underlying value anyways (thus making it super awkward to use).

This of course cannot be polyfilled, and it can only be transpiled for code aware of the possibility.

And yes, I'm aware of how long this history goes back in pushing for this proxy hook.

2. Split delegation from traps

Split delegation from traps by adding a special type to encapsulate the concept of delegation.

  • Promise.createDelegated(promise, delegation) - Create a delegator promise. delegation exists as a simple arbitrary value that is returned from Promise.delegate while promise is pending. delegation is itself immediately resolved as per Promise.resolve. Once promise resolves, the delegator promise also resolves.

  • Promise.delegate(promise) - Resolve promise, then with the resolved result:

    • If the result's pending and delegated, return the delegation object
    • If the result's in any other state, return a proxy forwarding all calls to the inner resolved value of the promise itself. This does not recurse after resolving the promise.

Delegations would be preserved across Promise.resolve calls and async function returns, but nothing else. (Importantly, this does not result in any observable effects for awaits within async functions.)

The intent is implementations and the spec both could model the state of delegation as distinct from resolved, rejected, and pending, ensuring it could easily be done efficiently and without risk of leaks.

This makes it much simpler to reason with, and also a lot easier for engines to implement and optimize. (In fact, it'd be near instant and take almost no code, making it very usable in resource-constrained devices.) Additionally, this hides both the identity of the presence and the object itself, ensuring encapsulation.

For Promise.delegate, the resulting object works effectively like the following at a high level:

return new Proxy({}, {
    get: async (_, key) => (await promise)[key],
    apply: async (_, thisArg, args) => (await promise).call(thisArg, ...args),
    send: async (_, key, args) => (await promise)[key](...args),
})

In effect, this means the following:

  • Promise.delegated.eventualGet(promise, key)Promise.delegate(promise)[key]
  • Promise.delegated.eventualApply(promise, args)Promise.delegate(promise)(...args)
  • Promise.delegated.eventualSend(promise, key, args)Promise.delegate(promise)[key](...args)

If that Promise.delegate looks suspiciously like an E, that's because it's literally the same thing. This isn't some random quirk, but literally by design - the delegator handles all the encapsulation naturally.

Comparison with #26 API example from #26: ```js const E = promise => omitted(/* insert complicated proxy logic */) const createPromise = async () => { // Do something that may need a delay to complete. const { err, presenceHandler, other } = await determineResolution(); if (presenceHandler) { // presence is marked as an identity whose handler // is presenceHandler. The targetP below will be resolved to this // presence. const presence = { toString: () => 'My Special Presence', }; return Promise.delegated.resolveWithPresence(presence, presenceHandler); } else if (err) { // Reject targetP with err. throw err; } else { // Resolve targetP to other, using other's handler if there is one. return other; } }; // Create a delegated promise with an initial handler. // A pendingHandler could speculatively send traffic to remote hosts. const targetP = Promise.delegated(createPromise(), pendingHandler); E(E(targetP).remoteMethod(someArg, someArg2)).callOnResult(...otherArgs); ``` This suggestion: ```js const E = Promise.delegate const createPromise = async () => { // Do something that may need a delay to complete. const { err, presenceHandler, other } = await determineResolution(); if (presenceHandler) { // presence is marked as an identity whose handler // is presenceHandler. The targetP below will be resolved to this // presence. const presence = { toString: () => 'My Special Presence', }; return new Proxy(presence, presenceHandler); } else if (err) { // Reject targetP with err. throw err; } else { // Resolve targetP to other, using other's handler if there is one. return other; } }; // Create a delegated promise with an initial handler. // A pendingHandler could speculatively send traffic to remote hosts. const targetP = Promise.createDelegated(createPromise(), new Proxy({}, pendingHandler)); E(E(targetP).remoteMethod(someArg, someArg2)).callOnResult(...otherArgs); ```

Note that unlike the current proposal or #22/#26, this would require no change in semantics to Promise.resolve or even that logic in general. This can literally just be polyfilled in terms of proxies, and the polyfill wouldn't even need to monkey patch globals much, either.

Rough polyfill sketch Yes, it's that simple. ```js // In practice, this could just be stored in the promise state slot. const delegations = new WeakMap() function isPromise(x) { try { Promise.prototype.then.call(x) return true } catch { return false } } const oldResolve = Promise.resolve Promise.resolve = function (value) { const next = oldResolve.call(this, value) if (delegations.has(promise) && isPromise(next)) { delegations.set(next, delegations.get(promise)) } return next } Promise.createDelegated = function (promise, delegation) { promise = this.resolve(promise) delegation = this.resolve(delegation) // This wouldn't actually invoke `finally` - it's just easier to explain const delegated = this.resolve(promise).finally(() => { delegations.delete(delegated) }) delegations.set(delegated, delegation) return delegated } Promise.delegate = function (promise) { promise = this.resolve(promise) if (delegations.has(promise)) return delegations.get(promise) return new Proxy(Object.create(null), { // Handlers not listed here delegate to the target of `Object.create(null)` set: () => false, defineProperty: () => false, deleteProperty: () => false, preventExtensions: () => false, get: async (_, key, receiver) => Reflect.get(await promise, key, receiver), apply: async (_, thisArg, args) => Reflect.apply(await promise, thisArg, args), // If the above "send" proxy trap gets added send: async (_, key, args) => Reflect.send(await promise, key, args), }) } ```

3. Change wavy dot to simply act as a delegation piercing operator

The wavy dot operator would change to have the following semantics:

  • promise~.keyPromise.delegate(promise).key
  • promise~.(...args)Promise.delegate(promise)(...args)
  • promise~.key(...args)Promise.delegate(promise).key(...args)

This is relatively straightforward, and implementations could heavily optimize for these, avoiding the intermediate proxy and just returning the resulting promise. Not much to elaborate here.

The use of native promises does open the door for an easier time modeling eventual assignment and eventual deletion.

Case study

All the above would synthesize together to provide everything wanted as part of this, providing a very seamless experience across it all. I'll elaborate this with a case study on a seemingly very simple wavy dot method call: void promise~.remoteMethod(...args)

The first case has to necessarily wait for the promise to resolve, but the current eventual send proposal also forces the second to wait for the promise to resolve. This proposal would let it be able to even queue the subsequent request before it even has an object to work with, avoiding the need to wait for even the first round trip, giving it better flexibility on when to issue requests.

Security

I know that security is one of the main concerns around the design. Here's how this addresses it:

zarutian commented 2 years ago

Hmm... been trying to see if this violates the seperation between immediate invocations and eventual sends. (Seperation, which incidentially promted my comment in #22.)

And it seems it does.

This seperation is crucial when implementing local presences of remote objects like done in capTP subsystems.

... and eliminate the idea of a presence.

The entire idea behind this proposal repo is to support use of such presences. Thence I think this issue is a dead end.

michaelfig commented 1 year ago

Sorry for the long delay, @dead-claudia. That was a lot to digest, and I'm still trying to analyse the gaps between what you have here and what our designs have been. I definitely want to take advantage of the parts of your design that can simplify the proposal and make it more comprehensible.

I think your three suggestions are a constructive approach to framing the problem and design space, but I must insist on the immediate/eventual separation that @zarutian mentioned above.

  1. Add a general-purpose "send" proxy trap

There are more language features (such as optional chaining or void returns) that need additional context for optimal remote execution besides just "is it a get, apply or send". Perhaps addressing them comprehensively in a separate proposal is the right thing to do. I would be happy if something like an options bag could be added to the existing proxy traps.

  1. Split delegation from traps and eliminate the idea of a presence

Your approach above puts a lot of responsibility on the delegator to protect their own objects from the caller, and does not address how the caller can protect themselves from the delegator. A key part of the current proposal is that we try to simplify this responsibility: always separate requests and responses into their own asynchronous turns. A misbehaving delegator can't cause the caller to throw an exception, and a misbehaving caller can't cause the service code to throw (such as by constructing a call from a very deep stack).

A presence is just a stable identity (i.e. it can be compared such that references to it are ===) associated with the augmented proxy traps described above. We can certainly align the design of those augmented traps closer to the existing Proxy traps. However, an implementation that requires reifying delegators as proxies seems pessimal when they need to be hidden behind presences anyways to prevent synchronous access to them.

  1. Change wavy dot to simply act as a delegation piercing operator

That's essentially what it is intended to be, so long as it remains async.

Thank you for your detailed thoughts about this proposal!

dead-claudia commented 1 year ago

Sorry for the long delay, @dead-claudia. That was a lot to digest, and I'm still trying to analyse the gaps between what you have here and what our designs have been. I definitely want to take advantage of the parts of your design that can simplify the proposal and make it more comprehensible.

@michaelfig No problem! In fact, I've taken a step back in large part because this is such a complex topic, and I'd have to sit down across at minimum a few days to a week to fully wrap my head around everything. Likewise, I've been sitting on #22, waiting to ultimately come back to it.

I'm not to the point of abandoning this issue, but I do have to admit this is a very difficult problem area to fully wrap my head around. It certainly didn't help that the main proposal itself is a bit hand-wavy around the problem, and I do feel some extra explanation about the precise problem and the general approach behind the shape of the solution could've helped. (It's a bit late to help me, though, as I do generally understand the core concepts now.)

There are more language features (such as optional chaining or void returns) that need additional context for optimal remote execution besides just "is it a get, apply or send". Perhaps addressing them comprehensively in a separate proposal is the right thing to do. I would be happy if something like an options bag could be added to the existing proxy traps.

Now I see why the options bag existed. I didn't really see anything explaining the options bag at all in the main proposal. And yeah, I could see it being useful to add.

Not sure how optional chaining would fit in, though. The operator is applied to the end value, and foo.bar?.baz semantically accesses bar on foo then (optionally) baz on the result of accessing bar on foo. Maybe, I could see a trap for nullish checks that works for ?.. (Stopping short of suggesting ??, since that'd beg the question of other operators, and that's a bit of a slippery slope to go down.) I also have some mild concerns around performance in that case.

Your approach above puts a lot of responsibility on the delegator to protect their own objects from the caller, and does not address how the caller can protect themselves from the delegator. A key part of the current proposal is that we try to simplify this responsibility: always separate requests and responses into their own asynchronous turns. A misbehaving delegator can't cause the caller to throw an exception, and a misbehaving caller can't cause the service code to throw (such as by constructing a call from a very deep stack).

This is a good callout. I'll have to keep it in mind in future suggestions, and I entirely understand how that could potentially be exploitable. Do want to mention that the promise timings in particular could be easily rectified in the polyfill.

[...] However, an implementation that requires reifying delegators as proxies seems pessimal when they need to be hidden behind presences anyways to prevent synchronous access to them.

That's true, and I could just change Promise.delegate to also do the proxy stuff as well to merge the two. The idea was just to try to reuse existing functionality to reduce the new security surface area.

Also, an engine could, technically speaking, flatten the proxy and delegator object into a single object if it wanted to as an optimization.

michaelfig commented 1 year ago

One minor correction.

The first case has to necessarily wait for the promise to resolve, but the current eventual send proposal also forces the second to wait for the promise to resolve. This proposal would let it be able to even queue the subsequent request before it even has an object to work with, avoiding the need to wait for even the first round trip, giving it better flexibility on when to issue requests.

The current proposal also is designed for the "promise pipelining" you describe above. Maybe it wasn't described very well, but the unfulfilledHandler on a delegated promise is used when the delegated promise is pending (before it settles).

michaelfig commented 1 year ago

I like const delpromise = Promise.createDelegated(promise, delegation) as an API. I think my concerns can be solved by adjusting Promise.delegate(...) to enforce a turn boundary before touching delegation.

As an aside, an idea that we've been playing with at Agoric is that we can polyfill the promise pipelining behaviour by making "delegated promises" to be non-thenables. Then, with your APIs, we'd define Promise.delegate(promise).then(...) specially to shorten the promise chain recursively, explicitly preserving pipelining when we come across a delegated promise.

With non-thenables, a delegated promise can stand in for how we use presences. So, I think your intuition of how to clean this all up is pretty sound.

cc @erights @mhofman

zarutian commented 1 year ago

I like const delpromise = Promise.createDelegated(promise, delegation) as an API. I think my concerns can be solved by adjusting Promise.delegate(...) to enforce a turn boundary before touching delegation.

I am not understanding why this turn boundary is required.

As an aside, an idea that we've been playing with at Agoric is that we can polyfill the promise pipelining behaviour by making "delegated promises" to be non-thenables. Then, with your APIs, we'd define Promise.delegate(promise).then(...) specially to shorten the promise chain recursively, explicitly preserving pipelining when we come across a delegated promise.

Hmm… by having “delegated promises” as non-thenables it precludes resolvement intrest detection like (will be) used for op:listen and other such uses. (It is handy when the vat doing the eventual sends does not care about what such “delegated promises” resolves to but cares to use it as either an eventual send target or argument)

With non-thenables, a delegated promise can stand in for how we use presences. So, I think your intuition of how to clean this all up is pretty sound.

How does Agoric use presences?

cc @erights @mhofman

Can you two kindly chime in too?

mhofman commented 1 year ago

I am not familiar with the variants of captp besides what we do at Agoric, and more precisely in Swingset.

The idea is to have non-thenable pseudo-promises that represents distributed or virtual promises. The lack of absorption by native/platform promise is what enables us to do shortening through async functions, as returning such a pseudo-promise would simply pass it through without transforming it into a platform promise (this is all JS specific behavior, don't know what other languages do regarding thenables). Ultimately the delivery layer can realize that the result of a delivery is tied to a distributed (or virtual) promise.

For the program to use the result of such pseudo-promise you'd have to use an explicit "unwrapper". This is also beneficial in the case of JavaScript as it provides a safeguard against thenables that have synchronous side effects (evil promises). In our case we're considering using the E() helper as-is (and deprecate E.when()), aka const result = await E(pseudoPromise) or E(pseudoPromise).then(result => {}), as we have a standing requirement that no distributed object be a thenable (no then() method). Edit: without this requirement, we could probably imagine heuristics where if the arguments are not a couple of optional non-remotable functions, we'd still perform an eventual-send, or delay the eventual send to the resolution. Neither is optimal though.

by having “delegated promises” as non-thenables it precludes resolvement intrest detection like (will be) used for op:listen and other such uses. (It is handy when the vat doing the eventual sends does not care about what such “delegated promises” resolves to but cares to use it as either an eventual send target or argument)

So in Swingset, vats do have an explicit subscribe syscall for promises. However since we use platform promises for the result of eventual sends, we currently cannot know if the program is interested in the result or not, and our liveslots layer in vats auto-subscribes to all promises. Using non-promise thenables would indeed be one approach to detect interest. However the explicit unwrap above is another approach we'd prefer for a host of other reasons.

Since we have a system of virtual objects as well (the state is stored on disk, and a memory representative is re-created when needed by the program), we also have a helper watchPromise(promise, handler) where the promise can be a pseudo promise, and the handler a virtual object (with onFullfiled and onRejected methods), such that we don't pay any heap cost while the resolution of a promise is pending. That is another way to express resolvement interest for Swingset.

Once we have pseudo-promises we anticipate no longer needing to auto-subscribe to imported promises.

dead-claudia commented 1 year ago

I like const delpromise = Promise.createDelegated(promise, delegation) as an API. I think my concerns can be solved by adjusting Promise.delegate(...) to enforce a turn boundary before touching delegation.

I am not understanding why this turn boundary is required.

@zarutian Here's the threat model:

  1. Attacker creates a stack arbitrarily near the limit.
  2. Attacker invokes promise delegate handler.

If a turn boundary is enforced, the delegate handler won't be impacted by that deep stack. If a turn boundary is not enforced, the delegate handler may unexpectedly (and uncontrollably) error due to a stack overflow.

If no turn boundary is enforced, an attacker could use nested functions to build a stack depth to enforce an error right in the spot they want it to happen. They may be able to reliably trigger things like partial updates and resource exhaustion this way, depending on the way the handler is coded. This would of course constitute a significant security vulnerability. Stack overflows generally can't be caught, so there's no real way for handlers to recover from this and defend against it, either.

And unlike in Node, in browsers, stack overflows in JS do not crash the page. So any invalid state caused by stack overflows will continue to persist and potentially impact future calls (which might not be written expecting it).

zarutian commented 1 year ago

I am not familiar with the variants of captp besides what we do at Agoric, and more precisely in Swingset.

The idea is to have non-thenable pseudo-promises that represents distributed or virtual promises. The lack of absorption by native/platform promise is what enables us to do shortening through async functions, as returning such a pseudo-promise would simply pass it through without transforming it into a platform promise (this is all JS specific behavior, don't know what other languages do regarding thenables). Ultimately the delivery layer can realize that the result of a delivery is tied to a distributed (or virtual) promise.

Oh, it is because of the promise absorption of thenables into native promises. A ‘feature’ I saw no point in when it got introduced. Why was is introduced? Just a history curiosity by me btw.

For the program to use the result of such pseudo-promise you'd have to use an explicit "unwrapper". This is also beneficial in the case of JavaScript as it provides a safeguard against thenables that have synchronous side effects (evil promises). In our case we're considering using the E() helper as-is (and deprecate E.when()), aka const result = await E(pseudoPromise) or E(pseudoPromise).then(result => {}), as we have a standing requirement that no distributed object be a thenable (no then() method).

I recommend not to deprecate E.when() but have that or it and additional E.unwrap() the sole means to get thenable or promise out. (To me it looks that await heavy code leads to much ‘colour confusion’ regarding functions, too easily hides points of asynchronity where assumptions/invariants get violated, and precudes promise pipelining potentials.)

This allows resolvement interest detection to be done inside E.when() and E.unwrap().then() and reflected to the delegated promise trap handler on the psuedo-promise given as first argument to the two aforementioned menthods on E.

Edit: without this requirement, we could probably imagine heuristics where if the arguments are not a couple of optional non-remotable functions, we'd still perform an eventual-send, or delay the eventual send to the resolution. Neither is optimal though.

Or either monkey-patch Promise.prototype.then() to invoke the callback or errback via E.sendOnly(). Or have a heutistic in the recipiant side captp that looks for then as the verb and substitutes in a callback and errback that invoke the original callback or errback via aforesaid E.sendOnly(). That way it should work without error. It just looks wierd.

by having “delegated promises” as non-thenables it precludes resolvement intrest detection like (will be) used for op:listen and other such uses. (It is handy when the vat doing the eventual sends does not care about what such “delegated promises” resolves to but cares to use it as either an eventual send target or argument)

So in Swingset, vats do have an explicit subscribe syscall for promises. However since we use platform promises for the result of eventual sends, we currently cannot know if the program is interested in the result or not, and our liveslots layer in vats auto-subscribes to all promises. Using non-promise thenables would indeed be one approach to detect interest. However the explicit unwrap above is another approach we'd prefer for a host of other reasons.

I am curious what those other reasons are.

Since we have a system of virtual objects as well (the state is stored on disk, and a memory representative is re-created when needed by the program), we also have a helper watchPromise(promise, handler) where the promise can be a pseudo promise, and the handler a virtual object (with onFullfiled and onRejected methods), such that we don't pay any heap cost while the resolution of a promise is pending. That is another way to express resolvement interest for Swingset.

Once we have pseudo-promises we anticipate no longer needing to auto-subscribe to imported promises.

michaelfig commented 1 year ago

I'd like to invite comments on a slideshow of my suggestions for an Eventual Send API Redesign inspired by the discussion in this issue. If access permits you, you can make comments there, or feel free to add them to this issue.

It makes the layering of the proposal more explicit, and attempts to clarify the current proposal (and address inconsistencies in it pointed out by @dead-claudia, @zarutian, and the folks over at Agoric).

After some review, I'd like to work out a shim for the new API (as part of Endo's eventual-send shim), and incorporate lessons learned from it into a new version of the TC39 proposal.