Skip to content

Commit 5ee18f8

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 e2bdf7e commit 5ee18f8

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
@@ -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

Comments
 (0)