Skip to content

Commit 1e60219

Browse files
authored
Fixed run_process() and open_process().__aexit__ leaking an orphan process when cancelled (#672)
1 parent 3f14df8 commit 1e60219

File tree

6 files changed

+120
-34
lines changed

6 files changed

+120
-34
lines changed

docs/versionhistory.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.
1010
- Fixed passing ``total_tokens`` to ``anyio.CapacityLimiter()`` as a keyword argument
1111
not working on the ``trio`` backend
1212
(`#515 <https://github.com/agronholm/anyio/issues/515>`_)
13+
- Fixed ``Process.aclose()`` not performing the minimum level of necessary cleanup when
14+
cancelled. Previously:
15+
16+
- Cancellation of ``Process.aclose()`` could leak an orphan process
17+
- Cancellation of ``run_process()`` could very briefly leak an orphan process.
18+
- Cancellation of ``Process.aclose()`` or ``run_process()`` on Trio could leave
19+
standard streams unclosed
20+
21+
(PR by Ganden Schaffner)
22+
- Fixed ``Process.stdin.aclose()``, ``Process.stdout.aclose()``, and
23+
``Process.stderr.aclose()`` not including a checkpoint on asyncio (PR by Ganden
24+
Schaffner)
1325

1426
**4.2.0**
1527

src/anyio/_backends/_asyncio.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,7 @@ async def receive(self, max_bytes: int = 65536) -> bytes:
918918

919919
async def aclose(self) -> None:
920920
self._stream.feed_eof()
921+
await AsyncIOBackend.checkpoint()
921922

922923

923924
@dataclass(eq=False)
@@ -930,6 +931,7 @@ async def send(self, item: bytes) -> None:
930931

931932
async def aclose(self) -> None:
932933
self._stream.close()
934+
await AsyncIOBackend.checkpoint()
933935

934936

935937
@dataclass(eq=False)
@@ -940,14 +942,22 @@ class Process(abc.Process):
940942
_stderr: StreamReaderWrapper | None
941943

942944
async def aclose(self) -> None:
943-
if self._stdin:
944-
await self._stdin.aclose()
945-
if self._stdout:
946-
await self._stdout.aclose()
947-
if self._stderr:
948-
await self._stderr.aclose()
949-
950-
await self.wait()
945+
with CancelScope(shield=True):
946+
if self._stdin:
947+
await self._stdin.aclose()
948+
if self._stdout:
949+
await self._stdout.aclose()
950+
if self._stderr:
951+
await self._stderr.aclose()
952+
953+
try:
954+
await self.wait()
955+
except BaseException:
956+
self.kill()
957+
with CancelScope(shield=True):
958+
await self.wait()
959+
960+
raise
951961

952962
async def wait(self) -> int:
953963
return await self._process.wait()

src/anyio/_backends/_trio.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -283,14 +283,21 @@ class Process(abc.Process):
283283
_stderr: abc.ByteReceiveStream | None
284284

285285
async def aclose(self) -> None:
286-
if self._stdin:
287-
await self._stdin.aclose()
288-
if self._stdout:
289-
await self._stdout.aclose()
290-
if self._stderr:
291-
await self._stderr.aclose()
292-
293-
await self.wait()
286+
with CancelScope(shield=True):
287+
if self._stdin:
288+
await self._stdin.aclose()
289+
if self._stdout:
290+
await self._stdout.aclose()
291+
if self._stderr:
292+
await self._stderr.aclose()
293+
294+
try:
295+
await self.wait()
296+
except BaseException:
297+
self.kill()
298+
with CancelScope(shield=True):
299+
await self.wait()
300+
raise
294301

295302
async def wait(self) -> int:
296303
return await self._process.wait()

src/anyio/_core/_subprocesses.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,18 @@ async def drain_stream(stream: AsyncIterable[bytes], index: int) -> None:
6565
start_new_session=start_new_session,
6666
) as process:
6767
stream_contents: list[bytes | None] = [None, None]
68-
try:
69-
async with create_task_group() as tg:
70-
if process.stdout:
71-
tg.start_soon(drain_stream, process.stdout, 0)
72-
if process.stderr:
73-
tg.start_soon(drain_stream, process.stderr, 1)
74-
if process.stdin and input:
75-
await process.stdin.send(input)
76-
await process.stdin.aclose()
77-
78-
await process.wait()
79-
except BaseException:
80-
process.kill()
81-
raise
68+
async with create_task_group() as tg:
69+
if process.stdout:
70+
tg.start_soon(drain_stream, process.stdout, 0)
71+
72+
if process.stderr:
73+
tg.start_soon(drain_stream, process.stderr, 1)
74+
75+
if process.stdin and input:
76+
await process.stdin.send(input)
77+
await process.stdin.aclose()
78+
79+
await process.wait()
8280

8381
output, errors = stream_contents
8482
if check and process.returncode != 0:

tests/test_subprocesses.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
from textwrap import dedent
99

1010
import pytest
11+
from _pytest.fixtures import FixtureRequest
1112

12-
from anyio import open_process, run_process
13+
from anyio import CancelScope, ClosedResourceError, open_process, run_process
1314
from anyio.streams.buffered import BufferedByteReceiveStream
1415

1516
pytestmark = pytest.mark.anyio
@@ -176,3 +177,61 @@ async def test_run_process_inherit_stdout(capfd: pytest.CaptureFixture[str]) ->
176177
out, err = capfd.readouterr()
177178
assert out == "stdout-text" + os.linesep
178179
assert err == "stderr-text" + os.linesep
180+
181+
182+
async def test_process_aexit_cancellation_doesnt_orphan_process() -> None:
183+
"""
184+
Regression test for #669.
185+
186+
Ensures that open_process.__aexit__() doesn't leave behind an orphan process when
187+
cancelled.
188+
189+
"""
190+
with CancelScope() as scope:
191+
async with await open_process(
192+
[sys.executable, "-c", "import time; time.sleep(1)"]
193+
) as process:
194+
scope.cancel()
195+
196+
assert process.returncode is not None
197+
assert process.returncode != 0
198+
199+
200+
async def test_process_aexit_cancellation_closes_standard_streams(
201+
request: FixtureRequest,
202+
anyio_backend_name: str,
203+
) -> None:
204+
"""
205+
Regression test for #669.
206+
207+
Ensures that open_process.__aexit__() closes standard streams when cancelled. Also
208+
ensures that process.std{in.send,{out,err}.receive}() raise ClosedResourceError on a
209+
closed stream.
210+
211+
"""
212+
if anyio_backend_name == "asyncio":
213+
# Avoid pytest.xfail here due to https://github.com/pytest-dev/pytest/issues/9027
214+
request.node.add_marker(
215+
pytest.mark.xfail(reason="#671 needs to be resolved first")
216+
)
217+
218+
with CancelScope() as scope:
219+
async with await open_process(
220+
[sys.executable, "-c", "import time; time.sleep(1)"]
221+
) as process:
222+
scope.cancel()
223+
224+
assert process.stdin is not None
225+
226+
with pytest.raises(ClosedResourceError):
227+
await process.stdin.send(b"foo")
228+
229+
assert process.stdout is not None
230+
231+
with pytest.raises(ClosedResourceError):
232+
await process.stdout.receive(1)
233+
234+
assert process.stderr is not None
235+
236+
with pytest.raises(ClosedResourceError):
237+
await process.stderr.receive(1)

tests/test_taskgroups.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ async def taskfunc(*, task_status: TaskStatus) -> None:
185185
assert not finished
186186

187187

188+
@pytest.mark.xfail(
189+
sys.version_info < (3, 9), reason="Requires a way to detect cancellation source"
190+
)
188191
@pytest.mark.parametrize("anyio_backend", ["asyncio"])
189192
async def test_start_native_host_cancelled() -> None:
190193
started = finished = False
@@ -199,9 +202,6 @@ async def start_another() -> None:
199202
async with create_task_group() as tg:
200203
await tg.start(taskfunc)
201204

202-
if sys.version_info < (3, 9):
203-
pytest.xfail("Requires a way to detect cancellation source")
204-
205205
task = asyncio.get_running_loop().create_task(start_another())
206206
await wait_all_tasks_blocked()
207207
task.cancel()

0 commit comments

Comments
 (0)