Repository: microsoft/ptvsd

Issue:
Double "ptvsd_attach" for subprocesses
The same subprocess can end up getting reported to the IDE twice occasionally, due to a race condition in this code:
https://github.com/microsoft/ptvsd/blob/db7b0b64a37c2e51303351b64723bb9b1914168a/src/ptvsd/adapter/ide.py#L352-L357
This will cause the IDE to try to attach to it twice, and the second attach will fail, and get reported as such to the user (the first one will still work fine). It can also cause sporadic test failures.


Discussion:

Resolving patch:
diff --git a/src/ptvsd/_vendored/pydevd/pydevd.py b/src/ptvsd/_vendored/pydevd/pydevd.py
--- a/src/ptvsd/_vendored/pydevd/pydevd.py
+++ b/src/ptvsd/_vendored/pydevd/pydevd.py
@@ -164,7 +164,7 @@ def __init__(self, py_db):
     @overrides(PyDBDaemonThread._on_run)
     def _on_run(self):
         # Delay a bit this initialization to wait for the main program to start.
-        time.sleep(0.3)
+        self._py_db_command_thread_event.wait(0.3)
 
         if self._kill_received:
             return
@@ -620,6 +620,13 @@ def new_trace_dispatch(frame, event, arg):
         # Stop the tracing as the last thing before the actual shutdown for a clean exit.
         atexit.register(stoptrace)
 
+    def wait_for_ready_to_run(self):
+        while not self.ready_to_run:
+            # busy wait until we receive run command
+            self.process_internal_commands()
+            self._py_db_command_thread_event.clear()
+            self._py_db_command_thread_event.wait(0.1)
+
     def on_initialize(self):
         '''
         Note: only called when using the DAP (Debug Adapter Protocol).
@@ -631,6 +638,7 @@ def on_configuration_done(self):
         Note: only called when using the DAP (Debug Adapter Protocol).
         '''
         self._on_configuration_done_event.set()
+        self._py_db_command_thread_event.set()
 
     def is_attached(self):
         return self._on_configuration_done_event.is_set()
@@ -652,8 +660,18 @@ def ignore_system_exit_code(self, system_exit_exc):
         else:
             return system_exit_exc in self._ignore_system_exit_codes
 
-    def block_until_configuration_done(self, timeout=None):
-        return self._on_configuration_done_event.wait(timeout)
+    def block_until_configuration_done(self, cancel=None):
+        if cancel is None:
+            cancel = NULL
+
+        while not cancel.is_set():
+            if self._on_configuration_done_event.is_set():
+                cancel.set()  # Set cancel to prevent reuse
+                return
+
+            self.process_internal_commands()
+            self._py_db_command_thread_event.clear()
+            self._py_db_command_thread_event.wait(1 / 15.)
 
     def add_fake_frame(self, thread_id, frame_id, frame):
         self.suspended_frames_manager.add_fake_frame(thread_id, frame_id, frame)
@@ -1253,6 +1271,9 @@ def post_method_as_internal_command(self, thread_id, method, *args, **kwargs):
         else:
             internal_cmd = InternalThreadCommand(thread_id, method, *args, **kwargs)
         self.post_internal_command(internal_cmd, thread_id)
+        if thread_id == '*':
+            # Notify so that the command is handled as soon as possible.
+            self._py_db_command_thread_event.set()
 
     def post_internal_command(self, int_cmd, thread_id):
         """ if thread_id is *, post to the '*' queue"""
@@ -2115,8 +2136,7 @@ def run(self, file, globals=None, locals=None, is_module=False, set_trace=True):
             sys.path.insert(0, os.path.split(rPath(file))[0])
 
         if set_trace:
-            while not self.ready_to_run:
-                time.sleep(0.1)  # busy wait until we receive run command
+            self.wait_for_ready_to_run()
 
             # call prepare_to_run when we already have all information about breakpoints
             self.prepare_to_run()
@@ -2414,13 +2434,7 @@ def _wait_for_attach(cancel=None):
     if py_db is None:
         raise AssertionError('Debugger still not created. Please use _enable_attach() before using _wait_for_attach().')
 
-    if cancel is None:
-        py_db.block_until_configuration_done()
-    else:
-        while not cancel.is_set():
-            if py_db.block_until_configuration_done(0.1):
-                cancel.set()  # Set cancel to prevent reuse
-                return
+    py_db.block_until_configuration_done(cancel=cancel)
 
 
 def _is_attached():
@@ -2598,11 +2612,8 @@ def _locked_settrace(
         if not wait_for_ready_to_run:
             py_db.ready_to_run = True
 
-        while not py_db.ready_to_run:
-            time.sleep(0.1)  # busy wait until we receive run command
-
+        py_db.wait_for_ready_to_run()
         py_db.start_auxiliary_daemon_threads()
-
         if trace_only_current_thread:
             py_db.enable_tracing()
         else:
diff --git a/src/ptvsd/adapter/ide.py b/src/ptvsd/adapter/ide.py
--- a/src/ptvsd/adapter/ide.py
+++ b/src/ptvsd/adapter/ide.py
@@ -70,6 +70,11 @@ def __init__(self, sock):
             only if and when the "launch" or "attach" response is sent.
             """
 
+            self._known_subprocesses = set()
+            """servers.Connection instances for subprocesses that this IDE has been
+            made aware of.
+            """
+
             session.ide = self
             session.register()
 
@@ -280,15 +285,16 @@ def attach_request(self, request):
         # in response to a "ptvsd_attach" event. If so, the debug server should be
         # connected already, and thus the wait timeout is zero.
         #
-        # If neither are specified, but "listen" is true, this is attach-by-socket
+        # If neither is specified, and "waitForAttach" is true, this is attach-by-socket
         # with the server expected to connect to the adapter via ptvsd.attach(). There
         # is no PID known in advance, so just wait until the first server connection
         # indefinitely, with no timeout.
         #
-        # If neither are specified, but "listen" is false, this is attach-by-socket
+        # If neither is specified, and "waitForAttach" is false, this is attach-by-socket
         # in which the server has spawned the adapter via ptvsd.enable_attach(). There
         # is no PID known to the IDE in advance, but the server connection should be
-        # there already, so the wait timeout is zero.
+        # either be there already, or the server should be connecting shortly, so there
+        # must be a timeout.
         #
         # In the last two cases, if there's more than one server connection already,
         # this is a multiprocess re-attach. The IDE doesn't know the PID, so we just
@@ -309,7 +315,7 @@ def attach_request(self, request):
         else:
             if sub_pid == ():
                 pid = any
-                timeout = None if request("waitForAttach", False) else 0
+                timeout = None if request("waitForAttach", False) else 10
             else:
                 pid = sub_pid
                 timeout = 0
@@ -353,7 +359,6 @@ def configurationDone_request(self, request):
         # being debugged.
         for conn in servers.connections():
             if conn.server is None and conn.ppid == self.session.pid:
-                # FIXME: race condition with server.Connection()
                 self.notify_of_subprocess(conn)
 
     @message_handler
@@ -402,7 +407,7 @@ def disconnect_request(self, request):
 
     def notify_of_subprocess(self, conn):
         with self.session:
-            if self.start_request is None:
+            if self.start_request is None or conn in self._known_subprocesses:
                 return
             if "processId" in self.start_request.arguments:
                 log.warning(
@@ -414,6 +419,7 @@ def notify_of_subprocess(self, conn):
 
             log.info("Notifying {0} about {1}.", self, conn)
             body = dict(self.start_request.arguments)
+            self._known_subprocesses.add(conn)
 
         body["request"] = "attach"
         if "host" not in body:
diff --git a/src/ptvsd/adapter/servers.py b/src/ptvsd/adapter/servers.py
--- a/src/ptvsd/adapter/servers.py
+++ b/src/ptvsd/adapter/servers.py
@@ -14,7 +14,6 @@
 from ptvsd.common import compat, fmt, json, log, messaging, sockets
 from ptvsd.adapter import components
 
-
 _lock = threading.RLock()
 
 _connections = []
@@ -55,8 +54,49 @@ def __init__(self, sock):
             self.ppid = process_info("ppid", int, optional=True)
             if self.ppid == ():
                 self.ppid = None
-
             self.channel.name = stream.name = str(self)
+
+            ptvsd_dir = os.path.dirname(os.path.dirname(ptvsd.__file__))
+            # Note: we must check if 'ptvsd' is not already in sys.modules because the
+            # evaluation of an import at the wrong time could deadlock Python due to
+            # its import lock.
+            #
+            # So, in general this evaluation shouldn't do anything. It's only
+            # important when pydevd attaches automatically to a subprocess. In this
+            # case, we have to make sure that ptvsd is properly put back in the game
+            # for users to be able to use it.v
+            #
+            # In this case (when the import is needed), this evaluation *must* be done
+            # before the configurationDone request is sent -- if this is not respected
+            # it's possible that pydevd already started secondary threads to handle
+            # commands, in which case it's very likely that this command would be
+            # evaluated at the wrong thread and the import could potentially deadlock
+            # the program.
+            #
+            # Note 2: the sys module is guaranteed to be in the frame globals and
+            # doesn't need to be imported.
+            inject_ptvsd = """
+if 'ptvsd' not in sys.modules:
+    sys.path.insert(0, {ptvsd_dir!r})
+    try:
+        import ptvsd
+    finally:
+        del sys.path[0]
+"""
+            inject_ptvsd = fmt(inject_ptvsd, ptvsd_dir=ptvsd_dir)
+
+            try:
+                self.channel.request(
+                    "evaluate", {"expression": inject_ptvsd}
+                )
+            except messaging.MessageHandlingError:
+                # Failure to inject is not a fatal error - such a subprocess can
+                # still be debugged, it just won't support "import ptvsd" in user
+                # code - so don't terminate the session.
+                log.exception(
+                    "Failed to inject ptvsd into {0}:", self, level="warning"
+                )
+
             with _lock:
                 if any(conn.pid == self.pid for conn in _connections):
                     raise KeyError(


Test patch:
diff --git a/tests/debug/comms.py b/tests/debug/comms.py
--- a/tests/debug/comms.py
+++ b/tests/debug/comms.py
@@ -38,13 +38,19 @@ def accept_worker():
                 self.port,
             )
 
+            server_socket = self._server_socket
+            if server_socket is None:
+                return  # concurrent close()
+
             try:
-                self._socket, _ = self._server_socket.accept()
+                sock, _ = server_socket.accept()
             except socket.timeout:
                 raise log.exception("Timed out waiting for {0} to connect", self)
 
-            self._socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+            sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
             log.info("Incoming connection from {0} accepted.", self)
+
+            self._socket = sock
             self._setup_stream()
 
         accept_thread = threading.Thread(
@@ -69,21 +75,23 @@ def send(self, value):
         return t
 
     def close(self):
-        if self._socket:
+        sock = self._socket
+        if sock:
+            self._socket = None
             log.debug("Closing {0} socket of {1}...", self, self.session)
             try:
-                self._socket.shutdown(socket.SHUT_RDWR)
+                sock.shutdown(socket.SHUT_RDWR)
             except Exception:
                 pass
-            self._socket = None
 
-        if self._server_socket:
+        server_socket = self._server_socket
+        if server_socket:
+            self._server_socket = None
             log.debug("Closing {0} server socket of {1}...", self, self.session)
             try:
-                self._server_socket.shutdown(socket.SHUT_RDWR)
+                server_socket.shutdown(socket.SHUT_RDWR)
             except Exception:
                 pass
-            self._server_socket = None
 
 
 class ScratchPad(object):
diff --git a/tests/ptvsd/server/test_multiproc.py b/tests/ptvsd/server/test_multiproc.py
--- a/tests/ptvsd/server/test_multiproc.py
+++ b/tests/ptvsd/server/test_multiproc.py
@@ -8,17 +8,13 @@
 import pytest
 import sys
 
+import ptvsd
 from ptvsd.common import messaging
 from tests import debug
 from tests.debug import runners
 from tests.patterns import some
-from tests.timeline import Event, Request
 
 
-# pytestmark = pytest.mark.skip("https://github.com/microsoft/ptvsd/issues/1706")
-
-
-@pytest.mark.timeout(30)
 @pytest.mark.parametrize(
     "start_method",
     [""]
@@ -28,6 +24,9 @@
     else ["spawn", "fork"],
 )
 def test_multiprocessing(pyfile, target, run, start_method):
+    if start_method == "spawn" and platform.system() != "Windows":
+        pytest.skip("https://github.com/microsoft/ptvsd/issues/1887")
+
     @pyfile
     def code_to_debug():
         import debug_me  # noqa
@@ -134,25 +133,26 @@ def grandchild(q, a):
                 parent_backchannel.send("continue")
 
 
-@pytest.mark.timeout(30)
-@pytest.mark.skip("Needs refactoring to use the new debug.Session API")
-@pytest.mark.parametrize(
-    "start_method", [runners.launch, runners.attach_by_socket["cli"]]
-)
-def test_subprocess(pyfile, start_method, run_as):
+def test_subprocess(pyfile, target, run):
     @pyfile
     def child():
+        import os
         import sys
-        from debug_me import backchannel
 
+        assert "ptvsd" in sys.modules
+
+        from debug_me import backchannel, ptvsd
+
+        backchannel.send(os.getpid())
+        backchannel.send(ptvsd.__file__)
         backchannel.send(sys.argv)
 
     @pyfile
     def parent():
+        import debug_me  # noqa
         import os
         import subprocess
         import sys
-        import debug_me  # noqa
 
         argv = [sys.executable, sys.argv[1], "--arg1", "--arg2", "--arg3"]
         env = os.environ.copy()
@@ -165,39 +165,37 @@ def parent():
         )
         process.wait()
 
-    with debug.Session(start_method, backchannel=True) as parent_session:
-        parent_backchannel = parent_session.backchannel
-        parent_session.configure(run_as, parent, subProcess=True, args=[child])
-        parent_session.start_debugging()
+    with debug.Session() as parent_session:
+        backchannel = parent_session.open_backchannel()
 
-        root_start_request, = parent_session.all_occurrences_of(
-            Request("launch") | Request("attach")
-        )
-        root_process, = parent_session.all_occurrences_of(Event("process"))
-        root_pid = int(root_process.body["systemProcessId"])
+        with run(parent_session, target(parent, args=[child])):
+            pass
 
-        child_subprocess = parent_session.wait_for_next(Event("ptvsd_subprocess"))
-        assert child_subprocess == Event(
-            "ptvsd_subprocess",
+        expected_child_config = dict(parent_session.config)
+        expected_child_config.update(
             {
-                "rootProcessId": root_pid,
-                "parentProcessId": root_pid,
-                "processId": some.int,
+                "request": "attach",
+                "subProcessId": some.int,
+                "host": some.str,
                 "port": some.int,
-                "rootStartRequest": {
-                    "seq": some.int,
-                    "type": "request",
-                    "command": root_start_request.command,
-                    "arguments": root_start_request.arguments,
-                },
-            },
+            }
         )
+
+        child_config = parent_session.wait_for_next_event("ptvsd_attach")
+        assert child_config == expected_child_config
         parent_session.proceed()
 
-        with parent_session.attach_to_subprocess(child_subprocess) as child_session:
-            child_session.start_debugging()
+        with debug.Session(child_config) as child_session:
+            with child_session.start():
+                pass
+
+            child_pid = backchannel.receive()
+            assert child_pid == child_config["subProcessId"]
+
+            ptvsd_file = backchannel.receive()
+            assert ptvsd_file == ptvsd.__file__
 
-            child_argv = parent_backchannel.receive()
+            child_argv = backchannel.receive()
             assert child_argv == [child, "--arg1", "--arg2", "--arg3"]