Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Why is a new promise API needed at all? Why REST? #5

Open
devsnek opened this issue Jul 13, 2019 · 16 comments
Open

Why is a new promise API needed at all? Why REST? #5

devsnek opened this issue Jul 13, 2019 · 16 comments

Comments

@devsnek
Copy link
Member

devsnek commented Jul 13, 2019

Why do these desugar to promise api calls instead of regular js syntax? The system for overriding behaviour in JS is proxies, and it seems really weird to disrupt that, especially using the Promise namespace, which seems very unrelated to MOP operations. Also, why is REST terminology being used for the prototype names? We generally use set in js, not put.

@michaelfig
Copy link
Collaborator

Why do these desugar to promise api calls instead of regular js syntax?

We want infix bang to be usable to defer operations in code with regular, not-explicitly-handled Promises (in which case Promise.resolve(p) is an identity function), as well as ordinary objects on which you want to make deferred operations. Just as await works for Promises and non-Promises, so does infix bang.

The system for overriding behaviour in JS is proxies, and it seems really weird to disrupt that, especially using the Promise namespace, which seems very unrelated to MOP operations.

Handled Promises are not implementable by Proxy, because the Proxy isn't invoked when an object is wrapped with Promise.resolve(o), nor when (in a future version of this proposal) the handler needs to know when one Promise has been forwarded to another one to properly implement pipelining. Perhaps it would make more sense to know that handled Promises were originally called remote Promises?

Also, why is REST terminology being used for the prototype names? We generally use set in js, not put.

I'm not the best person to answer this, so maybe @erights or @FUDCo will chime in.

Thanks for your comments,
Michael.

@michaelfig
Copy link
Collaborator

Oh, and they desugar to Promise API calls because the implementation needs to look up the handler (if any) for the call, and that is a power that cannot be wielded without access to a global object.

@FUDCo
Copy link
Collaborator

FUDCo commented Jul 13, 2019

Also, why is REST terminology being used for the prototype names? We generally use set in js, not put.

I'm not the best person to answer this, so maybe @erights or @FUDCo will chime in.

I'm actually not sure how we got to put instead of set. I think I would have naturally used set myself. @erights may have a better background on the etymology there. I'm pretty sure it didn't have anything to do with REST though. I'm really not much of a REST enthusiast.

@bathos
Copy link

bathos commented Jul 13, 2019

I was pretty confused by the table of default behaviors. Ignoring the first two rows, the methods correspond to operations that have existing names in ES (apply, get (same), set, deleteProperty), so it was strange to see them with new names appearing like HTTP methods.

That they’re the defaults, meant to be implementable with other behaviors, probably goes a good way towards explaining the choice. Nonetheless, it doesn’t seem intuitive to me. In particular — returning to those first two rows — I could not understand why target!key(arg) expands to Promise.resolve(target).post('key', [ arg ]) rather than Promise.resolve(target).get('key').post([ arg ]).

Should an argument-before-the-arguments be there, I’d have expected it to be the receiver, as it is everywhere else (Reflect.apply, Function.prototype.apply, Function.prototype.bind, Function.prototype.call), not an optional property name that changes the operation from apply-this to
get-something-from-this-and-apply-that.

It’s a very intriguing proposal but I don’t think I really grok it and I think these object-internal-method-but-not-quite operations could use some more explanation.

@devsnek
Copy link
Member Author

devsnek commented Jul 13, 2019

@michaelfig thanks for the explanation 😃

I would imagine a!b would become Promise.resolve(a).then((v) => v.b), and v would be the proxy, or a itself could be a proxy. This seems like the correct level of abstraction to me. Anything beyond that seems to be library territory, not language builtin territory, just based on how labyrinthine it becomes. I definitely like the idea of a syntax where you can do stuff like fetch(url)!.json()!.something, and I think that feature would even stand as its own proposal to the language.

@devsnek
Copy link
Member Author

devsnek commented Jul 13, 2019

I've been rolling this around in my head for an hour or so and so far I'm unable to figure out how the whole promise api idea is better than just a regular fluent API.

// does the proxy magic defined in the readme
target!foo(1, 2, 3).then(console.log)

vs

// target could even be a proxy similar to the one in the
// readme, calling or returning `queueMessage` on accesses
// and deletions and such.
target.foo(1, 2, 3).then(console.log)

@FUDCo
Copy link
Collaborator

FUDCo commented Jul 13, 2019 via email

@devsnek
Copy link
Member Author

devsnek commented Jul 13, 2019

I just really don't understand why the chaining needs to involve promises. What constraint or use case is imposing that promises form a MOP here?

x = target.foo(1, 2, 3);
y = x.bar("hello").baz(4, 5, {thing: 47}).quxx(2);
z = y.somethingelse().anotherThing().andAnother(); w = z.transmogrify().then(console.log);

these can all chain together to not send anything until then is called. Your object there can even be a Proxy and do all the complex queueMessage stuff based on property names and such.

@erights
Copy link
Collaborator

erights commented Jul 13, 2019

Reasoning about interleaving and reentrancy.

When object Alice says const v = bob.foo() it evaluates Bob's foo method now, suspending Alice's activity until Bob is done, at which point Bob's returned value is bound to Alice's v variable and Alice continues. From the perspective of reasoning about side effects, this has virtues and hazards:

  • virtue: No time passes between Alice making the request and Bob receiving the request. Bob reacts to the request in exactly the world state that existed when Alice formulated the request.
  • hazard: Bob's activity happens in the midst of Alice's activity, and Alice's caller's activity, and so on. If Alice has suspended invariants and Bob happens to call back into Alice, then Alice can lose her integrity. This is a reentrancy attack, a form of plan interference attack that led to the DAO bug. https://medium.com/agoric/preventing-reentrancy-attacks-in-smart-contracts-3899bf837f23
  • locality: Bob must be co-located with Alice.

When Alice says const p = bobP ! foo() it queues the need to deliver the foo() message to Bob eventually, in a separate turn, and immediately returns control to Alice, with Alice's p variable bound to a promise for what Bob will eventually return.

  • virtue: Alice's activity, and Alice's caller, etc, all get to complete without interference from whatever Bob's foo method will do. Bob's foo method gets to start from an empty stack state in which all invariants should have been restored.
  • hazard: An arbitrary number of interleavings may have happened between Alice making the request and Bob receiving the request. Thus, the world in which Bob acts on the request may be very different than the world that led Alice to make the request.
  • locality: Bob can be anywhere. In the absence of partition, the message will eventually be delivered as a separate turn.

See the Part III of Robust Composition, especially Chapter 14 "Two Ways to Postpone Plans".

@erights
Copy link
Collaborator

erights commented Jul 13, 2019

Yes, the names GET, POST, PUT, DELETE did come from REST via the Waterken Q library. A nice example of a handler is actually mapping these to Restful/JSON requests. See https://github.com/tvcutsem/es-lab/blob/master/src/ses/makeFarResourceMaker.js for an earlier version we should update to use the current proposal. (We should also update it to use Fetch rather than XMLHttpRequest.)

However, I am not stuck on these names. This is indeed something to bikeshed about.

@ljharb
Copy link
Member

ljharb commented Jul 13, 2019

Deferred evaluation of messages is usually what I’d call “part of a function body”, and then I’d invoke it (for sync) or pass it into .then (for async).

Is this proposal effectively sugar for building up a function body, one statement at a time?

@devsnek
Copy link
Member Author

devsnek commented Jul 13, 2019

@erights to clarify, you want to call bob but you don't trust bob? with this proposal, you still have to trust that bobP's GET and POST handlers don't do the things you listed (calling back into alice, etc). All these other points are the same between bob.foo() and bobP!foo(), because i can make an interface like this:

const handler = {
  get(t, key) {
    if (key === 'then') return doFullTransaction();
    const x = () => {};
    x.key = key;
    return new Proxy(x, handler);
  },
  apply(t, thisArg, args) {
    return queuePartOfTransaction(t.key, args);
  },
};
const bob = new Proxy({}, handler);

bob
  .foo(1, 2, 3) // queuePartOfTransaction('foo', [1, 2, 3])
  .bar()        // queuePartOfTransaction('bar', [])
  .then(cb)     // doFullTransaction, cb handles effects of foo and bar

@erights
Copy link
Collaborator

erights commented Jul 13, 2019

I would imagine a!b would become Promise.resolve(a).then((v) => v.b), and v would be the proxy, or a itself could be a proxy.

For the local case, that works fine, but would sacrifice promise pipelining for the remote case.

This could only work once the promise a is fulfilled. Until a is fulfilled, the then callback must not fire. But we need to send the request b to the location where the fulfillment of a will get decided, before that decision happens, in order to get promise pipelining.

@erights
Copy link
Collaborator

erights commented Jul 13, 2019

@devsnek good catch!

Filed https://github.com/Agoric/eventual-send/issues/19

Consider this proposal under the assumption that there will be a protective turn boundary before the handler is invoked. Thanks!!

@devsnek
Copy link
Member Author

devsnek commented Jul 13, 2019

I think with my example above, queuePartOfTransaction can even handle the case where one of the arguments is another bob api chain. bob.foo(bob.bar()) becomes queuePartOfTransaction('foo', [bob.bar()]), and i think that is the idea of the pipelining discussed in http://www.erights.org/elib/distrib/pipeline.html

@wmertens
Copy link

wmertens commented Jul 28, 2019

x = targetP!foo(1, 2, 3);
y = x!bar("hello")!baz(4, 5, {thing: 47})!quxx(2);
z = y!somethingelse()!anotherThing()!andAnother();
w = z!transmogrify().then(console.log);`

could also be written as

x = (async () => (await targetP).foo(1,2,3))()
y = (async () => (await (await (await x).bar("hello")).baz(4, 5, {thing: 47})).quxx(2))()
z = (async () => (await (await (await y).somethingelse()).anotherThing()).andAnother())()
w = (async () => console.log(await (await z).transmogrify()))()

Right?

And that could maybe sugared by combining with tc39/proposal-do-expressions#4 :

x = do async (await targetP).foo(1,2,3)
y = do async (await (await (await x).bar("hello")).baz(4, 5, {thing: 47})).quxx(2)
z = do async (await (await (await y).somethingelse()).anotherThing()).andAnother()
w = do async console.log(await (await z).transmogrify())

the message sending part of the proposal would then be implemented by proxies on the returned objects.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

7 participants