endojs / Jessie

Tiny subset of JavaScript for ocap-safe universal mobile code
Apache License 2.0
281 stars 16 forks source link

Add async and a restricted form of await #45

Closed dckc closed 2 years ago

dckc commented 4 years ago

parse-time detection of async / await is too handy for a js2rho project I'm working on, but I recall some discussion of the hazard.

Would you please either

erights commented 4 years ago

It is not primarily the hazard keeping it out of Jessie, though that is an issue.

The hazard is that, when both writing and reading a program, especially when reading other people's programs, an explicit await is not salient enough for people to understand that there's a turn boundary there, and that arbitrary turns interleave at that point, invalidating stateful assumptions. I have looked through many tutorials on when to use await in async programming, and NOT ONE explains the need to think about this when writing or seeing an await in code.

One of the goals of Jessie is that it be easily implementable by a straightforward eval/apply style interpreter on top of a great variety of other languages. Both yield and await either require

The first two are not simple. The last is not universally available across desired host languages.

Interesting discovery since then, due to @dtribble: async functions without await

The semantics of an async function without await is that its outcome is always turned into a returned promise. If the function body returns a non-thenable, the function returns a promise fulfilled to that non-thenable. If the function body throws something, the function returns a promise rejected with that something as the reason. This is useful.

Thus, we will include async functions in Jessie.

By the same reasoning, it would be safe to include generators (function *) without yield in Jessie. But is there any reason to?

And finally, it would be safe to include async generators (async function*) without yield or await in Jessie. But again, is there any reason to?

dckc commented 4 years ago

FWIW, my use case is using JavaScript tools to develop contracts to run on RChain, with hopes of actually running Jessie / Zoe / ERTP contracts on RChain.

For example, 2.check_balance.rho begins with the following, where RevVaultCh, vaultCh, and balanceCh are a boring syntactic burden, due to get a shorthand syntax in rholang 1.1:

new
  rl(`rho:registry:lookup`), RevVaultCh,
  vaultCh, balanceCh,
  stdout(`rho:io:stdout`)
in {

  rl!(`rho:rchain:revVault`, *RevVaultCh) |
  for (@(_, RevVault) <- RevVaultCh) {
...

I have convinced the machine to take check_balance.js starting this way:

import { tuple } from '@rchain-community/js2rho';

import rl from 'rho:registry:lookup';

import E from '@agoric/eventual-send';

export default
async function main() {
    const { _0: _, _1: RevVault } = await E(rl)('rho:rchain:revVault');
...

and deal with the boring syntax bits for me to produce check_balance.rho:

new rl(`rho:registry:lookup`),
console(`rho:io:stdout`)
in {

  new AwaitExpression_9c36_0
  in {
    rl!("rho:rchain:revVault", *AwaitExpression_9c36_0)
    |
    for(@{ (*_, *RevVault) } <- AwaitExpression_9c36_0) {
...

The AwaitExpression tag in the estree made this particularly straightforward, but perhaps I could recognize E(...).then(...) without too much more difficulty.

erights commented 4 years ago

await is just sugar for the code CPS transformed into using explicit promises and .then. So yes, I recommend recognizing explicit promise patterns, including E and tildot ~.

Above, you almost certainly did not mean E(...).then(...). This would try to send then(...) as a remote message to whatever the promise within the E(...) designates. For the equivalent of await you should recognize

Promise.resolve(...).then(...)

or its HandledPromise equivalent.

erights commented 4 years ago

@michaelfig and I are thinking about adding

E.when(..., ...)

as a brief convenience for

HandledPromise.resolve(...).then(...)
erights commented 4 years ago

We have now decided to add async functions, and a restricted form of await within them. Using pseudo-bnf, the idea is something like:

functionBody ::= (topLevelDeclaration | topLevelStatement)*;

topLevelDeclaration ::=
    ("const" | "let") destructingPattern "=" "await" expression ";"
|   declaration;  // existing Jessie declaration without "await"

topLevelStatement ::=
    "await" expression ";"
|   statement;  // existing Jessie statement without "await"

Your example above

async function main() {
    const { _0: _, _1: RevVault } = await E(rl)('rho:rchain:revVault');
    ...
}

is within this grammar, because it occurs only at the top level of a function, and it precedes the entire initialization expression.

This should adequately satisfy both criteria:

  1. It is not too hazard prone to reason about interleaving. With practice, it may well be less hazardous than reasoning about thens. Separately, we may have an a specialized lint rule suggesting that an // AWAIT comment occur immediately after these topLevelDeclarations and topLevelStatements.
  2. It is not onerous to implement even in a very simple eval/apply interpreter written in other language. It is even simple enough that it could be implemented directly in the interpreter rather than by a cps rewrite. If one did do a rewrite, it would be a trivial form of cps that would not lose readability compared to either the original or the explicit .then form.
erights commented 4 years ago

Further, a bit of experience shows that it covers most of the practical pain we seem to encounter from avoiding await

warner commented 4 years ago

The permitted form would exclude catching an exception, right?:

async function main() {
  try {
    await E(x).foo();
  } catch (err) {
    react_to(err);
  }
}

I think that's probably fine, and I imagine the interpreter/transform would be more complicated if it needed to allow this case.. just wanted to check.

michaelfig commented 4 years ago

The permitted form would exclude catching an exception, right?:

Yes, our discussion thus far has excluded that form. It's easy enough to break such a try into a separate (async) function, then use myFn().catch(...).

katelynsills commented 4 years ago

What would be an example of a use of await that wouldn't be allowed?

erights commented 4 years ago

@warner 's example above is one. Some others:

// Can't nest `await` within an expression
foo(await bar());
// can't use within a control construct
if (await foo()) { ... }
if (...) { await foo(); }
erights commented 4 years ago

Surprising

// can't use on return expression
return await foo();

With regard to our criteria, we could allow it. However, the await here has no observable effect, but it is not obvious that it has no effect, so it is good that our simple rules happen to ban it.

michaelfig commented 4 years ago
// can't use on return expression
return await foo();

With regard to our criteria, we could allow it. However, the await here has no observable effect, but it is not obvious that it has no effect, so it is good that our simple rules happen to ban it.

Eslint rules also ban it, at least the ones we use.