Closed mhofman closed 1 year ago
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.
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
.
(please be aware of async do { stmt_list; }
from do expression proposal)
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.
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.
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.
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.
(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.
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 await
ed 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 await
ing 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.
(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 asusing async
orasync using
. (I prefer the first but could live with either.) This is a perfect use ofasync
-- it says beware of a possibleawait
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.
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.
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, do you still have reservations about not having an explicit
await
marker? Would the fact that an async function itself is annotated with anasync
keyword not be enough of an indication, along with the fact thatasync using
declarations themselves are annotated with anasync
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-levelawait
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?
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...
Given that we allow a top level
await
in a module, I think we should allow a top levelasync using
. Seems analogous to the top level of an async function body or the top level of afor 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
:
async using
at the top of a future async do {}
block.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
I like this analysis. I think we're converging. Thanks!
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.
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).
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.
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.
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.
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.)
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
.
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.
There was some discussion about this on Matrix.
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.
@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.
Pinging @kriskowal since they expressed interest in this topic on Matrix during the plenary.
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
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.async
is treated as a contextual modifier and thus always comes to the left of the thing it modifies.using await
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.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.
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?
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
}
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 anyusing
bindings appear in the block.
To clarify, would this syntax meet that requirement?
{
using await x = getSomeAsyncResource();
}
Given that:
using
keyword, which indicates something happens at the end of the block.await
keyword, which indicates that the something that happens will result in an async interleave point.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 anawait
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;
}
@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 anawait
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 https://github.com/tc39/proposal-explicit-resource-management/issues/136.
@ljharb:
In other words, any
using
statement would, likeconst
orlet
, "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?
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.
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.
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.
Although this is a weaker preference, I also don't think we should have a forced interleave point if the
using await
binding isnull
orundefined
, 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 returnsundefined
. 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.
await foo
does not conditionally interleave based on the value of foo
@mhofman yes it does? if foo
is a non-thenable, i'm pretty sure it won't add a tick.
It definitely adds a tick. Thankfully the committee didn't make that mistake.
@mhofman ah, you're right - i was thinking of the optimizations that reduce, but not eliminate, the ticks in this case.
We did in fact argue about it in tc39. The mandatory tick won on Zalgo-prevention grounds.
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.
[...] 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 returnsundefined
. 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 offoo
. Any end-block awaiting must not depend on the RHS value ofusing await
statements.
That is acceptable to me. As I said above, the conditionality of using await x = null
is a weak preference.
I put up a PR (#6) for what we've been discussing here:
using await x = expr
syntaxlabel: { 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
.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
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 tc39/proposal-async-explicit-resource-management#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 theusing
syntax is that a block containing ausing
declaration implicitly creates aDisposableStack
. Everyusing
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 anAsyncDisposableStack
. At that point anyusing
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 theusing
declaration, as nothing is awaited at the declaration time. Similarly I would be against any syntax that doesn't use theawait
keyword on the block as a marker of interleaving. I would be ok with aasync 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 ...)
, andusing
declarations insidefor-await-of
blocks in general, would implicitly use anAsyncDisposableStack
.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.