Skip to content

Commit e5017d7

Browse files
authored
Fix arq quoting to work in runInTerminal (#1981)
* Fix arq quoting to work in runInTerminal * Default was backwards * Fix ruff errors * Fix failing tests * Only strip quotes on the exe * Try fixing gw worker failures * Skip certain test because of cmd limitations * Need to skip all 'code' based tests on windows
1 parent 1e3fd91 commit e5017d7

File tree

10 files changed

+144
-18
lines changed

10 files changed

+144
-18
lines changed

CONTRIBUTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ The tests are run concurrently, and the default number of workers is 8. You can
9393

9494
While tox is the recommended way to run the test suite, pytest can also be invoked directly from the root of the repository. This requires packages in tests/requirements.txt to be installed first.
9595

96+
Using a venv created by tox in the '.tox' folder can make it easier to get the pytest configuration correct. Debugpy needs to be installed into the venv for the tests to run, so using the tox generated .venv makes that easier.
97+
9698
#### Keeping logs on test success
9799

98100
There's an internal setting `debugpy_log_passed` that if set to true will not erase the logs after a successful test run. Just search for this in the code and remove the code that deletes the logs on success.

pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[pytest]
22
testpaths=tests
3-
timeout=60
3+
timeout=120
44
timeout_method=thread
55
addopts=-n8

src/debugpy/adapter/launchers.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,23 @@ def on_launcher_connected(sock):
153153
request_args["cwd"] = cwd
154154
if shell_expand_args:
155155
request_args["argsCanBeInterpretedByShell"] = True
156+
157+
# VS Code debugger extension may pass us an argument indicating the
158+
# quoting character to use in the terminal. Otherwise default based on platform.
159+
default_quote = '"' if os.name != "nt" else "'"
160+
quote_char = arguments["terminalQuoteCharacter"] if "terminalQuoteCharacter" in arguments else default_quote
161+
162+
# VS code doesn't quote arguments if `argsCanBeInterpretedByShell` is true,
163+
# so we need to do it ourselves for the arguments up to the call to the adapter.
164+
args = request_args["args"]
165+
for i in range(len(args)):
166+
if args[i] == "--":
167+
break
168+
s = args[i]
169+
if " " in s and not ((s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'"))):
170+
s = f"{quote_char}{s}{quote_char}"
171+
args[i] = s
172+
156173
try:
157174
# It is unspecified whether this request receives a response immediately, or only
158175
# after the spawned command has completed running, so do not block waiting for it.

tests/debug/comms.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def listen(self):
2727
self._server_socket = sockets.create_server("127.0.0.1", 0, self.TIMEOUT)
2828
_, self.port = sockets.get_address(self._server_socket)
2929
self._server_socket.listen(0)
30+
log.info("{0} created server socket on port {1}", self, self.port)
3031

3132
def accept_worker():
3233
log.info(
@@ -67,8 +68,14 @@ def _setup_stream(self):
6768
self._established.set()
6869

6970
def receive(self):
70-
self._established.wait()
71-
return self._stream.read_json()
71+
log.info("{0} waiting for connection to be established...", self)
72+
if not self._established.wait(timeout=self.TIMEOUT):
73+
log.error("{0} timed out waiting for connection after {1} seconds", self, self.TIMEOUT)
74+
raise TimeoutError(f"{self} timed out waiting for debuggee to connect")
75+
log.info("{0} connection established, reading JSON...", self)
76+
result = self._stream.read_json()
77+
log.info("{0} received: {1}", self, result)
78+
return result
7279

7380
def send(self, value):
7481
self.session.timeline.unfreeze()

tests/debug/session.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,11 @@ def __exit__(self, exc_type, exc_val, exc_tb):
281281

282282
if self.adapter_endpoints is not None and self.expected_exit_code is not None:
283283
log.info("Waiting for {0} to close listener ports ...", self.adapter_id)
284+
timeout_start = time.time()
284285
while self.adapter_endpoints.check():
286+
if time.time() - timeout_start > 10:
287+
log.warning("{0} listener ports did not close within 10 seconds", self.adapter_id)
288+
break
285289
time.sleep(0.1)
286290

287291
if self.adapter is not None:
@@ -290,8 +294,20 @@ def __exit__(self, exc_type, exc_val, exc_tb):
290294
self.adapter_id,
291295
self.adapter.pid,
292296
)
293-
self.adapter.wait()
294-
watchdog.unregister_spawn(self.adapter.pid, self.adapter_id)
297+
try:
298+
self.adapter.wait(timeout=10)
299+
except Exception:
300+
log.warning("{0} did not exit gracefully within 10 seconds, force-killing", self.adapter_id)
301+
try:
302+
self.adapter.kill()
303+
self.adapter.wait(timeout=5)
304+
except Exception as e:
305+
log.error("Failed to force-kill {0}: {1}", self.adapter_id, e)
306+
307+
try:
308+
watchdog.unregister_spawn(self.adapter.pid, self.adapter_id)
309+
except Exception as e:
310+
log.warning("Failed to unregister adapter spawn: {0}", e)
295311
self.adapter = None
296312

297313
if self.backchannel is not None:
@@ -366,9 +382,23 @@ def _make_env(self, base_env, codecov=True):
366382
return env
367383

368384
def _make_python_cmdline(self, exe, *args):
369-
return [
370-
str(s.strpath if isinstance(s, py.path.local) else s) for s in [exe, *args]
371-
]
385+
def normalize(s, strip_quotes=False):
386+
# Convert py.path.local to string
387+
if isinstance(s, py.path.local):
388+
s = s.strpath
389+
else:
390+
s = str(s)
391+
# Strip surrounding quotes if requested
392+
if strip_quotes and len(s) >= 2 and " " in s and (s[0] == s[-1] == '"' or s[0] == s[-1] == "'"):
393+
s = s[1:-1]
394+
return s
395+
396+
# Strip quotes from exe
397+
result = [normalize(exe, strip_quotes=True)]
398+
for arg in args:
399+
# Don't strip quotes on anything except the exe
400+
result.append(normalize(arg, strip_quotes=False))
401+
return result
372402

373403
def spawn_debuggee(self, args, cwd=None, exe=sys.executable, setup=None):
374404
assert self.debuggee is None

tests/debugpy/test_args.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# Licensed under the MIT License. See LICENSE in the project root
33
# for license information.
44

5+
import os
6+
import sys
57
import pytest
68

79
from debugpy.common import log
@@ -35,9 +37,15 @@ def code_to_debug():
3537
@pytest.mark.parametrize("target", targets.all)
3638
@pytest.mark.parametrize("run", runners.all_launch)
3739
@pytest.mark.parametrize("expansion", ["preserve", "expand"])
38-
def test_shell_expansion(pyfile, target, run, expansion):
40+
@pytest.mark.parametrize("python_with_space", [False, True])
41+
def test_shell_expansion(pyfile, tmpdir, target, run, expansion, python_with_space):
3942
if expansion == "expand" and run.console == "internalConsole":
4043
pytest.skip('Shell expansion is not supported for "internalConsole"')
44+
45+
# Skip tests with python_with_space=True and target="code" on Windows
46+
# because .cmd wrappers cannot properly handle multiline string arguments
47+
if (python_with_space and target == targets.Code and sys.platform == "win32"):
48+
pytest.skip('Windows .cmd wrapper cannot handle multiline code arguments')
4149

4250
@pyfile
4351
def code_to_debug():
@@ -57,14 +65,34 @@ def expand(args):
5765
args[i] = arg[1:]
5866
log.info("After expansion: {0}", args)
5967

68+
captured_run_in_terminal_args = []
69+
6070
class Session(debug.Session):
6171
def run_in_terminal(self, args, cwd, env):
72+
captured_run_in_terminal_args.append(args[:]) # Capture a copy of the args
6273
expand(args)
6374
return super().run_in_terminal(args, cwd, env)
6475

6576
argslist = ["0", "$1", "2"]
6677
args = argslist if expansion == "preserve" else " ".join(argslist)
78+
6779
with Session() as session:
80+
# Create a Python wrapper with a space in the path if requested
81+
if python_with_space:
82+
# Create a directory with a space in the name
83+
python_dir = tmpdir / "python with space"
84+
python_dir.mkdir()
85+
86+
if sys.platform == "win32":
87+
wrapper = python_dir / "python.cmd"
88+
wrapper.write(f'@echo off\n"{sys.executable}" %*')
89+
else:
90+
wrapper = python_dir / "python.sh"
91+
wrapper.write(f'#!/bin/sh\nexec "{sys.executable}" "$@"')
92+
os.chmod(wrapper.strpath, 0o777)
93+
94+
session.config["python"] = wrapper.strpath
95+
6896
backchannel = session.open_backchannel()
6997
with run(session, target(code_to_debug, args=args)):
7098
pass
@@ -73,3 +101,15 @@ def run_in_terminal(self, args, cwd, env):
73101

74102
expand(argslist)
75103
assert argv == [some.str] + argslist
104+
105+
# Verify that the python executable path is correctly quoted if it contains spaces
106+
if python_with_space and captured_run_in_terminal_args:
107+
terminal_args = captured_run_in_terminal_args[0]
108+
log.info("Captured runInTerminal args: {0}", terminal_args)
109+
110+
# Check if the python executable (first arg) contains a space
111+
python_arg = terminal_args[0]
112+
assert "python with space" in python_arg, \
113+
f"Expected 'python with space' in python path: {python_arg}"
114+
if expansion == "expand":
115+
assert (python_arg.startswith('"') or python_arg.startswith("'")), f"Python_arg is not quoted: {python_arg}"

tests/debugpy/test_django.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66

77
from tests import code, debug, log, net, test_data
8-
from tests.debug import runners, targets
8+
from tests.debug import targets
99
from tests.patterns import some
1010

1111
pytestmark = pytest.mark.timeout(60)
@@ -25,7 +25,6 @@ class lines:
2525

2626

2727
@pytest.fixture
28-
@pytest.mark.parametrize("run", [runners.launch, runners.attach_connect["cli"]])
2928
def start_django(run):
3029
def start(session, multiprocess=False):
3130
# No clean way to kill Django server, expect non-zero exit code

tests/debugpy/test_flask.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sys
77

88
from tests import code, debug, log, net, test_data
9-
from tests.debug import runners, targets
9+
from tests.debug import targets
1010
from tests.patterns import some
1111

1212
pytestmark = pytest.mark.timeout(60)
@@ -27,7 +27,6 @@ class lines:
2727

2828

2929
@pytest.fixture
30-
@pytest.mark.parametrize("run", [runners.launch, runners.attach_connect["cli"]])
3130
def start_flask(run):
3231
def start(session, multiprocess=False):
3332
# No clean way to kill Flask server, expect non-zero exit code

tests/net.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
used_ports = set()
1919

20-
def get_test_server_port():
20+
def get_test_server_port(max_retries=10):
2121
"""Returns a server port number that can be safely used for listening without
2222
clashing with another test worker process, when running with pytest-xdist.
2323
@@ -27,6 +27,9 @@ def get_test_server_port():
2727
2828
Note that if multiple test workers invoke this function with different ranges
2929
that overlap, conflicts are possible!
30+
31+
Args:
32+
max_retries: Number of times to retry finding an available port
3033
"""
3134

3235
try:
@@ -39,11 +42,32 @@ def get_test_server_port():
3942
), "Unrecognized PYTEST_XDIST_WORKER format"
4043
n = int(worker_id[2:])
4144

45+
# Try multiple times to find an available port, with retry logic
46+
for attempt in range(max_retries):
47+
port = 5678 + (n * 300) + attempt
48+
while port in used_ports:
49+
port += 1
50+
51+
# Verify the port is actually available by trying to bind to it
52+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
53+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
54+
try:
55+
sock.bind(("127.0.0.1", port))
56+
sock.close()
57+
used_ports.add(port)
58+
log.info("Allocated port {0} for worker {1}", port, n)
59+
return port
60+
except OSError as e:
61+
log.warning("Port {0} unavailable (attempt {1}/{2}): {3}", port, attempt + 1, max_retries, e)
62+
sock.close()
63+
time.sleep(0.1 * (attempt + 1)) # Exponential backoff
64+
65+
# Fall back to original behavior if all retries fail
4266
port = 5678 + (n * 300)
4367
while port in used_ports:
4468
port += 1
4569
used_ports.add(port)
46-
70+
log.warning("Using fallback port {0} after {1} retries", port, max_retries)
4771
return port
4872

4973

tests/pytest_fixtures.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,27 @@ def write_log(filename, data):
4646

4747
session.Session.reset_counter()
4848

49-
session.Session.tmpdir = long_tmpdir
49+
# Add worker-specific isolation for tmpdir and log directory
50+
try:
51+
worker_id = os.environ.get("PYTEST_XDIST_WORKER", "gw0")
52+
worker_suffix = f"_{worker_id}"
53+
except Exception:
54+
worker_suffix = ""
55+
56+
session.Session.tmpdir = long_tmpdir / f"session{worker_suffix}"
57+
session.Session.tmpdir.ensure(dir=True)
5058
original_log_dir = log.log_dir
5159

5260
failed = True
5361
try:
5462
if log.log_dir is None:
55-
log.log_dir = (long_tmpdir / "debugpy_logs").strpath
63+
log.log_dir = (long_tmpdir / f"debugpy_logs{worker_suffix}").strpath
5664
else:
5765
log_subdir = request.node.nodeid
5866
log_subdir = log_subdir.replace("::", "/")
5967
for ch in r":?*|<>":
6068
log_subdir = log_subdir.replace(ch, f"&#{ord(ch)};")
61-
log.log_dir += "/" + log_subdir
69+
log.log_dir += "/" + log_subdir + worker_suffix
6270

6371
try:
6472
py.path.local(log.log_dir).remove()

0 commit comments

Comments
 (0)