Repository: strawberry-graphql/strawberry

Issue:
Deleting generator in subscription clean up will not call a 'finally' statement
<!-- Provide a general summary of the bug in the title above. -->

<!--- This template is entirely optional and can be removed, but is here to help both you and us. -->
<!--- Anything on lines wrapped in comments like these will not show up in the final text. -->

## Describe the Bug
I have found an issue with the way subscriptions are cleaned up when using the graphql-transport-ws protocol. 

My subscription returns a AsyncIterator which look something like this:
```
async def get_value() -> AsyncIterator[int]:
    try:
        while True:
            # Call some other code to 
            # get the value from another object
    finally:
        # Do some important cleanup here.
```
I have multiple subscriptions, which are being updated at 10Hz. When I cancel the subscriptions (for example, close the webpage calling subscribe), I get a `ConnectionResetError: "Cannot write to closing transport"` on the server side, presumably as the task has not yet been cancelled and is trying to send an updated value while the websocket is closing. Looking at the Strawberry code that handles this (subscriptions/protocols/graphql_transport_ws/handlers.py), I can see that this error is caught as a `BaseException` ([here](https://github.com/strawberry-graphql/strawberry/blob/fe9737b9d218f437500db215525a147de2ea1d64/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py#L218)) and so this task and generator are deleted from the internal dictionaries. 

What I have found however, is that when the generator is simply deleted in this clean up, the 'finally' statement in my try-finally implementation does not get executed and so I cannot perform any clean up. I have seen this lead to a memory leak, which is only stopped when the garbage collector comes round and runs the 'finally' statement. 

To ensure the generator runs to completion and executes the 'finally' statement, I wonder whether the generator should be explicitly closed first, before deleting it from the internal subscriptions list, much like is done in the cleanup_operation() method ([here](https://github.com/strawberry-graphql/strawberry/blob/fe9737b9d218f437500db215525a147de2ea1d64/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py#L282?)).

## System Information

 - Operating system: Centos 7
 - Strawberry version (if applicable): 0.121.1
 - Python version: 3.8.12

## Additional Context

<!-- Add any other relevant information about the problem here. -->



Discussion:
> To ensure the generator runs to completion and executes the 'finally' statement, I wonder whether the generator should be explicitly closed first, before deleting it from the internal subscriptions list, much like is done in the cleanup_operation() method ([here](https://github.com/strawberry-graphql/strawberry/blob/fe9737b9d218f437500db215525a147de2ea1d64/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py#L282?)).

I think this makes sense :)

By the way, do you have a small reproduction case? 
Thanks for the fast response! I do, I will just tidy it up and send a link asap.
I have created a Strawberry server to demo this issue. It is very simple and just prints out when the 'finally' statement is run. It keeps a running total of the number of subscription calls made verses the number of times the finally statement is called. If everything is OK then these values should be equal. Also, just to emphasize the issue, I am updating the subscription's value at 20Hz.

I have also create a simple React client that will simply make 100 subscription calls to the server using the graphql-transport-ws protocol.

**Steps to reproduce issue**
- Clone the server code from: https://github.com/rjwills28/demo_strawberry_server
Installation instructions are included in the readme.
- Clone the client code from: https://github.com/rjwills28/demo_multi_subscription_client
Again, installation instruction using node are included in the readme.
- Start the Strawberry server with: 
`demo_strawberry_server`
- In another terminal, start the React client with:
`npm start`
- This will load a webpage with text indicating that it has created 100 subscriptions. This will be evident in the terminal running the server as it will print 100 x 'Subscribing to id: XX'.
- Now close the React webpage. In the server terminal you will see print statements indicating that 'finally' has been called for each subscription and will show the number of finally calls vs number of subscriptions. These should be equal if every subscription has been closed successfully with the generator running to completion. However often this is not the case and the finally does not get called for all subscriptions.
- Note that this issue is a race condition between the subscription being cancelled and it trying to update a value. This means you will not see the issue every time. Please run the steps of opening the React client webpage and closing it a couple of times:
   -  In a browser, return to: localhost:3000/
   - Close the page
 - What you should see is that the number of finally calls is less than the number of subscriptions made. When you come to quit the server (with Ctrl+c) you will then see that the missing 'finally' statements are eventually called.

Please let me know if you have any problems reproducing or any other questions.
@rjwills28 thank you so much for the detailed reproduction case! I don't know if I'll be able to fix this today, but hopefully I'll be working on it during the weekend 😊
ah, @rjwills28 I think you were exactly right on moving the code, I just did a quick test, updating the [code you referenced](https://github.com/strawberry-graphql/strawberry/blob/fe9737b9d218f437500db215525a147de2ea1d64/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py#L282?) to look like this:

```python
    async def cleanup_operation(self, operation_id: str) -> None:
        if operation_id not in self.subscriptions:
            return
        generator = self.subscriptions.pop(operation_id)
        task = self.tasks.pop(operation_id)
        task.cancel()

        # since python 3.8, generators cannot be reliably closed
        with suppress(RuntimeError):
            await generator.aclose()

        with suppress(BaseException):
            await task
```

and it seems to work well! Would you mind opening a PR? I feel like you did all the work and I don't want to take credit for it 😊
Thanks for looking at this @patrick91! I will create a PR now.

Resolving patch:
diff --git a/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py b/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py
--- a/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py
+++ b/strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py
@@ -217,6 +217,11 @@ async def operation_task(
             await self.handle_async_results(result_source, operation_id)
         except BaseException:  # pragma: no cover
             # cleanup in case of something really unexpected
+            # wait for generator to be closed to ensure that any existing
+            # 'finally' statement is called
+            result_source = self.subscriptions[operation_id]
+            with suppress(RuntimeError):
+                await result_source.aclose()
             del self.subscriptions[operation_id]
             del self.tasks[operation_id]
             raise
@@ -275,14 +280,14 @@ async def send_message(self, message: GraphQLTransportMessage) -> None:
     async def cleanup_operation(self, operation_id: str) -> None:
         if operation_id not in self.subscriptions:
             return
-        generator = self.subscriptions.pop(operation_id)
+        result_source = self.subscriptions.pop(operation_id)
         task = self.tasks.pop(operation_id)
-        # since python 3.8, generators cannot be reliably closed
-        with suppress(RuntimeError):
-            await generator.aclose()
         task.cancel()
         with suppress(BaseException):
             await task
+        # since python 3.8, generators cannot be reliably closed
+        with suppress(RuntimeError):
+            await result_source.aclose()
 
     async def reap_completed_tasks(self) -> None:
         """


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -28,7 +28,7 @@ jobs:
     steps:
       - uses: actions/checkout@v3
       - run: pipx install poetry
-      - uses: actions/setup-python@v3.1.2
+      - uses: actions/setup-python@v4
         id: setup-python
         with:
           python-version: ${{ matrix.python-version }}
@@ -58,7 +58,7 @@ jobs:
     steps:
       - uses: actions/checkout@v3
       - run: pipx install poetry
-      - uses: actions/setup-python@v3.1.2
+      - uses: actions/setup-python@v4
         id: setup-python
         with:
           python-version: "3.10"
@@ -93,7 +93,7 @@ jobs:
     steps:
       - uses: actions/checkout@v3
       - run: pipx install poetry
-      - uses: actions/setup-python@v3.1.2
+      - uses: actions/setup-python@v4
         id: setup-python
         with:
           python-version: "3.10"
@@ -128,7 +128,7 @@ jobs:
     steps:
       - uses: actions/checkout@v3
       - run: pipx install poetry
-      - uses: actions/setup-python@v3.1.2
+      - uses: actions/setup-python@v4
         id: setup-python
         with:
           python-version: "3.10"