@@ -383,13 +383,6 @@ class Exporter(AsyncContextManagerMixin, Metadata):
383383 """Name of the most recently completed lease, used to filter trailing
384384 status ticks after handle_lease's finally has cleaned up."""
385385
386- _pending_lease_status : jumpstarter_pb2 .StatusResponse | None = field (init = False , default = None )
387- """Stashed status from a lease reassignment, replayed after handle_lease's
388- finally clears _lease_context so the new lease can be acquired."""
389-
390- _status_replay_tx : MemoryObjectSendStream | None = field (init = False , default = None )
391- """Send side of the status channel, used to replay _pending_lease_status
392- back into the status loop after a lease transition."""
393386 _lease_context : LeaseContext | None = field (init = False , default = None )
394387 """Encapsulates all resources associated with the current lease.
395388
@@ -1132,6 +1125,47 @@ async def session_for_lease(self):
11321125 yield session , main_path , hook_path
11331126 logger .info ("Session closed" )
11341127
1128+ def _ensure_hook_event_set (self , lease_scope : LeaseContext ) -> None :
1129+ """Set before_lease_hook if no hook executor is configured.
1130+
1131+ When conn_tg is cancelled before the no-hook path reaches
1132+ lease_scope.before_lease_hook.set(), the flag remains unset and
1133+ _cleanup_after_lease (shielded) deadlocks. Only apply when NO
1134+ hooks are configured - with hooks, run_before_lease_hook's
1135+ finally block sets the event after updating skip_after_lease_hook.
1136+ """
1137+ if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
1138+ lease_scope .before_lease_hook .set ()
1139+
1140+ async def _finalize_lease_context (self , lease_scope : LeaseContext ) -> None :
1141+ """Clean up lease context ownership after handle_lease exits.
1142+
1143+ Ensures event flags are set (preventing deadlocks in shielded
1144+ cleanup), adds a brief delay after session teardown to prevent
1145+ SSL corruption from overlapping connections, and clears context.
1146+
1147+ Shielded from cancellation so that _lease_context is always
1148+ cleared even when the task group is cancelled mid-cleanup.
1149+ """
1150+ with CancelScope (shield = True ):
1151+ if self ._lease_context is not lease_scope :
1152+ return
1153+ if not lease_scope .before_lease_hook .is_set ():
1154+ lease_scope .before_lease_hook .set ()
1155+ if not lease_scope .after_lease_hook_done .is_set ():
1156+ lease_scope .after_lease_hook_done .set ()
1157+ if lease_scope .session is not None :
1158+ # Brief delay to ensure session is fully closed before next lease.
1159+ # Prevents SSL corruption from overlapping connections.
1160+ await sleep (0.2 )
1161+ self ._last_completed_lease = lease_scope .lease_name
1162+ self ._lease_context = None
1163+ if self .exit_on_lease_end :
1164+ self ._stop_requested = True
1165+ clear_log_context ()
1166+ set_log_context (exporter = self .name )
1167+ logger .debug ("Ready for next lease" )
1168+
11351169 async def _cleanup_after_lease (self , lease_scope : LeaseContext ) -> None :
11361170 """Run afterLease hook cleanup when handle_lease exits.
11371171
@@ -1209,7 +1243,7 @@ async def _skip_stale_lease(self, lease_name: str, lease_scope: LeaseContext, co
12091243 lease_scope .after_lease_hook_done .set ()
12101244 return True
12111245
1212- async def handle_lease (self , lease_name : str , tg : TaskGroup , lease_scope : LeaseContext ) -> None : # noqa: C901
1246+ async def handle_lease (self , lease_name : str , conns_tg : TaskGroup , lease_scope : LeaseContext ) -> None : # noqa: C901
12131247 """Handle all incoming client connections for a lease.
12141248
12151249 This method orchestrates the complete lifecycle of managing connections during
@@ -1225,7 +1259,7 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
12251259
12261260 Args:
12271261 lease_name: Name of the lease to handle connections for
1228- tg: TaskGroup for spawning concurrent connection handler tasks
1262+ conns_tg: Data-plane TaskGroup for spawning connection handler tasks
12291263 lease_scope: LeaseScope with before_lease_hook event (session/socket set here)
12301264
12311265 Note:
@@ -1246,13 +1280,6 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
12461280 if await self ._skip_stale_lease (lease_name , lease_scope , "before session creation" ):
12471281 return
12481282
1249- logger .info ("Listening for incoming connection requests on lease %s" , lease_name )
1250-
1251- # Buffer Listen responses to avoid blocking when responses arrive before
1252- # process_connections starts iterating. This prevents a race condition where
1253- # the client dials immediately after lease acquisition but before the session is ready.
1254- listen_tx , listen_rx = create_memory_object_stream [jumpstarter_pb2 .ListenResponse ](max_buffer_size = 10 )
1255-
12561283 # Create session for the lease duration and populate lease_scope
12571284 # Uses dual sockets: main socket for clients, hook socket for j commands
12581285 async with self .session_for_lease () as (session , main_path , hook_path ):
@@ -1267,14 +1294,12 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
12671294 session .update_status (lease_scope .current_status , lease_scope .status_message )
12681295 logger .debug ("Session sockets: main=%s, hook=%s" , main_path , hook_path )
12691296
1270- # Check if lease ended during session creation - serve() often
1271- # processes the buffered leased=False while session_for_lease is
1272- # setting up sockets and gRPC servers. Bailing here avoids the
1273- # Listen stream, conn_tg, and _cleanup_after_lease overhead.
1274- # The session context manager handles teardown on return.
12751297 if await self ._skip_stale_lease (lease_name , lease_scope , "during session setup" ):
12761298 return
12771299
1300+ logger .info ("Listening for incoming connection requests on lease %s" , lease_name )
1301+ listen_tx , listen_rx = create_memory_object_stream [jumpstarter_pb2 .ListenResponse ](max_buffer_size = 10 )
1302+
12781303 # Accept connections immediately - driver calls will be gated internally
12791304 # until the beforeLease hook completes. This allows LogStream to work
12801305 # during hook execution for real-time log streaming.
@@ -1285,7 +1310,8 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
12851310 # session creation (e.g., BEFORE_LEASE_HOOK when hooks are configured).
12861311
12871312 # Start task to handle EndSession requests (runs afterLease hook when client signals done)
1288- tg .start_soon (self ._handle_end_session , lease_scope )
1313+ # Runs on control-plane group so it's cancelled with Status/Listen, not data-plane
1314+ self ._tg .start_soon (self ._handle_end_session , lease_scope )
12891315
12901316 # Process client connections until lease ends
12911317 # The lease can end via:
@@ -1323,7 +1349,7 @@ async def process_connections():
13231349 lease_name ,
13241350 request .router_endpoint ,
13251351 )
1326- tg .start_soon (
1352+ conns_tg .start_soon (
13271353 self ._handle_client_conn ,
13281354 lease_scope .socket_path ,
13291355 request .router_endpoint ,
@@ -1342,37 +1368,15 @@ async def process_connections():
13421368 await self ._report_status (ExporterStatus .LEASE_READY , "Ready for commands" )
13431369 lease_scope .before_lease_hook .set ()
13441370 finally :
1345- # Ensure before_lease_hook is set so _cleanup_after_lease never
1346- # blocks forever. When conn_tg is cancelled before the no-hook
1347- # path reaches lease_scope.before_lease_hook.set(), this flag
1348- # remains unset and _cleanup_after_lease (shielded) deadlocks.
1349- # Only apply this fallback when NO hooks are configured - when
1350- # hooks ARE configured, run_before_lease_hook's finally block
1351- # sets the event after updating skip_after_lease_hook. Setting
1352- # it here prematurely would race with that flag update.
1353- if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
1354- lease_scope .before_lease_hook .set ()
1371+ self ._ensure_hook_event_set (lease_scope )
13551372 # Close the listen stream to signal termination to listen_rx
13561373 await listen_tx .aclose ()
13571374 # Run afterLease hook before closing the session
13581375 # This ensures the socket is still available for driver calls within the hook
13591376 # Shield from cancellation so the hook can complete even during shutdown
13601377 await self ._cleanup_after_lease (lease_scope )
13611378 finally :
1362- if self ._lease_context is lease_scope :
1363- session_was_created = lease_scope .session is not None
1364- if session_was_created :
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 )
1379+ await self ._finalize_lease_context (lease_scope )
13761380
13771381 async def serve (self ):
13781382 """Serve the exporter, handling leases until stopped."""
@@ -1383,14 +1387,22 @@ async def serve(self):
13831387 pass
13841388 status_tx , status_rx = create_memory_object_stream [jumpstarter_pb2 .StatusResponse ](max_buffer_size = 5 )
13851389 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- )
1390+ async with create_task_group () as conns_tg :
1391+ await self ._run_control_plane (status_tx , status_rx , conns_tg )
1392+ if self ._fatal_stream_error :
1393+ name , err = self ._fatal_stream_error
1394+ logger .warning (
1395+ "Control plane down (%s: %s), cancelling active connections" ,
1396+ name ,
1397+ err ,
1398+ )
1399+ # The control plane has stopped, so serve() is returning and conns_tg
1400+ # must finish. handle_lease blocks on lease_ended, which nobody sets
1401+ # here: the lease is still valid on the controller, we've only lost
1402+ # contact with it. Cancelling unsticks handle_lease; its shielded
1403+ # _cleanup_after_lease still runs the afterLease hook and closes the
1404+ # session, which drops the tunnels.
1405+ conns_tg .cancel_scope .cancel ()
13941406 finally :
13951407 if self .exit_on_lease_end :
13961408 # Ensure the runtime container exits whenever this exporter is
@@ -1415,11 +1427,11 @@ async def _run_control_plane(
14151427 self ,
14161428 status_tx : MemoryObjectSendStream [jumpstarter_pb2 .StatusResponse ],
14171429 status_rx : MemoryObjectReceiveStream [jumpstarter_pb2 .StatusResponse ],
1430+ conns_tg : TaskGroup ,
14181431 ) -> None :
14191432 """Start control-plane streams and process status updates."""
14201433 async with create_task_group () as tg :
14211434 self ._tg = tg
1422- self ._status_replay_tx = status_tx
14231435 self ._status_rpc_event = Event ()
14241436 self ._pending_status_request = None
14251437 self ._status_drain_active = True
@@ -1434,13 +1446,14 @@ async def _run_control_plane(
14341446 on_exhausted = self ._on_status_exhausted ,
14351447 ))
14361448 async for status in status_rx :
1437- if await self ._apply_status (status , tg ):
1449+ if await self ._apply_status (status , tg , conns_tg ):
14381450 break
14391451
14401452 async def _apply_status (
14411453 self ,
14421454 status : jumpstarter_pb2 .StatusResponse ,
14431455 tg : TaskGroup ,
1456+ conns_tg : TaskGroup ,
14441457 ) -> bool :
14451458 """Process a single status update. Returns True to stop the status loop."""
14461459 previous_state = self ._lease_state
@@ -1454,18 +1467,12 @@ async def _apply_status(
14541467 if status .lease_name == self ._last_completed_lease :
14551468 logger .debug ("Ignoring trailing status for completed lease %s" , status .lease_name )
14561469 return False
1457- self ._on_lease_acquired (status , tg )
1470+ self ._on_lease_acquired (status , tg , conns_tg )
14581471 elif (
14591472 previous_state == LeaseState .LEASED
14601473 and self ._lease_context
14611474 and self ._lease_context .lease_name != status .lease_name
14621475 ):
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
14691476 if not self ._lease_context .lease_ended .is_set ():
14701477 logger .warning (
14711478 "Controller reassigned exporter from lease %s to %s; tearing down current lease" ,
@@ -1485,6 +1492,7 @@ def _on_lease_acquired(
14851492 self ,
14861493 status : jumpstarter_pb2 .StatusResponse ,
14871494 tg : TaskGroup ,
1495+ conns_tg : TaskGroup ,
14881496 ) -> None :
14891497 """Handle new lease assignment: create context and spawn lease handler."""
14901498 self ._started = True
@@ -1506,7 +1514,7 @@ def _on_lease_acquired(
15061514 self .stop ,
15071515 self ._request_lease_release ,
15081516 )
1509- tg .start_soon (self .handle_lease , status .lease_name , tg , lease_scope )
1517+ conns_tg .start_soon (self .handle_lease , status .lease_name , conns_tg , lease_scope )
15101518
15111519 def _on_lease_update (self , status : jumpstarter_pb2 .StatusResponse ) -> None :
15121520 """Update client info on every leased status tick."""
0 commit comments