Skip to content

Commit 6c59c02

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 c40e6aa commit 6c59c02

2 files changed

Lines changed: 225 additions & 121 deletions

File tree

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

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

Comments
 (0)