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
`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.
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
@@ -191,7 +191,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
 
@@ -210,7 +211,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
@@ -233,6 +233,33 @@ async def handler(request):
     assert msg.type == aiohttp.WSMsgType.CLOSED
 
 
+async def test_concurrent_task_close(aiohttp_client: Any) -> 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_concurrent_close(aiohttp_client: Any) -> None:
     client_ws = None