Skip to content

Commit a61b1ec

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 48be4be commit a61b1ec

2 files changed

Lines changed: 165 additions & 43 deletions

File tree

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

Lines changed: 62 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ class Exporter(AsyncContextManagerMixin, Metadata):
272272
"""
273273

274274
_last_completed_lease: str | None = field(init=False, default=None)
275+
275276
_lease_context: LeaseContext | None = field(init=False, default=None)
276277
"""Encapsulates all resources associated with the current lease.
277278
@@ -942,6 +943,38 @@ async def session_for_lease(self):
942943
yield session, main_path, hook_path
943944
logger.info("Session closed")
944945

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

1022-
async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseContext) -> None:
1055+
async def handle_lease(self, lease_name: str, conns_tg: TaskGroup, lease_scope: LeaseContext) -> None:
10231056
"""Handle all incoming client connections for a lease.
10241057
10251058
This method orchestrates the complete lifecycle of managing connections during
@@ -1035,7 +1068,7 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10351068
10361069
Args:
10371070
lease_name: Name of the lease to handle connections for
1038-
tg: TaskGroup for spawning concurrent connection handler tasks
1071+
conns_tg: Data-plane TaskGroup for spawning connection handler tasks
10391072
lease_scope: LeaseScope with before_lease_hook event (session/socket set here)
10401073
10411074
Note:
@@ -1095,7 +1128,8 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
10951128
# session creation (e.g., BEFORE_LEASE_HOOK when hooks are configured).
10961129

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

11001134
# Process client connections until lease ends
11011135
# The lease can end via:
@@ -1133,7 +1167,7 @@ async def process_connections():
11331167
lease_name,
11341168
request.router_endpoint,
11351169
)
1136-
tg.start_soon(
1170+
conns_tg.start_soon(
11371171
self._handle_client_conn,
11381172
lease_scope.socket_path,
11391173
request.router_endpoint,
@@ -1152,48 +1186,38 @@ async def process_connections():
11521186
await self._report_status(ExporterStatus.LEASE_READY, "Ready for commands")
11531187
lease_scope.before_lease_hook.set()
11541188
finally:
1155-
# Ensure before_lease_hook is set so _cleanup_after_lease never
1156-
# blocks forever. When conn_tg is cancelled before the no-hook
1157-
# path reaches lease_scope.before_lease_hook.set(), this flag
1158-
# remains unset and _cleanup_after_lease (shielded) deadlocks.
1159-
# Only apply this fallback when NO hooks are configured — when
1160-
# hooks ARE configured, run_before_lease_hook's finally block
1161-
# sets the event after updating skip_after_lease_hook. Setting
1162-
# it here prematurely would race with that flag update.
1163-
if not self.hook_executor and not lease_scope.before_lease_hook.is_set():
1164-
lease_scope.before_lease_hook.set()
1189+
self._ensure_hook_event_set(lease_scope)
11651190
# Close the listen stream to signal termination to listen_rx
11661191
await listen_tx.aclose()
11671192
# Run afterLease hook before closing the session
11681193
# This ensures the socket is still available for driver calls within the hook
11691194
# Shield from cancellation so the hook can complete even during shutdown
11701195
await self._cleanup_after_lease(lease_scope)
11711196
finally:
1172-
if self._lease_context is lease_scope:
1173-
session_was_created = lease_scope.session is not None
1174-
if session_was_created:
1175-
# Brief delay to ensure session is fully closed before next lease.
1176-
# Prevents SSL corruption from overlapping connections.
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")
1197+
await self._finalize_lease_context(lease_scope)
11821198

11831199
async def serve(self):
11841200
"""Serve the exporter, handling leases until stopped."""
11851201
async with self.session():
11861202
pass
11871203
status_tx, status_rx = create_memory_object_stream[jumpstarter_pb2.StatusResponse](max_buffer_size=5)
11881204
try:
1189-
await self._run_control_plane(status_tx, status_rx)
1190-
if self._fatal_stream_error:
1191-
name, err = self._fatal_stream_error
1192-
logger.warning(
1193-
"Control plane down (%s: %s)",
1194-
name,
1195-
err,
1196-
)
1205+
async with create_task_group() as conns_tg:
1206+
await self._run_control_plane(status_tx, status_rx, conns_tg)
1207+
if self._fatal_stream_error:
1208+
name, err = self._fatal_stream_error
1209+
logger.warning(
1210+
"Control plane down (%s: %s), cancelling active connections",
1211+
name,
1212+
err,
1213+
)
1214+
# The control plane has stopped, so serve() is returning and conns_tg
1215+
# must finish. handle_lease blocks on lease_ended, which nobody sets
1216+
# here: the lease is still valid on the controller, we've only lost
1217+
# contact with it. Cancelling unsticks handle_lease; its shielded
1218+
# _cleanup_after_lease still runs the afterLease hook and closes the
1219+
# session, which drops the tunnels.
1220+
conns_tg.cancel_scope.cancel()
11971221
finally:
11981222
self._tg = None
11991223
self._fatal_stream_error = None
@@ -1204,6 +1228,7 @@ async def _run_control_plane(
12041228
self,
12051229
status_tx: MemoryObjectSendStream[jumpstarter_pb2.StatusResponse],
12061230
status_rx: MemoryObjectReceiveStream[jumpstarter_pb2.StatusResponse],
1231+
conns_tg: TaskGroup,
12071232
) -> None:
12081233
"""Start control-plane streams and process status updates."""
12091234
async with create_task_group() as tg:
@@ -1220,13 +1245,14 @@ async def _run_control_plane(
12201245
on_exhausted=self._on_status_exhausted,
12211246
))
12221247
async for status in status_rx:
1223-
if await self._apply_status(status, tg):
1248+
if await self._apply_status(status, tg, conns_tg):
12241249
break
12251250

12261251
async def _apply_status(
12271252
self,
12281253
status: jumpstarter_pb2.StatusResponse,
12291254
tg: TaskGroup,
1255+
conns_tg: TaskGroup,
12301256
) -> bool:
12311257
"""Process a single status update. Returns True to stop the status loop."""
12321258
previous_state = self._lease_state
@@ -1240,7 +1266,7 @@ async def _apply_status(
12401266
if status.lease_name == self._last_completed_lease:
12411267
logger.debug("Ignoring trailing status for completed lease %s", status.lease_name)
12421268
return False
1243-
self._on_lease_acquired(status, tg)
1269+
self._on_lease_acquired(status, tg, conns_tg)
12441270
elif (
12451271
previous_state == LeaseState.LEASED
12461272
and self._lease_context
@@ -1265,6 +1291,7 @@ def _on_lease_acquired(
12651291
self,
12661292
status: jumpstarter_pb2.StatusResponse,
12671293
tg: TaskGroup,
1294+
conns_tg: TaskGroup,
12681295
) -> None:
12691296
"""Handle new lease assignment: create context and spawn lease handler."""
12701297
self._started = True
@@ -1286,7 +1313,7 @@ def _on_lease_acquired(
12861313
self.stop,
12871314
self._request_lease_release,
12881315
)
1289-
tg.start_soon(self.handle_lease, status.lease_name, tg, lease_scope)
1316+
conns_tg.start_soon(self.handle_lease, status.lease_name, conns_tg, lease_scope)
12901317

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

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

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,7 @@ async def test_overlap_rejection_returns_false(self):
11481148
status.context = {}
11491149

11501150
async with create_task_group() as tg:
1151-
result = await exporter._apply_status(status, tg)
1151+
result = await exporter._apply_status(status, tg, tg)
11521152
tg.cancel_scope.cancel()
11531153

11541154
assert result is False
@@ -1166,7 +1166,7 @@ async def test_overlap_same_lease_name_not_rejected(self):
11661166
status.context = {}
11671167

11681168
async with create_task_group() as tg:
1169-
result = await exporter._apply_status(status, tg)
1169+
result = await exporter._apply_status(status, tg, tg)
11701170
tg.cancel_scope.cancel()
11711171

11721172
assert result is False
@@ -1189,7 +1189,7 @@ async def fake_handle_lease(lease_name, tg, lease_scope):
11891189
status.context = {}
11901190

11911191
async with create_task_group() as tg:
1192-
result = await exporter._apply_status(status, tg)
1192+
result = await exporter._apply_status(status, tg, tg)
11931193
await anyio.sleep(0.05)
11941194
tg.cancel_scope.cancel()
11951195

@@ -1224,7 +1224,7 @@ async def fake_handle_lease(lease_name, tg, lease_scope):
12241224
status.context = {"env": "staging"}
12251225

12261226
async with create_task_group() as tg:
1227-
await exporter._apply_status(status, tg)
1227+
await exporter._apply_status(status, tg, tg)
12281228
await anyio.sleep(0.05)
12291229
tg.cancel_scope.cancel()
12301230

@@ -1246,7 +1246,7 @@ async def test_leased_to_idle_calls_on_lease_released(self):
12461246
status.context = {}
12471247

12481248
async with create_task_group() as tg:
1249-
await exporter._apply_status(status, tg)
1249+
await exporter._apply_status(status, tg, tg)
12501250
tg.cancel_scope.cancel()
12511251

12521252
assert lease_ctx.lease_ended.is_set()
@@ -1265,7 +1265,7 @@ async def test_trailing_tick_for_completed_lease_ignored(self):
12651265
status.context = {}
12661266

12671267
async with create_task_group() as tg:
1268-
result = await exporter._apply_status(status, tg)
1268+
result = await exporter._apply_status(status, tg, tg)
12691269
tg.cancel_scope.cancel()
12701270

12711271
assert result is False
@@ -1284,7 +1284,7 @@ async def test_new_lease_after_completed_lease_accepted(self):
12841284
status.context = {}
12851285

12861286
async with create_task_group() as tg:
1287-
await exporter._apply_status(status, tg)
1287+
await exporter._apply_status(status, tg, tg)
12881288
tg.cancel_scope.cancel()
12891289

12901290
assert exporter._lease_context is not None
@@ -1303,7 +1303,7 @@ async def test_not_leased_clears_last_completed(self):
13031303
status.context = {}
13041304

13051305
async with create_task_group() as tg:
1306-
await exporter._apply_status(status, tg)
1306+
await exporter._apply_status(status, tg, tg)
13071307
tg.cancel_scope.cancel()
13081308

13091309
assert exporter._last_completed_lease is None
@@ -1357,6 +1357,7 @@ async def fake_retry_stream(stream_name, stream_factory, send_tx, **kwargs):
13571357
exporter._cleanup_after_lease = AsyncMock()
13581358

13591359
async with create_task_group() as tg:
1360+
exporter._tg = tg
13601361
tg.start_soon(exporter.handle_lease, "conn-lease", tg, lease_ctx)
13611362
with fail_after(5):
13621363
await conn_arrived.wait()
@@ -1400,6 +1401,7 @@ async def fake_retry_stream(stream_name, stream_factory, send_tx, **kwargs):
14001401
exporter._listen_stream_factory = MagicMock(return_value=MagicMock())
14011402

14021403
async with create_task_group() as tg:
1404+
exporter._tg = tg
14031405
tg.start_soon(exporter.handle_lease, "fallback-lease", tg, lease_ctx)
14041406
await anyio.sleep(0.1)
14051407
lease_ctx.lease_ended.set()
@@ -1450,6 +1452,7 @@ def _make_serve_exporter(exit_on_lease_end=False):
14501452
exporter._pending_status_request = None
14511453
exporter._status_rpc_event = Event()
14521454
exporter._fatal_stream_error = None
1455+
exporter._last_completed_lease = None
14531456

14541457
@asynccontextmanager
14551458
async def fake_session():
@@ -1643,3 +1646,95 @@ def tracking_set(**kwargs):
16431646

16441647
assert calls == [{"client": "ci-bot"}]
16451648
clear_log_context()
1649+
1650+
1651+
class TestTaskGroupIsolation:
1652+
"""Verify that control-plane failure does not cancel data-plane connections.
1653+
1654+
The split: inner tg (control-plane: Status/Listen streams) and outer
1655+
conns_tg (data-plane: handle_lease, _handle_client_conn). When
1656+
_cancel_with_fatal_error cancels tg, connections on conns_tg must
1657+
remain alive until serve() explicitly cancels conns_tg.
1658+
"""
1659+
1660+
@pytest.mark.anyio
1661+
async def test_conn_alive_after_control_plane_cancel(self):
1662+
"""Between _cancel_with_fatal_error and serve() cancelling conns_tg,
1663+
connection tasks on conns_tg are still running."""
1664+
exporter = _make_serve_exporter()
1665+
conn_alive_after_cp_cancel = False
1666+
conn_started = Event()
1667+
cp_cancelled = Event()
1668+
1669+
async def fake_conn():
1670+
nonlocal conn_alive_after_cp_cancel
1671+
conn_started.set()
1672+
await cp_cancelled.wait()
1673+
conn_alive_after_cp_cancel = True
1674+
1675+
async def fake_retry_stream(name, factory, tx, **kwargs):
1676+
if name == "Status":
1677+
await tx.send(
1678+
MagicMock(leased=True, lease_name="test-lease", client_name="c", context={})
1679+
)
1680+
await conn_started.wait()
1681+
exporter._cancel_with_fatal_error("Status", Exception("controller gone"))
1682+
cp_cancelled.set()
1683+
else:
1684+
await anyio.sleep_forever()
1685+
1686+
exporter._retry_stream = fake_retry_stream
1687+
1688+
async def fake_handle_lease(lease_name, conns_tg, lease_ctx):
1689+
conns_tg.start_soon(fake_conn)
1690+
await lease_ctx.lease_ended.wait()
1691+
lease_ctx.after_lease_hook_done.set()
1692+
1693+
exporter.handle_lease = fake_handle_lease
1694+
1695+
await exporter.serve()
1696+
1697+
assert conn_alive_after_cp_cancel, (
1698+
"Connection task was killed before serve() cancelled conns_tg — "
1699+
"control-plane cancellation leaked into data-plane"
1700+
)
1701+
1702+
@pytest.mark.anyio
1703+
async def test_conns_cancelled_on_shutdown(self):
1704+
"""serve() cancels conns_tg on exit, even with a long-running connection."""
1705+
exporter = _make_serve_exporter()
1706+
conn_cancelled = False
1707+
1708+
async def long_conn():
1709+
nonlocal conn_cancelled
1710+
try:
1711+
await anyio.sleep_forever()
1712+
except anyio.get_cancelled_exc_class():
1713+
conn_cancelled = True
1714+
raise
1715+
1716+
async def fake_retry_stream(stream_name, stream_factory, send_tx, **kwargs):
1717+
if stream_name == "Status":
1718+
await send_tx.send(
1719+
MagicMock(leased=True, lease_name="test-lease", client_name="c", context={})
1720+
)
1721+
await anyio.sleep(0.1)
1722+
await send_tx.send(MagicMock(leased=False, lease_name="", client_name="", context={}))
1723+
await anyio.sleep(0.1)
1724+
exporter.stop()
1725+
else:
1726+
await anyio.sleep_forever()
1727+
1728+
exporter._retry_stream = fake_retry_stream
1729+
1730+
async def fake_handle_lease(lease_name, conns_tg, lease_ctx):
1731+
conns_tg.start_soon(long_conn)
1732+
await lease_ctx.lease_ended.wait()
1733+
lease_ctx.after_lease_hook_done.set()
1734+
1735+
exporter.handle_lease = fake_handle_lease
1736+
1737+
with fail_after(3):
1738+
await exporter.serve()
1739+
1740+
assert conn_cancelled, "Long-running connection was not cancelled by serve()"

0 commit comments

Comments
 (0)