Skip to content

Commit 9434709

Browse files
authored
gh-132975: Improve Remote PDB interrupt handling (#133223)
1 parent 24ebb9c commit 9434709

File tree

3 files changed

+346
-188
lines changed

3 files changed

+346
-188
lines changed

Lib/pdb.py

+216-57
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
import json
7878
import token
7979
import types
80+
import atexit
8081
import codeop
8182
import pprint
8283
import signal
@@ -92,11 +93,12 @@
9293
import itertools
9394
import traceback
9495
import linecache
96+
import selectors
97+
import threading
9598
import _colorize
9699
import _pyrepl.utils
97100

98-
from contextlib import closing
99-
from contextlib import contextmanager
101+
from contextlib import ExitStack, closing, contextmanager
100102
from rlcompleter import Completer
101103
from types import CodeType
102104
from warnings import deprecated
@@ -2670,12 +2672,21 @@ async def set_trace_async(*, header=None, commands=None):
26702672
# Remote PDB
26712673

26722674
class _PdbServer(Pdb):
2673-
def __init__(self, sockfile, owns_sockfile=True, **kwargs):
2675+
def __init__(
2676+
self,
2677+
sockfile,
2678+
signal_server=None,
2679+
owns_sockfile=True,
2680+
**kwargs,
2681+
):
26742682
self._owns_sockfile = owns_sockfile
26752683
self._interact_state = None
26762684
self._sockfile = sockfile
26772685
self._command_name_cache = []
26782686
self._write_failed = False
2687+
if signal_server:
2688+
# Only started by the top level _PdbServer, not recursive ones.
2689+
self._start_signal_listener(signal_server)
26792690
super().__init__(colorize=False, **kwargs)
26802691

26812692
@staticmethod
@@ -2731,15 +2742,49 @@ def _ensure_valid_message(self, msg):
27312742
f"PDB message doesn't follow the schema! {msg}"
27322743
)
27332744

2745+
@classmethod
2746+
def _start_signal_listener(cls, address):
2747+
def listener(sock):
2748+
with closing(sock):
2749+
# Check if the interpreter is finalizing every quarter of a second.
2750+
# Clean up and exit if so.
2751+
sock.settimeout(0.25)
2752+
sock.shutdown(socket.SHUT_WR)
2753+
while not shut_down.is_set():
2754+
try:
2755+
data = sock.recv(1024)
2756+
except socket.timeout:
2757+
continue
2758+
if data == b"":
2759+
return # EOF
2760+
signal.raise_signal(signal.SIGINT)
2761+
2762+
def stop_thread():
2763+
shut_down.set()
2764+
thread.join()
2765+
2766+
# Use a daemon thread so that we don't detach until after all non-daemon
2767+
# threads are done. Use an atexit handler to stop gracefully at that point,
2768+
# so that our thread is stopped before the interpreter is torn down.
2769+
shut_down = threading.Event()
2770+
thread = threading.Thread(
2771+
target=listener,
2772+
args=[socket.create_connection(address, timeout=5)],
2773+
daemon=True,
2774+
)
2775+
atexit.register(stop_thread)
2776+
thread.start()
2777+
27342778
def _send(self, **kwargs):
27352779
self._ensure_valid_message(kwargs)
27362780
json_payload = json.dumps(kwargs)
27372781
try:
27382782
self._sockfile.write(json_payload.encode() + b"\n")
27392783
self._sockfile.flush()
2740-
except OSError:
2741-
# This means that the client has abruptly disconnected, but we'll
2742-
# handle that the next time we try to read from the client instead
2784+
except (OSError, ValueError):
2785+
# We get an OSError if the network connection has dropped, and a
2786+
# ValueError if detach() if the sockfile has been closed. We'll
2787+
# handle this the next time we try to read from the client instead
27432788
# of trying to handle it from everywhere _send() may be called.
27442789
# Track this with a flag rather than assuming readline() will ever
27452790
# return an empty string because the socket may be half-closed.
@@ -2967,10 +3012,15 @@ def default(self, line):
29673012

29683013

29693014
class _PdbClient:
2970-
def __init__(self, pid, sockfile, interrupt_script):
3015+
def __init__(self, pid, server_socket, interrupt_sock):
29713016
self.pid = pid
2972-
self.sockfile = sockfile
2973-
self.interrupt_script = interrupt_script
3017+
self.read_buf = b""
3018+
self.signal_read = None
3019+
self.signal_write = None
3020+
self.sigint_received = False
3021+
self.raise_on_sigint = False
3022+
self.server_socket = server_socket
3023+
self.interrupt_sock = interrupt_sock
29743024
self.pdb_instance = Pdb()
29753025
self.pdb_commands = set()
29763026
self.completion_matches = []
@@ -3012,8 +3062,7 @@ def _send(self, **kwargs):
30123062
self._ensure_valid_message(kwargs)
30133063
json_payload = json.dumps(kwargs)
30143064
try:
3015-
self.sockfile.write(json_payload.encode() + b"\n")
3016-
self.sockfile.flush()
3065+
self.server_socket.sendall(json_payload.encode() + b"\n")
30173066
except OSError:
30183067
# This means that the client has abruptly disconnected, but we'll
30193068
# handle that the next time we try to read from the client instead
@@ -3022,10 +3071,44 @@ def _send(self, **kwargs):
30223071
# return an empty string because the socket may be half-closed.
30233072
self.write_failed = True
30243073

3025-
def read_command(self, prompt):
3026-
self.multiline_block = False
3027-
reply = input(prompt)
3074+
def _readline(self):
3075+
if self.sigint_received:
3076+
# There's a pending unhandled SIGINT. Handle it now.
3077+
self.sigint_received = False
3078+
raise KeyboardInterrupt
3079+
3080+
# Wait for either a SIGINT or a line or EOF from the PDB server.
3081+
selector = selectors.DefaultSelector()
3082+
selector.register(self.signal_read, selectors.EVENT_READ)
3083+
selector.register(self.server_socket, selectors.EVENT_READ)
3084+
3085+
while b"\n" not in self.read_buf:
3086+
for key, _ in selector.select():
3087+
if key.fileobj == self.signal_read:
3088+
self.signal_read.recv(1024)
3089+
if self.sigint_received:
3090+
# If not, we're reading wakeup events for sigints that
3091+
# we've previously handled, and can ignore them.
3092+
self.sigint_received = False
3093+
raise KeyboardInterrupt
3094+
elif key.fileobj == self.server_socket:
3095+
data = self.server_socket.recv(16 * 1024)
3096+
self.read_buf += data
3097+
if not data and b"\n" not in self.read_buf:
3098+
# EOF without a full final line. Drop the partial line.
3099+
self.read_buf = b""
3100+
return b""
3101+
3102+
ret, sep, self.read_buf = self.read_buf.partition(b"\n")
3103+
return ret + sep
3104+
3105+
def read_input(self, prompt, multiline_block):
3106+
self.multiline_block = multiline_block
3107+
with self._sigint_raises_keyboard_interrupt():
3108+
return input(prompt)
30283109

3110+
def read_command(self, prompt):
3111+
reply = self.read_input(prompt, multiline_block=False)
30293112
if self.state == "dumb":
30303113
# No logic applied whatsoever, just pass the raw reply back.
30313114
return reply
@@ -3048,10 +3131,9 @@ def read_command(self, prompt):
30483131
return prefix + reply
30493132

30503133
# Otherwise, valid first line of a multi-line statement
3051-
self.multiline_block = True
3052-
continue_prompt = "...".ljust(len(prompt))
3134+
more_prompt = "...".ljust(len(prompt))
30533135
while codeop.compile_command(reply, "<stdin>", "single") is None:
3054-
reply += "\n" + input(continue_prompt)
3136+
reply += "\n" + self.read_input(more_prompt, multiline_block=True)
30553137

30563138
return prefix + reply
30573139

@@ -3076,11 +3158,70 @@ def readline_completion(self, completer):
30763158
finally:
30773159
readline.set_completer(old_completer)
30783160

3161+
@contextmanager
3162+
def _sigint_handler(self):
3163+
# Signal handling strategy:
3164+
# - When we call input() we want a SIGINT to raise KeyboardInterrupt
3165+
# - Otherwise we want to write to the wakeup FD and set a flag.
3166+
# We'll break out of select() when the wakeup FD is written to,
3167+
# and we'll check the flag whenever we're about to accept input.
3168+
def handler(signum, frame):
3169+
self.sigint_received = True
3170+
if self.raise_on_sigint:
3171+
# One-shot; don't raise again until the flag is set again.
3172+
self.raise_on_sigint = False
3173+
self.sigint_received = False
3174+
raise KeyboardInterrupt
3175+
3176+
sentinel = object()
3177+
old_handler = sentinel
3178+
old_wakeup_fd = sentinel
3179+
3180+
self.signal_read, self.signal_write = socket.socketpair()
3181+
with (closing(self.signal_read), closing(self.signal_write)):
3182+
self.signal_read.setblocking(False)
3183+
self.signal_write.setblocking(False)
3184+
3185+
try:
3186+
old_handler = signal.signal(signal.SIGINT, handler)
3187+
3188+
try:
3189+
old_wakeup_fd = signal.set_wakeup_fd(
3190+
self.signal_write.fileno(),
3191+
warn_on_full_buffer=False,
3192+
)
3193+
yield
3194+
finally:
3195+
# Restore the old wakeup fd if we installed a new one
3196+
if old_wakeup_fd is not sentinel:
3197+
signal.set_wakeup_fd(old_wakeup_fd)
3198+
finally:
3199+
self.signal_read = self.signal_write = None
3200+
if old_handler is not sentinel:
3201+
# Restore the old handler if we installed a new one
3202+
signal.signal(signal.SIGINT, old_handler)
3203+
3204+
@contextmanager
3205+
def _sigint_raises_keyboard_interrupt(self):
3206+
if self.sigint_received:
3207+
# There's a pending unhandled SIGINT. Handle it now.
3208+
self.sigint_received = False
3209+
raise KeyboardInterrupt
3210+
3211+
try:
3212+
self.raise_on_sigint = True
3213+
yield
3214+
finally:
3215+
self.raise_on_sigint = False
3216+
30793217
def cmdloop(self):
3080-
with self.readline_completion(self.complete):
3218+
with (
3219+
self._sigint_handler(),
3220+
self.readline_completion(self.complete),
3221+
):
30813222
while not self.write_failed:
30823223
try:
3083-
if not (payload_bytes := self.sockfile.readline()):
3224+
if not (payload_bytes := self._readline()):
30843225
break
30853226
except KeyboardInterrupt:
30863227
self.send_interrupt()
@@ -3098,11 +3239,17 @@ def cmdloop(self):
30983239
self.process_payload(payload)
30993240

31003241
def send_interrupt(self):
3101-
print(
3102-
"\n*** Program will stop at the next bytecode instruction."
3103-
" (Use 'cont' to resume)."
3104-
)
3105-
sys.remote_exec(self.pid, self.interrupt_script)
3242+
if self.interrupt_sock is not None:
3243+
# Write to a socket that the PDB server listens on. This triggers
3244+
# the remote to raise a SIGINT for itself. We do this because
3245+
# Windows doesn't allow triggering SIGINT remotely.
3246+
# See https://stackoverflow.com/a/35792192 for many more details.
3247+
self.interrupt_sock.sendall(signal.SIGINT.to_bytes())
3248+
else:
3249+
# On Unix we can just send a SIGINT to the remote process.
3250+
# This is preferable to using the signal thread approach that we
3251+
# use on Windows because it can interrupt IO in the main thread.
3252+
os.kill(self.pid, signal.SIGINT)
31063253

31073254
def process_payload(self, payload):
31083255
match payload:
@@ -3172,7 +3319,7 @@ def complete(self, text, state):
31723319
if self.write_failed:
31733320
return None
31743321

3175-
payload = self.sockfile.readline()
3322+
payload = self._readline()
31763323
if not payload:
31773324
return None
31783325

@@ -3189,11 +3336,18 @@ def complete(self, text, state):
31893336
return None
31903337

31913338

3192-
def _connect(host, port, frame, commands, version):
3339+
def _connect(*, host, port, frame, commands, version, signal_raising_thread):
31933340
with closing(socket.create_connection((host, port))) as conn:
31943341
sockfile = conn.makefile("rwb")
31953342

3196-
remote_pdb = _PdbServer(sockfile)
3343+
# The client requests this thread on Windows but not on Unix.
3344+
# Most tests don't request this thread, to keep them simpler.
3345+
if signal_raising_thread:
3346+
signal_server = (host, port)
3347+
else:
3348+
signal_server = None
3349+
3350+
remote_pdb = _PdbServer(sockfile, signal_server=signal_server)
31973351
weakref.finalize(remote_pdb, sockfile.close)
31983352

31993353
if Pdb._last_pdb_instance is not None:
@@ -3214,43 +3368,48 @@ def _connect(host, port, frame, commands, version):
32143368

32153369
def attach(pid, commands=()):
32163370
"""Attach to a running process with the given PID."""
3217-
with closing(socket.create_server(("localhost", 0))) as server:
3371+
with ExitStack() as stack:
3372+
server = stack.enter_context(
3373+
closing(socket.create_server(("localhost", 0)))
3374+
)
32183375
port = server.getsockname()[1]
32193376

3220-
with tempfile.NamedTemporaryFile("w", delete_on_close=False) as connect_script:
3221-
connect_script.write(
3222-
textwrap.dedent(
3223-
f"""
3224-
import pdb, sys
3225-
pdb._connect(
3226-
host="localhost",
3227-
port={port},
3228-
frame=sys._getframe(1),
3229-
commands={json.dumps("\n".join(commands))},
3230-
version={_PdbServer.protocol_version()},
3231-
)
3232-
"""
3377+
connect_script = stack.enter_context(
3378+
tempfile.NamedTemporaryFile("w", delete_on_close=False)
3379+
)
3380+
3381+
use_signal_thread = sys.platform == "win32"
3382+
3383+
connect_script.write(
3384+
textwrap.dedent(
3385+
f"""
3386+
import pdb, sys
3387+
pdb._connect(
3388+
host="localhost",
3389+
port={port},
3390+
frame=sys._getframe(1),
3391+
commands={json.dumps("\n".join(commands))},
3392+
version={_PdbServer.protocol_version()},
3393+
signal_raising_thread={use_signal_thread!r},
32333394
)
3395+
"""
32343396
)
3235-
connect_script.close()
3236-
sys.remote_exec(pid, connect_script.name)
3237-
3238-
# TODO Add a timeout? Or don't bother since the user can ^C?
3239-
client_sock, _ = server.accept()
3397+
)
3398+
connect_script.close()
3399+
sys.remote_exec(pid, connect_script.name)
32403400

3241-
with closing(client_sock):
3242-
sockfile = client_sock.makefile("rwb")
3401+
# TODO Add a timeout? Or don't bother since the user can ^C?
3402+
client_sock, _ = server.accept()
3403+
stack.enter_context(closing(client_sock))
32433404

3244-
with closing(sockfile):
3245-
with tempfile.NamedTemporaryFile("w", delete_on_close=False) as interrupt_script:
3246-
interrupt_script.write(
3247-
'import pdb, sys\n'
3248-
'if inst := pdb.Pdb._last_pdb_instance:\n'
3249-
' inst.set_trace(sys._getframe(1))\n'
3250-
)
3251-
interrupt_script.close()
3405+
if use_signal_thread:
3406+
interrupt_sock, _ = server.accept()
3407+
stack.enter_context(closing(interrupt_sock))
3408+
interrupt_sock.setblocking(False)
3409+
else:
3410+
interrupt_sock = None
32523411

3253-
_PdbClient(pid, sockfile, interrupt_script.name).cmdloop()
3412+
_PdbClient(pid, client_sock, interrupt_sock).cmdloop()
32543413

32553414

32563415
# Post-Mortem interface

0 commit comments

Comments
 (0)