Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Support using and await using declarations #54505

Merged
merged 33 commits into from Jun 22, 2023
Merged

Support using and await using declarations #54505

merged 33 commits into from Jun 22, 2023

Conversation

rbuckton
Copy link
Member

@rbuckton rbuckton commented Jun 2, 2023

This adds support for the using and await using declarations from the TC39 Explicit Resource Management proposal, which is currently at Stage 3.

NOTE: This implementation is based on the combined specification text from tc39/proposal-explicit-resource-management#154, as per TC39 plenary consensus to merge the sync and async proposals together now that they both are at Stage 3.

Overview

A using declaration is a new block-scoped variable form that allows for the declaration of a disposable resource. When the variable is initialized with a value, that value's [Symbol.dispose]() method is recorded and is then invoked when evaluation exits the containing block scope:

{
    // 'resource' is declared
    using resource = new MyResource();
    ...

} // 'resource' is disposed (e.g., `resource[Symbol.dispose]()` is evaluated)

An await using declaration is similar to a using declaration, but instead declares an asynchronously disposable resource. In this case, the value must have a [Symbol.asyncDispose]() method that will be awaited at the end of the block:

async function f() {
    // 'resource' is declared
    await using resource = new MyAsyncResource();
    ...

} // 'resource' is disposed (e.g., `await resource[Symbol.asyncDispose]()` is evaluated)

Disposable Resources

A disposable resource must conform to the Disposable interface:

// lib.esnext.disposable.d.ts
interface Disposable {
    [Symbol.dispose](): void;
}

While an asynchronously disposable resource must conform to either the Disposable interface, or the AsyncDisposable interface:

// lib.esnext.disposable.d.ts
interface AsyncDisposable {
    [Symbol.asyncDispose](): Promise<void>;
}

using Declarations

A using declaration is a block-scoped declaration with an immutable binding, much like const.

As with const, a using declaration must have an initializer and multiple using declarations can be declared in a single statement:

using x; // syntax error

using x = f(), y = g(); // ok

No Binding Patterns

However, unlike const, a using declaration may not be a binding pattern:

using res = getResource(); // ok
using { x, y } = getResource(); // syntax error
using [ x, y ] = getResource(); // syntax error

Instead, it is better to perform destructuring in a secondary step:

// (a) if 'res' is disposable and you want to destructure 'x' and 'y':
using res = getResource();
const { x, y } = res;

// (b) if 'res' is not disposable, but 'res.x' and 'res.y' are:
const { x, y } = getResource();
using _x = x, _y = y;

NOTE: If option (b) seems less than ideal, that's because it may indicate a bad practice on the part of the resource producer (i.e., getResource()), not the consumer, since there's no guarantee that x and y have no dependencies with respect to disposal order.

Allowed Values

When a using declaration is initialized, the runtime captures the value of the initializer (e.g., the resource) as well as its [Symbol.dispose]() method for later invocation. If the resource does not have a [Symbol.dispose]() method, and is neither null nor undefined, an error is thrown:

using x = {}; // throws at runtime
using x = { [Symbol.dispose]() {} }; // ok
using x = null; // ok
using x = undefined; // ok

As each using declaration is initialized, the resource's disposal operation is recorded in a stack, such that resources will be disposed in the reverse of the order in which they were declared:

{
    using x = getX(), y = getY();
    using z = getZ();
    ...
} // disposes `z`, then `y`, then `x`.

Where can a using be declared?

A using declaration is legal anywhere a const declaration is legal, with the exception of the top level of a non-module Script when not otherwise enclosed in a Block:

// @filename: script1.ts
using x = getX(); // error, `using` at top level and file is not a module

// @filename: script2.ts
{
    using x = getX(); // ok, file is not a module, but `using` is inside of a Block
}

// @filename: module.ts
using x = getX(); // ok, `using` at top level and file is a module
export {};

This is because a const declaration in a script is essentially global, and therefore has no scoped lifetime in which its disposal could meaningfully execute.

Exception Handling

Resources are guaranteed to be disposed even if subsequent code in the block throws, as well as if exceptions are thrown during the disposal of other resources. This can result in a case where disposal could throw an exception that would otherwise suppress another exception being thrown:

try {
    using c = { [Symbol.dispose]() { throw new Error("c"); } };
    using b = { [Symbol.dispose]() { throw new Error("b"); } };
    throw new Error("a");
}
catch (e) {
    e; // ?
}

To avoid losing the information associated with the suppressed error, the proposal introduced a new native SuppressedError exception. In the case of the above example, e would be

SuppressedError {
    error: Error("c"),
    suppressed: SuppressedError {
        error: Error("b"),
        suppressed: Error("a")
    }
}

allowing you to walk the entire stack of error suppressesions.

await using Declarations

An await using declaration is similar to using, except that it operates on asynchronously disposable resources. These are resources whose disposal may depend on an asynchronous operation, and thus should be awaited when the resource is disposed:

class Transaction implements AsyncDisposable {
    #conn: DatabaseConnection;
    #disposed = false;
    #complete = false;

    private constructor(conn: DatabaseConnection) {
        this.#conn = conn;
    }

    static async begin(conn: DatabaseConnection) {
        await conn.exec("begin transaction");
        return new Transaction(conn);
    }

    setComplete() {
        if (this.#disposed) throw new Error("Object is disposed");
        this.#complete = true;
    }

    async [Symbol.asyncDispose]() {
        if (this.#disposed) return;
        this.#disposed = true;
        if (this.#complete) {
            await this.#conn.exec("commit transaction");
        }
        else {
            await this.#conn.exec("rollback transaction");
        }
    }
}

async function transfer(from: Accouint, to: Account, amount: number) {
    await using conn = new DatabaseConnection(CONNECTION_STRING);
    await using tx = Transaction.begin(conn);
    const [fromValue, toValue] = await conn.exec(TRANSFER_SQL, { from: from.id, to: to.id, amount });
    from.value = fromValue;
    to.value = toValue;
    tx.setComplete(); // if we get to this line without an exception, we can commit the transaction

} // either commit or rollback the transaction, and then close the connection, even if an exception was thrown

Allowed Values

The resource supplied to an await using declaration must either have a [Symbol.asyncDispose]() method or a [Symbol.dispose]() method, or be either null or undefined, otherwise an error is thrown:

await using x = {}; // throws at runtime
await using x = { async [Symbol.asyncDispose]() {} }; // ok
await using x = { [Symbol.dispose]() {} }; // ok
await using x = null; // ok
await using x = undefined; // ok

Please note that while a [Symbol.asyncDispose]() method doesn't necessarily need to return a Promise in JavaScript, for TypeScript code we've opted to make it an error if the return type is not Promise-like to better surface potential typos or missing return statements.

Where can an await using be declared?

Since this functionality depends on the ability to use await, an await using declaration may only appear in places where an await or for await of statement might be legal.

Implicit await at end of Block

It is important to note that any Block containing an await using statement will have an implicit await that occurs at the end of that block, as long as the await using statement is actually evaluated:

async function f() {
    {
        if (true) break;
        await using res = getResource();
    } // will not implicitly `await` since the `await using` was not evaluated.
}

async function g() {
    {
        await using res = null;
    } // will still implicitly `await` since the `await using` was evaluated, even if no resource was tracked.
}

This can have implications on code that follows the block, as it is not guaranteed to run in the same microtask as the last statement of the block.

for Statements

using and await using declarations are allowed in the head of a for statement:

for (using res = getResource(); !res.done; res.advance()) {
    ...
} // 'res' is disposed when iteration has finished or block exits

In this case, the resource (res) is not disposed until either iteration completes (i.e., res.done is true) or the for is exited early due to return, throw, break, or a non-local continue.

for-in Statements

using and await using declarations are not allowed in the head of a for-in statement:

for (using x in obj) { // syntax error
}

for-of Statements

using and await using declarations are allowed in the head of a for-of statement:

function * produceResources() {
    yield getResource1();
    yield getResource2();
}

for (using res of produceResouces()) { // ok
    ...
} // each instance of 'res' is disposed when a single iteration completes

In a for-of statement, block-scoped bindings are initialized once per each iteration, and thus are disposed at the end of each iteration.

for-await-of Statements

Much like for-of, using and await using may be used in the head of a for-await-of statement:

for await (using res of produceResources()) { ... } // ok
for await (await using res of produceResources()) { ... } // ok

It is important to note that there is a distinction between the above two statements. A for-await-of does not implicitly support asynchronously disposed resources when combined with a synchronous using, thus an AsyncIterable<AsyncDisposable> will require both awaits in for await (await using ....

switch Statements

A using or await using declaration may appear in in the statement list of a case or default clause a switch statement. In this case, any resources that are tracked for disposal will be disposed when exiting the CaseBlock:

const x = 0;
switch (x) {
    case 0:
        using res0 = getResource0();
        // falls through
        // NOTE: res0 is not disposed at this point, as it is still visible to `case 1`:
    case 1:
        using res1 = getResource1();
        break;
} // disposes `res1`, then `res0`

Downlevel Emit

The using and await using statements are supported down-level as long as the following globals are available at runtime:

  • Symbol.dispose — To support the Disposable protocol.
  • Symbol.asyncDispose — To support the AsyncDisposable protocol.
  • SuppressedError — To support the error handling semantics of using and await using.
  • Promise — To support await using.

A using declaration is transformed into a try-catch-finally block as follows:

// source:
{
    using res = getResource();
    res.work();
}

// generated:
var __addDisposableResource = ...; // helper
var __disposeResources = ...; // helper

const env_1 = { stack: [], error: void 0, hasError: false };
try {
    const res = __addDisposableResource(env_1, getResource(), false);
    res.work();
}
catch (e_1) {
    env_1.error = e_1;
    env_1.hasError = true;
}
finally {
    __disposeResources(env_1);
}

The env_ variable holds the stack of resources added by each using statement, as well as any potential error thrown by any subsequent statements.

The emit for an await using differs only slightly:

// source:
async function f() {
    await using res = getResource();
    res.work();
}

// generated:
var __addDisposableResource = ...; // helper
var __disposeResources = ...; // helper

async function f() {
    const env_1 = { stack: [], error: void 0, hasError: false };
    try {
        const res = __addDisposableResource(env_1, getResource(), true); // <- indicates an 'await using'
        res.work();
    }
    catch (e_1) {
        env_1.error = e_1;
        env_1.hasError = true;
    }
    finally {
        const result_1 = __disposeResources(env_1);
        if (result_1) {
            await result_1;
        }
    }
}

For await using, we conditionally await the result of the __disposeResources call. The return value will always be a Promise if at least one await using declaration was evaluated, even if its initializer was null or undefined (see Implicit await at end of Block, above).

Important Considerations

super()

The introduction of a try-catch-finally wrapper breaks certain expectations around the use of super() that we've had in a number of our existing transforms. We had a number of places where we expected super() to only be in the top-level statements of a constructor, and that broke when I started transforming

class C extends B {
    constructor() {
        using res = getResource();
        super();
    }
}

into

class C extends B {
    constructor() {
        var env_1 = { ... };
        try {
            const res = __addDisposableResource(env_1, res, false);
            super();
        }
        catch ...
        finally ...
    }
}

The approach I've taken in this PR isn't perfect as it is directly tied into the try-catch-finally wrapper produced by using. I have a longer-term solution I'm considering that will give us far more flexibility with super(), but it requires significant rework of super() handling in the es2015 transform and should not hold up this feature.

Modules and top-level using

This also required similar changes to support top-level using in a module. Luckily, most of those changes were able to be isolated into the using declarations transform.

Transformer Order

For a number of years now we have performed our legacy --experimentalDecorators transform (transforms/legacyDecorators.ts) immediately after the ts transform (transforms/ts.ts), and before the JSX (transforms/jsx.ts) and ESNext (transforms/esnext.ts) transforms. However, this had to change now that decorators are being standardized. As a result, the native decorators transform (transforms/esDecorators.ts) has been moved to run after the ESNext transform. This unfortunately required a bit of churn in both the native decorators transform and class fields transform (transforms/classFields.ts). The legacy decorators transform will still run immediately after the ts transform, since it is still essentially TypeScript-specific syntax.

Future Work

There is still some open work to finish once this PR has merged, including:

  • Updates to the TypeScript TextMate language file for proper syntax highlighting.
  • Updates to tslib for the new helpers.
  • Quick fixes and refactors in the Language Service, such as potentially converting a try-finally containing a resource into a using.

Fixes #52955

}

// `typeNode` is not merged as it only applies to comment emit for a variable declaration.
// TODO: `typeNode` should overwrite the destination
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left this TODO since changing this is out of scope for this PR.

Copy link
Member

@weswigham weswigham left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs declaration emit tests, since these can be pulled into declaration emit via typeof types nodes, eg

await using d1 = { async [Symbol.asyncDispose]() {} };
export type ExprType = typeof d1;

I'm pretty sure we'll need to transform them to normal non-using variable declarations, since there's no disposal stuff as far as the types care.

src/compiler/checker.ts Outdated Show resolved Hide resolved
dispose = value[Symbol.dispose];
}
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
env.stack.push({ value: value, dispose: dispose, async: async });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated: at what point will it be OK for us to use es6 features like object shorthands in our esnext downlevel helpers?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If our helpers were an AST instead of a string, then we could arguably downlevel them on demand. Unfortunately, that wouldn't work for tslib. Since we aren't downleveling the helpers, I'd say we can use new syntax only if we retire --target es5 and --target es3.

src/compiler/factory/emitHelpers.ts Outdated Show resolved Hide resolved
src/compiler/factory/nodeFactory.ts Outdated Show resolved Hide resolved
Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some initial comments before looking at the tests.

src/compiler/factory/nodeFactory.ts Outdated Show resolved Hide resolved
}
declare var SuppressedError: SuppressedErrorConstructor;

interface DisposableStack {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To check my understanding, is this a user-visible utility class to allow people to non-disposables to be disposed, and avoid disposal at the end of the block if they choose?
Is use basically equivalent to using, except that it also makes the disposable managed by the stack?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, use is the imperative equivalent of using. One way to conceptualize this is that

{
  using x = getX();
  using y = getY();
  doSomething();
}

is roughly equivalent to

const stack = new DisposableStack();
try {
  const x = stack.use(getX());
  const y = stack.use(getY());
  doSomething();
}
finally {
  stack[Symbol.dispose]();
}

(except that using has additional semantics around handling error suppressions caused by disposal)

src/compiler/utilities.ts Outdated Show resolved Hide resolved
src/compiler/utilities.ts Outdated Show resolved Hide resolved
src/compiler/utilities.ts Outdated Show resolved Hide resolved
src/compiler/transformers/esDecorators.ts Show resolved Hide resolved
src/compiler/transformers/esDecorators.ts Outdated Show resolved Hide resolved
src/compiler/transformers/esDecorators.ts Outdated Show resolved Hide resolved
src/compiler/transformers/esDecorators.ts Show resolved Hide resolved
src/compiler/transformers/es2015.ts Show resolved Hide resolved
@sandersn sandersn moved this from Not started to Waiting on author in PR Backlog Jun 19, 2023
@DanielRosenwasser
Copy link
Member

DanielRosenwasser commented Jun 20, 2023

One of the RAII patterns I've used in C++ was to acquire a lock within a scope by constructing a variable. That variable wouldn't get referenced at all beyond its declaration because it's just used for automatic cleanup.

One thing I found kind of "off" was that in the current implementation:

  1. In --noUnusedLocals, we will always error on an unused using declaration.
  2. Outside of --noUnusedLocals, we will give an editor suggestion about the variable being unused.

I think it probably makes sense to make the same exception we have for parameters, exempting them from this check as long as they're prefixed with an underscore. If we want, we can limit this to just using and async using.

Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a couple of questions from me.

@rbuckton
Copy link
Member Author

One of the RAII patterns I've used in C++ was to acquire a lock within a scope by constructing a variable. That variable wouldn't get referenced at all beyond its declaration because it's just used for automatic cleanup.

One thing I found kind of "off" was that in the current implementation:

  1. In --noUnusedLocals, we will always error on an unused using declaration.
  2. Outside of --noUnusedLocals, we will give an editor suggestion about the variable being unused.

I think it probably makes sense to make the same exception we have for parameters, exempting them from this check as long as they're prefixed with an underscore. If we want, we can limit this to just using and async using.

I think that makes sense, and I can look into that today. The proposal used to include a bindingless form a la using void = getResource(), but that was deferred to a follow on to achieve an MVP for the proposal.

@DanielRosenwasser
Copy link
Member

One thing that I think we should seriousy consider is whether Disposable and AsyncDisposable should be named DiposableLike and AsyncDiposableLike.

The thing I'm wary of is something like Iterator happening again, where we name the type the most obvious thing but it conflicts with future work from TC39 where an instance constructed by Iterators is now more special in some weird way.

@rbuckton
Copy link
Member Author

One thing that I think we should seriousy consider is whether Disposable and AsyncDisposable should be named DiposableLike and AsyncDiposableLike.

The thing I'm wary of is something like Iterator happening again, where we name the type the most obvious thing but it conflicts with future work from TC39 where an instance constructed by Iterators is now more special in some weird way.

I'm not sure I like using the Like suffix for a well-defined symbol-based protocol. We use it for PromiseLike, but that is because we are just looking for a then method and there were many objects that were suited at the time, such as JQuery's Deferred. I know that, in general, we avoid prefix notation like I, but I might honestly prefer IDisposable/IAsyncDisposable over DisposableLike/AsyncDisposableLike since it seems more definitive? It's a weak preference though.

@rbuckton
Copy link
Member Author

I've modified __disposeResources to fall back to Error when SuppressedError doesn't exist. It's not a fully spec-conformant SuppressedError, but it reduces the minimal shim necessary to support this feature at runtime such that it only needs to include definitions for Symbol.dispose and Symbol.asyncDispose to use the feature.

@rbuckton
Copy link
Member Author

One thing that I think we should seriousy consider is whether Disposable and AsyncDisposable should be named DiposableLike and AsyncDiposableLike.
The thing I'm wary of is something like Iterator happening again, where we name the type the most obvious thing but it conflicts with future work from TC39 where an instance constructed by Iterators is now more special in some weird way.

I'm not sure I like using the Like suffix for a well-defined symbol-based protocol. We use it for PromiseLike, but that is because we are just looking for a then method and there were many objects that were suited at the time, such as JQuery's Deferred. I know that, in general, we avoid prefix notation like I, but I might honestly prefer IDisposable/IAsyncDisposable over DisposableLike/AsyncDisposableLike since it seems more definitive? It's a weak preference though.

Iterator/AsyncIterator are a bit unique in that they have behavior where it makes sense to model additional behavior on top of it. Disposable/AsyncDisposable is less likely to run into that, because the behavior they model is essentially "once and done".

If there were a global Disposable constructor, I could only really see it being something like new Disposable(() => { ... }). However, I think DisposableStack is a better, existing option to fall back on instead:

const stack = new DisposableStack();
stack.defer(() => { ... });

I think we're more likely to see special-case disposables that use a more specific name, such as a built-in SafeHandle that ensures things like handles, file descriptors, etc., are closed when the handle is GC'd:

class SafeHandle<T> {
    static #dispose = ({ unsafeHandle, cleanup }: { unsafeHandle: T, cleanup: (unsafeHandle: T) => void }) => {
        cleanup(unsafeHandle);
    };
    static #registry = new FinalizationRegistry(SafeHandle.#dispose);
    #unregisterToken = {};
    #data: { unsafeHandle: T, cleanup: (unsafeHandle: T) => void } | undefined;

    constructor(unsafeHandle: T, cleanup: (unsafeHandle: T) => void) {
        this.#data = { unsafeHandle, cleanup };
        SafeHandle.#registry.register(this, this.#data, this.#unregisterToken);
    }

    get unsafeHandle() {
        if (!this.#data) throw new ReferenceError("Object is disposed");
        return this.#data.unsafeHandle;
    }

    dispose() {
        if (this.#data) {
            SafeHandle.#registry.unregister(this.#unregisterToken);
            const data = this.#data;
            this.#data = undefined;
            SafeHandle.#dispose(data);
        }
    }

    [Symbol.dispose]() {
        return this.dispose();
    }
}

@rbuckton
Copy link
Member Author

@weswigham: I addressed the declaration emit request. Do you have any further feedback?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Author: Team For Milestone Bug PRs that fix a bug with a specific milestone
Projects
PR Backlog
  
Done
Development

Successfully merging this pull request may close these issues.

ECMAScript Explicit Resource Management & using Declarations
6 participants