Skip to content

Commit cd2c238

Browse files
Fix __breakpointhook__ when it's called from another thread.
Calling `breakpoint()` in a thread other than the one running the Application would crash the process with a "RuntimeError: no running event loop". ``` File ".../site-packages/prompt_toolkit/application/application.py", line 1026, in trace_dispatch with app.input.detach(): File ".../lib/python3.9/contextlib.py", line 119, in __enter__ return next(self.gen) File ".../site-packages/prompt_toolkit/input/vt100.py", line 203, in _detached_input loop = get_running_loop() RuntimeError: no running event loop ```
1 parent a329a88 commit cd2c238

File tree

1 file changed

+66
-10
lines changed

1 file changed

+66
-10
lines changed

src/prompt_toolkit/application/application.py

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ def __init__(
271271
self._is_running = False
272272
self.future: Future[_AppResult] | None = None
273273
self.loop: AbstractEventLoop | None = None
274+
self._loop_thread: threading.Thread | None = None
274275
self.context: contextvars.Context | None = None
275276

276277
#: Quoted insert. This flag is set if we go into quoted insert mode.
@@ -771,14 +772,16 @@ def flush_input() -> None:
771772
return result
772773

773774
@contextmanager
774-
def get_loop() -> Iterator[AbstractEventLoop]:
775+
def set_loop() -> Iterator[AbstractEventLoop]:
775776
loop = get_running_loop()
776777
self.loop = loop
778+
self._loop_thread = threading.current_thread()
777779

778780
try:
779781
yield loop
780782
finally:
781783
self.loop = None
784+
self._loop_thread = None
782785

783786
@contextmanager
784787
def set_is_running() -> Iterator[None]:
@@ -853,7 +856,7 @@ def create_future(
853856
# `max_postpone_time`.
854857
self._invalidated = False
855858

856-
loop = stack.enter_context(get_loop())
859+
loop = stack.enter_context(set_loop())
857860

858861
stack.enter_context(set_handle_sigint(loop))
859862
stack.enter_context(set_exception_handler_ctx(loop))
@@ -999,6 +1002,10 @@ def _breakpointhook(self, *a: object, **kw: object) -> None:
9991002
"""
10001003
Breakpointhook which uses PDB, but ensures that the application is
10011004
hidden and input echoing is restored during each debugger dispatch.
1005+
1006+
This can be called from any thread. In any case, the application's
1007+
event loop will be blocked while the PDB input is displayed. The event
1008+
will continue after leaving the debugger.
10021009
"""
10031010
app = self
10041011
# Inline import on purpose. We don't want to import pdb, if not needed.
@@ -1007,22 +1014,71 @@ def _breakpointhook(self, *a: object, **kw: object) -> None:
10071014

10081015
TraceDispatch = Callable[[FrameType, str, Any], Any]
10091016

1010-
class CustomPdb(pdb.Pdb):
1011-
def trace_dispatch(
1012-
self, frame: FrameType, event: str, arg: Any
1013-
) -> TraceDispatch:
1017+
@contextmanager
1018+
def hide_app_from_eventloop_thread() -> Generator[None, None, None]:
1019+
"""Stop application if `__breakpointhook__` is called from within
1020+
the App's event loop."""
1021+
# Hide application.
1022+
app.renderer.erase()
1023+
1024+
# Detach input and dispatch to debugger.
1025+
with app.input.detach():
1026+
with app.input.cooked_mode():
1027+
yield
1028+
1029+
# Note: we don't render the application again here, because
1030+
# there's a good chance that there's a breakpoint on the next
1031+
# line. This paint/erase cycle would move the PDB prompt back
1032+
# to the middle of the screen.
1033+
1034+
@contextmanager
1035+
def hide_app_from_other_thread() -> Generator[None, None, None]:
1036+
"""Stop application if `__breakpointhook__` is called from a
1037+
thread other than the App's event loop."""
1038+
ready = threading.Event()
1039+
done = threading.Event()
1040+
1041+
async def in_loop() -> None:
1042+
# from .run_in_terminal import in_terminal
1043+
# async with in_terminal():
1044+
# ready.set()
1045+
# await asyncio.get_running_loop().run_in_executor(None, done.wait)
1046+
# return
1047+
10141048
# Hide application.
10151049
app.renderer.erase()
10161050

10171051
# Detach input and dispatch to debugger.
10181052
with app.input.detach():
10191053
with app.input.cooked_mode():
1054+
ready.set()
1055+
# Here we block the App's event loop thread until the
1056+
# debugger resumes. We could have used `with
1057+
# run_in_terminal.in_terminal():` like the commented
1058+
# code above, but it seems to work better if we
1059+
# completely stop the main event loop while debugging.
1060+
done.wait()
1061+
1062+
self.create_background_task(in_loop())
1063+
ready.wait()
1064+
try:
1065+
yield
1066+
finally:
1067+
done.set()
1068+
1069+
class CustomPdb(pdb.Pdb):
1070+
def trace_dispatch(
1071+
self, frame: FrameType, event: str, arg: Any
1072+
) -> TraceDispatch:
1073+
if app._loop_thread is None:
1074+
return super().trace_dispatch(frame, event, arg)
1075+
1076+
if app._loop_thread == threading.current_thread():
1077+
with hide_app_from_eventloop_thread():
10201078
return super().trace_dispatch(frame, event, arg)
10211079

1022-
# Note: we don't render the application again here, because
1023-
# there's a good chance that there's a breakpoint on the next
1024-
# line. This paint/erase cycle would move the PDB prompt back
1025-
# to the middle of the screen.
1080+
with hide_app_from_other_thread():
1081+
return super().trace_dispatch(frame, event, arg)
10261082

10271083
frame = sys._getframe().f_back
10281084
CustomPdb(stdout=sys.__stdout__).set_trace(frame)

0 commit comments

Comments
 (0)