Skip to content

Commit 938b88c

Browse files
committed
fix(forward): make tracker connection_lost fire exactly once
The local-forward _forward() coroutine calls connection_lost(exc) manually on channel-open failure, and the local transport's later close fires a second connection_lost(None) on the protocol via asyncio. Clearing self._tracker on the first call makes the hook fire exactly once. Also tightens the patch: - Drop redundant self._tracker = None in _create_tracker's except (the __init__ default already covers a raising factory). - New regression test asserting the tracker's connection_lost fires exactly once on a denied-by-accept_handler forward. - Replace asyncio.sleep(0.1) await-for-lost in existing tests with a per-test asyncio.Event resolved by the hook itself.
1 parent 0a670b9 commit 938b88c

2 files changed

Lines changed: 64 additions & 10 deletions

File tree

asyncssh/forward.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,9 @@ def _create_tracker(self) -> None:
316316
try:
317317
self._tracker = self._tracker_factory()
318318
except Exception: # pylint: disable=broad-exception-caught
319-
# A buggy factory must not break forwarding.
320-
self._tracker = None
319+
# A buggy factory must not break forwarding;
320+
# self._tracker remains the __init__ default of None.
321+
pass
321322

322323
def data_received(self, data: bytes,
323324
datatype: Optional[int] = None) -> None:
@@ -343,11 +344,20 @@ def write(self, data: bytes) -> None:
343344
super().write(data)
344345

345346
def connection_lost(self, exc: Optional[Exception]) -> None:
346-
"""Handle a closed local connection"""
347+
"""Handle a closed local connection
347348
348-
if self._tracker is not None:
349+
This is also called manually from `_forward()` on a channel
350+
open failure, so the local transport's eventual close fires
351+
a second `connection_lost(None)` on the protocol. The tracker
352+
reference is cleared on the first call so the hook fires
353+
exactly once per connection.
354+
"""
355+
356+
tracker, self._tracker = self._tracker, None
357+
358+
if tracker is not None:
349359
try:
350-
self._tracker.connection_lost(exc)
360+
tracker.connection_lost(exc)
351361
except Exception: # pylint: disable=broad-exception-caught
352362
pass
353363

tests/test_forward.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -702,20 +702,22 @@ async def test_forward_local_port_tracker_factory_fires_made_and_lost(self):
702702
"""A port tracker sees connection_made and connection_lost"""
703703

704704
events = []
705+
lost = asyncio.Event()
705706

706707
class _RecordingTracker(asyncssh.SSHPortForwardTracker):
707708
def connection_made(self, forwarder, orig_host, orig_port):
708709
events.append(('made', forwarder, orig_host, orig_port))
709710

710711
def connection_lost(self, exc):
711712
events.append(('lost', exc))
713+
lost.set()
712714

713715
async with self.connect() as conn:
714716
async with conn.forward_local_port(
715717
'', 0, '', 7,
716718
tracker_factory=_RecordingTracker) as listener:
717719
await self._check_local_connection(listener.get_port())
718-
await asyncio.sleep(0.1)
720+
await asyncio.wait_for(lost.wait(), timeout=1.0)
719721

720722
kinds = [event[0] for event in events]
721723
self.assertIn('made', kinds)
@@ -731,10 +733,15 @@ async def test_forward_local_port_tracker_factory_per_connection(self):
731733
"""The factory is called once per accepted connection"""
732734

733735
trackers = []
736+
lost_events = []
734737

735738
class _CountingTracker(asyncssh.SSHPortForwardTracker):
736739
def __init__(self):
737740
trackers.append(self)
741+
lost_events.append(asyncio.Event())
742+
743+
def connection_lost(self, exc):
744+
lost_events[trackers.index(self)].set()
738745

739746
def factory():
740747
return _CountingTracker()
@@ -745,7 +752,9 @@ def factory():
745752
listen_port = listener.get_port()
746753
await self._check_local_connection(listen_port)
747754
await self._check_local_connection(listen_port)
748-
await asyncio.sleep(0.1)
755+
await asyncio.wait_for(
756+
asyncio.gather(*(e.wait() for e in lost_events)),
757+
timeout=1.0)
749758

750759
self.assertEqual(len(trackers), 2)
751760

@@ -755,6 +764,7 @@ async def test_forward_local_port_tracker_byte_hooks(self):
755764

756765
local_bytes = bytearray()
757766
remote_bytes = bytearray()
767+
lost = asyncio.Event()
758768

759769
class _ByteTracker(asyncssh.SSHPortForwardTracker):
760770
def forward_local_bytes(self, data):
@@ -763,12 +773,15 @@ def forward_local_bytes(self, data):
763773
def forward_remote_bytes(self, data):
764774
remote_bytes.extend(data)
765775

776+
def connection_lost(self, exc):
777+
lost.set()
778+
766779
async with self.connect() as conn:
767780
async with conn.forward_local_port(
768781
'', 0, '', 7,
769782
tracker_factory=_ByteTracker) as listener:
770783
await self._check_local_connection(listener.get_port())
771-
await asyncio.sleep(0.1)
784+
await asyncio.wait_for(lost.wait(), timeout=1.0)
772785

773786
line = (str(id(self)) + '\n').encode('utf-8')
774787
self.assertEqual(bytes(local_bytes), line)
@@ -809,7 +822,36 @@ def forward_remote_bytes(self, data):
809822
'', 0, '', 7,
810823
tracker_factory=_BuggyTracker) as listener:
811824
await self._check_local_connection(listener.get_port(),
812-
delay=0.1)
825+
delay=0.1)
826+
827+
@asynctest
828+
async def test_forward_local_port_tracker_lost_fires_exactly_once(self):
829+
"""connection_lost fires once even when ChannelOpenError triggers
830+
a manual notify in _forward() followed by the asyncio close path."""
831+
832+
lost_count = 0
833+
834+
class _Counting(asyncssh.SSHPortForwardTracker):
835+
def connection_lost(self, exc):
836+
nonlocal lost_count
837+
lost_count += 1
838+
839+
async def deny(_h, _p):
840+
return False
841+
842+
async with self.connect() as conn:
843+
async with conn.forward_local_port(
844+
'', 0, '', 7,
845+
accept_handler=deny,
846+
tracker_factory=_Counting) as listener:
847+
reader, writer = await asyncio.open_connection(
848+
'127.0.0.1', listener.get_port())
849+
self.assertEqual((await reader.read()), b'')
850+
writer.close()
851+
await maybe_wait_closed(writer)
852+
await asyncio.sleep(0.1) # bounded: upper-bound for any spurious duplicate
853+
854+
self.assertEqual(lost_count, 1)
813855

814856
@unittest.skipIf(sys.platform == 'win32',
815857
'skip UNIX domain socket tests on Windows')
@@ -1268,20 +1310,22 @@ async def test_forward_local_path_tracker_factory(self):
12681310
"""A path tracker sees connection_made (no addr) and connection_lost"""
12691311

12701312
events = []
1313+
lost = asyncio.Event()
12711314

12721315
class _RecordingTracker(asyncssh.SSHPathForwardTracker):
12731316
def connection_made(self, forwarder):
12741317
events.append(('made', forwarder))
12751318

12761319
def connection_lost(self, exc):
12771320
events.append(('lost', exc))
1321+
lost.set()
12781322

12791323
async with self.connect() as conn:
12801324
async with conn.forward_local_path(
12811325
'local', '/echo',
12821326
tracker_factory=_RecordingTracker):
12831327
await self._check_local_unix_connection('local')
1284-
await asyncio.sleep(0.1)
1328+
await asyncio.wait_for(lost.wait(), timeout=1.0)
12851329

12861330
try_remove('local')
12871331

0 commit comments

Comments
 (0)