@@ -311,13 +311,6 @@ class Exporter(AsyncContextManagerMixin, Metadata):
311311 """Name of the most recently completed lease, used to filter trailing
312312 status ticks after handle_lease's finally has cleaned up."""
313313
314- _pending_lease_status : jumpstarter_pb2 .StatusResponse | None = field (init = False , default = None )
315- """Stashed status from a lease reassignment, replayed after handle_lease's
316- finally clears _lease_context so the new lease can be acquired."""
317-
318- _status_replay_tx : MemoryObjectSendStream | None = field (init = False , default = None )
319- """Send side of the status channel, used to replay _pending_lease_status
320- back into the status loop after a lease transition."""
321314 _lease_context : LeaseContext | None = field (init = False , default = None )
322315 """Encapsulates all resources associated with the current lease.
323316
@@ -1060,6 +1053,47 @@ async def session_for_lease(self):
10601053 yield session , main_path , hook_path
10611054 logger .info ("Session closed" )
10621055
1056+ def _ensure_hook_event_set (self , lease_scope : LeaseContext ) -> None :
1057+ """Set before_lease_hook if no hook executor is configured.
1058+
1059+ When conn_tg is cancelled before the no-hook path reaches
1060+ lease_scope.before_lease_hook.set(), the flag remains unset and
1061+ _cleanup_after_lease (shielded) deadlocks. Only apply when NO
1062+ hooks are configured - with hooks, run_before_lease_hook's
1063+ finally block sets the event after updating skip_after_lease_hook.
1064+ """
1065+ if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
1066+ lease_scope .before_lease_hook .set ()
1067+
1068+ async def _finalize_lease_context (self , lease_scope : LeaseContext ) -> None :
1069+ """Clean up lease context ownership after handle_lease exits.
1070+
1071+ Ensures event flags are set (preventing deadlocks in shielded
1072+ cleanup), adds a brief delay after session teardown to prevent
1073+ SSL corruption from overlapping connections, and clears context.
1074+
1075+ Shielded from cancellation so that _lease_context is always
1076+ cleared even when the task group is cancelled mid-cleanup.
1077+ """
1078+ with CancelScope (shield = True ):
1079+ if self ._lease_context is not lease_scope :
1080+ return
1081+ if not lease_scope .before_lease_hook .is_set ():
1082+ lease_scope .before_lease_hook .set ()
1083+ if not lease_scope .after_lease_hook_done .is_set ():
1084+ lease_scope .after_lease_hook_done .set ()
1085+ if lease_scope .session is not None :
1086+ # Brief delay to ensure session is fully closed before next lease.
1087+ # Prevents SSL corruption from overlapping connections.
1088+ await sleep (0.2 )
1089+ self ._last_completed_lease = lease_scope .lease_name
1090+ self ._lease_context = None
1091+ if self .exit_on_lease_end :
1092+ self ._stop_requested = True
1093+ clear_log_context ()
1094+ set_log_context (exporter = self .name )
1095+ logger .debug ("Ready for next lease" )
1096+
10631097 async def _cleanup_after_lease (self , lease_scope : LeaseContext ) -> None :
10641098 """Run afterLease hook cleanup when handle_lease exits.
10651099
@@ -1137,7 +1171,7 @@ async def _skip_stale_lease(self, lease_name: str, lease_scope: LeaseContext, co
11371171 lease_scope .after_lease_hook_done .set ()
11381172 return True
11391173
1140- async def handle_lease (self , lease_name : str , tg : TaskGroup , lease_scope : LeaseContext ) -> None : # noqa: C901
1174+ async def handle_lease (self , lease_name : str , conns_tg : TaskGroup , lease_scope : LeaseContext ) -> None : # noqa: C901
11411175 """Handle all incoming client connections for a lease.
11421176
11431177 This method orchestrates the complete lifecycle of managing connections during
@@ -1153,7 +1187,7 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
11531187
11541188 Args:
11551189 lease_name: Name of the lease to handle connections for
1156- tg: TaskGroup for spawning concurrent connection handler tasks
1190+ conns_tg: Data-plane TaskGroup for spawning connection handler tasks
11571191 lease_scope: LeaseScope with before_lease_hook event (session/socket set here)
11581192
11591193 Note:
@@ -1174,13 +1208,6 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
11741208 if await self ._skip_stale_lease (lease_name , lease_scope , "before session creation" ):
11751209 return
11761210
1177- logger .info ("Listening for incoming connection requests on lease %s" , lease_name )
1178-
1179- # Buffer Listen responses to avoid blocking when responses arrive before
1180- # process_connections starts iterating. This prevents a race condition where
1181- # the client dials immediately after lease acquisition but before the session is ready.
1182- listen_tx , listen_rx = create_memory_object_stream [jumpstarter_pb2 .ListenResponse ](max_buffer_size = 10 )
1183-
11841211 # Create session for the lease duration and populate lease_scope
11851212 # Uses dual sockets: main socket for clients, hook socket for j commands
11861213 async with self .session_for_lease () as (session , main_path , hook_path ):
@@ -1195,14 +1222,12 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
11951222 session .update_status (lease_scope .current_status , lease_scope .status_message )
11961223 logger .debug ("Session sockets: main=%s, hook=%s" , main_path , hook_path )
11971224
1198- # Check if lease ended during session creation - serve() often
1199- # processes the buffered leased=False while session_for_lease is
1200- # setting up sockets and gRPC servers. Bailing here avoids the
1201- # Listen stream, conn_tg, and _cleanup_after_lease overhead.
1202- # The session context manager handles teardown on return.
12031225 if await self ._skip_stale_lease (lease_name , lease_scope , "during session setup" ):
12041226 return
12051227
1228+ logger .info ("Listening for incoming connection requests on lease %s" , lease_name )
1229+ listen_tx , listen_rx = create_memory_object_stream [jumpstarter_pb2 .ListenResponse ](max_buffer_size = 10 )
1230+
12061231 # Accept connections immediately - driver calls will be gated internally
12071232 # until the beforeLease hook completes. This allows LogStream to work
12081233 # during hook execution for real-time log streaming.
@@ -1213,7 +1238,8 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
12131238 # session creation (e.g., BEFORE_LEASE_HOOK when hooks are configured).
12141239
12151240 # Start task to handle EndSession requests (runs afterLease hook when client signals done)
1216- tg .start_soon (self ._handle_end_session , lease_scope )
1241+ # Runs on control-plane group so it's cancelled with Status/Listen, not data-plane
1242+ self ._tg .start_soon (self ._handle_end_session , lease_scope )
12171243
12181244 # Process client connections until lease ends
12191245 # The lease can end via:
@@ -1251,7 +1277,7 @@ async def process_connections():
12511277 lease_name ,
12521278 request .router_endpoint ,
12531279 )
1254- tg .start_soon (
1280+ conns_tg .start_soon (
12551281 self ._handle_client_conn ,
12561282 lease_scope .socket_path ,
12571283 request .router_endpoint ,
@@ -1270,37 +1296,15 @@ async def process_connections():
12701296 await self ._report_status (ExporterStatus .LEASE_READY , "Ready for commands" )
12711297 lease_scope .before_lease_hook .set ()
12721298 finally :
1273- # Ensure before_lease_hook is set so _cleanup_after_lease never
1274- # blocks forever. When conn_tg is cancelled before the no-hook
1275- # path reaches lease_scope.before_lease_hook.set(), this flag
1276- # remains unset and _cleanup_after_lease (shielded) deadlocks.
1277- # Only apply this fallback when NO hooks are configured - when
1278- # hooks ARE configured, run_before_lease_hook's finally block
1279- # sets the event after updating skip_after_lease_hook. Setting
1280- # it here prematurely would race with that flag update.
1281- if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
1282- lease_scope .before_lease_hook .set ()
1299+ self ._ensure_hook_event_set (lease_scope )
12831300 # Close the listen stream to signal termination to listen_rx
12841301 await listen_tx .aclose ()
12851302 # Run afterLease hook before closing the session
12861303 # This ensures the socket is still available for driver calls within the hook
12871304 # Shield from cancellation so the hook can complete even during shutdown
12881305 await self ._cleanup_after_lease (lease_scope )
12891306 finally :
1290- if self ._lease_context is lease_scope :
1291- session_was_created = lease_scope .session is not None
1292- if session_was_created :
1293- await sleep (0.2 )
1294- self ._last_completed_lease = lease_scope .lease_name
1295- self ._lease_context = None
1296- clear_log_context ()
1297- set_log_context (exporter = self .name )
1298- logger .debug ("Ready for next lease" )
1299- pending = self ._pending_lease_status
1300- if pending is not None :
1301- self ._pending_lease_status = None
1302- if self ._status_replay_tx is not None :
1303- await self ._status_replay_tx .send (pending )
1307+ await self ._finalize_lease_context (lease_scope )
13041308
13051309 async def serve (self ):
13061310 """Serve the exporter, handling leases until stopped."""
@@ -1311,14 +1315,22 @@ async def serve(self):
13111315 pass
13121316 status_tx , status_rx = create_memory_object_stream [jumpstarter_pb2 .StatusResponse ](max_buffer_size = 5 )
13131317 try :
1314- await self ._run_control_plane (status_tx , status_rx )
1315- if self ._fatal_stream_error :
1316- name , err = self ._fatal_stream_error
1317- logger .warning (
1318- "Control plane down (%s: %s)" ,
1319- name ,
1320- err ,
1321- )
1318+ async with create_task_group () as conns_tg :
1319+ await self ._run_control_plane (status_tx , status_rx , conns_tg )
1320+ if self ._fatal_stream_error :
1321+ name , err = self ._fatal_stream_error
1322+ logger .warning (
1323+ "Control plane down (%s: %s), cancelling active connections" ,
1324+ name ,
1325+ err ,
1326+ )
1327+ # The control plane has stopped, so serve() is returning and conns_tg
1328+ # must finish. handle_lease blocks on lease_ended, which nobody sets
1329+ # here: the lease is still valid on the controller, we've only lost
1330+ # contact with it. Cancelling unsticks handle_lease; its shielded
1331+ # _cleanup_after_lease still runs the afterLease hook and closes the
1332+ # session, which drops the tunnels.
1333+ conns_tg .cancel_scope .cancel ()
13221334 finally :
13231335 self ._tg = None
13241336 self ._fatal_stream_error = None
@@ -1338,11 +1350,11 @@ async def _run_control_plane(
13381350 self ,
13391351 status_tx : MemoryObjectSendStream [jumpstarter_pb2 .StatusResponse ],
13401352 status_rx : MemoryObjectReceiveStream [jumpstarter_pb2 .StatusResponse ],
1353+ conns_tg : TaskGroup ,
13411354 ) -> None :
13421355 """Start control-plane streams and process status updates."""
13431356 async with create_task_group () as tg :
13441357 self ._tg = tg
1345- self ._status_replay_tx = status_tx
13461358 self ._status_rpc_event = Event ()
13471359 self ._pending_status_request = None
13481360 self ._status_drain_active = True
@@ -1357,13 +1369,14 @@ async def _run_control_plane(
13571369 on_exhausted = self ._on_status_exhausted ,
13581370 ))
13591371 async for status in status_rx :
1360- if await self ._apply_status (status , tg ):
1372+ if await self ._apply_status (status , tg , conns_tg ):
13611373 break
13621374
13631375 async def _apply_status (
13641376 self ,
13651377 status : jumpstarter_pb2 .StatusResponse ,
13661378 tg : TaskGroup ,
1379+ conns_tg : TaskGroup ,
13671380 ) -> bool :
13681381 """Process a single status update. Returns True to stop the status loop."""
13691382 previous_state = self ._lease_state
@@ -1377,18 +1390,12 @@ async def _apply_status(
13771390 if status .lease_name == self ._last_completed_lease :
13781391 logger .debug ("Ignoring trailing status for completed lease %s" , status .lease_name )
13791392 return False
1380- self ._on_lease_acquired (status , tg )
1393+ self ._on_lease_acquired (status , tg , conns_tg )
13811394 elif (
13821395 previous_state == LeaseState .LEASED
13831396 and self ._lease_context
13841397 and self ._lease_context .lease_name != status .lease_name
13851398 ):
1386- # Controller reassigned the exporter to a different lease.
1387- # Stash the new status and signal the old lease to tear down.
1388- # handle_lease's finally block replays the stashed status
1389- # after clearing _lease_context. The controller won't
1390- # re-send it because proto.Equal suppresses duplicates.
1391- self ._pending_lease_status = status
13921399 if not self ._lease_context .lease_ended .is_set ():
13931400 logger .warning (
13941401 "Controller reassigned exporter from lease %s to %s; tearing down current lease" ,
@@ -1408,6 +1415,7 @@ def _on_lease_acquired(
14081415 self ,
14091416 status : jumpstarter_pb2 .StatusResponse ,
14101417 tg : TaskGroup ,
1418+ conns_tg : TaskGroup ,
14111419 ) -> None :
14121420 """Handle new lease assignment: create context and spawn lease handler."""
14131421 self ._started = True
@@ -1429,7 +1437,7 @@ def _on_lease_acquired(
14291437 self .stop ,
14301438 self ._request_lease_release ,
14311439 )
1432- tg .start_soon (self .handle_lease , status .lease_name , tg , lease_scope )
1440+ conns_tg .start_soon (self .handle_lease , status .lease_name , conns_tg , lease_scope )
14331441
14341442 def _on_lease_update (self , status : jumpstarter_pb2 .StatusResponse ) -> None :
14351443 """Update client info on every leased status tick."""
0 commit comments