Skip to content
This repository has been archived by the owner on Jun 20, 2023. It is now read-only.

Async using syntax #1

Closed
mhofman opened this issue Sep 15, 2022 · 46 comments · Fixed by #6
Closed

Async using syntax #1

mhofman opened this issue Sep 15, 2022 · 46 comments · Fixed by #6

Comments

@mhofman
Copy link
Member

mhofman commented Sep 15, 2022

I've seen different syntax suggestions related to async disposal spread over different issues, in particular tc39/proposal-explicit-resource-management#16 tc39/proposal-explicit-resource-management#76 tc39/proposal-explicit-resource-management#84 #4. I wanted to concentrate the discussion in a single issue.

I'd first like to discuss the ongoing assumption that using an async-disposable requires specific syntax.
The way I model the using syntax is that a block containing a using declaration implicitly creates a DisposableStack. Every using declaration implicitly results in a .use() on the stack, and that exiting the block implicitly calls [Symbol.dispose]() on the stack.

Now if we assume that we have a concept of "async blocks", instead of a DisposableStack being implicitly created, it'd be an AsyncDisposableStack. At that point any using declaration would similarly call the .use() of the implicit async disposable stack, which is a synchronous operation, and it's the exit of the "async block" which awaits the [Symbol.asyncDispose]() call.

As such I would be against any syntax that uses the await keyword for the using declaration, as nothing is awaited at the declaration time. Similarly I would be against any syntax that doesn't use the await keyword on the block as a marker of interleaving. I would be ok with a async using declaration to be explicit, but I would consider that unnecessary verbosity.

Given the mental model above, I believe it would be entirely natural that for await (using r of ...), and using declarations inside for-await-of blocks in general, would implicitly use an AsyncDisposableStack. for-await-of is conceptually the only "async block" in the language at the moment. Then it become a matter of specifying what the syntax of an "async block" not tied to iteration looks like.

@mhofman
Copy link
Member Author

mhofman commented Sep 15, 2022

Also I would like to clarify my position from the presentation.

If future syntax for async disposables will require an explicit async using marker, I am ok with the proposal continuing as-is with for-await-of loops invoking [Symbol.dispose]() for using declarations (whether in the for declaration or in the block), however we have to realize that we are permanently closing the door to not requiring the async marker.

@mhofman
Copy link
Member Author

mhofman commented Sep 15, 2022

Thinking more about this, I believe that the top level block of an async function could also be considered as an "async block", at which point using declarations at the top level of async functions would similarly trigger @@asyncDispose before falling back to @@dispose.

@Jack-Works
Copy link
Member

(please be aware of async do { stmt_list; } from do expression proposal)

@rbuckton
Copy link
Collaborator

I think having using support asyncDispose in an async function was previously discussed during a plenary several years ago. @erights has repeatedly contended that we must have some kind of marker denoting the implicit await. While I agree that an async function body is an obvious boundary, we would need some way to annotate a given block as containing this implicit await to satisfy this concern.

I've never been 100% convinced that such an annotation is strictly necessary. Synchronous using will have an effect on the surrounding block given that [Symbol.dispose] will now be called, so anyone employing using would need to be aware of this relationship. I feel that a similar expectation could be applied to an async function or generator: a block containing an await using in async code will not only call [Symbol.asyncDispose] but would also await its result. If we didn't have the requirement to syntactically document the containing block, I'd add await using back in a heartbeat.

That said, I don't think a plain using declaration should ever use [Symbol.asyncDispose] magically. Much of this proposal is about being intentional about how you track your resources: No destructuring in using, throwing on missing [Symbol.dispose] early, no DisposableStack.from(iterable) due to potential foot-guns, etc. I think being explicit about the kind of resource you are expecting to track is important.

Assuming we were to continue that explicitness guarantee, and could add something like await using, I'd expect the following scenarios:

for (using res of iterable) ...; // sync iteration, sync dispose
for (await using res of iterable) ..; // sync iteration, async dispose
for await (using res of asyncIterable) ...; // async iteration, sync dispose
for await (await using res of asyncIterable) ...; // async iteration, async dispose

I'm far more likely to drop support for using in for, for-of, and for-await-of than to have a using in for-await-of choose to use @@asyncDispose and break with how using works in other cases (especially given @bakkot's concern about for(using x = ...; ;) ...;).

I've previously mentioned that C# (from which the using and await using declarations were borrowed) does not require a block marker. IIRC, @erights contention to that was that C# is multi-threaded and thus any code could potentially be preempted (@erights, please correct me if I am mischaracterizing your argument), but I think it's perfectly reasonable to write C# code that can execute sequentially with async functions where such preempting doesn't occur, as well as to use a SynchronizationContext that schedules async completions on an event loop.

@rbuckton
Copy link
Collaborator

rbuckton commented Sep 15, 2022

I also think that the fact we had to introduce for-await-of syntax itself is an indicator that we should be explicit and intentional. Having using magically support @@asyncDispose in an async function would be more akin to having for-of magically support @@asyncIterable in an async function. You have to opt-in to the async behavior with the await keyword, and I believe requiring the same explicit opt-in for using aligns with that premise.

@mhofman
Copy link
Member Author

mhofman commented Sep 15, 2022

I would like to stress as explained above that await using declaration is a misnomer. I would be entirely opposed to having the await keyword on the declaration as there is no interleaving point at the declaration time.

Both @erights and I want to make sure the place where any interleaving happens is not surprising. In a for await of it's explicit that there is interleaving due to the iteration. An async function top level block would effectively have no interleaving since the async disposal happens after return or throw. However nested blocks cannot start introducing implicit interleaving points, which motivates our requirement for await marking the blocks themselves.

Given that AsyncDisposableStack.prototype.use first attempts to use asyncDispose, falling back to dispose, i do not find surprising that using would do the same. For most usages, it should be entirely unsurprising since authors can keep using a sync dispose resource in "async blocks". The only surprising part may be for authors trying to using an async dispose resource in a non-"async block", however that is something both static type checkers and exception at the declaration time will quickly prevent. All this is assuming resources would be either async or sync dispose, I don't see a valid use case for object that have both symbols with different behaviors.

@mhofman mhofman closed this as completed Sep 15, 2022
@mhofman mhofman reopened this Sep 15, 2022
@rbuckton
Copy link
Collaborator

rbuckton commented Sep 16, 2022

Given that AsyncDisposableStack.prototype.use first attempts to use asyncDispose, falling back to dispose, i do not find surprising that using would do the same.

This still doesn't align with how for-of and for-await-of work in async functions. A for-of in an async function doesn't look for an @@asyncIterator, only for-await-of does. A using in a sync or async function should work the same way regardless (i.e., synchronous). A variation of using that looks for an @@asyncDispose first must be distinguishable from the synchronous version, whether that is via an await prefix or some other keyword. I feel await using is reasonable since it implies that an await occurs, just as a for-await-of implies an await occurs. I'd be fine with a different prefix keyword, if one can be found that is acceptable to all parties.

I don't see a valid use case for object that have both symbols with different behaviors.

While I don't disagree that objects shouldn't have both, a platform like NodeJS could reasonably have a [Symbol.dispose]() that blocks the main thread while closing a file stream (i.e., via fs.closeSync(fd)), and a [Symbol.asyncDispose]() that does the same thing asynchronously (i.e., via fs.close(fd, callback)). The caller could then choose whether to use the non-blocking version (via await using) or the blocking version (via using) based on their needs.

@erights
Copy link

erights commented Sep 16, 2022

(via await using)

As @mhofman has stated, this is the wrong place for the await to appear, because it is not where the interleaving would happen. However, that doesn't mean we cannot make this distinction. I suggest that we spell it as using async or async using. (I prefer the first but could live with either.) This is a perfect use of async -- it says beware of a possible await interleaving point elsewhere close by.

@ZachHaber
Copy link

For what it's worth, I think async using is preferable to me over await using since it makes it clear that it's async and not trying to await the result of the function called to create the resource. It also stays consistent with difference between sync vs async functions: async function vs function and async ()=>{} vs ()=>{}. using async would probably make it more readable as a sentence than async using does, but at the cost of having to remember that you stick async in a different location than with functions.

async function test(){
  // synchronous resource and disposal for comparison
  using sync = getSyncResource();
  // await using
  await using db = await getConnection();
  // await using implicitly awaits.
  await using db = getConnection();
  // async using
  async using db = await getConnection();
  // using async
  using async db = await getConnection();
}

I think I'd only prefer await using or using await if they implicitly awaited the right side expression. Which somewhat assumes that anything that needs an async disposal would be a Promise to start with and would mean that it would waste time awaiting values if the right side isn't a promise or has already been resolved earlier.


In regards to explicit block syntax for async disposal, I think that async {} aka async code block would work well for it. It is currently a syntax error, so it won't break anything, it adds the information at the top of the block for easier lexing and human understanding, and it remains consistent with meanings. The downside is: a basic async code block wouldn't really make sense on it's own without awaiting it, and so would require await keyword to make the syntax await async {}. There's also no way to shorten await async {} to await {} as await {} is valid syntax currently which awaits an object literal.

This concept would then combine well with proposals that make new styles of code blocks that have other functionalities: async do expressions and async pattern match enhancement both could be considered as a proper interleave point with an async disposal.

This does mean that if you use using inside of an async do block, and the return the value you ran using with, it would still get disposed and thus be invalid to return, and async match wouldn't allow using statements without combining it with async do as using appears to be a statement rather than an expression (assuming I have that terminology correct).

// async block
await async {
  async using db = await getConnection();
}

// async do block
async using toCleanup = await async do {
  // This `using` is inside the async do block, so it would get disposed properly
  using async pool = await getPool();
  // explicit return for clarity, as the syntax hasn't been fully nailed down yet
  // Note: no `using` here, since we want this to live until outside the do block.
  return await pool.getConnection();
}

At the very least, even without an explicit way to specify an async disposal block, any async block should be a candidate for async disposal. And then the disposal can just bubble up to the nearest async block, even if that ends up being the module itself (Top-level await in ESModules). This would also allow async IIFEs to be used for async disposal until there's other more terse ways to create an async block.

I feel that the async part of this proposal is more important to have in the language than the synchronous part since most file system support, databases, network, will ideally be async and require async cleanup to handle correctly, and without both parts being added together (or in short succession), I can see people just using the synchronous disposing for these and just swallowing the error (.catch(()=>{})) or logging it only to get the benefits of cleaner code.

The semantics of the async disposal would have to be different than sync disposal unless you have to specify that whatever block they are used in is an async block to allow them to work at all, which seems limiting and confusing, OR all blocks in async code will allow for async disposal regardless, which is problematic due to the implicit interleaving that that creates.

@rbuckton
Copy link
Collaborator

(via await using)

As @mhofman has stated, this is the wrong place for the await to appear, because it is not where the interleaving would happen. However, that doesn't mean we cannot make this distinction. I suggest that we spell it as using async or async using. (I prefer the first but could live with either.) This is a perfect use of async -- it says beware of a possible await interleaving point elsewhere close by.

I am leaning toward async using so it isn't confused with using async = where async is a regular identifier.

@erights, do you still have reservations about not having an explicit await marker? Would the fact that an async function itself is annotated with an async keyword not be enough of an indication, along with the fact that async using declarations themselves are annotated with an async keyword? Would banning async using at the top level of a module help (even though top-level await is still valid)?

If so, I will continue with my plan to postpone async using to a follow-on proposal, though it would be nice to move forward with async using at the upcoming plenary if we can find a compromise.

@mhofman
Copy link
Member Author

mhofman commented Nov 18, 2022

I believe async using should only be valid at the top level of an async function and at the top level for-await-of block, but not other places since those wouldn't already be understood to have interleaving points on scope exit.

As I mentioned, I'm still not sure the explicit async marker on using is necessary. IMO it's the kind of scope (async function / for-await-of) that implies the async nature of the dispose stack, and using is syntactic sugar for the stack.use() method.

@bakkot
Copy link

bakkot commented Nov 18, 2022

but not other places since those wouldn't already be understood to have interleaving points on scope exit.

I suspect that very, very few people are aware that the end of a for-await-of loop is an interleaving point, and I don't think that's actually caused many problems in practice. So I personally don't think that restriction makes sense.

@erights
Copy link

erights commented Nov 18, 2022

@erights, do you still have reservations about not having an explicit await marker? Would the fact that an async function itself is annotated with an async keyword not be enough of an indication, along with the fact that async using declarations themselves are annotated with an async keyword?

As @mhofman says, the fact that a function is annotated with async is enough for an async using that's at the top level of that function. Likewise, one that's at the top level of a for await of loop body. Unlike @mhofman , I favor the explicitness of the async using in this position. I no longer like having the context cause an implicit change in the meaning of a bare using.

Would banning async using at the top level of a module help (even though top-level await is still valid)?

Given that we allow a top level await in a module, I think we should allow a top level async using. Seems analogous to the top level of an async function body or the top level of a for await of loop body. Am I missing a salient difference?

@erights
Copy link

erights commented Nov 18, 2022

but not other places since those wouldn't already be understood to have interleaving points on scope exit.

I suspect that very, very few people are aware that the end of a for-await-of loop is an interleaving point, and I don't think that's actually caused many problems in practice. So I personally don't think that restriction makes sense.

We have been working hard on formalizing some safety rules around interleaving points. In the process, I've been amazed at how dangerous undisciplined use of await already is. Removing the guardrails we already have would make a bad situation much worse. More later on this when we have something written up...

@rbuckton
Copy link
Collaborator

Given that we allow a top level await in a module, I think we should allow a top level async using. Seems analogous to the top level of an async function body or the top level of a for await of loop body. Am I missing a salient difference?

I'm mostly just sounding out the concerns. Top-level await doesn't prevent you from using await inside of a Block. If you believe that async using would be permitted at the top level as well, from your prior comments I would assume that it wouldn't be permitted inside of a Block at the top level, which would indicate a difference.

I'd be tempted to move forward with a restriction such that async using is permitted at the top level of a module or in the body of an async function or for-await-of as long as it is an immediate child of the module, function body, or for-await-of (i.e., not otherwise contained within a Block, CaseClause, or DefaultClause. However, that would be fairly limiting for async disposables.

Since the concern about an explicit marker for the interleave point stands, I see only a few options that would continue along the path of RAII-style async using:

  • Permit async using at the top of a future async do {} block.
  • Introduce a new async {} block specifically for this purpose.

Based on my understanding of how async do {} might operate, this would mean that any nested block containing async using would look something like this:

async function f() {
  // top level
  async using x = ...; // ok

  // nested
  await async do {
    async using y = ...; // ok

  }; // dispose y

  // back at top level

} // dispose x

Alternatively, an explicit async {} block would reduce the footprint to a single keyword:

async function f() {
  // top level
  async using x = ...; // ok

  // nested
  async {
    async using y = ...; // ok

  } // dispose y

  // back at top level

} // dispose x

@erights
Copy link

erights commented Nov 19, 2022

I like this analysis. I think we're converging. Thanks!

@rbuckton
Copy link
Collaborator

If I had to choose, I'd pick the async {} block, purely because it only indicates there might be an async interleave point, as opposed to await async do {} which is always an async interleave point because it's awaiting an expression (i.e., await (async do {})). I feel that is important because async using can be used with both sync and async disposables, and doesn't await if the result of calling the disposal method is undefined to avoid needless extra Awaits.

The downside of async {} is that is has no other purpose outside of marking the interleave point for async using. I'm curious if anyone else has any opinions or suggestions.

@mhofman
Copy link
Member Author

mhofman commented Nov 19, 2022

In my opinion async {} is disqualified as it doesn't indicate an interleaving point.

The unconditional Await in await async do {} is actually a virtue, as conditional awaiting is a footgun (we actually have a lint rule that disallow nested/conditional await, and requires them top level).

@rbuckton
Copy link
Collaborator

In my opinion async {} is disqualified as it doesn't indicate an interleaving point.

It indicates an async interleave point as much as async function does, so I don't see why it's disqualified. I mostly find await async do {} to be a lot of boilerplate for the async case when the sync case is just {}. It is a poor development experience and would be my last choice given any other options.

@mhofman
Copy link
Member Author

mhofman commented Nov 19, 2022

It is not the same. The body of the function does not have an interleaving point, it's the promise returned by the function which "awaits" the disposal.

@rbuckton
Copy link
Collaborator

Yet we would allow async using at the top of an async function? As you say, exiting an async function body does adopt the result, which could potentially be a Promise (or a foreign promise-like), so there is a continuation (and possibly even user code) that may be executed when an async function body exits.

My main point is that an async {} block means precisely what we define it to mean. If async function () {} is an acceptable scope for an async using, then an async {} block would be as well.

@erights
Copy link

erights commented Nov 19, 2022

Hi @rbuckton , respectfully, you're missing @mhofman 's point. There is no interleaving point at the end of an async function body because there is no control-flow within the function after the end of the function body. The current invariant: "All interleaving points are marked with a yield or an await" is about interleaving points within what otherwise looks like intra-function sequential control flow. This invariant is important for informal reasoning about correctness. You can't waive it away by redefining terms.

The closest we have to a violation is the one that you pointed out: The implicit await iter.return() at a break, return, or throw in a for await of loop body. But since it is in a loop, the await at the top of the loop is adequate to account for it. It is as if we go back to the top and await there before exiting, just as we do for an early body exit via continue. (Thanks to @mhofman for this insight about why we have not yet lost this invariant.)

@rbuckton
Copy link
Collaborator

Hi @rbuckton , respectfully, you're missing @mhofman 's point. [...] You can't waive it away by redefining terms.

Apologies, I was trying to clarify why we would allow async using at the top of an async function when there's no explicit await, and to understand whether that logic would apply to an async {} block, not to attempt to redefine anything. I see your point about the explicit await/yield demarcation.

Per your position, async {} would not be sufficient given that control flow would continue past the end of the async {} block. I have some concerns about using await async do {}, however, since async do {} would be legal without the await, making it easy for someone to forget to add it:

async function f() {
  ...
  async do {
    async using x = ...;
    // rest of block completes synchronously
  }
  
  // uh oh, `x` may not actually be disposed yet since we forgot to 'await'
}

Also, since async do {} is an expression, it comes with potential ASI pitfalls:

await async do {
}

(foo); // potentially interpreted as `await (async do {}(foo))`, depending on TBD spec

In lieu of async {}, I might propose to instead introduce an await using {} block (same idea, different spelling):

async function f() {
  // explicit 'await' demarcation
  // indicates exactly what we're going to 'await'
  // 'using {}' not legal on its own, so can't forget 'await'
  // 'await {}' is legal, but is not a Block, so can't forget 'using'
  await using {
    async using x = ...;

    // rest of block completes synchronously (or asynchronously)
  }

  // ok, 'x' should be disposed
}

And an await using {} block would reduce ASI pitfalls because it's a Block-like form and not an expression, and the off chance of an await using followed by a new-line before the block means that any async using x = ... would be an error since it's not in a valid await using { } block.

Unfortunately, if/when async do advances then there is still the potential for someone to accidentally drop the await.

@bakkot
Copy link

bakkot commented Dec 1, 2022

await async do {}

A much more serious problem is that async do {} would not allow control statements which would affect the surrounding context; that is

async function f(){
  await async do {
    return;
  };
}

is not legal. It can't be, because without the await the async could be executing after the surrounding function has already finished execution. And I don't think it makes sense to special case await async do, which isn't really a thing which would be used otherwise.

Since async do {} has this limitation, it's not something which you can reasonably make use of for stuff like this, where you actually do intend to do straight-line execution; it breaks compositionally in weird ways.

@bakkot
Copy link

bakkot commented Dec 1, 2022

There was some discussion about this on Matrix.

@rbuckton rbuckton transferred this issue from tc39/proposal-explicit-resource-management Dec 1, 2022
@MadProbe
Copy link

Wouldn't a currently existing {} block do the job of marker of disposal point as it currently is with sync version of this proposal if I am not mistaken?
Also I don't think await using binding would be any hazardous as I cannot think of an example it is currently legal statement.

@rbuckton
Copy link
Collaborator

rbuckton commented Jan 5, 2023

@erights, perhaps you can clarify your concerns regarding await and potential pitfalls of implicit async interleaving points? You mentioned intending to formalize your concerns in this comment.

I personally favor just using a plain Block, as I still believe the async using statement itself is enough of an indicator. For example, an earlier revision of the syntax looked like this:

async function foo() {
  using await x = ...;
  {
    someCodeBeforeUsing();

    using await y = ...;

    someCodeAfterUsing();
  }
}

This matched languages like C#, which also has an async using statement (spelled await using) and block-scoping without other indicators.

The switch to an async using statement and using await {} block was only made to specifically address @erights's concerns. The marker for the block isn't necessary syntactically, it is mostly ceremonial and purely intended to draw attention to an implicit side-effect. To me this still seems like an unnecessary guard rail that could be just as easily enforced via a linter with rules that require an // await using comment at the end of the block, or that require all async using statements be declared at the top of the block. These seem like stylistic decisions to me, and I'm not sure I agree with enforcing such a style decision on an entire development community. I know I would not enable such a rule were it available.

In addition, IDEs like VS Code and Eclipse can perform syntax highlighting and add text editor decorations. These could easily be used to flag such blocks for you, much like VS Code's inlay hints for parameter names, meaning even lint rules aren't strictly necessary.

@rbuckton
Copy link
Collaborator

rbuckton commented Jan 5, 2023

Pinging @kriskowal since they expressed interest in this topic on Matrix during the plenary.

@rbuckton
Copy link
Collaborator

rbuckton commented Jan 5, 2023

Also, one quick clarification: I don't want us to spend too much time dwelling on the actual spelling of the async using statement, and instead focus on whether an explicit marker is necessary. Consensus on that decision will drive the final spelling.

I've generally been spelling the async using statement in one of two ways:

async using id = value;

using await id = value;

I may use them interchangeably as part of this discussion, but I'm not tied to either spelling. However, these spellings were chosen for specific reasons:

  • async using

    • Explicitly does not use the await keyword.
    • async must come before using because async is not a reserved word, thus using async has a potentially ambiguous parse since async could either be a keyword or identifier.
    • When used as a keyword elsewhere in the language, async is treated as a contextual modifier and thus always comes to the left of the thing it modifies.
  • using await

    • Explicitly uses the await keyword.
    • await cannot come before using since using is not a reserved word, thus await using is already legal JavaScript and disambiguation would require a cover grammar.
    • await can come after using because await is a reserved word inside of an async context, and is also reserved in strict-mode code. This means that it's possible that using await = ... could be legal in loose mode code, but we could just add a lookahead restriction to the using statement to prevent that and avoid a cover grammar as well.

@mhofman
Copy link
Member Author

mhofman commented Jan 5, 2023

I'm answering on behalf of @erights.

We've discussed this again since the last plenary and after the Matrix discussion that stemmed from questions raised by @syg and @bakkot.

We still strongly believe that the programmer must be able to reason about where asynchronous interleaving point happen. The fact that most programmers don't pay attention doesn't mean it's not important, it just means they haven't realized how interleaving may impact their program's execution.

That said, we are willing to abandon our requirement that the exact point of interleaving be marked by an explicit await keyword, as long as a simple syntactic glance at the source allows to realize an interleaving point does exist. As highlighted in Matrix, this would be consistent with understanding a synchronous execution of the dispose steps happen when exiting a block if any using bindings appear in the block.

One question that came up however is regarding the conditionality of the interleaving point when exiting a block. Our above requirement implies that the programmer should be able to infer the presence of an interleaving point based on the syntactic content of the block. We believe the best way to accomplish this while not changing existing execution semantics is to mandate an interleaving point when exiting a block if the block contains an async using / using await statement, regardless of whether that statement has been reached during the block's execution. In particular, an early return or exception thrown before reaching or while executing the right hand side of such statements would still result in the equivalent of an await happening.

@ljharb
Copy link
Member

ljharb commented Jan 5, 2023

In other words, any using statement would, like const or let, "hoist" a "mark this block as interleaving" to the top of the block, regardless of it being reached in normal execution?

How does that interact with eval (direct or indirect)? Do using statements work in eval?

@mhofman
Copy link
Member Author

mhofman commented Jan 5, 2023

I would say that using (whether sync or async) is invalid in either kind of eval for the same reason that await is invalid in them?

(async () => { console.log(eval('await Promise.resolve(42)')) })()
Promise {
  <rejected> SyntaxError: await is only valid in async functions and the top level bodies of modules
}

@rbuckton
Copy link
Collaborator

rbuckton commented Jan 5, 2023

That said, we are willing to abandon our requirement that the exact point of interleaving be marked by an explicit await keyword, as long as a simple syntactic glance at the source allows to realize an interleaving point does exist. As highlighted in Matrix, this would be consistent with understanding a synchronous execution of the dispose steps happen when exiting a block if any using bindings appear in the block.

To clarify, would this syntax meet that requirement?

{
  using await x = getSomeAsyncResource();

}

Given that:

  • The syntax includes the using keyword, which indicates something happens at the end of the block.
  • The syntax includes the await keyword, which indicates that the something that happens will result in an async interleave point.
  • A using await statement is always scoped to some block, with the exception being a top-level using await statement in a Module.

This means that, given consistent indentation and formatting, you can easily scan down a column within a block to observe any using await statements within the block.

One question that came up however is regarding the conditionality of the interleaving point when exiting a block. Our above requirement implies that the programmer should be able to infer the presence of an interleaving point based on the syntactic content of the block. We believe the best way to accomplish this while not changing existing execution semantics is to mandate an interleaving point when exiting a block if the block contains an async using / using await statement, regardless of whether that statement has been reached during the block's execution. In particular, an early return or exception thrown before reaching or while executing the right hand side of such statements would still result in the equivalent of an await happening.

I'm not sure I understand why you would force an async interleaving point in that case. If you are writing code that expects a given block will have "exactly N interleaving points", then you are running afoul of "releasing Zalgo". If you are writing code that expects a given code block to be asynchronous, but it happens to complete synchronously, then you would already be protected, so introducing an extra interleaving point is unnecessary.

This would be like requiring a block to introduce an interleaving point in an else branch just because you wrote if (x) { await y }, or mandating an implicit await in the break statement below if you wrote:

x: {
  if (y) break x;
  await p;
}

@bakkot
Copy link

bakkot commented Jan 5, 2023

@mhofman:

One question that came up however is regarding the conditionality of the interleaving point when exiting a block. Our above requirement implies that the programmer should be able to infer the presence of an interleaving point based on the syntactic content of the block. We believe the best way to accomplish this while not changing existing execution semantics is to mandate an interleaving point when exiting a block if the block contains an async using / using await statement, regardless of whether that statement has been reached during the block's execution. In particular, an early return or exception thrown before reaching or while executing the right hand side of such statements would still result in the equivalent of an await happening.

I don't object to this requirement, but I also don't understand it - programmers understand that an await only causes an interleaving if it's actually reached; why would they expect that an async using would cause an interleaving even if not reached?

@ljharb

How does that interact with eval (direct or indirect)?

My assumption would be that the eval creates an implicit "block" which would contain the using statements, such that any deferred dispose calls would happen when the eval finished, rather than the surrounding block finished. There's already an implicit "block" created for the evaluation of the contents of an eval, as evidenced by the fact that let bindings created within an eval are not visible outside of it - { let y = 0; eval('let y = 1; console.log(y)'); console.log(y); } prints 1, 0.

I think the current spec actually doesn't handle eval at all (or I'm missing where it gets handled); I opened tc39/proposal-explicit-resource-management#136.

@mhofman
Copy link
Member Author

mhofman commented Jan 5, 2023

@ljharb:

In other words, any using statement would, like const or let, "hoist" a "mark this block as interleaving" to the top of the block, regardless of it being reached in normal execution?

My mental model is that when an async using or using await statement appears in a block, an implicit AsyncDisposableStack exists for that block, and its dispose is implicitly called and awaited on when exiting the block. It's the easiest way to explain to programmers that's what happens. So yes by that model, the implicit dispose stack is hoisted.

@rbuckton:

To clarify, would this syntax meet that requirement?

Yes. And I was sure to not debate the async using vs using await here. For this purpose I think either would do.

If you are writing code that expects a given block will have "exactly N interleaving points", then you are running afoul of "releasing Zalgo". If you are writing code that expects a given code block to be asynchronous, but it happens to complete synchronously, then you would already be protected, so introducing an extra interleaving point is unnecessary.

I'm not sure I follow. I don't see how forcing an interleaving point would release Zalgo if that interleaving point is unconditional.

Sometimes asynchronous blocks is what we're concerned about, especially in the case of exceptions. for-await-of and async function top level both have an unconditional interleaving when exiting.

It would also be harder to explain what happens when an using async statement happens: create an AsyncDisposableStack and attach it to the surrounding block if one doesn't already exist?

@rbuckton
Copy link
Collaborator

rbuckton commented Jan 5, 2023

I'm not sure I follow. I don't see how forcing an interleaving point would release Zalgo if that interleaving point is unconditional.

Writing code that depends on counting interleaving points has the potential to release Zalgo. That is the case whether this always has an implicit interleaving point or not. A user might try to depend on the existence of a forced interleaving point to try to run code in between microtasks. The potential to "release Zalgo" in either case is purely based on the code that the user writes. Since counting interleaving points in either case has the potential to "release Zalgo", I generally favor the approach that doesn't introduce unnecessary extra delays.

Sometimes asynchronous blocks is what we're concerned about, especially in the case of exceptions. for-await-of and async function top level both have an unconditional interleaving when exiting.

"Sometimes asynchronous blocks" already exist (i.e., my { if (y) break x; await p } example). Intentionally avoiding them is a coding style preference and a linting policy decision.

It would also be harder to explain what happens when an using async statement happens: create an AsyncDisposableStack and attach it to the surrounding block if one doesn't already exist?

I do not share the same mental model, even if there is overlap. If the spec were to use a stack in this way, I imagine I'd conditionally initialize it at the time the first using binding is initialized. Unexecuted code generally shouldn't have side effects.

@rbuckton
Copy link
Collaborator

rbuckton commented Jan 5, 2023

I think the current spec actually doesn't handle eval at all (or I'm missing where it gets handled); I opened tc39/proposal-explicit-resource-management#136.

eval parses its contents using the Script goal symbol. using is not allowed at the top level of a Script, so eval(`using x = y;`) is an early error.

@rbuckton
Copy link
Collaborator

rbuckton commented Jan 5, 2023

Unexecuted code generally shouldn't have side effects.

Although this is a weaker preference, I also don't think we should have a forced interleave point if the using await binding is null or undefined, since a dispose will never be called:

{
  using await x = null;
} // nothing will be disposed, why force a delay?

And the way the spec is written currently, there isn't even an interleave point if the using await binding is a sync dispose that returns undefined. I generally didn't want to introduce artificial delays in program execution.

@mhofman
Copy link
Member Author

mhofman commented Jan 6, 2023

Although this is a weaker preference, I also don't think we should have a forced interleave point if the using await binding is null or undefined, since a dispose will never be called:

{
  using await x = null;
} // nothing will be disposed, why force a delay?

And the way the spec is written currently, there isn't even an interleave point if the using await binding is a sync dispose that returns undefined. I generally didn't want to introduce artificial delays in program execution.

That is definitely a non-starter for us on the same grounds that await foo does not conditionally interleave based on the value of foo. Any end-block awaiting must not depend on the RHS value of using await statements.

Writing code that depends on counting interleaving points has the potential to release Zalgo. That is the case whether this always has an implicit interleaving point or not. A user might try to depend on the existence of a forced interleaving point to try to run code in between microtasks. The potential to "release Zalgo" in either case is purely based on the code that the user writes. Since counting interleaving points in either case has the potential to "release Zalgo", I generally favor the approach that doesn't introduce unnecessary extra delays.

The Zalgo issues we're concerned about have to do with the maybe existence of an interleaving point, not how many interleaving points may exist. We will try to write something to better explain our concerns, but it's basically a regular Zalgo issue of a continuation that is sometimes executed synchronously and sometimes not.

@ljharb

This comment was marked as outdated.

@mhofman

This comment was marked as resolved.

@ljharb

This comment was marked as resolved.

@erights
Copy link

erights commented Jan 6, 2023

We did in fact argue about it in tc39. The mandatory tick won on Zalgo-prevention grounds.

@mhofman
Copy link
Member Author

mhofman commented Jan 6, 2023

i was thinking of the optimizations that reduce, but not eliminate, the ticks in this case.

Right, the Zalgo problems are in general not about the number of ticks if 1 or more, but whether there are 0 or 1 ticks. Of course some code out there will be sensitive about the number of ticks, but I really don't care about those.

@rbuckton
Copy link
Collaborator

rbuckton commented Jan 6, 2023

[...] We will try to write something to better explain our concerns, but it's basically a regular Zalgo issue of a continuation that is sometimes executed synchronously and sometimes not.

The Zalgo issue is a complex topic, but the main intent of the original article was to address how asynchronous APIs should be written, and focused primarily on how to handle asynchronous completion. You don't want an API whose result is sometimes available synchronously, and sometimes available asynchronously. That generally means that you have to wait a tick to observe a result, which is why all Promise continuations occur in a later tick.

User code isn't an API, it produces an API. An async function like the following is reasonable, and whether or not it uses await conditionally is unobservable to the consumer unless they are counting interleaving points:

async function foo(x) {
  z: {
    if (x) break z;
    await bar();
  }
}

await foo();

I think it's perfectly reasonable to have the same expectations in this code as well:

async function foo(x) {
  z: {
    if (x) break z;
    using await y = bar(); // this code is never hit
  }
}

await foo();

And the way the spec is written currently, there isn't even an interleave point if the using await binding is a sync dispose that returns undefined. I generally didn't want to introduce artificial delays in program execution.

That is definitely a non-starter for us on the same grounds that await foo does not conditionally interleave based on the value of foo. Any end-block awaiting must not depend on the RHS value of using await statements.

That is acceptable to me. As I said above, the conditionality of using await x = null is a weak preference.

@rbuckton
Copy link
Collaborator

rbuckton commented Jan 17, 2023

I put up a PR (#6) for what we've been discussing here:

  • using await x = expr syntax
  • No other syntactic marker (i.e., no specialized block form)
  • label: { break label; using await x = ...; } does not introduce an implicit await since the using await declaration is never evaluated and x is never bound.
  • { using await x = null; } does introduce an implicit await since the declaration is evaluated, even when the resource is null or undefined.
  • I've opted to keep for-await-of and using await isolated.

for-await-of and using-await isolation means async iteration of async resources will look like this:

for await (using await x of y) ;

However, I think this is consistent given the following matrix:

// sync iteration, sync disposal
for (using x of y) ;

// sync iteration, async disposal
for (using await x of y) ;

// async iteration, sync disposal
for await (using x of y) ;

// async iteration, async disposal
for await (using await x of y) ;

While having two await markers might seem redundant, I feel it is necessary for consistency and clarity. Both for and using perform runtime type checks for specific symbols, and I'd like to make sure we maintain that invariant:

for (const x of y) ; // @@iterator
for await (const x of y) ; // @@asyncIterator, @@iterator

using x = y; // @@dispose
using await x = y; // @@asyncDispose, @@dispose

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

Successfully merging a pull request may close this issue.

8 participants