Repository navigation
asyncio.create_subprocess_exec does not respond properly to asyncio.CancelledError #103847
Copy link
Copy link
Closed
Closed
Copy link
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
asyncio programs that call proc = await asyncio.create_subprocess_exec but do not reach the call to await proc.communicate are not properly cancelled.
This can be observed in the following script (it may take a few runs to observe):
import asyncio
import functools
import signal
counter = 0
async def run_bash_sleep():
global counter
counter += 1
local_counter = counter
try:
print(f"Started - {local_counter}")
proc = await asyncio.create_subprocess_exec(
'bash', '-c', 'sleep .001',
stdout = asyncio.subprocess.PIPE,
stderr = asyncio.subprocess.PIPE,
start_new_session = True
)
print(f"Waiting - {local_counter}")
stdout, stderr = await proc.communicate()
print(f"Done - {local_counter}!")
except asyncio.CancelledError:
print(f"Canceled - {local_counter}!")
async def run_loop(loop):
max_jobs = 8
active_tasks = []
while True:
try:
# Add jobs to the list of active jobs
while len(active_tasks) < max_jobs:
active_tasks.append(loop.create_task(run_bash_sleep()))
# All tasks have finished, end the loop
if len(active_tasks) == 0:
break
# Wait for a test to finish (or a 1 second timeout)
done, pending = await asyncio.wait(
active_tasks,
timeout = 1,
return_when = asyncio.FIRST_COMPLETED
)
print(f"Running jobs: {len(active_tasks)}")
# Update the active jobs
active_tasks = list(pending)
except asyncio.CancelledError:
max_jobs = 0
def stop_asyncio_loop(signame, loop):
for task in asyncio.all_tasks(loop):
task.cancel()
def main():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for signame in {'SIGINT', 'SIGTERM'}:
loop.add_signal_handler(
getattr(signal, signame),
functools.partial(stop_asyncio_loop, signame, loop)
)
loop.run_until_complete(loop.create_task(run_loop(loop)))
main()When the signal handler cancels the tasks, any task that hasn't made it to await proc.communicate() will never complete.
A subsequent SIGTERM to the script can then actually terminate the task; however, I'd expect the first call to cancel() to disrupt the coroutine.
Your environment
- CPython versions tested on: 3.11.2, 3.11.3
- Operating system and architecture: Fedora 38, x86_64
Linked PRs
- gh-103847: fix cancellation safety of
asyncio.create_subprocess_exec#140805 - [3.14] gh-103847: fix cancellation safety of
asyncio.create_subprocess_exec(GH-140805) #141446 - [3.13] gh-103847: fix cancellation safety of
asyncio.create_subprocess_exec(GH-140805) #141447 - gh-103847: fix some asyncio subprocess cancellation bugs #146571
- [3.14] gh-103847: fix some asyncio subprocess cancellation bugs (GH-146571) #154632
- [3.15] gh-103847: fix some asyncio subprocess cancellation bugs (GH-146571) #154633
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
- StatusShow more project fieldsDone
Activity
DarkArc commentedon Apr 25, 2023
Curiously this doesn't seem to affect Python 3.6.8 (running on 3.6 requires a change to the
stop_asyncio_loopfunction, switching toasyncio.Task.all_tasksinstead ofasyncio.all_tasks).Instead of an indefinite hang, a message is printed akin to
Unknown child process pid 37535, will report returncode 255gvanrossum commentedon Apr 26, 2023
That looks like a nasty problem. Do you want to help by checking the logic of asyncio signals and subprocess creation? There might even be a simple fix.
DarkArc commentedon May 1, 2023
I don't currently have the bandwidth personally or professionally at the moment unfortunately. :(
I took a quick look through the
create_subprocess_execlogic before reporting the issue, and didn't see anything that jumped out to me, but that doesn't mean there's nothing there.gvanrossum commentedon May 2, 2023
I’m in a similar situation, so we‘ll have to leave this open for a while.
graingert commentedon Oct 19, 2024
Copying my analysis from: #125502 (comment)
the problem occurs when asyncio.runners._cancel_all_tasks is run at an inopportune instant when connecting pipes:
This task gets cancelled:
cpython/Lib/asyncio/base_subprocess.py
Line 56 in 92af191
which means self._pending_calls is never run:
cpython/Lib/asyncio/base_subprocess.py
Lines 199 to 202 in 92af191
so when _try_finish appends self._call_connection_lost to self._pending_calls:
cpython/Lib/asyncio/base_subprocess.py
Line 257 in 92af191
call_connection_lost is never called, which means self._exit_waiters are never woken:
cpython/Lib/asyncio/base_subprocess.py
Lines 259 to 270 in 92af191
Here's a demo that hangs every time for me:
graingert commentedon Oct 19, 2024
@kumaraditya303 I think the fix is to use:
Then in
def _wake_waiter_and_call_pending_calls_or_close(self, task): ...check the exception/done/cancelled state of task and do the right thingI've spent a little time fiddling around but can't get the tests to pass and not spray a bunch of errors!
graingert commentedon Oct 21, 2024
Are these the same issue?
#115787
#105288
savannahostrowski commentedon Oct 21, 2024
@graingert Happy to take a look at this if no one else has started!
kumaraditya303 commentedon Oct 24, 2024
I think the same although I don't think it's that simple, we possibly need to rework and audit whole cancelation of subprocess, I am pretty sure it is broken at more places than this.
1st1 commentedon Oct 29, 2024
@graingert @kumaraditya303
I'm not so sure. This might fix the problem now, but IMO lead to code that's hard to maintain. If, say,
connect_pipestask in time gets some other code that mustn't be cancelled and instead should be put into the callback.In general, I think that separating logic between async/await code and done callbacks is a big antipattern.
Instead I'd think about shielding the connect_pipes/init code from cancellation, or handling it explicitly.
12 remaining items
gh-103847: fix cancellation safety of `asyncio.create_subprocess_exec` (
pythongh-103847: fix cancellation safety of `asyncio.create_subproces…
pythongh-103847: fix cancellation safety of `asyncio.create_subproces…
[3.14] gh-103847: fix cancellation safety of `asyncio.create_subproce…
[3.13] gh-103847: fix cancellation safety of `asyncio.create_subproce…
pythongh-103847: fix cancellation safety of `asyncio.create_subproces…
fantix commentedon Jan 12, 2026
#140805 introduced a race condition;
test_cancel_post_initcaptured the race condition in MagicStack/uvloop#709, but not in cpython because the unhandled exception in event loop is not failing thetest_subprocesstests. Here's a patch to reveal this race condition:0001-gh-103847-catch-unhandled-exc-for-test_subprocess.patch
This patch is not a PR because it will make the test suite flaky. I shall loop back for a proper fix of the race condition if this wasn't fixed by then.
test: skip test_shell_orphan_reaped on Python 3.12 due to asyncio bug
kumaraditya303 commentedon Mar 28, 2026
@fantix I think it is fixed now with #145541.
gh-103847: fix some asyncio subprocess cancellation bugs (#146571)
[3.14] gh-103847: fix some asyncio subprocess cancellation bugs (GH-1…
[3.15] gh-103847: fix some asyncio subprocess cancellation bugs (GH-1…