-
-
Notifications
You must be signed in to change notification settings - Fork 34.2k
Open
Labels
stdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directorytopic-asynciotype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
Description
Bug report
Bug description
When SIGINT arrives while subprocess _connect_pipes tasks are in flight, asyncio.run() cancels the main task which cascades into cancelling _connect_pipes. This leaves _pipes_connected=False.
The fix in GH-140805 (backported as GH-141446) added code to _try_finish() that wakes up exit waiters when _pipes_connected is False to prevent hangs. However, _try_finish() then also checks whether all pipes are disconnected — and when _pipes is empty or all pipes are disconnected, it schedules _call_connection_lost(). _call_connection_lost() iterates the same _exit_waiters list and calls set_result() again, raising InvalidStateError.
The flow:
_connect_pipesis cancelled →_pipes_connectedstaysFalse- Process exits →
_process_exited()→_try_finish() _try_finish()sees_pipes_connected is False→ callswaiter.set_result(self._returncode)for all non-cancelled waiters_try_finish()checksall(p is not None and p.disconnected for p in self._pipes.values())— this isTrue(vacuously when_pipesis empty, or because pipes were disconnected)- Schedules
_call_connection_lost() _call_connection_lost()iterates_exit_waiters→waiter.set_result(self._returncode)→InvalidStateErrorbecause the waiter already has a result from step 3
Suggested fix
Change if not waiter.cancelled() to if not waiter.done() in _call_connection_lost():
def _call_connection_lost(self, exc):
try:
self._protocol.connection_lost(exc)
finally:
for waiter in self._exit_waiters:
if not waiter.done(): # was: if not waiter.cancelled()
waiter.set_result(self._returncode)
self._exit_waiters = None
...Reproducer
import asyncio
import os
import signal
import subprocess
async def main() -> None:
loop = asyncio.get_running_loop()
loop.call_later(0.001, os.kill, os.getpid(), signal.SIGINT)
procs: list[asyncio.subprocess.Process] = []
tasks = [
asyncio.create_task(
asyncio.create_subprocess_exec(
"sleep", "10",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
)
for _ in range(50)
]
try:
await asyncio.gather(*tasks, return_exceptions=True)
await asyncio.sleep(10)
except asyncio.CancelledError:
pass
finally:
for task in tasks:
if task.done() and not task.cancelled() and task.exception() is None:
procs.append(task.result())
for proc in procs:
try:
proc.kill()
except ProcessLookupError:
pass
for proc in procs:
await proc.wait()
try:
asyncio.run(main())
except KeyboardInterrupt:
passOutput:
Exception in callback BaseSubprocessTransport._call_connection_lost()
handle: <Handle BaseSubprocessTransport._call_connection_lost()>
Traceback (most recent call last):
File "/.../asyncio/events.py", line 94, in _run
self._context.run(self._callback, *self._args)
File "/.../asyncio/base_subprocess.py", line 282, in _call_connection_lost
waiter.set_result(self._returncode)
asyncio.exceptions.InvalidStateError: invalid state
CPython versions tested on:
3.14.2
Operating systems tested on:
Linux
Linked PRs
- gh-103847: fix cancellation safety of
asyncio.create_subprocess_exec#140805 / [3.14] gh-103847: fix cancellation safety ofasyncio.create_subprocess_exec(GH-140805) #141446 (introduced the regression)
Linked PRs
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
stdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directorytopic-asynciotype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
Projects
Status
Todo