Skip to content

Commit 6d50fc5

Browse files
rchiodoCopilot
andcommitted
Address review feedback: fix runtime bugs and add tests
- messaging.Message.__call__: fix dead no-arg fast path (args.count -> len(args)) - messaging.OutgoingRequest.wait_for_response: honor raise_if_failed=False by returning the error body instead of asserting; return type now MessageDict|Exception - messaging: complete AssociableMessageDict migration; associate_with is now a real method backed by a shared associated_dicts list instead of a setattr closure - server/api._settrace: only latch called on success so a failed settrace no longer permanently blocks configure(); drop no-op except/finally - adapter/servers.authenticate: fail closed when authorize result is an Exception - common/util.Observable.observers: revert class default to immutable tuple to avoid shared-mutable footgun; typed and callers reassign instead of in-place += - common/json._converter: document the int/float narrowing from numbers.Number - add unit tests for wait_for_response(raise_if_failed=False), Message.__call__() no-arg, and the configure() already-running guard incl. the settrace-failure case Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4ce8028 commit 6d50fc5

9 files changed

Lines changed: 152 additions & 28 deletions

File tree

src/debugpy/adapter/components.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def __init__(self, session: "Session", stream: "Union[messaging.JsonIOStream, No
6161
self.is_connected = True
6262

6363
# Do this last to avoid triggering useless notifications for assignments above.
64-
self.observers += [lambda *_: self.session.notify_changed()]
64+
self.observers = [*self.observers, lambda *_: self.session.notify_changed()]
6565

6666
def __str__(self):
6767
return f"{type(self).__name__}[{self.session.id}]"

src/debugpy/adapter/servers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,12 @@ def authenticate(self):
174174
auth = self.channel.request(
175175
"pydevdAuthorize", {"debugServerAccessToken": access_token}
176176
)
177-
if not isinstance(auth, Exception) and auth["clientAccessToken"] != adapter.access_token:
177+
# Fail closed: if the authorization request didn't yield a normal result,
178+
# treat the server as unauthorized rather than skipping the token check.
179+
if isinstance(auth, Exception):
180+
self.channel.close()
181+
raise RuntimeError("Failed to authorize with debug server.") from auth
182+
if auth["clientAccessToken"] != adapter.access_token:
178183
self.channel.close()
179184
raise RuntimeError('Mismatched "clientAccessToken"; server not authorized.')
180185

src/debugpy/adapter/sessions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def __init__(self):
6363
self.is_finalizing = False
6464
"""Whether finalize() has been invoked."""
6565

66-
self.observers += [lambda *_: self.notify_changed()]
66+
self.observers = [*self.observers, lambda *_: self.notify_changed()]
6767

6868
def __str__(self):
6969
return f"Session[{self.id}]"

src/debugpy/common/json.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ def __format__(self, format_spec):
9595

9696
def _converter(value: str, classinfo) -> Union[int, float, None]:
9797
"""Convert value (str) to number, otherwise return None if is not possible"""
98+
# Only int/float are accepted here (DAP number payloads are int or float);
99+
# this deliberately narrows from numbers.Number and does not handle
100+
# Decimal/complex/Fraction, which never appear in DAP messages.
98101
for one_info in classinfo:
99102
if issubclass(one_info, int) or issubclass(one_info, float):
100103
try:

src/debugpy/common/messaging.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -429,11 +429,22 @@ def wrap(self, key, *args, **kwargs):
429429

430430

431431
class AssociableMessageDict(MessageDict):
432+
# When this dict is parsed as part of an incoming message, all dicts that
433+
# belong to that message share this list, so associating the top-level dict
434+
# associates every nested dict with the same Message. It stays None for
435+
# synthesized payloads, which are associated individually.
436+
associated_dicts: "list[AssociableMessageDict] | None" = None
437+
432438
def associate_with(self, message: Message):
433-
self.message = message
439+
if self.associated_dicts is None:
440+
self.message = message
441+
return
442+
for d in self.associated_dicts:
443+
d.message = message
444+
434445

435446
def is_associable(obj) -> "TypeIs[AssociableMessageDict]":
436-
return isinstance(obj, MessageDict) and hasattr(obj, "associate_with")
447+
return isinstance(obj, AssociableMessageDict)
437448

438449
def _payload(value):
439450
"""JSON validator for message payload.
@@ -495,7 +506,7 @@ def payload(self) -> MessageDict | Exception:
495506
def __call__(self, *args, **kwargs) -> MessageDict | Any | int | float:
496507
"""Same as self.payload(...)."""
497508
assert not isinstance(self.payload, Exception)
498-
if args.count == 0 and kwargs == {}:
509+
if len(args) == 0 and not kwargs:
499510
return self.payload
500511
return self.payload(*args, **kwargs)
501512

@@ -793,7 +804,7 @@ def __init__(self, channel, seq, command, arguments):
793804
def describe(self):
794805
return f"{self.seq} request {json.repr(self.command)} to {self.channel}"
795806

796-
def wait_for_response(self, raise_if_failed=True)-> MessageDict:
807+
def wait_for_response(self, raise_if_failed=True) -> MessageDict | Exception:
797808
"""Waits until a response is received for this request, records the Response
798809
object for it in self.response, and returns response.body.
799810
@@ -810,8 +821,9 @@ def wait_for_response(self, raise_if_failed=True)-> MessageDict:
810821

811822
if raise_if_failed and not self.response.success and isinstance( self.response.body, BaseException):
812823
raise self.response.body
813-
814-
assert not isinstance(self.response.body, Exception)
824+
825+
# When raise_if_failed is False, a failed response intentionally returns its
826+
# error body (an Exception such as NoMoreMessages), so this must not assert.
815827
return self.response.body
816828

817829
def on_response(self, response_handler):
@@ -1377,7 +1389,9 @@ def object_hook(d):
13771389
d = AssociableMessageDict(None, d)
13781390
if "seq" in d:
13791391
self._prettify(d)
1380-
setattr(d, "associate_with", associate_with)
1392+
# Share the list of all dicts parsed for this message so that
1393+
# associate_with() on the top-level dict wires up every nested dict.
1394+
d.associated_dicts = message_dicts
13811395
message_dicts.append(d)
13821396
return d
13831397

@@ -1386,17 +1400,13 @@ def object_hook(d):
13861400
# cannot be done until the actual Message is created - which happens after the
13871401
# dicts are created during deserialization.
13881402
#
1389-
# So, upon deserialization, every dict in the message payload gets a method
1390-
# that can be called to set MessageDict.message for *all* dicts belonging to
1391-
# that message. This method can then be invoked on the top-level dict by the
1392-
# parser, after it has parsed enough of the dict to create the appropriate
1393-
# instance of Event, Request, or Response for this message.
1394-
def associate_with(message):
1395-
for d in message_dicts:
1396-
d.message = message
1397-
del d.associate_with
1398-
1399-
message_dicts = []
1403+
# So, upon deserialization, every dict in the message payload shares the
1404+
# message_dicts list below. AssociableMessageDict.associate_with() can then be
1405+
# invoked on the top-level dict by the parser, after it has parsed enough of the
1406+
# dict to create the appropriate instance of Event, Request, or Response for this
1407+
# message, and it will set MessageDict.message for *all* dicts belonging to that
1408+
# message.
1409+
message_dicts: "list[AssociableMessageDict]" = []
14001410
decoder = self.stream.json_decoder_factory(object_hook=object_hook)
14011411
message_dict = self.stream.read_json(decoder)
14021412
assert isinstance(message_dict, MessageDict) # make sure stream used decoder

src/debugpy/common/util.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import inspect
66
import os
77
import sys
8+
from typing import Any, Callable, Sequence
89

910

1011
def evaluate(code, path=__file__, mode="eval"):
@@ -19,7 +20,10 @@ def evaluate(code, path=__file__, mode="eval"):
1920
class Observable(object):
2021
"""An object with change notifications."""
2122

22-
observers = [] # used when attributes are set before __init__ is invoked
23+
# Immutable default, used when attributes are set before __init__ is invoked.
24+
# Kept as a tuple (rather than a list) so it can't be mutated and accidentally
25+
# shared across instances.
26+
observers: Sequence[Callable[..., Any]] = ()
2327

2428
def __init__(self):
2529
self.observers = []

src/debugpy/server/api.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,11 @@ def __new__(cls, *args, **kwargs):
4444
log.debug("pydevd.settrace(*{0!r}, **{1!r})", args, kwargs)
4545
# The stdin in notification is not acted upon in debugpy, so, disable it.
4646
kwargs.setdefault("notify_stdin", False)
47-
try:
48-
return pydevd.settrace(*args, **kwargs)
49-
except Exception:
50-
raise
51-
finally:
52-
cls.called = True
47+
result = pydevd.settrace(*args, **kwargs)
48+
# Only latch `called` after settrace has actually succeeded, so that a failed
49+
# attempt doesn't permanently block future configure() calls.
50+
cls.called = True
51+
return result
5352

5453

5554
def ensure_logging():

tests/debugpy/common/test_messaging.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,59 @@ def response4_handler(resp):
437437
assert response4 is request4.response
438438
assert isinstance(response4.body, messaging.NoMoreMessages)
439439

440+
def test_wait_for_response_raise_if_failed(self):
441+
def iter_responses():
442+
yield {
443+
"seq": 1,
444+
"type": "response",
445+
"request_seq": 1,
446+
"command": "pause",
447+
"success": False,
448+
"message": "pause not supported",
449+
}
450+
451+
stream = JsonMemoryStream(iter_responses(), [])
452+
channel = messaging.JsonMessageChannel(stream, None)
453+
channel.start()
454+
455+
request = channel.send_request("pause")
456+
457+
# raise_if_failed=False must return the error body instead of raising, even
458+
# though a failed response carries an Exception as its body.
459+
body = request.wait_for_response(raise_if_failed=False)
460+
assert isinstance(body, messaging.MessageHandlingError)
461+
assert body is request.response.body
462+
assert "pause not supported" in str(body)
463+
464+
# raise_if_failed=True (the default) must raise that same error body.
465+
with pytest.raises(messaging.MessageHandlingError):
466+
request.wait_for_response()
467+
468+
def test_message_call_no_args_returns_payload(self):
469+
EVENTS = [
470+
{
471+
"seq": 1,
472+
"type": "event",
473+
"event": "stopped",
474+
"body": {"reason": "pause", "threadId": 3},
475+
},
476+
]
477+
478+
captured = []
479+
480+
class Handlers(object):
481+
def stopped_event(self, event):
482+
# Calling the message with no arguments returns the whole payload.
483+
captured.append(event())
484+
485+
stream = JsonMemoryStream(EVENTS, [])
486+
channel = messaging.JsonMessageChannel(stream, Handlers())
487+
channel.start()
488+
channel.wait()
489+
490+
(payload,) = captured
491+
assert payload == {"reason": "pause", "threadId": 3}
492+
440493
def test_invalid_request_handling(self):
441494
REQUESTS = [
442495
{

tests/debugpy/server/test_api.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License. See LICENSE in the project root
3+
# for license information.
4+
5+
"""Unit tests for debugpy.server.api behaviors that don't require a live session."""
6+
7+
import pytest
8+
9+
from debugpy.server import api
10+
11+
12+
@pytest.fixture
13+
def reset_settrace_called():
14+
"""Isolate the global _settrace.called latch used by configure()."""
15+
original = api._settrace.called
16+
api._settrace.called = False
17+
try:
18+
yield
19+
finally:
20+
api._settrace.called = original
21+
22+
23+
def test_settrace_failure_does_not_block_configure(monkeypatch, reset_settrace_called):
24+
# ensure_logging() writes log files; stub it out for this unit test.
25+
monkeypatch.setattr(api, "ensure_logging", lambda: None)
26+
27+
def failing_settrace(*args, **kwargs):
28+
raise RuntimeError("settrace failed")
29+
30+
monkeypatch.setattr(api.pydevd, "settrace", failing_settrace)
31+
32+
with pytest.raises(RuntimeError, match="settrace failed"):
33+
api._settrace()
34+
35+
# A failed settrace must not latch `called`, ...
36+
assert api._settrace.called is False
37+
38+
# ... so configure() must not reject the call as "already running".
39+
api.configure()
40+
41+
42+
def test_settrace_success_blocks_configure(monkeypatch, reset_settrace_called):
43+
monkeypatch.setattr(api, "ensure_logging", lambda: None)
44+
monkeypatch.setattr(api.pydevd, "settrace", lambda *args, **kwargs: None)
45+
46+
api._settrace()
47+
assert api._settrace.called is True
48+
49+
with pytest.raises(RuntimeError, match="already running"):
50+
api.configure()

0 commit comments

Comments
 (0)