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

Resource Management Story #408

Closed
benjamingr opened this issue Jul 25, 2018 · 13 comments
Closed

Resource Management Story #408

benjamingr opened this issue Jul 25, 2018 · 13 comments

Comments

@benjamingr
Copy link
Contributor

Hey,

Thinking about the I/O stuff made me realize that since we don't have defer in JavaScript and we have exceptions so:

func main() {
    f, err := os.Open("/tmp/test.txt")
    if err != nil {
      return
    }
    defer f.Close();
    n, err := fmt.Fprintln(f, "data")
    // skip checking n and `err` for brevity but code should
}

Could be:

async function main() {
   const f = await createFile('/tmp/test.txt'); // ignore API bikeshed
   try {
     const n = await f.write(data);
   } finally {
     f.close();
  }
}

Which unlike defer doesn't really stack up too nicely with multiple files:

async function main() {
   const f = await createFile('/tmp/test.txt');
   try {
     try {
       const f2 = await createFile('/tmp/test2.txt');
       await f.write('data');
       await f2.write('data'); // or Promise.all and do it concurrently
     } finally {
        f2.close(); // by the way - is this synchronous?
     }
   } finally {
     f.close();
  }
}

This sort of code is very hard to write correctly - especially if resources are acquired concurrently (we don't await before the first createFile finishes to do the second) and some of them fail.

We can do other stuff instead for resource management:

  • We can expose disposers for resource management.
  • We can expose a using function from deno and have a "real" resource" story.
  • Something else?

Here is what the above example looks like with exposing a using:

import { using } from 'deno' 

async function main() {
  await using(createFile('/tmp/test.txt'), createFile('/tmp/test2.txt'), async (test1, test2) => {
     // when the promise this returns resolves - both files are closed
  }); // can .catch here to handle errors or wrap with try/catch
}

We wrote some prior art in bluebird in here - C# also does this with using (I asked before defining using in bluebird and got an interesting perspective from Eric). Python has with and Java has try with resource.

Since this is a problem "browser TypeScript" doesn't really have commonly I suspect it will be a few years before a solution might come from TC39 if at all - issues were opened in TypeScript but it was deemed out of scope. Some CCs to get the discussion started:

  • @spion who worked on defer for bluebird coroutines and using with me.
  • @littledan for a TC39 reference and knowing if there is interest in solving this at a language level.
  • @1st1 who did the work on asynchronous contexts for Python

We also did some discussion in Node but there is no way I'm aware of for Node to do this nicely that won't be super risky - Deno seems like the perfect candidate for a safer API for resource management.

@ry if you prefer this as a PR with a concrete proposal for either approach let me know. Alternatively if you prefer the discussion to happen at a later future point let me know.

@spion
Copy link

spion commented Jul 25, 2018

Here are some examples of how co.defer works in generators. Its hacky without first-class syntax:

petkaantonov/bluebird@f944db7#diff-3129285757f0c44eca9973432b3bb15aR704

The Go example would look like this:

funcion* test() {
    let f = yield openFile("/tmp/test.txt");
    co.defer(() => f.close());
    n = yield fmt.Fprintln(f, "data")
    return n;
}

Its possible to patch typescript to make co.defer work with async/await when the target is ES6, but not sure if thats desireable.

It would be much nicer if we had official syntax for all this 😀

@benjamingr
Copy link
Contributor Author

benjamingr commented Jul 25, 2018

As we were able to get a hold of @1st1 who built this for Python in Europython (yay, thanks a ton @ztane for getting the hold!). I have some directed questions:

  • What were the most challenging problems and bugs people had with implementing types with __aenter__ and __aexit__ ?
  • Are you happy with the API you ended up with? Would you have changed it today?
  • If you are familiar with TypeScript and its gradual type system (which are pretty similar to Python's new ish types) - are there any specific type safety concerns with disposers?
  • What do you think is the correct way to handle a context exit terminating?
  • Python traditionally had some GC reliant semantics (like closing generators on GC). What should Deno's behavior be in this regard?

@1st1
Copy link
Contributor

1st1 commented Jul 26, 2018

  • What were the most challenging problems and bugs people had with implementing types with aenter and aexit ?

Hm, nothing comes to mind. I don't think people have any problems implementing asynchronous context managers in Python.

  • Are you happy with the API you ended up with? Would you have changed it today?

Yes, quite happy. It mirrors the API for synchronous context managers in Python:

  • with a calls a.__enter__() and a.__exit__()
  • async with a calls await a.__aenter__() and await a.__aexit__()

As for changing/extending the API—we don't have any issues with the current one, so no.

  • If you are familiar with TypeScript and its gradual type system (which are pretty similar to Python's new ish types) - are there any specific type safety concerns with disposers?

I can only answer for Python: no, there are no type safety concerns as far as I know.

  • What do you think is the correct way to handle a context exit terminating?

Could you please clarify this one?

  • Python traditionally had some GC reliant semantics (like closing generators on GC). What should Deno's behavior be in this regard?

In general we don't care about GC when we create (async-)context managers in Python. Both with o and async with o call the corresponding enter and exit methods automatically and deterministically. Of course it's possible to implement __del__ method on any object in Python to do extra cleanups on GC, but using it for context managers is rather unusual.

@littledan
Copy link

On the language side, there is ongoing work in this area in @rbuckton's using proposal. It just achieved Stage 1 in TC39. See https://github.com/tc39-transfer/proposal-using-statement for more information.

@benjamingr
Copy link
Contributor Author

benjamingr commented Jul 26, 2018

Awesome @littledan - @rbuckton reading that proposal I'm not sure it's safe in terms of resource management - especially with multiple async resources acquired - Consider reading the debates in petkaantonov/bluebird#65 and the Python async context manager work mentioned above.

Basically - safety with multiple resources is very hard - so using is very hard to write correctly with the current proposal.

If there is better place to provide feedback please do let me know and I'd love to help.

@benjamingr
Copy link
Contributor Author

@1st1 thanks a lot, really appreciate it!

Hm, nothing comes to mind. I don't think people have any problems implementing asynchronous context managers in Python.

That's awesome to hear, thanks!

Yes, quite happy. It mirrors the API for synchronous context managers in Python:

We don't have one for JavaScript yet - but acquire and exit semantics like with sound very reasonable - or even just exit semantics like C#.

I can only answer for Python: no, there are no type safety concerns as far as I know.

Thanks, very helpful

What do you think is the correct way to handle a context exit terminating?

Sorry, rereading that it wasn't clear - I'm asking what to do if __aexit__ throws an error when trying to clean up a resource where the async with raised an exception itself (so there are two exceptions).

Is there anything reasonable to do in this case?

but using it for context managers is rather unusual.

Good :)

@littledan
Copy link

@benjamingr I encourage you to file issues on that proposal repository--that will make your feedback more visible to everyone engaging in TC39.

@1st1
Copy link
Contributor

1st1 commented Jul 30, 2018

What do you think is the correct way to handle a context exit terminating?

Sorry, rereading that it wasn't clear - I'm asking what to do if aexit throws an error when trying to clean up a resource where the async with raised an exception itself (so there are two exceptions).

Is there anything reasonable to do in this case?

Both synchronous and asynchronous context manager protocols work exactly the same in Python. The only difference is that synchronous protocol calls __enter__() and __exit__(), whereas asynchronous awaits on __aenter__() and __aexit__().

Now I'll try to showcase all error cases and explain how Python handles them using a synchronous protocol. Sorry if you know all of this stuff already!

For example, for the given code:

class Foo:
    def __enter__(self):
        1 / 0

    def __exit__(self, *e):
        print('exiting')

with Foo():
    pass

Python will output

Traceback (most recent call last):
  File "t.py", line 8, in <module>
    with Foo():
  File "t.py", line 3, in __enter__
    1 / 0
ZeroDivisionError: division by zero

I.e. any exception that happens while we are entering the context manager doesn't get intercepted by __exit__ or __aexit__ methods.


Now let's make the wrapped code to raise an error:

class Foo:
    def __enter__(self):
        pass

    def __exit__(self, *e):
        print('exiting', e)

with Foo():
    1 / 0

Now the output is this:

exiting (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x10945cd48>)
Traceback (most recent call last):
  File "t.py", line 9, in <module>
    1 / 0
ZeroDivisionError: division by zero

So an exception has occurred, got passed to the __exit__ block, and was propagated after __exit__ has completed.


Now, if we add return True to our __exit__ method, the exception will be ignored:

class Foo:
    def __enter__(self):
        pass

    def __exit__(self, *e):
        print('exiting', e)
        return True

with Foo():
    1 / 0

Running it will show you:

exiting (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x107394d48>)

Now, we're getting to the meat of your question. Let's raise another error in __exit__:

class Foo:
    def __enter__(self):
        pass

    def __exit__(self, *e):
        print('exiting', e)
        1 + 'aaa'

with Foo():
    1 / 0

Now we have:

exiting (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x10e03ddc8>)
Traceback (most recent call last):
  File "t.py", line 10, in <module>
    1 / 0
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "t.py", line 10, in <module>
    1 / 0
  File "t.py", line 7, in __exit__
    1 + 'aaa'
TypeError: unsupported operand type(s) for +: 'int' and 'str'

So another exception—TypeError—occurred while we were handling the original ZeroDivisionError. In cases like this, Python always propagates the latest exception, but it uses the special __context__ attribute on it to point to the other exception that was unhandled at that point. Therefore, Python is able to render the full report of what happened.


This mechanism duplicates the behaviour of how try..except works on Python:

try:
    1/0
except:
    1 + 'aa'

Will produce

Traceback (most recent call last):
  File "t.py", line 2, in <module>
    1/0
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "t.py", line 4, in <module>
    1 + 'aa'
TypeError: unsupported operand type(s) for +: 'int' and 'str'

Hopefully this explains how CMs work in Python (and I hope I correctly interpreted your question!) :)

@benjamingr
Copy link
Contributor Author

Hopefully this explains how CMs work in Python (and I hope I correctly interpreted your question!) :)

Thanks, that clears everything entirely. The analogy for us would be to attach a .source or .origin property to the raised inner exception in a using which makes sense.

@hayd
Copy link
Contributor

hayd commented Feb 18, 2019

I made a repo with python-like with statements for deno: https://github.com/hayd/deno-using

@ry
Copy link
Member

ry commented Oct 18, 2019

We cannot automatically clean up resources. They must be destroyed by the caller.

@ry ry closed this as completed Oct 18, 2019
@spion
Copy link

spion commented Jun 20, 2023

Looks like this may be finding its way into TS soon microsoft/TypeScript#54505

@bombillazo
Copy link

how can I try out using with TypeScript 5.2 in Deno?

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

No branches or pull requests

7 participants