Repository: microsoft/ptvsd

Issue:
Detaching from a remote debugger leaves the program in a bad state
Assume we're remotely debugging a block of code that contains a for loop.
* Add a breakpoint inside the for loop
* Detach the debugger
The program is no longer running even though the debugger is disconnected.

I'd expect the program to continue running once the debugger disconnects.
The old debugger seems to do the same thing (however that could be a problem with the implementation of the older version of PTVSD in VSC).


Discussion:
This is also this issue that occurs on detach: https://github.com/Microsoft/ptvsd/issues/236

Detach on local also stops the program: https://github.com/Microsoft/ptvsd/issues/86
@ericsnowcurrently - does Atatch/Detach merge fix this?
The change I made shouldn't trigger the program to continue.  @DonJayamanne can verify.
Closing then, please reopen if needed
Reopening as this is still a bug.

> The change I made shouldn't trigger the program to continue. @DonJayamanne can verify.

Sorry I didn't verify this.

@ericsnowcurrently, `it SHOULD trigger the program to continue`

In #521 I have stubbed out the hook that will be needed to resolve this: `VSCLifecycleMsgProcessor._handle_detached`.

Resolving patch:
diff --git a/ptvsd/_util.py b/ptvsd/_util.py
--- a/ptvsd/_util.py
+++ b/ptvsd/_util.py
@@ -47,6 +47,36 @@ def call_all(callables, *args, **kwargs):
     return results
 
 
+########################
+# pydevd stuff
+
+from _pydevd_bundle import pydevd_comm  # noqa
+
+
+def log_pydevd_msg(cmdid, seq, args, inbound,
+                   log=debug, prefix=None, verbose=False):
+    """Log a representation of the given pydevd msg."""
+    if log is None or (log is debug and not DEBUG):
+        return
+    if not verbose and cmdid == pydevd_comm.CMD_WRITE_TO_CONSOLE:
+        return
+
+    if prefix is None:
+        prefix = '-> ' if inbound else '<- '
+    try:
+        cmdname = pydevd_comm.ID_TO_MEANING[str(cmdid)]
+    except KeyError:
+        for cmdname, value in vars(pydevd_comm).items():
+            if cmdid == value:
+                break
+        else:
+            cmdname = '???'
+    cmd = '{} ({})'.format(cmdid, cmdname)
+    args = args.replace('\n', '\\n')
+    msg = '{}{:28} [{:>10}]: |{}|'.format(prefix, cmd, seq, args)
+    log(msg)
+
+
 ########################
 # threading stuff
 
diff --git a/ptvsd/wrapper.py b/ptvsd/wrapper.py
--- a/ptvsd/wrapper.py
+++ b/ptvsd/wrapper.py
@@ -383,6 +383,7 @@ def send(self, data):
         cmd_id, seq, args = data.split('\t', 2)
         cmd_id = int(cmd_id)
         seq = int(seq)
+        _util.log_pydevd_msg(cmd_id, seq, args, inbound=True)
         with self.lock:
             loop, fut = self.requests.pop(seq, (None, None))
         if fut is None:
@@ -405,12 +406,14 @@ def make_packet(self, cmd_id, args):
 
     def pydevd_notify(self, cmd_id, args):
         # TODO: docstring
-        _, s = self.make_packet(cmd_id, args)
+        seq, s = self.make_packet(cmd_id, args)
+        _util.log_pydevd_msg(cmd_id, seq, args, inbound=False)
         os.write(self.pipe_w, s.encode('utf8'))
 
     def pydevd_request(self, loop, cmd_id, args):
         # TODO: docstring
         seq, s = self.make_packet(cmd_id, args)
+        _util.log_pydevd_msg(cmd_id, seq, args, inbound=False)
         fut = loop.create_future()
         with self.lock:
             self.requests[seq] = loop, fut
@@ -854,7 +857,7 @@ def process_messages():
                 self._listening = threading.Lock()
             try:
                 self.process_messages()
-            except (EOFError, TimeoutError):
+            except (EOFError, TimeoutError) as exc:
                 debug('client socket closed')
                 with self._connlock:
                     _util.lock_release(self._listening)
@@ -1081,8 +1084,9 @@ def on_disconnect(self, request, args):
         self.send_response(request)
         self._set_disconnected()
 
-        if self.start_reason == 'attach' and not self._debuggerstopped:
-            self._handle_detach()
+        if self.start_reason == 'attach':
+            if not self._debuggerstopped:
+                self._handle_detach()
         # TODO: We should be able drop the "launch" branch.
         elif self.start_reason == 'launch':
             if not self._closed:
@@ -1187,6 +1191,7 @@ def __init__(self, socket, pydevd_notify, pydevd_request,
 
         # adapter state
         self.path_casing = PathUnNormcase()
+        self._detached = False
 
     def _start_event_loop(self):
         self.loop = futures.EventLoop()
@@ -1262,11 +1267,14 @@ def decorate(f):
 
     def on_pydevd_event(self, cmd_id, seq, args):
         # TODO: docstring
-        try:
-            f = self.pydevd_events[cmd_id]
-        except KeyError:
-            raise UnsupportedPyDevdCommandError(cmd_id)
-        return f(self, seq, args)
+        if not self._detached:
+            try:
+                f = self.pydevd_events[cmd_id]
+            except KeyError:
+                raise UnsupportedPyDevdCommandError(cmd_id)
+            return f(self, seq, args)
+        else:
+            return None
 
     @staticmethod
     def parse_xml_response(args):
@@ -1286,10 +1294,18 @@ def context():
         yield futures.Result(context())
 
     def _wait_for_pydevd_ready(self):
-        # TODO: Possibly send a request and wait for a response.
-        # See GH-448.
+        # TODO: Call self._ensure_pydevd_requests_handled?
         pass
 
+    def _ensure_pydevd_requests_handled(self):
+        # PyDevd guarantees that a response means all previous requests
+        # have been handled.  (PyDevd handles messages sequentially.)
+        # See GH-448.
+        #
+        # This is particularly useful for those requests that do not
+        # have responses (e.g. CMD_SET_BREAK).
+        return self._send_cmd_version_command()
+
     # VSC protocol handlers
 
     def _handle_configurationDone(self, args):
@@ -1392,9 +1408,39 @@ def _handle_launch(self, args):
         self._initialize_path_maps(args)
         yield self._send_cmd_version_command()
 
-    # TODO: Implement _handle_detach() (see GH-285):
-    #   Remove all breakpoints.
-    #   Resume if stopped.
+    def _handle_detach(self):
+        debug('detaching')
+        # TODO: Skip if already detached?
+        self._detached = True
+
+        self._clear_output_redirection()
+        self._reset_project_roots()
+        self._clear_breakpoints()
+        self.exceptions_mgr.remove_all_exception_breaks()
+        self._resume_all_threads()
+
+    def _clear_output_redirection(self):
+        self.pydevd_request(pydevd_comm.CMD_REDIRECT_OUTPUT, '')
+
+    def _reset_project_roots(self):
+        # We leave the "project roots" alone (see CMD_SET_PROJECT_ROOTS).
+        pass
+
+    def _clear_breakpoints(self, filename=None):
+        cmd = pydevd_comm.CMD_REMOVE_BREAK
+        for pyd_bpid, vsc_bpid in self.bp_map.pairs():
+            if filename is None:
+                filename = pyd_bpid[0]
+            elif pyd_bpid[0] != filename:
+                continue
+            msg = '{}\t{}\t{}'.format('python-line', filename, vsc_bpid)
+            self.pydevd_notify(cmd, msg)
+            self.bp_map.remove(pyd_bpid, vsc_bpid)
+        # TODO: Wait until the last request has been handled?
+
+    def _resume_all_threads(self):
+        for tid in self.stack_traces:
+            self.pydevd_notify(pydevd_comm.CMD_THREAD_RUN, tid)
 
     def send_process_event(self, start_method):
         # TODO: docstring
@@ -1982,6 +2028,7 @@ def on_setBreakpoints(self, request, args):
                 bp_type = 'jinja2-line'
 
         # First, we must delete all existing breakpoints in that source.
+        # TODO: Call self._clear_breakpoints(path) instead?
         cmd = pydevd_comm.CMD_REMOVE_BREAK
         for pyd_bpid, vsc_bpid in self.bp_map.pairs():
             if pyd_bpid[0] == path:
@@ -2024,6 +2071,7 @@ def on_setBreakpoints(self, request, args):
                 'verified': True,
                 'line': line,
             })
+        yield self._ensure_pydevd_requests_handled()
 
         if request is not None:
             self.send_response(request, breakpoints=bps)


Test patch:
diff --git a/tests/helpers/script.py b/tests/helpers/script.py
--- a/tests/helpers/script.py
+++ b/tests/helpers/script.py
@@ -66,6 +66,59 @@ def find_line(script, label):
 ########################
 # wait points
 
+def insert_release(script, lockfile, label=None):
+    """Return (script, wait func) after adding a done script to the original.
+
+    If a label is provided then the done script is inserted just before
+    the label.  Otherwise it is added to the end of the script.
+
+    The script will unblock the wait func at the label (or the end).
+    """
+    if isinstance(lockfile, str):
+        lockfile = Lockfile(lockfile)
+
+    donescript, wait = lockfile.wait_for_script()
+    if label is None:
+        script += donescript
+    else:
+        leading = []
+        lines = iter(script.splitlines())
+        for line, matched in iter_until_label(lines, label):
+            if matched:
+                leading.extend([
+                    donescript,
+                    line,
+                    '',  # Make sure the label is on its own line.
+                ])
+                break
+            leading.append(line)
+        # TODO: Use os.linesep?
+        script = '\n'.join(leading) + '\n'.join(lines)
+    return script, wait
+
+
+def set_release(filename, lockfile, label=None, script=None):
+    """Return (script, wait func) after adding a done script to the original.
+
+    In addition to the functionality of insert_release(), this function
+    writes the resulting script to the given file.  If no original
+    script is given then it is read from the file.
+    """
+    if script is None:
+        if not os.path.exists(filename):
+            raise ValueError(
+                'invalid filename {!r} (file missing)'.format(filename))
+        with open(filename) as scriptfile:
+            script = scriptfile.read()
+
+    script, wait = insert_release(script, lockfile, label)
+
+    with open(filename, 'w') as scriptfile:
+        scriptfile.write(script)
+
+    return script, wait
+
+
 def insert_lock(script, lockfile, label=None, timeout=5):
     """Return (done func, script) after adding a wait script to the original.
 
diff --git a/tests/highlevel/__init__.py b/tests/highlevel/__init__.py
--- a/tests/highlevel/__init__.py
+++ b/tests/highlevel/__init__.py
@@ -747,7 +747,7 @@ def wait_for_events(self, events):
 
     @contextlib.contextmanager
     def expect_debugger_command(self, cmdid):
-        with self._pydevd.expected_command(cmdid):
+        with self._pydevd.expect_command(cmdid):
             yield
 
     def set_debugger_response(self, cmdid, payload, **kwargs):
@@ -820,8 +820,8 @@ def suspend(self, thread, reason, *stack):
         with self.wait_for_event('stopped'):
             if isinstance(reason, Exception):
                 exc = reason
-                self._pydevd.send_caught_exception_events(thread, exc, *stack)
                 self._pydevd.set_exception_var_response(exc)
+                self._pydevd.send_caught_exception_events(thread, exc, *stack)
             else:
                 self._pydevd.send_suspend_event(thread, reason, *stack)
 
diff --git a/tests/highlevel/test_messages.py b/tests/highlevel/test_messages.py
--- a/tests/highlevel/test_messages.py
+++ b/tests/highlevel/test_messages.py
@@ -28,6 +28,7 @@
     CMD_THREAD_KILL,
     CMD_THREAD_RUN,
     CMD_THREAD_SUSPEND,
+    CMD_VERSION,
     CMD_WRITE_TO_CONSOLE,
     CMD_STEP_INTO_MY_CODE,
 )
@@ -999,6 +1000,7 @@ def test_initial(self):
                 '1\tpython-line\tspam.py\t10\tNone\tNone\tNone\tNone\tNone'),
             self.expected_pydevd_request(
                 '2\tpython-line\tspam.py\t15\tNone\ti == 3\tNone\tNone\tNone'),
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
     def test_with_hit_condition(self):
@@ -1054,6 +1056,7 @@ def test_with_hit_condition(self):
              '4\tpython-line\tspam.py\t25\tNone\tNone\tNone\t@HIT@ % 5 == 0\tNone'), # noqa
             self.expected_pydevd_request(
                 '5\tpython-line\tspam.py\t30\tNone\tNone\tNone\tx\tNone'),
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
     def test_with_logpoint(self):
@@ -1102,6 +1105,7 @@ def test_with_logpoint(self):
                 '3\tpython-line\tspam.py\t20\tNone\tNone\t"{}".format(a)\tNone\tTrue'), # noqa
             self.expected_pydevd_request(
              '4\tpython-line\tspam.py\t25\tNone\tNone\t"{}+{}=Something".format(a, b)\tNone\tTrue'), # noqa
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
     def test_with_existing(self):
@@ -1112,13 +1116,14 @@ def test_with_existing(self):
                     '1\tpython-line\tspam.py\t10\tNone\tNone\tNone\tNone\tNone') # noqa
                 p2 = self.expected_pydevd_request(
                     '2\tpython-line\tspam.py\t17\tNone\tNone\tNone\tNone\tNone') # noqa
-                self.fix.send_request('setBreakpoints', dict(
-                    source={'path': 'spam.py'},
-                    breakpoints=[
-                        {'line': '10'},
-                        {'line': '17'},
-                    ],
-                ))
+                with self.expect_debugger_command(CMD_VERSION):
+                    self.fix.send_request('setBreakpoints', dict(
+                        source={'path': 'spam.py'},
+                        breakpoints=[
+                            {'line': '10'},
+                            {'line': '17'},
+                        ],
+                    ))
                 self.wait_for_pydevd(p1, p2)
             self.send_request(
                 source={'path': 'spam.py'},
@@ -1160,11 +1165,12 @@ def test_with_existing(self):
         self.PYDEVD_CMD = CMD_SET_BREAK
         self.assert_received(self.debugger, removed + [
             self.expected_pydevd_request(
-                '3\tpython-line\tspam.py\t113\tNone\tNone\tNone\tNone\tNone'), # noqa
+                '3\tpython-line\tspam.py\t113\tNone\tNone\tNone\tNone\tNone'),
             self.expected_pydevd_request(
                 '4\tpython-line\tspam.py\t2\tNone\tNone\tNone\tNone\tNone'),
             self.expected_pydevd_request(
                 '5\tpython-line\tspam.py\t10\tNone\tNone\tNone\tNone\tNone'),
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
     def test_multiple_files(self):
@@ -1201,8 +1207,10 @@ def test_multiple_files(self):
         self.assert_received(self.debugger, [
             self.expected_pydevd_request(
                 '1\tpython-line\tspam.py\t10\tNone\tNone\tNone\tNone\tNone'),
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
             self.expected_pydevd_request(
                 '2\tpython-line\teggs.py\t17\tNone\tNone\tNone\tNone\tNone'),
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
     def test_vs_django(self):
@@ -1238,8 +1246,10 @@ def test_vs_django(self):
         self.assert_received(self.debugger, [
             self.expected_pydevd_request(
                 '1\tpython-line\tspam.py\t10\tNone\tNone\tNone\tNone\tNone'),
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
             self.expected_pydevd_request(
                 '2\tdjango-line\teggs.html\t17\tNone\tNone\tNone\tNone\tNone'), # noqa
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
     def test_vs_django_logpoint(self):
@@ -1275,8 +1285,10 @@ def test_vs_django_logpoint(self):
         self.assert_received(self.debugger, [
             self.expected_pydevd_request(
                 '1\tpython-line\tspam.py\t10\tNone\tNone\t' + repr("Hello World") + '\tNone\tTrue'), # noqa
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
             self.expected_pydevd_request(
                 '2\tdjango-line\teggs.html\t17\tNone\tNone\t' + repr("Hello Django World") + '\tNone\tTrue'), # noqa
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
     def test_vs_flask_jinja2(self):
@@ -1312,8 +1324,10 @@ def test_vs_flask_jinja2(self):
         self.assert_received(self.debugger, [
             self.expected_pydevd_request(
                 '1\tpython-line\tspam.py\t10\tNone\tNone\tNone\tNone\tNone'),
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
             self.expected_pydevd_request(
                 '2\tjinja2-line\teggs.html\t17\tNone\tNone\tNone\tNone\tNone'), # noqa
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
     def test_vs_flask_jinja2_logpoint(self):
@@ -1349,8 +1363,10 @@ def test_vs_flask_jinja2_logpoint(self):
         self.assert_received(self.debugger, [
             self.expected_pydevd_request(
                 '1\tpython-line\tspam.py\t10\tNone\tNone\t' + repr("Hello World") + '\tNone\tTrue'), # noqa
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
             self.expected_pydevd_request(
                 '2\tjinja2-line\teggs.html\t17\tNone\tNone\t' + repr("Hello Jinja World") + '\tNone\tTrue'), # noqa
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
     def test_vsc_flask_jinja2(self):
@@ -1386,8 +1402,10 @@ def test_vsc_flask_jinja2(self):
         self.assert_received(self.debugger, [
             self.expected_pydevd_request(
                 '1\tpython-line\tspam.py\t10\tNone\tNone\tNone\tNone\tNone'),
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
             self.expected_pydevd_request(
                 '2\tjinja2-line\teggs.html\t17\tNone\tNone\tNone\tNone\tNone'), # noqa
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
     def test_vsc_jinja2(self):
@@ -1423,8 +1441,10 @@ def test_vsc_jinja2(self):
         self.assert_received(self.debugger, [
             self.expected_pydevd_request(
                 '1\tpython-line\tspam.py\t10\tNone\tNone\tNone\tNone\tNone'),
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
             self.expected_pydevd_request(
                 '2\tjinja2-line\teggs.html\t17\tNone\tNone\tNone\tNone\tNone'), # noqa
+            self.debugger_msgs.new_request(CMD_VERSION, '1.1\tUNIX\tID'),
         ])
 
 
diff --git a/tests/system_tests/test_main.py b/tests/system_tests/test_main.py
--- a/tests/system_tests/test_main.py
+++ b/tests/system_tests/test_main.py
@@ -8,7 +8,8 @@
 from ptvsd.wrapper import INITIALIZE_RESPONSE  # noqa
 from tests.helpers.debugadapter import DebugAdapter
 from tests.helpers.debugclient import EasyDebugClient as DebugClient
-from tests.helpers.script import find_line, set_lock
+from tests.helpers.lock import LockTimeoutError
+from tests.helpers.script import find_line, set_lock, set_release
 from tests.helpers.threading import get_locked_and_waiter
 from tests.helpers.vsc import parse_message, VSCMessages
 from tests.helpers.workspace import Workspace, PathEntry
@@ -563,6 +564,215 @@ def test_reattach(self):
             self.new_event('terminated'),
         ])
 
+    def test_detach_clear_and_resume(self):
+        addr = Address('localhost', 8888)
+        filename = self.write_script('spam.py', """
+            import sys
+            sys.path.insert(0, {!r})
+            import ptvsd
+
+            # <start>
+
+            addr = {}
+            ptvsd.enable_attach(addr)
+
+            # <before>
+            print('==before==')
+
+            # <after>
+            print('==after==')
+
+            # <done>
+            """.format(ROOT, tuple(addr)))
+        lockfile1 = self.workspace.lockfile()
+        _, wait1 = set_release(filename, lockfile1, 'start')
+        lockfile2 = self.workspace.lockfile()
+        done1, _ = set_lock(filename, lockfile2, 'before')
+        lockfile3 = self.workspace.lockfile()
+        _, wait2 = set_release(filename, lockfile3, 'done')
+        lockfile4 = self.workspace.lockfile()
+        done2, script = set_lock(filename, lockfile4, 'done')
+
+        bp1 = find_line(script, 'before')
+        bp2 = find_line(script, 'after')
+
+        #DebugAdapter.VERBOSE = True
+        adapter = DebugAdapter.start_embedded(addr, filename)
+        with adapter:
+            wait1()
+            with DebugClient() as editor:
+                session1 = editor.attach_socket(addr, adapter, timeout=1)
+                #session1.VERBOSE = True
+                with session1.wait_for_event('thread') as result:
+                    with session1.wait_for_event('process'):
+                        (req_init1, req_attach1, req_config1,
+                         _, _, req_threads1,
+                         ) = lifecycle_handshake(session1, 'attach',
+                                                 threads=True)
+                tid1 = result['msg'].body['threadId']
+
+                req_bps = session1.send_request('setBreakpoints', **{
+                    'source': {'path': filename},
+                    'breakpoints': [
+                        {'line': bp1},
+                        {'line': bp2},
+                    ],
+                })
+                with session1.wait_for_event('stopped'):
+                    done1()
+                req_threads2 = session1.send_request('threads')
+                req_stacktrace1 = session1.send_request(
+                    'stackTrace',
+                    threadId=tid1,
+                )
+                out1 = str(adapter.output)
+
+                # Detach with execution stopped and 1 breakpoint left.
+                req_disconnect = session1.send_request('disconnect')
+                editor.detach(adapter)
+                try:
+                    wait2()
+                except LockTimeoutError:
+                    self.fail('execution never resumed upon detach '
+                              'or breakpoints never cleared')
+                out2 = str(adapter.output)
+
+                session2 = editor.attach_socket(addr, adapter, timeout=1)
+                #session2.VERBOSE = True
+                with session2.wait_for_event('thread') as result:
+                    with session2.wait_for_event('process'):
+                        (req_init2, req_attach2, req_config2,
+                         _, _, req_threads3,
+                         ) = lifecycle_handshake(session2, 'attach',
+                                                 threads=True)
+                tid2 = result['msg'].body['threadId']
+
+                done2()
+                adapter.wait()
+            out3 = str(adapter.output)
+
+        received = list(_strip_newline_output_events(session1.received))
+        self.assert_received(received, [
+            self.new_version_event(session1.received),
+            self.new_response(req_init1, **INITIALIZE_RESPONSE),
+            self.new_event('initialized'),
+            self.new_response(req_attach1),
+            self.new_event(
+                'thread',
+                threadId=tid1,
+                reason='started',
+            ),
+            self.new_response(req_threads1, **{
+                'threads': [{
+                    'id': 1,
+                    'name': 'MainThread',
+                }],
+            }),
+            self.new_response(req_config1),
+            self.new_event('process', **{
+                'isLocalProcess': True,
+                'systemProcessId': adapter.pid,
+                'startMethod': 'attach',
+                'name': filename,
+            }),
+            self.new_response(req_bps, **{
+                'breakpoints': [{
+                    'id': 1,
+                    'line': bp1,
+                    'verified': True,
+                }, {
+                    'id': 2,
+                    'line': bp2,
+                    'verified': True,
+                }],
+            }),
+            self.new_event(
+                'thread',
+                threadId=tid1,
+                reason='started',
+            ),
+            self.new_event(
+                'stopped',
+                threadId=tid1,
+                reason='breakpoint',
+                description=None,
+                text=None,
+            ),
+            self.new_response(req_threads2, **{
+                'threads': [{
+                    'id': 1,
+                    'name': 'MainThread',
+                }],
+            }),
+            self.new_event(
+                'module',
+                module={
+                    'id': 1,
+                    'name': '__main__',
+                    'path': filename,
+                    'package': None,
+                },
+                reason='new',
+            ),
+            self.new_response(req_stacktrace1, **{
+                'totalFrames': 1,
+                'stackFrames': [{
+                    'id': 1,
+                    'name': '<module>',
+                    'source': {
+                        'path': filename,
+                        'sourceReference': 1,
+                    },
+                    'line': bp1,
+                    'column': 1,
+                }],
+            }),
+            self.new_response(req_disconnect),
+        ])
+        self.messages.reset_all()
+        received = list(_strip_newline_output_events(session2.received))
+        # Sometimes the proc ends before the exited and terminated
+        # events are received.
+        received = list(_strip_exit(received))
+        self.assert_received(received, [
+            self.new_version_event(session2.received),
+            self.new_response(req_init2, **INITIALIZE_RESPONSE),
+            self.new_event('initialized'),
+            self.new_response(req_attach2),
+            self.new_event(
+                'thread',
+                threadId=tid2,
+                reason='started',
+            ),
+            self.new_response(req_threads3, **{
+                'threads': [{
+                    'id': 1,
+                    'name': 'MainThread',
+                }],
+            }),
+            self.new_response(req_config2),
+            self.new_event('process', **{
+                'isLocalProcess': True,
+                'systemProcessId': adapter.pid,
+                'startMethod': 'attach',
+                'name': filename,
+            }),
+            #self.new_event(
+            #    'thread',
+            #    threadId=tid2,
+            #    reason='exited',
+            #),
+            #self.new_event('exited', exitCode=0),
+            #self.new_event('terminated'),
+        ])
+        # at breakpoint
+        self.assertEqual(out1, '')
+        # after detaching
+        self.assertIn('==before==', out2)
+        self.assertIn('==after==', out2)
+        # after reattach
+        self.assertEqual(out3, out2)
+
     @unittest.skip('not implemented')
     def test_attach_exit_during_session(self):
         # TODO: Ensure we see the "terminated" and "exited" events.