Allow ptvsd to run as server and let code connect to it

Expose --connect and debugpy.connect() as a public API, and add tests for it.
This commit is contained in:
Pavel Minaev 2020-02-28 17:04:40 -08:00 committed by Pavel Minaev
parent 4f43e00a07
commit 6ad1382a8c
19 changed files with 154 additions and 65 deletions

View file

@ -15,6 +15,7 @@ __all__ = [
"__version__",
"breakpoint",
"configure",
"connect",
"debug_this_thread",
"is_client_connected",
"listen",

View file

@ -49,24 +49,24 @@ def main(args):
if args.for_server is None:
adapter.access_token = compat.force_str(codecs.encode(os.urandom(32), "hex"))
endpoints = {}
try:
server_host, server_port = servers.serve()
client_host, client_port = clients.serve(args.host, args.port)
except Exception as exc:
if args.for_server is None:
raise
endpoints = {"error": "Can't listen for server connections: " + str(exc)}
endpoints = {"error": "Can't listen for client connections: " + str(exc)}
else:
endpoints = {"server": {"host": server_host, "port": server_port}}
try:
client_host, client_port = clients.serve(args.host, args.port)
except Exception as exc:
if args.for_server is None:
raise
endpoints = {"error": "Can't listen for client connections: " + str(exc)}
else:
endpoints["client"] = {"host": client_host, "port": client_port}
endpoints["client"] = {"host": client_host, "port": client_port}
if args.for_server is not None:
try:
server_host, server_port = servers.serve()
except Exception as exc:
endpoints = {"error": "Can't listen for server connections: " + str(exc)}
else:
endpoints["server"] = {"host": server_host, "port": server_port}
log.info(
"Sending endpoints info to debug server at localhost:{0}:\n{1!j}",
args.for_server,
@ -101,7 +101,9 @@ def main(args):
try:
os.remove(listener_file)
except Exception:
log.swallow_exception("Failed to delete {0!r}", listener_file, level="warning")
log.swallow_exception(
"Failed to delete {0!r}", listener_file, level="warning"
)
try:
with open(listener_file, "w") as f:
@ -149,6 +151,10 @@ def _parse_argv(argv):
help="start the adapter in debugServer mode on the specified host",
)
parser.add_argument(
"--access-token", type=str, help="access token expected from the server"
)
parser.add_argument(
"--server-access-token", type=str, help="access token expected by the server"
)

View file

@ -8,6 +8,7 @@ import os
import sys
import debugpy
from debugpy import adapter
from debugpy.common import fmt, json, log, messaging, sockets
from debugpy.common.compat import unicode
from debugpy.adapter import components, servers, sessions
@ -275,15 +276,23 @@ class Client(components.Component):
)
console_title = request("consoleTitle", json.default("Python Debug Console"))
launchers.spawn_debuggee(
self.session, request, args, console, console_title
)
servers.serve()
launchers.spawn_debuggee(self.session, request, args, console, console_title)
@_start_message_handler
def attach_request(self, request):
if self.session.no_debug:
raise request.isnt_valid('"noDebug" is not supported for "attach"')
listen = request("listen", False)
if listen:
host = request("host", "127.0.0.1")
port = request("port", int)
adapter.access_token = None
host, port = servers.serve(host, port)
else:
host, port = servers.serve()
# There are four distinct possibilities here.
#
# If "processId" is specified, this is attach-by-PID. We need to inject the
@ -294,12 +303,12 @@ class Client(components.Component):
# in response to a "debugpyAttach" event. If so, the debug server should be
# connected already, and thus the wait timeout is zero.
#
# If neither is specified, and "waitForAttach" is true, this is attach-by-socket
# If neither is specified, and "listen" is true, this is attach-by-socket
# with the server expected to connect to the adapter via debugpy.connect(). There
# is no PID known in advance, so just wait until the first server connection
# indefinitely, with no timeout.
#
# If neither is specified, and "waitForAttach" is false, this is attach-by-socket
# If neither is specified, and "listen" is false, this is attach-by-socket
# in which the server has spawned the adapter via debugpy.listen(). There
# is no PID known to the client in advance, but the server connection should be
# either be there already, or the server should be connecting shortly, so there
@ -330,11 +339,12 @@ class Client(components.Component):
else:
if sub_pid == ():
pred = lambda conn: True
timeout = None if request("waitForAttach", False) else 10
timeout = None if listen else 10
else:
pred = lambda conn: conn.pid == sub_pid
timeout = 0
self.channel.send_event("debugpyWaitingForServer", {"host": host, "port": port})
conn = servers.wait_for_connection(self.session, pred, timeout)
if conn is None:
raise request.cant_handle(
@ -436,15 +446,17 @@ class Client(components.Component):
body = dict(self.start_request.arguments)
self._known_subprocesses.add(conn)
body.pop("processId", None)
if body.pop("listen", False):
body.pop("host", None)
body.pop("port", None)
body["name"] = fmt("Subprocess {0}", conn.pid)
body["request"] = "attach"
body["subProcessId"] = conn.pid
if "host" not in body:
body["host"] = "127.0.0.1"
if "port" not in body:
_, body["port"] = listener.getsockname()
if "processId" in body:
del body["processId"]
body["subProcessId"] = conn.pid
self.channel.send_event("debugpyAttach", body)

View file

@ -344,9 +344,9 @@ class Server(components.Component):
super(Server, self).disconnect()
def serve():
def serve(host="127.0.0.1", port=0):
global listener
listener = sockets.serve("Server", Connection, "127.0.0.1")
listener = sockets.serve("Server", Connection, host, port)
return listener.getsockname()

View file

@ -26,7 +26,7 @@ TARGET = "<filename> | -m <module> | -c <code> | --pid <pid>"
HELP = """debugpy {0}
See https://aka.ms/debugpy for documentation.
Usage: debugpy --listen [<address>:]<port>
Usage: debugpy [--listen | --connect] [<address>:]<port>
[--wait-for-client]
[--configure-<name> <value>]...
[--log-to <path>] [--log-to-stderr]

View file

@ -65,6 +65,7 @@ class DebugConfig(collections.MutableMapping):
# Attach by socket
"host": (),
"port": (),
"listen": False,
# Attach by PID
"processId": (),
}

View file

@ -19,10 +19,10 @@ be bound to specific arguments, by using either [] or with_options(), which can
chained arbitrarily::
# Direct invocation:
session.attach_by_socket("cli", log_dir="...")
session.attach_connect("cli", log_dir="...")
# Indirect invocation:
run = runners.attach_by_socket
run = runners.attach_connect
run = run["cli"]
run = run.with_options(log_dir="...")
run(session, target)
@ -59,12 +59,13 @@ import sys
import debugpy
from debugpy.common import compat, fmt, log
from tests import net
from tests import net, timeline
from tests.debug import session
from tests.patterns import some
def _runner(f):
assert f.__name__.startswith("launch") or f.__name__.startswith("attach")
# assert f.__name__.startswith("launch") or f.__name__.startswith("attach")
setattr(session.Session, f.__name__, f)
class Runner(object):
@ -159,7 +160,7 @@ def _attach_common_config(session, target, cwd):
@_runner
@contextlib.contextmanager
def attach_by_pid(session, target, cwd=None, wait=True):
def attach_pid(session, target, cwd=None, wait=True):
if sys.version_info < (3,) and sys.platform == "darwin":
pytest.skip("https://github.com/microsoft/ptvsd/issues/1916")
if wait and not sys.platform.startswith("linux"):
@ -188,7 +189,7 @@ while "debugpy" not in sys.modules:
from debuggee import scratchpad
while "_attach_by_pid" not in scratchpad:
while "_attach_pid" not in scratchpad:
time.sleep(0.1)
"""
else:
@ -202,24 +203,20 @@ while "_attach_by_pid" not in scratchpad:
yield
if wait:
session.scratchpad["_attach_by_pid"] = True
session.scratchpad["_attach_pid"] = True
@_runner
def attach_by_socket(
session, target, method, listener="server", cwd=None, wait=True, log_dir=None
):
def attach_listen(session, target, method, cwd=None, wait=True, log_dir=None):
log.info(
"Attaching {0} to {1} by socket using {2}.", session, target, method.upper()
)
assert method in ("api", "cli")
assert listener in ("server") # TODO: ("adapter", "server")
config = _attach_common_config(session, target, cwd)
host = config["host"] = attach_by_socket.host
port = config["port"] = attach_by_socket.port
config["host"] = host = attach_listen.host
config["port"] = port = attach_listen.port
if method == "cli":
args = [
@ -257,8 +254,57 @@ if {wait!r}:
return session.request_attach()
attach_by_socket.host = "127.0.0.1"
attach_by_socket.port = net.get_test_server_port(5678, 5800)
attach_listen.host = "127.0.0.1"
attach_listen.port = net.get_test_server_port(5678, 5800)
@_runner
def attach_connect(session, target, method, cwd=None, log_dir=None):
log.info(
"Attaching {0} to {1} by socket using {2}.", session, target, method.upper()
)
assert method in ("api", "cli")
config = _attach_common_config(session, target, cwd)
config["listen"] = True
config["host"] = host = attach_connect.host
config["port"] = port = attach_connect.port
if method == "cli":
args = [
os.path.dirname(debugpy.__file__),
"--connect",
compat.filename_str(host) + ":" + str(port),
]
if log_dir is not None:
args += ["--log-to", log_dir]
debuggee_setup = None
elif method == "api":
args = []
debuggee_setup = """
import debugpy
if {log_dir!r}:
debugpy.log_to({log_dir!r})
debugpy.connect({address!r})
"""
debuggee_setup = fmt(debuggee_setup, address=(host, port), log_dir=log_dir)
else:
raise ValueError
args += target.cli(session.spawn_debuggee.env)
def spawn_debuggee(occ):
assert occ.body == some.dict.containing({"host": host, "port": port})
session.spawn_debuggee(args, cwd=cwd, setup=debuggee_setup)
session.timeline.when(timeline.Event("debugpyWaitingForServer"), spawn_debuggee)
session.spawn_adapter(args=[] if log_dir is None else ["--log-dir", log_dir])
return session.request_attach()
attach_connect.host = "127.0.0.1"
attach_connect.port = net.get_test_server_port(5678, 5800)
all_launch = [
launch["internalConsole"],
@ -266,8 +312,18 @@ all_launch = [
launch["externalTerminal"],
]
all_attach_by_socket = [attach_by_socket["api"], attach_by_socket["cli"]]
all_attach_listen = [
attach_listen["api"],
attach_listen["cli"],
]
all_attach = all_attach_by_socket + [attach_by_pid]
all_attach_connect = [
attach_connect["api"],
attach_connect["cli"],
]
all_attach_socket = all_attach_listen + all_attach_connect
all_attach = all_attach_socket + [attach_pid]
all = all_launch + all_attach

View file

@ -166,8 +166,7 @@ class Session(object):
[
timeline.Event("module"),
timeline.Event("continued"),
# timeline.Event("exited"),
# timeline.Event("terminated"),
timeline.Event("debugpyWaitingForServer"),
timeline.Event("thread", some.dict.containing({"reason": "started"})),
timeline.Event("thread", some.dict.containing({"reason": "exited"})),
timeline.Event("output", some.dict.containing({"category": "stdout"})),
@ -390,11 +389,11 @@ class Session(object):
while not self.adapter_endpoints.check():
time.sleep(0.1)
def spawn_adapter(self):
def spawn_adapter(self, args=()):
assert self.adapter is None
assert self.channel is None
args = [sys.executable, os.path.dirname(debugpy.adapter.__file__)]
args = [sys.executable, os.path.dirname(debugpy.adapter.__file__)] + list(args)
env = self._make_env(self.spawn_adapter.env)
log.info(

View file

@ -53,7 +53,7 @@ def test_attach_api(pyfile, target, wait_for_client, is_client_connected, stop_m
time.sleep(0.1)
with debug.Session() as session:
host, port = runners.attach_by_socket.host, runners.attach_by_socket.port
host, port = runners.attach_connect.host, runners.attach_connect.port
session.config.update({"host": host, "port": port})
backchannel = session.open_backchannel()
@ -97,7 +97,7 @@ def test_attach_api(pyfile, target, wait_for_client, is_client_connected, stop_m
session.request_continue()
@pytest.mark.parametrize("run", runners.all_attach_by_socket)
@pytest.mark.parametrize("run", runners.all_attach_listen)
def test_reattach(pyfile, target, run):
@pyfile
def code_to_debug():
@ -146,7 +146,7 @@ def test_reattach(pyfile, target, run):
@pytest.mark.parametrize("pid_type", ["int", "str"])
def test_attach_by_pid_client(pyfile, target, pid_type):
def test_attach_pid_client(pyfile, target, pid_type):
@pyfile
def code_to_debug():
import debuggee
@ -178,7 +178,7 @@ def test_attach_by_pid_client(pyfile, target, pid_type):
session1.captured_output = set()
session1.expected_exit_code = None # not expected to exit on disconnect
with session1.attach_by_pid(target(code_to_debug), wait=False):
with session1.attach_pid(target(code_to_debug), wait=False):
session1.set_breakpoints(code_to_debug, all)
session1.wait_for_stop(expected_frames=[some.dap.frame(code_to_debug, "bp")])
@ -191,7 +191,7 @@ def test_attach_by_pid_client(pyfile, target, pid_type):
session1.wait_for_terminated()
with debug.Session() as session2:
with session2.attach_by_pid(pid, wait=False):
with session2.attach_pid(pid, wait=False):
session2.set_breakpoints(code_to_debug, all)
stop = session2.wait_for_stop(

View file

@ -17,7 +17,7 @@ from tests.patterns import some
bp_root = test_data / "bp"
@pytest.fixture(params=[runners.launch, runners.attach_by_socket["api"]])
@pytest.fixture(params=[runners.launch, runners.attach_listen["api"]])
def run(request):
return request.param

View file

@ -12,7 +12,7 @@ from tests.debug import runners
from tests.patterns import some
@pytest.mark.parametrize("run", runners.all_attach_by_socket)
@pytest.mark.parametrize("run", runners.all_attach_socket)
def test_continue_on_disconnect_for_attach(pyfile, target, run):
@pyfile
def code_to_debug():

View file

@ -28,7 +28,7 @@ class lines:
@pytest.fixture
@pytest.mark.parametrize("run", [runners.launch, runners.attach_by_socket["cli"]])
@pytest.mark.parametrize("run", [runners.launch, runners.attach_listen["cli"]])
def start_django(run):
def start(session, multiprocess=False):
# No clean way to kill Django server, expect non-zero exit code

View file

@ -30,7 +30,7 @@ class lines:
@pytest.fixture
@pytest.mark.parametrize("run", [runners.launch, runners.attach_by_socket["cli"]])
@pytest.mark.parametrize("run", [runners.launch, runners.attach_listen["cli"]])
def start_flask(run):
def start(session, multiprocess=False):
# No clean way to kill Flask server, expect non-zero exit code

View file

@ -60,7 +60,7 @@ def test_gevent(pyfile, target, run):
with debug.Session() as session:
session.config["gevent"] = True
if str(run) == "attach_by_socket(cli)":
if str(run) == "listen(cli)" or str(run) == "connect(cli)":
session.spawn_debuggee.env["GEVENT_SUPPORT"] = "True"
with run(session, target(code_to_debug)):

View file

@ -13,9 +13,9 @@ from tests.debug import runners, targets
@contextlib.contextmanager
def check_logs(tmpdir, run, pydevd_log):
# For attach_by_pid, there's ptvsd.server process that performs the injection,
# For attach_pid, there's ptvsd.server process that performs the injection,
# and then there's the debug server that is injected into the debuggee.
server_count = 2 if type(run).__name__ == "attach_by_pid" else 1
server_count = 2 if type(run).__name__ == "attach_pid" else 1
expected_logs = {
"debugpy.adapter-*.log": 1,
@ -33,18 +33,18 @@ def check_logs(tmpdir, run, pydevd_log):
assert actual_logs() == expected_logs
@pytest.mark.parametrize("run", runners.all_attach_socket)
@pytest.mark.parametrize("target", targets.all)
@pytest.mark.parametrize("method", ["api", "cli"])
def test_log_dir(pyfile, tmpdir, target, method):
def test_log_dir(pyfile, tmpdir, run, target):
@pyfile
def code_to_debug():
import debuggee
debuggee.setup()
# Depending on the method, attach_by_socket will use either `debugpy --log-dir ...`
# Depending on the method, the runner will use either `debugpy --log-dir ...`
# or `debugpy.log_to() ...`.
run = runners.attach_by_socket[method].with_options(log_dir=tmpdir.strpath)
run = run.with_options(log_dir=tmpdir.strpath)
with check_logs(tmpdir, run, pydevd_log=False):
with debug.Session() as session:
session.log_dir = None

View file

@ -13,7 +13,7 @@ from tests.debug import runners
from tests.patterns import some
@pytest.fixture(params=[runners.launch, runners.attach_by_socket["api"]])
@pytest.fixture(params=[runners.launch] + runners.all_attach_socket)
def run(request):
return request.param
@ -101,6 +101,7 @@ def test_multiprocessing(pyfile, target, run, start_method):
pass
expected_child_config = dict(parent_session.config)
expected_child_config.pop("listen", None)
expected_child_config.update(
{
"name": some.str,
@ -120,6 +121,7 @@ def test_multiprocessing(pyfile, target, run, start_method):
pass
expected_grandchild_config = dict(child_session.config)
expected_grandchild_config.pop("listen", None)
expected_grandchild_config.update(
{
"name": some.str,
@ -181,6 +183,7 @@ def test_subprocess(pyfile, target, run):
pass
expected_child_config = dict(parent_session.config)
expected_child_config.pop("listen", None)
expected_child_config.update(
{
"name": some.str,

View file

@ -12,7 +12,7 @@ from tests.debug import runners
from tests.patterns import some
@pytest.fixture(params=[runners.launch, runners.attach_by_socket["api"]])
@pytest.fixture(params=[runners.launch, runners.attach_listen["api"]])
def run(request):
return request.param

View file

@ -20,7 +20,7 @@ from tests.debug import runners, session, targets
if int(os.environ.get("DEBUGPY_TESTS_FULL", "0")):
TARGETS = targets.all_named
RUNNERS = runners.all_launch + runners.all_attach_by_socket
RUNNERS = runners.all_launch + runners.all_attach_socket
else:
TARGETS = [targets.Program]
RUNNERS = [runners.launch]

View file

@ -30,6 +30,7 @@ class Timeline(object):
self.name = str(name if name is not None else id(self))
self.ignore_unobserved = []
self._listeners = [] # [(expectation, callable)]
self._index_iter = itertools.count(1)
self._accepting_new = threading.Event()
self._finalized = threading.Event()
@ -341,6 +342,10 @@ class Timeline(object):
self._recorded_new.notify_all()
self._record_queue.task_done()
for exp, callback in tuple(self._listeners):
if exp == occ:
callback(occ)
def mark(self, id, block=True):
occ = Occurrence("mark", id)
occ.id = id
@ -360,6 +365,12 @@ class Timeline(object):
occ = ResponseOccurrence(request_occ, message)
return self._record(occ, block)
def when(self, expectation, callback):
"""For every occurrence recorded after this call, invokes callback(occurrence)
if occurrence == expectation.
"""
self._listeners.append((expectation, callback))
def _snapshot(self):
last = self._last
occ = self._beginning