@@ -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