Skip to content

Commit 055138c

Browse files
committed
refactor: split task group into data-plane and control-plane
Introduce an outer conns_tg (data-plane) that hosts handle_lease and _handle_client_conn, and an inner tg (control-plane) that hosts Status/Listen streams and _handle_end_session. When _cancel_with_fatal_error fires (Status stream terminal error), only the inner group is cancelled. Active client tunnels on conns_tg remain alive until serve() explicitly cancels the outer group. Add TestTaskGroupIsolation to verify a connection task survives control-plane cancellation. Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com> Assisted-by: claude-opus-4.6
1 parent f6cb7e4 commit 055138c

2 files changed

Lines changed: 226 additions & 127 deletions

File tree

python/packages/jumpstarter/jumpstarter/exporter/exporter.py

Lines changed: 73 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)