Skip to content

Commit 427f4bc

Browse files
Improve termination of Application. Don't ever suppress CancelledError.
This fixes a race condition when the prompt_toolkit Application gets cancelled while waiting for the background tasks to complete. Catching `CancelledError` at this point caused any code following the `Application.run_async` to continue, instead of being cancelled. In the future, we should probably adapt task groups (from anyio or Python 3.11), but until then, this is sufficient.
1 parent 0ddf173 commit 427f4bc

File tree

2 files changed

+64
-18
lines changed

2 files changed

+64
-18
lines changed

src/prompt_toolkit/application/application.py

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import time
88
from asyncio import (
99
AbstractEventLoop,
10-
CancelledError,
1110
Future,
1211
Task,
1312
ensure_future,
@@ -32,6 +31,7 @@
3231
Iterator,
3332
List,
3433
Optional,
34+
Set,
3535
Tuple,
3636
Type,
3737
TypeVar,
@@ -433,7 +433,7 @@ def reset(self) -> None:
433433

434434
self.exit_style = ""
435435

436-
self.background_tasks: List[Task[None]] = []
436+
self._background_tasks: Set[Task[None]] = set()
437437

438438
self.renderer.reset()
439439
self.key_processor.reset()
@@ -1066,32 +1066,75 @@ def create_background_task(
10661066
the `Application` terminates, unfinished background tasks will be
10671067
cancelled.
10681068
1069-
If asyncio had nurseries like Trio, we would create a nursery in
1070-
`Application.run_async`, and run the given coroutine in that nursery.
1069+
Given that we still support Python versions before 3.11, we can't use
1070+
task groups (and exception groups), because of that, these background
1071+
tasks are not allowed to raise exceptions. If they do, we'll call the
1072+
default exception handler from the event loop.
10711073
1072-
Not threadsafe.
1074+
If at some point, we have Python 3.11 as the minimum supported Python
1075+
version, then we can use a `TaskGroup` (with the lifetime of
1076+
`Application.run_async()`, and run run the background tasks in there.
1077+
1078+
This is not threadsafe.
10731079
"""
10741080
task: asyncio.Task[None] = get_event_loop().create_task(coroutine)
1075-
self.background_tasks.append(task)
1081+
self._background_tasks.add(task)
1082+
1083+
task.add_done_callback(self._on_background_task_done)
10761084
return task
10771085

1086+
def _on_background_task_done(self, task: "asyncio.Task[None]") -> None:
1087+
"""
1088+
Called when a background task completes. Remove it from
1089+
`_background_tasks`, and handle exceptions if any.
1090+
"""
1091+
self._background_tasks.discard(task)
1092+
1093+
if task.cancelled():
1094+
return
1095+
1096+
exc = task.exception()
1097+
if exc is not None:
1098+
get_event_loop().call_exception_handler(
1099+
{
1100+
"message": f"prompt_toolkit.Application background task {task!r} "
1101+
"raised an unexpected exception.",
1102+
"exception": exc,
1103+
"task": task,
1104+
}
1105+
)
1106+
10781107
async def cancel_and_wait_for_background_tasks(self) -> None:
10791108
"""
1080-
Cancel all background tasks, and wait for the cancellation to be done.
1109+
Cancel all background tasks, and wait for the cancellation to complete.
10811110
If any of the background tasks raised an exception, this will also
10821111
propagate the exception.
10831112
10841113
(If we had nurseries like Trio, this would be the `__aexit__` of a
10851114
nursery.)
10861115
"""
1087-
for task in self.background_tasks:
1116+
for task in self._background_tasks:
10881117
task.cancel()
10891118

1090-
for task in self.background_tasks:
1091-
try:
1092-
await task
1093-
except CancelledError:
1094-
pass
1119+
# Wait until the cancellation of the background tasks completes.
1120+
# `asyncio.wait()` does not propagate exceptions raised within any of
1121+
# these tasks, which is what we want. Otherwise, we can't distinguish
1122+
# between a `CancelledError` raised in this task because it got
1123+
# cancelled, and a `CancelledError` raised on this `await` checkpoint,
1124+
# because *we* got cancelled during the teardown of the application.
1125+
# (If we get cancelled here, then it's important to not suppress the
1126+
# `CancelledError`, and have it propagate.)
1127+
# NOTE: Currently, if we get cancelled at this point then we can't wait
1128+
# for the cancellation to complete (in the future, we should be
1129+
# using anyio or Python's 3.11 TaskGroup.)
1130+
# Also, if we had exception groups, we could propagate an
1131+
# `ExceptionGroup` if something went wrong here. Right now, we
1132+
# don't propagate exceptions, but have them printed in
1133+
# `_on_background_task_done`.
1134+
if len(self._background_tasks) > 0:
1135+
await asyncio.wait(
1136+
self._background_tasks, timeout=None, return_when=asyncio.ALL_COMPLETED
1137+
)
10951138

10961139
async def _poll_output_size(self) -> None:
10971140
"""

src/prompt_toolkit/contrib/telnet/server.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -323,11 +323,14 @@ async def stop(self) -> None:
323323
for t in self._application_tasks:
324324
t.cancel()
325325

326-
for t in self._application_tasks:
327-
try:
328-
await t
329-
except asyncio.CancelledError:
330-
logger.debug("Task %s cancelled", str(t))
326+
# (This is similar to
327+
# `Application.cancel_and_wait_for_background_tasks`. We wait for the
328+
# background tasks to complete, but don't propagate exceptions, because
329+
# we can't use `ExceptionGroup` yet.)
330+
if len(self._application_tasks) > 0:
331+
await asyncio.wait(
332+
self._application_tasks, timeout=None, return_when=asyncio.ALL_COMPLETED
333+
)
331334

332335
def _accept(self) -> None:
333336
"""

0 commit comments

Comments
 (0)