Repository: aio-libs/aiohttp

Issue:
`ClientWebSocketResponse.close_code` will be `None` when there are concurrent async task receiving data and closing connection
### Describe the bug

If one asyncio task is waiting on receiving data and another asyncio task is closing the connection. The `ClientWebSocketResponse.close_code` will be `None` after connection closed. 

It looks like the following is happening:

1. `close()` called and feed `WS_CLOSING_MESSAGE`: https://github.com/aio-libs/aiohttp/blob/fde452da30f326f4fc50cbf6cbe32d6387c48c0f/aiohttp/client_ws.py#L191-L193
2. `receive()` received `WS_CLOSING_MESSAGE` , mark `self._closing` to be True: https://github.com/aio-libs/aiohttp/blob/fde452da30f326f4fc50cbf6cbe32d6387c48c0f/aiohttp/client_ws.py#L285-L286
3. `close()` (after sent out the close message to server) find `self._closing` is True, close the response and return, **WITHOUT** setting `self._close_code`: https://github.com/aio-libs/aiohttp/blob/fde452da30f326f4fc50cbf6cbe32d6387c48c0f/aiohttp/client_ws.py#L210-L212


### To Reproduce

See the steps in https://github.com/wenleix/aiohttp_recv_upon_close

### Expected behavior

Expect to have closed code 1000, i.e.

```
Waiting for message
Closed code: 1000
```

### Logs/tracebacks

```python-traceback
The actual result is :

Waiting for message
Closed code: None
```
```


### Python Version

```console
$ python --version

Python 3.9.13
```
```


### aiohttp Version

```console
$ python -m pip show aiohttp

Name: aiohttp
Version: 3.8.4
Summary: Async http client/server framework (asyncio)
Home-page: https://github.com/aio-libs/aiohttp
Author:
Author-email:
License: Apache 2
Requires: aiosignal, async-timeout, attrs, charset-normalizer, frozenlist, multidict, yarl
Required-by:
```
```


### multidict Version

```console
$ python -m pip show multidict

Name: multidict
Version: 6.0.4
Summary: multidict implementation
Home-page: https://github.com/aio-libs/multidict
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache 2
Requires:
Required-by: aiohttp, yarl
```
```


### yarl Version

```console
$ python -m pip show yarl

Name: yarl
Version: 1.9.2
Summary: Yet another URL library
Home-page: https://github.com/aio-libs/yarl/
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache-2.0
Requires: idna, multidict
Required-by: aiohttp
```
```


### OS

macOS

### Related component

Client

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct


Discussion:
Looks to me like this should be changed:
```diff
- if self._waiting is not None and not self._closed:
+ if self._waiting is not None and not self._closing:
+     self._closing = True
```
Otherwise, multiple close() calls could send multiple CLOSING messages.

Then:
```diff
- if self._closing:
+ if self._close_code:
```
So, in error cases, where the code is already set, it closes immediately. In normal closing, it would continue to the end of the closing logic.

Resolving patch:
diff --git a/aiohttp/client_ws.py b/aiohttp/client_ws.py
--- a/aiohttp/client_ws.py
+++ b/aiohttp/client_ws.py
@@ -181,7 +181,8 @@ async def send_json(
     async def close(self, *, code: int = WSCloseCode.OK, message: bytes = b"") -> bool:
         # we need to break `receive()` cycle first,
         # `close()` may be called from different task
-        if self._waiting is not None and not self._closed:
+        if self._waiting is not None and not self._closing:
+            self._closing = True
             self._reader.feed_data(WS_CLOSING_MESSAGE, 0)
             await self._waiting
 
@@ -200,7 +201,7 @@ async def close(self, *, code: int = WSCloseCode.OK, message: bytes = b"") -> bo
                 self._response.close()
                 return True
 
-            if self._closing:
+            if self._close_code:
                 self._response.close()
                 return True
 


Test patch:
diff --git a/tests/test_client_ws_functional.py b/tests/test_client_ws_functional.py
--- a/tests/test_client_ws_functional.py
+++ b/tests/test_client_ws_functional.py
@@ -262,6 +262,33 @@ async def handler(request):
     assert msg.type == aiohttp.WSMsgType.CLOSED
 
 
+async def test_concurrent_task_close(aiohttp_client) -> None:
+    async def handler(request):
+        ws = web.WebSocketResponse()
+        await ws.prepare(request)
+        await ws.receive()
+        return ws
+
+    app = web.Application()
+    app.router.add_route("GET", "/", handler)
+
+    client = await aiohttp_client(app)
+    async with client.ws_connect("/") as resp:
+        # wait for the message in a separate task
+        task = asyncio.create_task(resp.receive())
+
+        # Make sure we start to wait on receiving message before closing the connection
+        await asyncio.sleep(0.1)
+
+        closed = await resp.close()
+
+        await task
+
+        assert closed
+        assert resp.closed
+        assert resp.close_code == 1000
+
+
 async def test_close_from_server(aiohttp_client) -> None:
     loop = asyncio.get_event_loop()
     closed = loop.create_future()