@@ -275,8 +275,6 @@ class Exporter(AsyncContextManagerMixin, Metadata):
275275 """Name of the most recently completed lease, used to filter trailing
276276 status ticks after handle_lease's finally has cleaned up."""
277277
278- _pending_lease_status : jumpstarter_pb2 .StatusResponse | None = field (init = False , default = None )
279- _status_replay_tx : MemoryObjectSendStream | None = field (init = False , default = None )
280278 _lease_context : LeaseContext | None = field (init = False , default = None )
281279 """Encapsulates all resources associated with the current lease.
282280
@@ -948,6 +946,46 @@ async def session_for_lease(self):
948946 yield session , main_path , hook_path
949947 logger .info ("Session closed" )
950948
949+ def _ensure_hook_event_set (self , lease_scope : LeaseContext ) -> None :
950+ """Set before_lease_hook if no hook executor is configured.
951+
952+ When conn_tg is cancelled before the no-hook path reaches
953+ lease_scope.before_lease_hook.set(), the flag remains unset and
954+ _cleanup_after_lease (shielded) deadlocks. Only apply when NO
955+ hooks are configured - with hooks, run_before_lease_hook's
956+ finally block sets the event after updating skip_after_lease_hook.
957+ """
958+ if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
959+ lease_scope .before_lease_hook .set ()
960+
961+ async def _finalize_lease_context (self , lease_scope : LeaseContext ) -> None :
962+ """Clean up lease context ownership after handle_lease exits.
963+
964+ Ensures event flags are set (preventing deadlocks in shielded
965+ cleanup), adds a brief delay after session teardown to prevent
966+ SSL corruption from overlapping connections, and clears context.
967+
968+ Shielded from cancellation so that _lease_context is always
969+ cleared even when the task group is cancelled mid-cleanup.
970+ """
971+ with CancelScope (shield = True ):
972+ if self ._lease_context is not lease_scope :
973+ return
974+ if not lease_scope .before_lease_hook .is_set ():
975+ lease_scope .before_lease_hook .set ()
976+ if not lease_scope .after_lease_hook_done .is_set ():
977+ lease_scope .after_lease_hook_done .set ()
978+ if lease_scope .session is not None :
979+ # Brief delay to ensure session is fully closed before next lease.
980+ # Prevents SSL corruption from overlapping connections.
981+ await sleep (0.2 )
982+ self ._last_completed_lease = lease_scope .lease_name
983+ self ._lease_context = None
984+ if self .exit_on_lease_end :
985+ self ._stop_requested = True
986+ clear_log_context ()
987+ logger .debug ("Ready for next lease" )
988+
951989 async def _cleanup_after_lease (self , lease_scope : LeaseContext ) -> None :
952990 """Run afterLease hook cleanup when handle_lease exits.
953991
@@ -1025,7 +1063,7 @@ async def _skip_stale_lease(self, lease_name: str, lease_scope: LeaseContext, co
10251063 lease_scope .after_lease_hook_done .set ()
10261064 return True
10271065
1028- async def handle_lease (self , lease_name : str , tg : TaskGroup , lease_scope : LeaseContext ) -> None : # noqa: C901
1066+ async def handle_lease (self , lease_name : str , conns_tg : TaskGroup , lease_scope : LeaseContext ) -> None : # noqa: C901
10291067 """Handle all incoming client connections for a lease.
10301068
10311069 This method orchestrates the complete lifecycle of managing connections during
@@ -1041,7 +1079,7 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10411079
10421080 Args:
10431081 lease_name: Name of the lease to handle connections for
1044- tg: TaskGroup for spawning concurrent connection handler tasks
1082+ conns_tg: Data-plane TaskGroup for spawning connection handler tasks
10451083 lease_scope: LeaseScope with before_lease_hook event (session/socket set here)
10461084
10471085 Note:
@@ -1062,13 +1100,6 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10621100 if await self ._skip_stale_lease (lease_name , lease_scope , "before session creation" ):
10631101 return
10641102
1065- logger .info ("Listening for incoming connection requests on lease %s" , lease_name )
1066-
1067- # Buffer Listen responses to avoid blocking when responses arrive before
1068- # process_connections starts iterating. This prevents a race condition where
1069- # the client dials immediately after lease acquisition but before the session is ready.
1070- listen_tx , listen_rx = create_memory_object_stream [jumpstarter_pb2 .ListenResponse ](max_buffer_size = 10 )
1071-
10721103 # Create session for the lease duration and populate lease_scope
10731104 # Uses dual sockets: main socket for clients, hook socket for j commands
10741105 async with self .session_for_lease () as (session , main_path , hook_path ):
@@ -1083,14 +1114,12 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10831114 session .update_status (lease_scope .current_status , lease_scope .status_message )
10841115 logger .debug ("Session sockets: main=%s, hook=%s" , main_path , hook_path )
10851116
1086- # Check if lease ended during session creation - serve() often
1087- # processes the buffered leased=False while session_for_lease is
1088- # setting up sockets and gRPC servers. Bailing here avoids the
1089- # Listen stream, conn_tg, and _cleanup_after_lease overhead.
1090- # The session context manager handles teardown on return.
10911117 if await self ._skip_stale_lease (lease_name , lease_scope , "during session setup" ):
10921118 return
10931119
1120+ logger .info ("Listening for incoming connection requests on lease %s" , lease_name )
1121+ listen_tx , listen_rx = create_memory_object_stream [jumpstarter_pb2 .ListenResponse ](max_buffer_size = 10 )
1122+
10941123 # Accept connections immediately - driver calls will be gated internally
10951124 # until the beforeLease hook completes. This allows LogStream to work
10961125 # during hook execution for real-time log streaming.
@@ -1101,7 +1130,8 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
11011130 # session creation (e.g., BEFORE_LEASE_HOOK when hooks are configured).
11021131
11031132 # Start task to handle EndSession requests (runs afterLease hook when client signals done)
1104- tg .start_soon (self ._handle_end_session , lease_scope )
1133+ # Runs on control-plane group so it's cancelled with Status/Listen, not data-plane
1134+ self ._tg .start_soon (self ._handle_end_session , lease_scope )
11051135
11061136 # Process client connections until lease ends
11071137 # The lease can end via:
@@ -1139,7 +1169,7 @@ async def process_connections():
11391169 lease_name ,
11401170 request .router_endpoint ,
11411171 )
1142- tg .start_soon (
1172+ conns_tg .start_soon (
11431173 self ._handle_client_conn ,
11441174 lease_scope .socket_path ,
11451175 request .router_endpoint ,
@@ -1158,51 +1188,38 @@ async def process_connections():
11581188 await self ._report_status (ExporterStatus .LEASE_READY , "Ready for commands" )
11591189 lease_scope .before_lease_hook .set ()
11601190 finally :
1161- # Ensure before_lease_hook is set so _cleanup_after_lease never
1162- # blocks forever. When conn_tg is cancelled before the no-hook
1163- # path reaches lease_scope.before_lease_hook.set(), this flag
1164- # remains unset and _cleanup_after_lease (shielded) deadlocks.
1165- # Only apply this fallback when NO hooks are configured - when
1166- # hooks ARE configured, run_before_lease_hook's finally block
1167- # sets the event after updating skip_after_lease_hook. Setting
1168- # it here prematurely would race with that flag update.
1169- if not self .hook_executor and not lease_scope .before_lease_hook .is_set ():
1170- lease_scope .before_lease_hook .set ()
1191+ self ._ensure_hook_event_set (lease_scope )
11711192 # Close the listen stream to signal termination to listen_rx
11721193 await listen_tx .aclose ()
11731194 # Run afterLease hook before closing the session
11741195 # This ensures the socket is still available for driver calls within the hook
11751196 # Shield from cancellation so the hook can complete even during shutdown
11761197 await self ._cleanup_after_lease (lease_scope )
11771198 finally :
1178- if self ._lease_context is lease_scope :
1179- session_was_created = lease_scope .session is not None
1180- if session_was_created :
1181- await sleep (0.2 )
1182- self ._last_completed_lease = lease_scope .lease_name
1183- self ._lease_context = None
1184- clear_log_context ()
1185- logger .debug ("Ready for next lease" )
1186- pending = self ._pending_lease_status
1187- if pending is not None :
1188- self ._pending_lease_status = None
1189- if self ._status_replay_tx is not None :
1190- await self ._status_replay_tx .send (pending )
1199+ await self ._finalize_lease_context (lease_scope )
11911200
11921201 async def serve (self ):
11931202 """Serve the exporter, handling leases until stopped."""
11941203 async with self .session ():
11951204 pass
11961205 status_tx , status_rx = create_memory_object_stream [jumpstarter_pb2 .StatusResponse ](max_buffer_size = 5 )
11971206 try :
1198- await self ._run_control_plane (status_tx , status_rx )
1199- if self ._fatal_stream_error :
1200- name , err = self ._fatal_stream_error
1201- logger .warning (
1202- "Control plane down (%s: %s)" ,
1203- name ,
1204- err ,
1205- )
1207+ async with create_task_group () as conns_tg :
1208+ await self ._run_control_plane (status_tx , status_rx , conns_tg )
1209+ if self ._fatal_stream_error :
1210+ name , err = self ._fatal_stream_error
1211+ logger .warning (
1212+ "Control plane down (%s: %s), cancelling active connections" ,
1213+ name ,
1214+ err ,
1215+ )
1216+ # The control plane has stopped, so serve() is returning and conns_tg
1217+ # must finish. handle_lease blocks on lease_ended, which nobody sets
1218+ # here: the lease is still valid on the controller, we've only lost
1219+ # contact with it. Cancelling unsticks handle_lease; its shielded
1220+ # _cleanup_after_lease still runs the afterLease hook and closes the
1221+ # session, which drops the tunnels.
1222+ conns_tg .cancel_scope .cancel ()
12061223 finally :
12071224 self ._tg = None
12081225 self ._fatal_stream_error = None
@@ -1213,11 +1230,11 @@ async def _run_control_plane(
12131230 self ,
12141231 status_tx : MemoryObjectSendStream [jumpstarter_pb2 .StatusResponse ],
12151232 status_rx : MemoryObjectReceiveStream [jumpstarter_pb2 .StatusResponse ],
1233+ conns_tg : TaskGroup ,
12161234 ) -> None :
12171235 """Start control-plane streams and process status updates."""
12181236 async with create_task_group () as tg :
12191237 self ._tg = tg
1220- self ._status_replay_tx = status_tx
12211238 self ._status_rpc_event = Event ()
12221239 self ._pending_status_request = None
12231240 self ._status_drain_active = True
@@ -1230,13 +1247,14 @@ async def _run_control_plane(
12301247 on_exhausted = self ._on_status_exhausted ,
12311248 ))
12321249 async for status in status_rx :
1233- if await self ._apply_status (status , tg ):
1250+ if await self ._apply_status (status , tg , conns_tg ):
12341251 break
12351252
12361253 async def _apply_status (
12371254 self ,
12381255 status : jumpstarter_pb2 .StatusResponse ,
12391256 tg : TaskGroup ,
1257+ conns_tg : TaskGroup ,
12401258 ) -> bool :
12411259 """Process a single status update. Returns True to stop the status loop."""
12421260 previous_state = self ._lease_state
@@ -1250,18 +1268,12 @@ async def _apply_status(
12501268 if status .lease_name == self ._last_completed_lease :
12511269 logger .debug ("Ignoring trailing status for completed lease %s" , status .lease_name )
12521270 return False
1253- self ._on_lease_acquired (status , tg )
1271+ self ._on_lease_acquired (status , tg , conns_tg )
12541272 elif (
12551273 previous_state == LeaseState .LEASED
12561274 and self ._lease_context
12571275 and self ._lease_context .lease_name != status .lease_name
12581276 ):
1259- # Controller reassigned the exporter to a different lease.
1260- # Stash the new status and signal the old lease to tear down.
1261- # handle_lease's finally block replays the stashed status
1262- # after clearing _lease_context. The controller won't
1263- # re-send it because proto.Equal suppresses duplicates.
1264- self ._pending_lease_status = status
12651277 if not self ._lease_context .lease_ended .is_set ():
12661278 logger .warning (
12671279 "Controller reassigned exporter from lease %s to %s; tearing down current lease" ,
@@ -1281,6 +1293,7 @@ def _on_lease_acquired(
12811293 self ,
12821294 status : jumpstarter_pb2 .StatusResponse ,
12831295 tg : TaskGroup ,
1296+ conns_tg : TaskGroup ,
12841297 ) -> None :
12851298 """Handle new lease assignment: create context and spawn lease handler."""
12861299 self ._started = True
@@ -1302,7 +1315,7 @@ def _on_lease_acquired(
13021315 self .stop ,
13031316 self ._request_lease_release ,
13041317 )
1305- tg .start_soon (self .handle_lease , status .lease_name , tg , lease_scope )
1318+ conns_tg .start_soon (self .handle_lease , status .lease_name , conns_tg , lease_scope )
13061319
13071320 def _on_lease_update (self , status : jumpstarter_pb2 .StatusResponse ) -> None :
13081321 """Update client info on every leased status tick."""
0 commit comments