Summary
I found a reproducible leak in local port forwarding when the local TCP client disconnects while the SSH direct-tcpip channel open is still in progress.
The race is:
SSHLocalForwarder._forward() is waiting in await self._coro(session_factory, *args).
- The local TCP transport closes before the channel-open completes, so
SSHLocalForwarder.connection_lost() runs and close() sees no peer yet.
- The channel-open later succeeds,
session_factory() creates the peer forwarder, and the SSH channel/session is started.
- The local transport is already gone and no further local
connection_lost() will arrive, so the SSH channel remains open and the server-side session never gets connection_lost().
Minimal reproducer
"""Reproduce an AsyncSSH local port-forward channel leak.
A local TCP client disconnects while the SSH direct-tcpip channel open is
still waiting on a slow server-side session factory. The accepted SSH channels
remain open and their sessions don't receive connection_lost().
"""
import asyncio
import socket
import struct
import asyncssh
ITERATIONS = 10
active_channels = set()
lost_callbacks = 0
class CountingSession(asyncssh.SSHTCPSession):
def connection_made(self, chan):
self._chan = chan
active_channels.add(chan)
def connection_lost(self, exc):
global lost_callbacks
lost_callbacks += 1
active_channels.discard(self._chan)
class SlowDirectTCPServer(asyncssh.SSHServer):
def begin_auth(self, username):
return False
def connection_requested(self, dest_host, dest_port, orig_host, orig_port):
async def make_session():
await asyncio.sleep(0.05)
return CountingSession()
return make_session()
async def reset_local_connection(port):
sock = socket.create_connection(('127.0.0.1', port))
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
struct.pack('ii', 1, 0))
sock.close()
await asyncio.sleep(0)
async def main():
server = await asyncssh.listen(
'127.0.0.1', 0,
server_factory=SlowDirectTCPServer,
server_host_keys=[asyncssh.generate_private_key('ssh-rsa')])
async with asyncssh.connect('127.0.0.1', server.get_port(),
username='user', known_hosts=None) as conn:
listener = await conn.forward_local_port('', 0, 'example.invalid', 123)
listen_port = listener.get_port()
for _ in range(ITERATIONS):
await reset_local_connection(listen_port)
await asyncio.sleep(0.5)
print(f'asyncssh_version={asyncssh.__version__}')
print(f'iterations={ITERATIONS}')
print(f'server_active_channels={len(active_channels)}')
print(f'server_connection_lost_callbacks={lost_callbacks}')
for chan in list(active_channels):
chan.close()
listener.close()
await listener.wait_closed()
server.close()
await server.wait_closed()
if __name__ == '__main__':
asyncio.run(main())
Observed output
Run against the clean PyPI release asyncssh==2.23.0:
asyncssh_version=2.23.0
iterations=10
server_active_channels=10
server_connection_lost_callbacks=0
The expected result would be that these channels are closed once the local side has already disconnected, and that the server-side sessions eventually receive connection_lost().
Environment
- AsyncSSH: 2.23.0 from PyPI
- Python: 3.14.4
- OS: Linux 7.0.0-27-generic x86_64
Notes
This appears to be independent of any local modifications: the forwarding control flow around SSHLocalForwarder._forward(), SSHForwarder.close(), and SSHForwarder.set_peer() is unchanged in the relevant path in v2.23.0. I observed this on the clean PyPI release as shown above.
A possible fix direction may be to re-check whether the local transport is still present after await self._coro(...) completes in SSHLocalForwarder._forward() and close the newly-created peer/channel if the local side has already gone away, but of course you may prefer a different cleanup point.
Summary
I found a reproducible leak in local port forwarding when the local TCP client disconnects while the SSH
direct-tcpipchannel open is still in progress.The race is:
SSHLocalForwarder._forward()is waiting inawait self._coro(session_factory, *args).SSHLocalForwarder.connection_lost()runs andclose()sees no peer yet.session_factory()creates the peer forwarder, and the SSH channel/session is started.connection_lost()will arrive, so the SSH channel remains open and the server-side session never getsconnection_lost().Minimal reproducer
Observed output
Run against the clean PyPI release
asyncssh==2.23.0:The expected result would be that these channels are closed once the local side has already disconnected, and that the server-side sessions eventually receive
connection_lost().Environment
Notes
This appears to be independent of any local modifications: the forwarding control flow around
SSHLocalForwarder._forward(),SSHForwarder.close(), andSSHForwarder.set_peer()is unchanged in the relevant path in v2.23.0. I observed this on the clean PyPI release as shown above.A possible fix direction may be to re-check whether the local transport is still present after
await self._coro(...)completes inSSHLocalForwarder._forward()and close the newly-created peer/channel if the local side has already gone away, but of course you may prefer a different cleanup point.