Skip to content

Commit b77f171

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 89ff48d commit b77f171

2 files changed

Lines changed: 228 additions & 81 deletions

File tree

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

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

Comments
 (0)