Observed behavior
WebSocketTransport.wait_closed() awaits self._close_task, but WebSocketTransport.close()
only assigns a real close task to _close_task when self._ws is set. If a wss connection
attempt fails before the WebSocket is established (TLS handshake reset, connection refused, DNS
failure, etc.), _ws is None, close() early-returns, and _close_task stays the unresolved
asyncio.Future() created in __init__. await self._close_task then never returns, so any
caller of Client.close() on such a client hangs indefinitely.
Offending code
nats/aio/transport.py — WebSocketTransport (2.14.0 line numbers):
def __init__(self, ...):
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
self._client: aiohttp.ClientSession = aiohttp.ClientSession()
self._close_task = asyncio.Future() # L194: unresolved Future
async def wait_closed(self):
await self._close_task # L248-249: awaits it
if self._client:
await self._client.close()
self._ws = self._client = None
def close(self):
if not self._ws:
return # L254-256: _ws is None → returns, _close_task stays unresolved
self._close_task = asyncio.create_task(self._ws.close())
Client._close() (nats/aio/client.py) unconditionally calls both on a transport that exists:
if self._transport is not None:
self._transport.close()
try:
await self._transport.wait_closed() # hangs here forever
except Exception as e:
await self._error_cb(e)
self._transport is created in _connect_to_server/_select_next_server before the
ws_connect that fails, so it is non-None even though _ws was never set.
Expected behavior
Regardless of whether the WebSocketTransport fails or succeeds, the close()call should just close the connection.
Server and client version
Versions
- Affected: 2.13.1 and 2.14.0 (latest at time of writing). Code is identical in both.
- Python 3.13,
allow_reconnect=True, transport wss:// (WebSocketTransport, aiohttp).
Host environment
No response
Steps to reproduce
Minimal example
import asyncio
from nats.aio.transport import WebSocketTransport
async def main():
t = WebSocketTransport() # _ws is None; _close_task is an unresolved Future
t.close() # early-returns (no _ws); _close_task left unresolved
await asyncio.wait_for(t.wait_closed(), timeout=2) # raises TimeoutError — hangs
asyncio.run(main())
Output: asyncio.TimeoutError — wait_closed() never returns.
Larger example
import asyncio
import nats
async def main():
nc = nats.NATS()
try:
await nc.connect(
"wss://127.0.0.1:1", # nothing listening → ws_connect fails before _ws is set
allow_reconnect=True,
max_reconnect_attempts=1,
reconnect_time_wait=0,
connect_timeout=1,
)
except Exception as e:
print("connect failed as expected:", type(e).__name__)
await asyncio.wait_for(nc.close(), timeout=10) # hangs → TimeoutError
asyncio.run(main())
Observed behavior
WebSocketTransport.wait_closed()awaitsself._close_task, butWebSocketTransport.close()only assigns a real close task to
_close_taskwhenself._wsis set. If a wss connectionattempt fails before the WebSocket is established (TLS handshake reset, connection refused, DNS
failure, etc.),
_wsisNone,close()early-returns, and_close_taskstays the unresolvedasyncio.Future()created in__init__.await self._close_taskthen never returns, so anycaller of
Client.close()on such a client hangs indefinitely.Offending code
nats/aio/transport.py—WebSocketTransport(2.14.0 line numbers):Client._close()(nats/aio/client.py) unconditionally calls both on a transport that exists:self._transportis created in_connect_to_server/_select_next_serverbefore thews_connectthat fails, so it is non-Noneeven though_wswas never set.Expected behavior
Regardless of whether the WebSocketTransport fails or succeeds, the
close()call should just close the connection.Server and client version
Versions
allow_reconnect=True, transportwss://(WebSocketTransport, aiohttp).Host environment
No response
Steps to reproduce
Minimal example
Output:
asyncio.TimeoutError—wait_closed()never returns.Larger example