@@ -381,13 +381,6 @@ class Exporter(AsyncContextManagerMixin, Metadata):
381381 """Name of the most recently completed lease, used to filter trailing
382382 status ticks after handle_lease's finally has cleaned up."""
383383
384- _pending_lease_status : jumpstarter_pb2 .StatusResponse | None = field (init = False , default = None )
385- """Stashed status from a lease reassignment, replayed after handle_lease's
386- finally clears _lease_context so the new lease can be acquired."""
387-
388- _status_replay_tx : MemoryObjectSendStream | None = field (init = False , default = None )
389- """Send side of the status channel, used to replay _pending_lease_status
390- back into the status loop after a lease transition."""
391384 _lease_context : LeaseContext | None = field (init = False , default = None )
392385 """Encapsulates all resources associated with the current lease.
393386
@@ -1130,6 +1123,47 @@ async def session_for_lease(self):
11301123 yield session , main_path , hook_path
11311124 logger .info ("Session closed" )
11321125
1126+ def _ensure_hook_event_set (self , lease_scope : LeaseContext ) -> None :
1127+ """Set before_lease_hook if no hook executor is configured.
1128+
1129+ When conn_tg is cancelled before the no-hook path reaches
1130+ lease_scope.before_lease_hook.set(), the flag remains unset and
1131+ _cleanup_after_lease (shielded) deadlocks. Only apply when NO
1132+ hooks are configured - with hooks, run_before_lease_hook's
1133+ finally block sets the event after updating skip_after_lease_hook.
1134+ """
1135+ if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
1136+ lease_scope .before_lease_hook .set ()
1137+
1138+ async def _finalize_lease_context (self , lease_scope : LeaseContext ) -> None :
1139+ """Clean up lease context ownership after handle_lease exits.
1140+
1141+ Ensures event flags are set (preventing deadlocks in shielded
1142+ cleanup), adds a brief delay after session teardown to prevent
1143+ SSL corruption from overlapping connections, and clears context.
1144+
1145+ Shielded from cancellation so that _lease_context is always
1146+ cleared even when the task group is cancelled mid-cleanup.
1147+ """
1148+ with CancelScope (shield = True ):
1149+ if self ._lease_context is not lease_scope :
1150+ return
1151+ if not lease_scope .before_lease_hook .is_set ():
1152+ lease_scope .before_lease_hook .set ()
1153+ if not lease_scope .after_lease_hook_done .is_set ():
1154+ lease_scope .after_lease_hook_done .set ()
1155+ if lease_scope .session is not None :
1156+ # Brief delay to ensure session is fully closed before next lease.
1157+ # Prevents SSL corruption from overlapping connections.
1158+ await sleep (0.2 )
1159+ self ._last_completed_lease = lease_scope .lease_name
1160+ self ._lease_context = None
1161+ if self .exit_on_lease_end :
1162+ self ._stop_requested = True
1163+ clear_log_context ()
1164+ set_log_context (exporter = self .name )
1165+ logger .debug ("Ready for next lease" )
1166+
11331167 async def _cleanup_after_lease (self , lease_scope : LeaseContext ) -> None :
11341168 """Run afterLease hook cleanup when handle_lease exits.
11351169
@@ -1207,7 +1241,7 @@ async def _skip_stale_lease(self, lease_name: str, lease_scope: LeaseContext, co
12071241 lease_scope .after_lease_hook_done .set ()
12081242 return True
12091243
1210- async def handle_lease (self , lease_name : str , tg : TaskGroup , lease_scope : LeaseContext ) -> None : # noqa: C901
1244+ async def handle_lease (self , lease_name : str , conns_tg : TaskGroup , lease_scope : LeaseContext ) -> None :
12111245 """Handle all incoming client connections for a lease.
12121246
12131247 This method orchestrates the complete lifecycle of managing connections during
@@ -1223,7 +1257,7 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
12231257
12241258 Args:
12251259 lease_name: Name of the lease to handle connections for
1226- tg: TaskGroup for spawning concurrent connection handler tasks
1260+ conns_tg: Data-plane TaskGroup for spawning connection handler tasks
12271261 lease_scope: LeaseScope with before_lease_hook event (session/socket set here)
12281262
12291263 Note:
@@ -1244,13 +1278,6 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
12441278 if await self ._skip_stale_lease (lease_name , lease_scope , "before session creation" ):
12451279 return
12461280
1247- logger .info ("Listening for incoming connection requests on lease %s" , lease_name )
1248-
1249- # Buffer Listen responses to avoid blocking when responses arrive before
1250- # process_connections starts iterating. This prevents a race condition where
1251- # the client dials immediately after lease acquisition but before the session is ready.
1252- listen_tx , listen_rx = create_memory_object_stream [jumpstarter_pb2 .ListenResponse ](max_buffer_size = 10 )
1253-
12541281 # Create session for the lease duration and populate lease_scope
12551282 # Uses dual sockets: main socket for clients, hook socket for j commands
12561283 async with self .session_for_lease () as (session , main_path , hook_path ):
@@ -1265,14 +1292,12 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
12651292 session .update_status (lease_scope .current_status , lease_scope .status_message )
12661293 logger .debug ("Session sockets: main=%s, hook=%s" , main_path , hook_path )
12671294
1268- # Check if lease ended during session creation - serve() often
1269- # processes the buffered leased=False while session_for_lease is
1270- # setting up sockets and gRPC servers. Bailing here avoids the
1271- # Listen stream, conn_tg, and _cleanup_after_lease overhead.
1272- # The session context manager handles teardown on return.
12731295 if await self ._skip_stale_lease (lease_name , lease_scope , "during session setup" ):
12741296 return
12751297
1298+ logger .info ("Listening for incoming connection requests on lease %s" , lease_name )
1299+ listen_tx , listen_rx = create_memory_object_stream [jumpstarter_pb2 .ListenResponse ](max_buffer_size = 10 )
1300+
12761301 # Accept connections immediately - driver calls will be gated internally
12771302 # until the beforeLease hook completes. This allows LogStream to work
12781303 # during hook execution for real-time log streaming.
@@ -1283,7 +1308,8 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
12831308 # session creation (e.g., BEFORE_LEASE_HOOK when hooks are configured).
12841309
12851310 # Start task to handle EndSession requests (runs afterLease hook when client signals done)
1286- tg .start_soon (self ._handle_end_session , lease_scope )
1311+ # Runs on control-plane group so it's cancelled with Status/Listen, not data-plane
1312+ self ._tg .start_soon (self ._handle_end_session , lease_scope )
12871313
12881314 # Process client connections until lease ends
12891315 # The lease can end via:
@@ -1321,7 +1347,7 @@ async def process_connections():
13211347 lease_name ,
13221348 request .router_endpoint ,
13231349 )
1324- tg .start_soon (
1350+ conns_tg .start_soon (
13251351 self ._handle_client_conn ,
13261352 lease_scope .socket_path ,
13271353 request .router_endpoint ,
@@ -1340,39 +1366,15 @@ async def process_connections():
13401366 await self ._report_status (ExporterStatus .LEASE_READY , "Ready for commands" )
13411367 lease_scope .before_lease_hook .set ()
13421368 finally :
1343- # Ensure before_lease_hook is set so _cleanup_after_lease never
1344- # blocks forever. When conn_tg is cancelled before the no-hook
1345- # path reaches lease_scope.before_lease_hook.set(), this flag
1346- # remains unset and _cleanup_after_lease (shielded) deadlocks.
1347- # Only apply this fallback when NO hooks are configured - when
1348- # hooks ARE configured, run_before_lease_hook's finally block
1349- # sets the event after updating skip_after_lease_hook. Setting
1350- # it here prematurely would race with that flag update.
1351- if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
1352- lease_scope .before_lease_hook .set ()
1369+ self ._ensure_hook_event_set (lease_scope )
13531370 # Close the listen stream to signal termination to listen_rx
13541371 await listen_tx .aclose ()
13551372 # Run afterLease hook before closing the session
13561373 # This ensures the socket is still available for driver calls within the hook
13571374 # Shield from cancellation so the hook can complete even during shutdown
13581375 await self ._cleanup_after_lease (lease_scope )
13591376 finally :
1360- if self ._lease_context is lease_scope :
1361- session_was_created = lease_scope .session is not None
1362- if session_was_created :
1363- # Brief delay to ensure session is fully closed before next lease.
1364- # Prevents SSL corruption from overlapping connections.
1365- await sleep (0.2 )
1366- self ._last_completed_lease = lease_scope .lease_name
1367- self ._lease_context = None
1368- clear_log_context ()
1369- set_log_context (exporter = self .name )
1370- logger .debug ("Ready for next lease" )
1371- pending = self ._pending_lease_status
1372- if pending is not None :
1373- self ._pending_lease_status = None
1374- if self ._status_replay_tx is not None :
1375- await self ._status_replay_tx .send (pending )
1377+ await self ._finalize_lease_context (lease_scope )
13761378
13771379 async def serve (self ):
13781380 """Serve the exporter, handling leases until stopped."""
@@ -1383,14 +1385,22 @@ async def serve(self):
13831385 pass
13841386 status_tx , status_rx = create_memory_object_stream [jumpstarter_pb2 .StatusResponse ](max_buffer_size = 5 )
13851387 try :
1386- await self ._run_control_plane (status_tx , status_rx )
1387- if self ._fatal_stream_error :
1388- name , err = self ._fatal_stream_error
1389- logger .warning (
1390- "Control plane down (%s: %s)" ,
1391- name ,
1392- err ,
1393- )
1388+ async with create_task_group () as conns_tg :
1389+ await self ._run_control_plane (status_tx , status_rx , conns_tg )
1390+ if self ._fatal_stream_error :
1391+ name , err = self ._fatal_stream_error
1392+ logger .warning (
1393+ "Control plane down (%s: %s), cancelling active connections" ,
1394+ name ,
1395+ err ,
1396+ )
1397+ # The control plane has stopped, so serve() is returning and conns_tg
1398+ # must finish. handle_lease blocks on lease_ended, which nobody sets
1399+ # here: the lease is still valid on the controller, we've only lost
1400+ # contact with it. Cancelling unsticks handle_lease; its shielded
1401+ # _cleanup_after_lease still runs the afterLease hook and closes the
1402+ # session, which drops the tunnels.
1403+ conns_tg .cancel_scope .cancel ()
13941404 finally :
13951405 if self .exit_on_lease_end :
13961406 # Ensure the runtime container exits whenever this exporter is
@@ -1415,11 +1425,11 @@ async def _run_control_plane(
14151425 self ,
14161426 status_tx : MemoryObjectSendStream [jumpstarter_pb2 .StatusResponse ],
14171427 status_rx : MemoryObjectReceiveStream [jumpstarter_pb2 .StatusResponse ],
1428+ conns_tg : TaskGroup ,
14181429 ) -> None :
14191430 """Start control-plane streams and process status updates."""
14201431 async with create_task_group () as tg :
14211432 self ._tg = tg
1422- self ._status_replay_tx = status_tx
14231433 self ._status_rpc_event = Event ()
14241434 self ._pending_status_request = None
14251435 self ._status_drain_active = True
@@ -1434,13 +1444,14 @@ async def _run_control_plane(
14341444 on_exhausted = self ._on_status_exhausted ,
14351445 ))
14361446 async for status in status_rx :
1437- if await self ._apply_status (status , tg ):
1447+ if await self ._apply_status (status , tg , conns_tg ):
14381448 break
14391449
14401450 async def _apply_status (
14411451 self ,
14421452 status : jumpstarter_pb2 .StatusResponse ,
14431453 tg : TaskGroup ,
1454+ conns_tg : TaskGroup ,
14441455 ) -> bool :
14451456 """Process a single status update. Returns True to stop the status loop."""
14461457 previous_state = self ._lease_state
@@ -1454,18 +1465,12 @@ async def _apply_status(
14541465 if status .lease_name == self ._last_completed_lease :
14551466 logger .debug ("Ignoring trailing status for completed lease %s" , status .lease_name )
14561467 return False
1457- self ._on_lease_acquired (status , tg )
1468+ self ._on_lease_acquired (status , tg , conns_tg )
14581469 elif (
14591470 previous_state == LeaseState .LEASED
14601471 and self ._lease_context
14611472 and self ._lease_context .lease_name != status .lease_name
14621473 ):
1463- # Controller reassigned the exporter to a different lease.
1464- # Stash the new status and signal the old lease to tear down.
1465- # handle_lease's finally block replays the stashed status
1466- # after clearing _lease_context. The controller won't
1467- # re-send it because proto.Equal suppresses duplicates.
1468- self ._pending_lease_status = status
14691474 if not self ._lease_context .lease_ended .is_set ():
14701475 logger .warning (
14711476 "Controller reassigned exporter from lease %s to %s; tearing down current lease" ,
@@ -1485,6 +1490,7 @@ def _on_lease_acquired(
14851490 self ,
14861491 status : jumpstarter_pb2 .StatusResponse ,
14871492 tg : TaskGroup ,
1493+ conns_tg : TaskGroup ,
14881494 ) -> None :
14891495 """Handle new lease assignment: create context and spawn lease handler."""
14901496 self ._started = True
@@ -1506,7 +1512,7 @@ def _on_lease_acquired(
15061512 self .stop ,
15071513 self ._request_lease_release ,
15081514 )
1509- tg .start_soon (self .handle_lease , status .lease_name , tg , lease_scope )
1515+ conns_tg .start_soon (self .handle_lease , status .lease_name , conns_tg , lease_scope )
15101516
15111517 def _on_lease_update (self , status : jumpstarter_pb2 .StatusResponse ) -> None :
15121518 """Update client info on every leased status tick."""
0 commit comments