Skip to content

Commit 7aefd09

Browse files
committed
fix(lfx): close capability-selected executor streams
1 parent eab0942 commit 7aefd09

2 files changed

Lines changed: 61 additions & 5 deletions

File tree

src/lfx/src/lfx/execution/coordinator.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,14 @@ async def run(
5656
]
5757
for unit in units:
5858
executor = self._registry.get(unit.executor_kind or self._executor_kind)
59-
async for item in executor.execute(unit):
60-
yield item
59+
inner = executor.execute(unit)
60+
try:
61+
async for item in inner:
62+
yield item
63+
finally:
64+
aclose = getattr(inner, "aclose", None)
65+
if aclose is not None:
66+
await aclose()
6167

6268
async def run_to_completion(
6369
self,
@@ -79,9 +85,13 @@ async def stream(
7985
inputs: list[dict[str, Any]] | None = None,
8086
**runtime_options: Any,
8187
) -> AsyncIterator[Any]:
82-
async for item in self.run(graph, inputs=inputs or [], **runtime_options):
83-
if isinstance(item, StepResult):
84-
yield item.payload
88+
inner = self.run(graph, inputs=inputs or [], **runtime_options)
89+
try:
90+
async for item in inner:
91+
if isinstance(item, StepResult):
92+
yield item.payload
93+
finally:
94+
await inner.aclose()
8595

8696
@staticmethod
8797
def _context_value(graph: Any, runtime_options: dict[str, Any], *names: str) -> str | None:

src/lfx/tests/unit/execution/test_capability_routing.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
from types import SimpleNamespace
67
from typing import TYPE_CHECKING, Any
78

@@ -31,6 +32,21 @@ async def execute(self, unit: Unit) -> AsyncIterator[StepResult | RunComplete]:
3132
yield RunComplete(outputs=[self.output])
3233

3334

35+
class _NeverEndingExecutor(Executor):
36+
kind = "sandbox"
37+
38+
def __init__(self) -> None:
39+
self.closed = False
40+
41+
async def execute(self, unit: Unit) -> AsyncIterator[StepResult | RunComplete]: # noqa: ARG002
42+
try:
43+
while True:
44+
yield StepResult(payload="tick")
45+
await asyncio.sleep(0)
46+
finally:
47+
self.closed = True
48+
49+
3450
@pytest.mark.asyncio
3551
async def test_coordinator_routes_untrusted_runs_to_capability_executor() -> None:
3652
sandbox = _RecordingExecutor("sandbox", "sandbox-output")
@@ -118,3 +134,33 @@ async def test_coordinator_skips_capability_service_when_passthrough() -> None:
118134
assert outputs == ["default-output"]
119135
assert default.units[0].executor_kind is None
120136
assert default.units[0].runtime_options == {}
137+
138+
139+
@pytest.mark.asyncio
140+
async def test_stream_close_finalizes_capability_selected_executor() -> None:
141+
sandbox = _NeverEndingExecutor()
142+
registry = ExecutorRegistry()
143+
registry.register(_RecordingExecutor("in-process", "in-process-output"))
144+
registry.register(sandbox)
145+
146+
class _Classifier:
147+
def trust_of_flow(self, _context: CapabilityContext) -> Trust:
148+
return Trust.UNTRUSTED
149+
150+
def is_untrusted_node(self, _node: dict[str, Any], _context: CapabilityContext | None = None) -> bool:
151+
return True
152+
153+
capability_service = CapabilityService(settings_service=_StubSettings())
154+
capability_service.install(classifier=_Classifier(), untrusted_executor_kind="sandbox")
155+
coordinator = Coordinator(
156+
registry=registry,
157+
executor_kind="in-process",
158+
capability_service=capability_service,
159+
)
160+
161+
stream = coordinator.stream(SimpleNamespace(), inputs=[{}])
162+
assert await anext(stream) == "tick"
163+
await stream.aclose()
164+
await asyncio.sleep(0)
165+
166+
assert sandbox.closed is True

0 commit comments

Comments
 (0)