Skip to content

Commit f0b2ec4

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 df4e753 commit f0b2ec4

2 files changed

Lines changed: 171 additions & 51 deletions

File tree

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

Lines changed: 63 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,40 @@ async def session_for_lease(self):
944944
yield session, main_path, hook_path
945945
logger.info("Session closed")
946946

947+
def _ensure_hook_event_set(self, lease_scope: LeaseContext) -> None:
948+
"""Set before_lease_hook if no hook executor is configured.
949+
950+
When conn_tg is cancelled before the no-hook path reaches
951+
lease_scope.before_lease_hook.set(), the flag remains unset and
952+
_cleanup_after_lease (shielded) deadlocks. Only apply when NO
953+
hooks are configured - with hooks, run_before_lease_hook's
954+
finally block sets the event after updating skip_after_lease_hook.
955+
"""
956+
if not self.hook_executor and not lease_scope.before_lease_hook.is_set():
957+
lease_scope.before_lease_hook.set()
958+
959+
async def _finalize_lease_context(self, lease_scope: LeaseContext) -> None:
960+
"""Clean up lease context ownership after handle_lease exits.
961+
962+
Ensures event flags are set (preventing deadlocks in shielded
963+
cleanup), adds a brief delay after session teardown to prevent
964+
SSL corruption from overlapping connections, and clears context.
965+
"""
966+
if self._lease_context is not lease_scope:
967+
return
968+
if not lease_scope.before_lease_hook.is_set():
969+
lease_scope.before_lease_hook.set()
970+
if not lease_scope.after_lease_hook_done.is_set():
971+
lease_scope.after_lease_hook_done.set()
972+
if lease_scope.session is not None:
973+
# Brief delay to ensure session is fully closed before next lease.
974+
# Prevents SSL corruption from overlapping connections.
975+
await sleep(0.2)
976+
self._last_completed_lease = lease_scope.lease_name
977+
self._lease_context = None
978+
clear_log_context()
979+
logger.debug("Ready for next lease")
980+
947981
async def _cleanup_after_lease(self, lease_scope: LeaseContext) -> None:
948982
"""Run afterLease hook cleanup when handle_lease exits.
949983
@@ -1021,7 +1055,7 @@ async def _skip_stale_lease(self, lease_name: str, lease_scope: LeaseContext, co
10211055
lease_scope.after_lease_hook_done.set()
10221056
return True
10231057

1024-
async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseContext) -> None:
1058+
async def handle_lease(self, lease_name: str, conns_tg: TaskGroup, lease_scope: LeaseContext) -> None:
10251059
"""Handle all incoming client connections for a lease.
10261060
10271061
This method orchestrates the complete lifecycle of managing connections during
@@ -1037,7 +1071,7 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10371071
10381072
Args:
10391073
lease_name: Name of the lease to handle connections for
1040-
tg: TaskGroup for spawning concurrent connection handler tasks
1074+
conns_tg: Data-plane TaskGroup for spawning connection handler tasks
10411075
lease_scope: LeaseScope with before_lease_hook event (session/socket set here)
10421076
10431077
Note:
@@ -1097,7 +1131,8 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10971131
# session creation (e.g., BEFORE_LEASE_HOOK when hooks are configured).
10981132

10991133
# Start task to handle EndSession requests (runs afterLease hook when client signals done)
1100-
tg.start_soon(self._handle_end_session, lease_scope)
1134+
# Runs on control-plane group so it's cancelled with Status/Listen, not data-plane
1135+
self._tg.start_soon(self._handle_end_session, lease_scope)
11011136

11021137
# Process client connections until lease ends
11031138
# The lease can end via:
@@ -1135,7 +1170,7 @@ async def process_connections():
11351170
lease_name,
11361171
request.router_endpoint,
11371172
)
1138-
tg.start_soon(
1173+
conns_tg.start_soon(
11391174
self._handle_client_conn,
11401175
lease_scope.socket_path,
11411176
request.router_endpoint,
@@ -1154,51 +1189,38 @@ async def process_connections():
11541189
await self._report_status(ExporterStatus.LEASE_READY, "Ready for commands")
11551190
lease_scope.before_lease_hook.set()
11561191
finally:
1157-
# Ensure before_lease_hook is set so _cleanup_after_lease never
1158-
# blocks forever. When conn_tg is cancelled before the no-hook
1159-
# path reaches lease_scope.before_lease_hook.set(), this flag
1160-
# remains unset and _cleanup_after_lease (shielded) deadlocks.
1161-
# Only apply this fallback when NO hooks are configured - when
1162-
# hooks ARE configured, run_before_lease_hook's finally block
1163-
# sets the event after updating skip_after_lease_hook. Setting
1164-
# it here prematurely would race with that flag update.
1165-
if not self.hook_executor and not lease_scope.before_lease_hook.is_set():
1166-
lease_scope.before_lease_hook.set()
1192+
self._ensure_hook_event_set(lease_scope)
11671193
# Close the listen stream to signal termination to listen_rx
11681194
await listen_tx.aclose()
11691195
# Run afterLease hook before closing the session
11701196
# This ensures the socket is still available for driver calls within the hook
11711197
# Shield from cancellation so the hook can complete even during shutdown
11721198
await self._cleanup_after_lease(lease_scope)
11731199
finally:
1174-
if self._lease_context is lease_scope:
1175-
session_was_created = lease_scope.session is not None
1176-
if session_was_created:
1177-
await sleep(0.2)
1178-
self._last_completed_lease = lease_scope.lease_name
1179-
self._lease_context = None
1180-
clear_log_context()
1181-
logger.debug("Ready for next lease")
1182-
pending = self._pending_lease_status
1183-
if pending is not None:
1184-
self._pending_lease_status = None
1185-
if self._status_replay_tx is not None:
1186-
await self._status_replay_tx.send(pending)
1200+
await self._finalize_lease_context(lease_scope)
11871201

11881202
async def serve(self):
11891203
"""Serve the exporter, handling leases until stopped."""
11901204
async with self.session():
11911205
pass
11921206
status_tx, status_rx = create_memory_object_stream[jumpstarter_pb2.StatusResponse](max_buffer_size=5)
11931207
try:
1194-
await self._run_control_plane(status_tx, status_rx)
1195-
if self._fatal_stream_error:
1196-
name, err = self._fatal_stream_error
1197-
logger.warning(
1198-
"Control plane down (%s: %s)",
1199-
name,
1200-
err,
1201-
)
1208+
async with create_task_group() as conns_tg:
1209+
await self._run_control_plane(status_tx, status_rx, conns_tg)
1210+
if self._fatal_stream_error:
1211+
name, err = self._fatal_stream_error
1212+
logger.warning(
1213+
"Control plane down (%s: %s), cancelling active connections",
1214+
name,
1215+
err,
1216+
)
1217+
# The control plane has stopped, so serve() is returning and conns_tg
1218+
# must finish. handle_lease blocks on lease_ended, which nobody sets
1219+
# here: the lease is still valid on the controller, we've only lost
1220+
# contact with it. Cancelling unsticks handle_lease; its shielded
1221+
# _cleanup_after_lease still runs the afterLease hook and closes the
1222+
# session, which drops the tunnels.
1223+
conns_tg.cancel_scope.cancel()
12021224
finally:
12031225
self._tg = None
12041226
self._fatal_stream_error = None
@@ -1209,6 +1231,7 @@ async def _run_control_plane(
12091231
self,
12101232
status_tx: MemoryObjectSendStream[jumpstarter_pb2.StatusResponse],
12111233
status_rx: MemoryObjectReceiveStream[jumpstarter_pb2.StatusResponse],
1234+
conns_tg: TaskGroup,
12121235
) -> None:
12131236
"""Start control-plane streams and process status updates."""
12141237
async with create_task_group() as tg:
@@ -1226,13 +1249,14 @@ async def _run_control_plane(
12261249
on_exhausted=self._on_status_exhausted,
12271250
))
12281251
async for status in status_rx:
1229-
if await self._apply_status(status, tg):
1252+
if await self._apply_status(status, tg, conns_tg):
12301253
break
12311254

12321255
async def _apply_status(
12331256
self,
12341257
status: jumpstarter_pb2.StatusResponse,
12351258
tg: TaskGroup,
1259+
conns_tg: TaskGroup,
12361260
) -> bool:
12371261
"""Process a single status update. Returns True to stop the status loop."""
12381262
previous_state = self._lease_state
@@ -1246,7 +1270,7 @@ async def _apply_status(
12461270
if status.lease_name == self._last_completed_lease:
12471271
logger.debug("Ignoring trailing status for completed lease %s", status.lease_name)
12481272
return False
1249-
self._on_lease_acquired(status, tg)
1273+
self._on_lease_acquired(status, tg, conns_tg)
12501274
elif (
12511275
previous_state == LeaseState.LEASED
12521276
and self._lease_context
@@ -1277,6 +1301,7 @@ def _on_lease_acquired(
12771301
self,
12781302
status: jumpstarter_pb2.StatusResponse,
12791303
tg: TaskGroup,
1304+
conns_tg: TaskGroup,
12801305
) -> None:
12811306
"""Handle new lease assignment: create context and spawn lease handler."""
12821307
self._started = True
@@ -1298,7 +1323,7 @@ def _on_lease_acquired(
12981323
self.stop,
12991324
self._request_lease_release,
13001325
)
1301-
tg.start_soon(self.handle_lease, status.lease_name, tg, lease_scope)
1326+
conns_tg.start_soon(self.handle_lease, status.lease_name, conns_tg, lease_scope)
13021327

13031328
def _on_lease_update(self, status: jumpstarter_pb2.StatusResponse) -> None:
13041329
"""Update client info on every leased status tick."""

0 commit comments

Comments
 (0)