@@ -944,6 +944,40 @@ async def session_for_lease(self):
944944 yield session , main_path , hook_path
945945 logger .info ("Session closed" )
946946
947+ def _ensure_hook_event_set (self , lease_scope : LeaseContext ) -> None :
948+ """Set before_lease_hook if no hook executor is configured.
949+
950+ When conn_tg is cancelled before the no-hook path reaches
951+ lease_scope.before_lease_hook.set(), the flag remains unset and
952+ _cleanup_after_lease (shielded) deadlocks. Only apply when NO
953+ hooks are configured - with hooks, run_before_lease_hook's
954+ finally block sets the event after updating skip_after_lease_hook.
955+ """
956+ if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
957+ lease_scope .before_lease_hook .set ()
958+
959+ async def _finalize_lease_context (self , lease_scope : LeaseContext ) -> None :
960+ """Clean up lease context ownership after handle_lease exits.
961+
962+ Ensures event flags are set (preventing deadlocks in shielded
963+ cleanup), adds a brief delay after session teardown to prevent
964+ SSL corruption from overlapping connections, and clears context.
965+ """
966+ if self ._lease_context is not lease_scope :
967+ return
968+ if not lease_scope .before_lease_hook .is_set ():
969+ lease_scope .before_lease_hook .set ()
970+ if not lease_scope .after_lease_hook_done .is_set ():
971+ lease_scope .after_lease_hook_done .set ()
972+ if lease_scope .session is not None :
973+ # Brief delay to ensure session is fully closed before next lease.
974+ # Prevents SSL corruption from overlapping connections.
975+ await sleep (0.2 )
976+ self ._last_completed_lease = lease_scope .lease_name
977+ self ._lease_context = None
978+ clear_log_context ()
979+ logger .debug ("Ready for next lease" )
980+
947981 async def _cleanup_after_lease (self , lease_scope : LeaseContext ) -> None :
948982 """Run afterLease hook cleanup when handle_lease exits.
949983
@@ -1021,7 +1055,7 @@ async def _skip_stale_lease(self, lease_name: str, lease_scope: LeaseContext, co
10211055 lease_scope .after_lease_hook_done .set ()
10221056 return True
10231057
1024- async def handle_lease (self , lease_name : str , tg : TaskGroup , lease_scope : LeaseContext ) -> None : # noqa: C901
1058+ async def handle_lease (self , lease_name : str , conns_tg : TaskGroup , lease_scope : LeaseContext ) -> None : # noqa: C901
10251059 """Handle all incoming client connections for a lease.
10261060
10271061 This method orchestrates the complete lifecycle of managing connections during
@@ -1037,7 +1071,7 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10371071
10381072 Args:
10391073 lease_name: Name of the lease to handle connections for
1040- tg: TaskGroup for spawning concurrent connection handler tasks
1074+ conns_tg: Data-plane TaskGroup for spawning connection handler tasks
10411075 lease_scope: LeaseScope with before_lease_hook event (session/socket set here)
10421076
10431077 Note:
@@ -1097,7 +1131,8 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10971131 # session creation (e.g., BEFORE_LEASE_HOOK when hooks are configured).
10981132
10991133 # Start task to handle EndSession requests (runs afterLease hook when client signals done)
1100- tg .start_soon (self ._handle_end_session , lease_scope )
1134+ # Runs on control-plane group so it's cancelled with Status/Listen, not data-plane
1135+ self ._tg .start_soon (self ._handle_end_session , lease_scope )
11011136
11021137 # Process client connections until lease ends
11031138 # The lease can end via:
@@ -1135,7 +1170,7 @@ async def process_connections():
11351170 lease_name ,
11361171 request .router_endpoint ,
11371172 )
1138- tg .start_soon (
1173+ conns_tg .start_soon (
11391174 self ._handle_client_conn ,
11401175 lease_scope .socket_path ,
11411176 request .router_endpoint ,
@@ -1154,51 +1189,38 @@ async def process_connections():
11541189 await self ._report_status (ExporterStatus .LEASE_READY , "Ready for commands" )
11551190 lease_scope .before_lease_hook .set ()
11561191 finally :
1157- # Ensure before_lease_hook is set so _cleanup_after_lease never
1158- # blocks forever. When conn_tg is cancelled before the no-hook
1159- # path reaches lease_scope.before_lease_hook.set(), this flag
1160- # remains unset and _cleanup_after_lease (shielded) deadlocks.
1161- # Only apply this fallback when NO hooks are configured - when
1162- # hooks ARE configured, run_before_lease_hook's finally block
1163- # sets the event after updating skip_after_lease_hook. Setting
1164- # it here prematurely would race with that flag update.
1165- if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
1166- lease_scope .before_lease_hook .set ()
1192+ self ._ensure_hook_event_set (lease_scope )
11671193 # Close the listen stream to signal termination to listen_rx
11681194 await listen_tx .aclose ()
11691195 # Run afterLease hook before closing the session
11701196 # This ensures the socket is still available for driver calls within the hook
11711197 # Shield from cancellation so the hook can complete even during shutdown
11721198 await self ._cleanup_after_lease (lease_scope )
11731199 finally :
1174- if self ._lease_context is lease_scope :
1175- session_was_created = lease_scope .session is not None
1176- if session_was_created :
1177- await sleep (0.2 )
1178- self ._last_completed_lease = lease_scope .lease_name
1179- self ._lease_context = None
1180- clear_log_context ()
1181- logger .debug ("Ready for next lease" )
1182- pending = self ._pending_lease_status
1183- if pending is not None :
1184- self ._pending_lease_status = None
1185- if self ._status_replay_tx is not None :
1186- await self ._status_replay_tx .send (pending )
1200+ await self ._finalize_lease_context (lease_scope )
11871201
11881202 async def serve (self ):
11891203 """Serve the exporter, handling leases until stopped."""
11901204 async with self .session ():
11911205 pass
11921206 status_tx , status_rx = create_memory_object_stream [jumpstarter_pb2 .StatusResponse ](max_buffer_size = 5 )
11931207 try :
1194- await self ._run_control_plane (status_tx , status_rx )
1195- if self ._fatal_stream_error :
1196- name , err = self ._fatal_stream_error
1197- logger .warning (
1198- "Control plane down (%s: %s)" ,
1199- name ,
1200- err ,
1201- )
1208+ async with create_task_group () as conns_tg :
1209+ await self ._run_control_plane (status_tx , status_rx , conns_tg )
1210+ if self ._fatal_stream_error :
1211+ name , err = self ._fatal_stream_error
1212+ logger .warning (
1213+ "Control plane down (%s: %s), cancelling active connections" ,
1214+ name ,
1215+ err ,
1216+ )
1217+ # The control plane has stopped, so serve() is returning and conns_tg
1218+ # must finish. handle_lease blocks on lease_ended, which nobody sets
1219+ # here: the lease is still valid on the controller, we've only lost
1220+ # contact with it. Cancelling unsticks handle_lease; its shielded
1221+ # _cleanup_after_lease still runs the afterLease hook and closes the
1222+ # session, which drops the tunnels.
1223+ conns_tg .cancel_scope .cancel ()
12021224 finally :
12031225 self ._tg = None
12041226 self ._fatal_stream_error = None
@@ -1209,6 +1231,7 @@ async def _run_control_plane(
12091231 self ,
12101232 status_tx : MemoryObjectSendStream [jumpstarter_pb2 .StatusResponse ],
12111233 status_rx : MemoryObjectReceiveStream [jumpstarter_pb2 .StatusResponse ],
1234+ conns_tg : TaskGroup ,
12121235 ) -> None :
12131236 """Start control-plane streams and process status updates."""
12141237 async with create_task_group () as tg :
@@ -1226,13 +1249,14 @@ async def _run_control_plane(
12261249 on_exhausted = self ._on_status_exhausted ,
12271250 ))
12281251 async for status in status_rx :
1229- if await self ._apply_status (status , tg ):
1252+ if await self ._apply_status (status , tg , conns_tg ):
12301253 break
12311254
12321255 async def _apply_status (
12331256 self ,
12341257 status : jumpstarter_pb2 .StatusResponse ,
12351258 tg : TaskGroup ,
1259+ conns_tg : TaskGroup ,
12361260 ) -> bool :
12371261 """Process a single status update. Returns True to stop the status loop."""
12381262 previous_state = self ._lease_state
@@ -1246,7 +1270,7 @@ async def _apply_status(
12461270 if status .lease_name == self ._last_completed_lease :
12471271 logger .debug ("Ignoring trailing status for completed lease %s" , status .lease_name )
12481272 return False
1249- self ._on_lease_acquired (status , tg )
1273+ self ._on_lease_acquired (status , tg , conns_tg )
12501274 elif (
12511275 previous_state == LeaseState .LEASED
12521276 and self ._lease_context
@@ -1277,6 +1301,7 @@ def _on_lease_acquired(
12771301 self ,
12781302 status : jumpstarter_pb2 .StatusResponse ,
12791303 tg : TaskGroup ,
1304+ conns_tg : TaskGroup ,
12801305 ) -> None :
12811306 """Handle new lease assignment: create context and spawn lease handler."""
12821307 self ._started = True
@@ -1298,7 +1323,7 @@ def _on_lease_acquired(
12981323 self .stop ,
12991324 self ._request_lease_release ,
13001325 )
1301- tg .start_soon (self .handle_lease , status .lease_name , tg , lease_scope )
1326+ conns_tg .start_soon (self .handle_lease , status .lease_name , conns_tg , lease_scope )
13021327
13031328 def _on_lease_update (self , status : jumpstarter_pb2 .StatusResponse ) -> None :
13041329 """Update client info on every leased status tick."""
0 commit comments