@@ -272,6 +272,7 @@ class Exporter(AsyncContextManagerMixin, Metadata):
272272 """
273273
274274 _last_completed_lease : str | None = field (init = False , default = None )
275+
275276 _lease_context : LeaseContext | None = field (init = False , default = None )
276277 """Encapsulates all resources associated with the current lease.
277278
@@ -942,6 +943,38 @@ async def session_for_lease(self):
942943 yield session , main_path , hook_path
943944 logger .info ("Session closed" )
944945
946+ def _ensure_hook_event_set (self , lease_scope : LeaseContext ) -> None :
947+ """Set before_lease_hook if no hook executor is configured.
948+
949+ When conn_tg is cancelled before the no-hook path reaches
950+ lease_scope.before_lease_hook.set(), the flag remains unset and
951+ _cleanup_after_lease (shielded) deadlocks. Only apply when NO
952+ hooks are configured — with hooks, run_before_lease_hook's
953+ finally block sets the event after updating skip_after_lease_hook.
954+ """
955+ if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
956+ lease_scope .before_lease_hook .set ()
957+
958+ async def _finalize_lease_context (self , lease_scope : LeaseContext ) -> None :
959+ """Clean up lease context ownership after handle_lease exits.
960+
961+ Ensures event flags are set (preventing deadlocks in shielded
962+ cleanup), adds a brief delay after session teardown to prevent
963+ SSL corruption from overlapping connections, and clears context.
964+ """
965+ if self ._lease_context is not lease_scope :
966+ return
967+ if not lease_scope .before_lease_hook .is_set ():
968+ lease_scope .before_lease_hook .set ()
969+ if not lease_scope .after_lease_hook_done .is_set ():
970+ lease_scope .after_lease_hook_done .set ()
971+ if lease_scope .session is not None :
972+ await sleep (0.2 )
973+ self ._last_completed_lease = lease_scope .lease_name
974+ self ._lease_context = None
975+ clear_log_context ()
976+ logger .debug ("Ready for next lease" )
977+
945978 async def _cleanup_after_lease (self , lease_scope : LeaseContext ) -> None :
946979 """Run afterLease hook cleanup when handle_lease exits.
947980
@@ -1019,7 +1052,7 @@ async def _skip_stale_lease(self, lease_name: str, lease_scope: LeaseContext, co
10191052 lease_scope .after_lease_hook_done .set ()
10201053 return True
10211054
1022- async def handle_lease (self , lease_name : str , tg : TaskGroup , lease_scope : LeaseContext ) -> None :
1055+ async def handle_lease (self , lease_name : str , conns_tg : TaskGroup , lease_scope : LeaseContext ) -> None :
10231056 """Handle all incoming client connections for a lease.
10241057
10251058 This method orchestrates the complete lifecycle of managing connections during
@@ -1035,7 +1068,7 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10351068
10361069 Args:
10371070 lease_name: Name of the lease to handle connections for
1038- tg: TaskGroup for spawning concurrent connection handler tasks
1071+ conns_tg: Data-plane TaskGroup for spawning connection handler tasks
10391072 lease_scope: LeaseScope with before_lease_hook event (session/socket set here)
10401073
10411074 Note:
@@ -1095,7 +1128,8 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10951128 # session creation (e.g., BEFORE_LEASE_HOOK when hooks are configured).
10961129
10971130 # Start task to handle EndSession requests (runs afterLease hook when client signals done)
1098- tg .start_soon (self ._handle_end_session , lease_scope )
1131+ # Runs on control-plane group so it's cancelled with Status/Listen, not data-plane
1132+ self ._tg .start_soon (self ._handle_end_session , lease_scope )
10991133
11001134 # Process client connections until lease ends
11011135 # The lease can end via:
@@ -1133,7 +1167,7 @@ async def process_connections():
11331167 lease_name ,
11341168 request .router_endpoint ,
11351169 )
1136- tg .start_soon (
1170+ conns_tg .start_soon (
11371171 self ._handle_client_conn ,
11381172 lease_scope .socket_path ,
11391173 request .router_endpoint ,
@@ -1152,48 +1186,38 @@ async def process_connections():
11521186 await self ._report_status (ExporterStatus .LEASE_READY , "Ready for commands" )
11531187 lease_scope .before_lease_hook .set ()
11541188 finally :
1155- # Ensure before_lease_hook is set so _cleanup_after_lease never
1156- # blocks forever. When conn_tg is cancelled before the no-hook
1157- # path reaches lease_scope.before_lease_hook.set(), this flag
1158- # remains unset and _cleanup_after_lease (shielded) deadlocks.
1159- # Only apply this fallback when NO hooks are configured — when
1160- # hooks ARE configured, run_before_lease_hook's finally block
1161- # sets the event after updating skip_after_lease_hook. Setting
1162- # it here prematurely would race with that flag update.
1163- if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
1164- lease_scope .before_lease_hook .set ()
1189+ self ._ensure_hook_event_set (lease_scope )
11651190 # Close the listen stream to signal termination to listen_rx
11661191 await listen_tx .aclose ()
11671192 # Run afterLease hook before closing the session
11681193 # This ensures the socket is still available for driver calls within the hook
11691194 # Shield from cancellation so the hook can complete even during shutdown
11701195 await self ._cleanup_after_lease (lease_scope )
11711196 finally :
1172- if self ._lease_context is lease_scope :
1173- session_was_created = lease_scope .session is not None
1174- if session_was_created :
1175- # Brief delay to ensure session is fully closed before next lease.
1176- # Prevents SSL corruption from overlapping connections.
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" )
1197+ await self ._finalize_lease_context (lease_scope )
11821198
11831199 async def serve (self ):
11841200 """Serve the exporter, handling leases until stopped."""
11851201 async with self .session ():
11861202 pass
11871203 status_tx , status_rx = create_memory_object_stream [jumpstarter_pb2 .StatusResponse ](max_buffer_size = 5 )
11881204 try :
1189- await self ._run_control_plane (status_tx , status_rx )
1190- if self ._fatal_stream_error :
1191- name , err = self ._fatal_stream_error
1192- logger .warning (
1193- "Control plane down (%s: %s)" ,
1194- name ,
1195- err ,
1196- )
1205+ async with create_task_group () as conns_tg :
1206+ await self ._run_control_plane (status_tx , status_rx , conns_tg )
1207+ if self ._fatal_stream_error :
1208+ name , err = self ._fatal_stream_error
1209+ logger .warning (
1210+ "Control plane down (%s: %s), cancelling active connections" ,
1211+ name ,
1212+ err ,
1213+ )
1214+ # The control plane has stopped, so serve() is returning and conns_tg
1215+ # must finish. handle_lease blocks on lease_ended, which nobody sets
1216+ # here: the lease is still valid on the controller, we've only lost
1217+ # contact with it. Cancelling unsticks handle_lease; its shielded
1218+ # _cleanup_after_lease still runs the afterLease hook and closes the
1219+ # session, which drops the tunnels.
1220+ conns_tg .cancel_scope .cancel ()
11971221 finally :
11981222 self ._tg = None
11991223 self ._fatal_stream_error = None
@@ -1204,6 +1228,7 @@ async def _run_control_plane(
12041228 self ,
12051229 status_tx : MemoryObjectSendStream [jumpstarter_pb2 .StatusResponse ],
12061230 status_rx : MemoryObjectReceiveStream [jumpstarter_pb2 .StatusResponse ],
1231+ conns_tg : TaskGroup ,
12071232 ) -> None :
12081233 """Start control-plane streams and process status updates."""
12091234 async with create_task_group () as tg :
@@ -1220,13 +1245,14 @@ async def _run_control_plane(
12201245 on_exhausted = self ._on_status_exhausted ,
12211246 ))
12221247 async for status in status_rx :
1223- if await self ._apply_status (status , tg ):
1248+ if await self ._apply_status (status , tg , conns_tg ):
12241249 break
12251250
12261251 async def _apply_status (
12271252 self ,
12281253 status : jumpstarter_pb2 .StatusResponse ,
12291254 tg : TaskGroup ,
1255+ conns_tg : TaskGroup ,
12301256 ) -> bool :
12311257 """Process a single status update. Returns True to stop the status loop."""
12321258 previous_state = self ._lease_state
@@ -1240,7 +1266,7 @@ async def _apply_status(
12401266 if status .lease_name == self ._last_completed_lease :
12411267 logger .debug ("Ignoring trailing status for completed lease %s" , status .lease_name )
12421268 return False
1243- self ._on_lease_acquired (status , tg )
1269+ self ._on_lease_acquired (status , tg , conns_tg )
12441270 elif (
12451271 previous_state == LeaseState .LEASED
12461272 and self ._lease_context
@@ -1265,6 +1291,7 @@ def _on_lease_acquired(
12651291 self ,
12661292 status : jumpstarter_pb2 .StatusResponse ,
12671293 tg : TaskGroup ,
1294+ conns_tg : TaskGroup ,
12681295 ) -> None :
12691296 """Handle new lease assignment: create context and spawn lease handler."""
12701297 self ._started = True
@@ -1286,7 +1313,7 @@ def _on_lease_acquired(
12861313 self .stop ,
12871314 self ._request_lease_release ,
12881315 )
1289- tg .start_soon (self .handle_lease , status .lease_name , tg , lease_scope )
1316+ conns_tg .start_soon (self .handle_lease , status .lease_name , conns_tg , lease_scope )
12901317
12911318 def _on_lease_update (self , status : jumpstarter_pb2 .StatusResponse ) -> None :
12921319 """Update client info on every leased status tick."""
0 commit comments