Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/debugpy/adapter/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ def attach_request(self, request):
connect = request("connect", dict, optional=True)
pid = request("processId", (int, str), optional=True)
sub_pid = request("subProcessId", int, optional=True)
on_terminate = request("onTerminate", bool, optional=True)
on_terminate = request("onTerminate", str, optional=True)

if on_terminate:
self._forward_terminate_request = on_terminate == "KeyboardInterrupt"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

📍 src/debugpy/adapter/clients.py:452
A rejected attach with "onTerminate": "KeyboardInterrupt" leaves this flag enabled if the client retries without onTerminate, causing termination to forward Ctrl+C instead of using the default hard kill. Assign unconditionally with self._forward_terminate_request = on_terminate == "KeyboardInterrupt" and add a retry regression case.

[verified]

Expand Down
52 changes: 52 additions & 0 deletions tests/debugpy/adapter/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ def write_json(self, value, encoder=None):
class _FakeSession(object):
"""Stands in for the reentrant session lock used by the message_handler wrapper."""

launcher = None

def __init__(self, server=None):
self.server = server

Expand Down Expand Up @@ -89,6 +91,56 @@ def test_configuration_done_out_of_order_is_rejected(start_request, has_started,
assert client.has_started is has_started


def _attach(arguments):
"""Runs "attach" far enough to parse its arguments, and returns the client.

"listen" and "connect" are mutually exclusive, and that check is the first one
after "onTerminate" is read, so the handler stops before it opens any socket.
"""
stream = _MemoryStream()
channel = messaging.JsonMessageChannel(stream, None)

client = clients.Client.__new__(clients.Client)
client.session = _FakeSession()
client.channel = channel
client.start_request = None
client.has_started = False
client._forward_terminate_request = False
client._initialize_request = messaging.Request(
channel, 1, "initialize", messaging.MessageDict(None, {})
)

arguments = dict(arguments)
arguments["listen"] = {"port": 5678}
arguments["connect"] = {"port": 5678}
request = messaging.Request(
channel, 2, "attach", messaging.MessageDict(None, arguments)
)

with pytest.raises(
messaging.InvalidMessageError,
match='"listen" and "connect" are mutually exclusive',
):
clients.Client.attach_request(client, request)
return client


@pytest.mark.parametrize(
"arguments, expected",
[
({"onTerminate": "KeyboardInterrupt"}, True),
({"onTerminate": "kill"}, False),
({}, False),
],
)
def test_attach_honors_on_terminate(arguments, expected):
# "onTerminate" is a string option, and the debug server compares it against
# "KeyboardInterrupt". Validating it as a bool in "attach" coerced the string to
# True, so the comparison could never hold and graceful terminate was unreachable.
client = _attach(arguments)
assert client._forward_terminate_request is expected


def test_evaluate_request_that_cannot_be_propagated_is_rejected():
stream = _MemoryStream()
channel = messaging.JsonMessageChannel(stream, None)
Expand Down
Loading