Repository: microsoft/ptvsd

Issue:
Output redirection is slow
Older version of PTVSD would redirect the stdout and stderr as the data was written into those streams. In the new versions there's a delay of around 300ms.

This introduces a slight delay in:
* Running a program to completion
E.g. running a program with a simple print statement takes a noticeable amount of time to complete.
* Similarly, when running a program to completion without debugging we notice the same delay

Once again, this is because of the fact that the new debugger polls the `new output` buffers for data every 300ms.

Can we implement the `push` approach the older PTVSD version had, instead of the current `pull` approach. That would make the UX more responsive.


Discussion:

Resolving patch:
diff --git a/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_io.py b/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_io.py
--- a/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_io.py
+++ b/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_io.py
@@ -3,28 +3,41 @@
 IS_PY3K = pydevd_constants.IS_PY3K
 
 class IORedirector:
-    '''This class works to redirect the write function to many streams
     '''
+    This class works to wrap a stream (stdout/stderr) with an additional redirect.
+    '''
+
+    def __init__(self, original, new_redirect, wrap_buffer=False):
+        '''
+        :param stream original:
+            The stream to be wrapped (usually stdout/stderr).
+
+        :param stream new_redirect:
+            Usually IOBuf (below).
 
-    def __init__(self, *args):
-        self._redirectTo = args
+        :param bool wrap_buffer:
+            Whether to create a buffer attribute (needed to mimick python 3 s
+            tdout/stderr which has a buffer to write binary data).
+        '''
+        self._redirect_to = (original, new_redirect)
+        if wrap_buffer:
+            self.buffer = IORedirector(original.buffer, new_redirect.buffer, False)
 
     def write(self, s):
-        for r in self._redirectTo:
-            try:
-                r.write(s)
-            except:
-                pass
+        # Note that writing to the original stream may fail for some reasons
+        # (such as trying to write something that's not a string or having it closed).
+        for r in self._redirect_to:
+            r.write(s)
 
     def isatty(self):
-        return False
+        return self._redirect_to[0].isatty()
 
     def flush(self):
-        for r in self._redirectTo:
+        for r in self._redirect_to:
             r.flush()
 
     def __getattr__(self, name):
-        for r in self._redirectTo:
+        for r in self._redirect_to:
             if hasattr(r, name):
                 return getattr(r, name)
         raise AttributeError(name)
@@ -40,14 +53,18 @@ def __init__(self):
         self.encoding = os.environ.get('PYTHONIOENCODING', 'utf-8')
 
     def getvalue(self):
-        b = (bytes.decode(v, self.encoding) if isinstance(v, bytes) else v for v in self.buflist)
-        self.buflist = [] #clear it
-        return ''.join(b)
+        b = self.buflist
+        self.buflist = []  # clear it
+        return ''.join(b)  # bytes on py2, str on py3.
     
     def write(self, s):
         if not IS_PY3K:
             if isinstance(s, unicode):
-                s = s.encode(self.encoding)
+                # can't use 'errors' as kwargs in py 2.6
+                s = s.encode(self.encoding, 'replace')
+        else:
+            if isinstance(s, bytes):
+                s = s.decode(self.encoding, errors='replace')
         self.buflist.append(s)
 
     def isatty(self):
@@ -82,7 +99,7 @@ def start_redirect(keep_original_redirection=False, std='stdout'):
         stack.append(original)
 
         if keep_original_redirection:
-            setattr(sys, std, IORedirector(buf, getattr(sys, std)))
+            setattr(sys, std, IORedirector(getattr(sys, std), buf))
         else:
             setattr(sys, std, buf)
     return buf
diff --git a/ptvsd/_vendored/pydevd/pydevd.py b/ptvsd/_vendored/pydevd/pydevd.py
--- a/ptvsd/_vendored/pydevd/pydevd.py
+++ b/ptvsd/_vendored/pydevd/pydevd.py
@@ -14,7 +14,7 @@
 
 from _pydevd_bundle.pydevd_constants import IS_JYTH_LESS25, IS_PYCHARM, get_thread_id, \
     dict_keys, dict_iter_items, DebugInfoHolder, PYTHON_SUSPEND, STATE_SUSPEND, STATE_RUN, get_frame, xrange, \
-    clear_cached_thread_id, INTERACTIVE_MODE_AVAILABLE, SHOW_DEBUG_INFO_ENV, IS_PY34_OR_GREATER
+    clear_cached_thread_id, INTERACTIVE_MODE_AVAILABLE, SHOW_DEBUG_INFO_ENV, IS_PY34_OR_GREATER, IS_PY2
 from _pydev_bundle import fix_getpass
 from _pydev_bundle import pydev_imports, pydev_log
 from _pydev_bundle._pydev_filesystem_encoding import getfilesystemencoding
@@ -150,8 +150,7 @@ def _on_run(self):
 
         while not self.killReceived:
             time.sleep(0.3)
-            if not self.py_db.has_threads_alive() and self.py_db.writer.empty() \
-                    and not has_data_to_redirect():
+            if not self.py_db.has_threads_alive() and self.py_db.writer.empty():
                 try:
                     pydev_log.debug("No alive threads, finishing debug session")
                     self.py_db.finish_debugging_session()
@@ -369,27 +368,9 @@ def check_output_redirect(self):
 
         if bufferStdOutToServer:
             init_stdout_redirect()
-            self.check_output(sys.stdoutBuf, 1) #@UndefinedVariable
 
         if bufferStdErrToServer:
             init_stderr_redirect()
-            self.check_output(sys.stderrBuf, 2) #@UndefinedVariable
-
-    def check_output(self, out, outCtx):
-        '''Checks the output to see if we have to send some buffered output to the debug server
-
-        @param out: sys.stdout or sys.stderr
-        @param outCtx: the context indicating: 1=stdout and 2=stderr (to know the colors to write it)
-        '''
-
-        try:
-            v = out.getvalue()
-
-            if v:
-                cmd = self.cmd_factory.make_io_message(v, outCtx)
-                self.writer.add_command(cmd)
-        except:
-            traceback.print_exc()
 
 
     def init_matplotlib_in_debug_console(self):
@@ -1086,7 +1067,8 @@ def run(self, file, globals=None, locals=None, is_module=False, set_trace=True):
         self.notify_thread_created(thread_id, t)
 
         # Note: important: set the tracing right before calling _exec.
-        pydevd_tracing.SetTrace(self.trace_dispatch, self.frame_eval_func, self.dummy_trace_dispatch)
+        if set_trace:
+            pydevd_tracing.SetTrace(self.trace_dispatch, self.frame_eval_func, self.dummy_trace_dispatch)
         
         return self._exec(is_module, entry_point_fn, module_name, file, globals, locals)
         
@@ -1168,28 +1150,73 @@ def usage(doExit=0):
         sys.exit(0)
 
 
-def init_stdout_redirect():
-    if not getattr(sys, 'stdoutBuf', None):
-        sys.stdoutBuf = pydevd_io.IOBuf()
-        sys.stdout_original = sys.stdout
-        sys.stdout = pydevd_io.IORedirector(sys.stdout, sys.stdoutBuf) #@UndefinedVariable
-
-def init_stderr_redirect():
-    if not getattr(sys, 'stderrBuf', None):
-        sys.stderrBuf = pydevd_io.IOBuf()
-        sys.stderr_original = sys.stderr
-        sys.stderr = pydevd_io.IORedirector(sys.stderr, sys.stderrBuf) #@UndefinedVariable
-
+class _CustomWriter(object):
+    
+    def __init__(self, out_ctx, wrap_stream, wrap_buffer, on_write=None):
+        '''
+        :param out_ctx:
+            1=stdout and 2=stderr
+            
+        :param wrap_stream:
+            Either sys.stdout or sys.stderr.
+            
+        :param bool wrap_buffer:
+            If True the buffer attribute (which wraps writing bytes) should be
+            wrapped.
+
+        :param callable(str) on_write:
+            May be a custom callable to be called when to write something.
+            If not passed the default implementation will create an io message
+            and send it through the debugger.
+        '''
+        self.encoding = getattr(wrap_stream, 'encoding', os.environ.get('PYTHONIOENCODING', 'utf-8'))
+        self._out_ctx = out_ctx
+        if wrap_buffer:
+            self.buffer = _CustomWriter(out_ctx, wrap_stream, wrap_buffer=False, on_write=on_write)
+        self._on_write = on_write
+            
+    def flush(self):
+        pass  # no-op here
+    
+    def write(self, s):
+        if self._on_write is not None:
+            self._on_write(s)
+            return
 
-def has_data_to_redirect():
-    if getattr(sys, 'stdoutBuf', None):
-        if not sys.stdoutBuf.empty():
-            return True
-    if getattr(sys, 'stderrBuf', None):
-        if not sys.stderrBuf.empty():
-            return True
+        if s:
+            if IS_PY2:
+                # Need s in bytes
+                if isinstance(s, unicode):
+                    s = s.encode('utf-8', errors='replace')
+            else:
+                # Need s in str
+                if isinstance(s, bytes):
+                    s = s.decode(self.encoding, errors='replace')
+            
+            py_db = get_global_debugger()
+            if py_db is not None:
+                # Note that the actual message contents will be a xml with utf-8, although
+                # the entry is str on py3 and bytes on py2.
+                cmd = py_db.cmd_factory.make_io_message(s, self._out_ctx)
+                py_db.writer.add_command(cmd)
+
+
+def init_stdout_redirect(on_write=None):
+    if not hasattr(sys, '_pydevd_out_buffer_'):
+        wrap_buffer = True if not IS_PY2 else False
+        original = sys.stdout
+        sys._pydevd_out_buffer_ = _CustomWriter(1, original, wrap_buffer, on_write)
+        sys.stdout_original = original
+        sys.stdout = pydevd_io.IORedirector(original, sys._pydevd_out_buffer_, wrap_buffer) #@UndefinedVariable
+
+def init_stderr_redirect(on_write=None):
+    if not hasattr(sys, '_pydevd_err_buffer_'):
+        wrap_buffer = True if not IS_PY2 else False
+        original = sys.stderr
+        sys._pydevd_err_buffer_ = _CustomWriter(2, original, wrap_buffer, on_write)
+        sys.stderr_original = original
+        sys.stderr = pydevd_io.IORedirector(original, sys._pydevd_err_buffer_, wrap_buffer) #@UndefinedVariable
 
-    return False
 
 #=======================================================================================================================
 # settrace
diff --git a/ptvsd/runner.py b/ptvsd/runner.py
--- a/ptvsd/runner.py
+++ b/ptvsd/runner.py
@@ -3,15 +3,12 @@
 # for license information.
 
 import pydevd
-import sys
 import time
 import threading
-import traceback
 
 from ptvsd.daemon import DaemonBase
 from ptvsd.session import DebugSession
-from ptvsd.wrapper import (
-    WAIT_FOR_THREAD_FINISH_TIMEOUT, VSCLifecycleMsgProcessor)
+from ptvsd.wrapper import VSCLifecycleMsgProcessor
 from pydevd import init_stdout_redirect, init_stderr_redirect
 
 
@@ -40,56 +37,6 @@ def run(address, filename, is_module, *args, **kwargs):
     time.sleep(OUTPUT_POLL_PERIOD + 0.1)
 
 
-class OutputRedirection(object):
-    # TODO: docstring
-
-    def __init__(self, on_output=lambda category, output: None):
-        self._on_output = on_output
-        self._stopped = False
-        self._thread = None
-
-    def start(self):
-        # TODO: docstring
-        init_stdout_redirect()
-        init_stderr_redirect()
-        self._thread = threading.Thread(
-            target=self._run, name='ptvsd.output.redirection')
-        self._thread.pydev_do_not_trace = True
-        self._thread.is_pydev_daemon_thread = True
-        self._thread.daemon = True
-        self._thread.start()
-
-    def stop(self):
-        # TODO: docstring
-        if self._stopped:
-            return
-
-        self._stopped = True
-        self._thread.join(WAIT_FOR_THREAD_FINISH_TIMEOUT)
-
-    def _run(self):
-        while not self._stopped:
-            self._check_output(sys.stdoutBuf, 'stdout')
-            self._check_output(sys.stderrBuf, 'stderr')
-            time.sleep(OUTPUT_POLL_PERIOD)
-
-    def _check_output(self, out, category):
-        '''Checks the output to see if we have to send some buffered,
-        output to the debug server
-
-        @param out: sys.stdout or sys.stderr
-        @param category: stdout or stderr
-        '''
-
-        try:
-            v = out.getvalue()
-
-            if v:
-                self._on_output(category, v)
-        except Exception:
-            traceback.print_exc()
-
-
 class Daemon(DaemonBase):
     """The process-level manager for the VSC protocol debug adapter."""
 
@@ -110,12 +57,24 @@ def wait_for_launch(self, addr, timeout=LAUNCH_TIMEOUT):
         return launched.wait(timeout)
 
     def _start(self):
-        self._output_monitor = OutputRedirection(self._send_output)
-        self._output_monitor.start()
+        import weakref
+        weak_self = weakref.ref(self)  # Avoid cyclic ref
+
+        def on_stdout(msg):
+            self = weak_self()
+            if self is not None:
+                self._send_output('stdout', msg)
+
+        def on_stderr(msg):
+            self = weak_self()
+            if self is not None:
+                self._send_output('stderr', msg)
+
+        init_stdout_redirect(on_stdout)
+        init_stderr_redirect(on_stderr)
         return NoSocket()
 
     def _close(self):
-        self._output_monitor.stop()
         super(Daemon, self)._close()
 
     def _send_output(self, category, output):


Test patch:
diff --git a/ptvsd/_vendored/pydevd/tests/test_pydev_ipython_011.py b/ptvsd/_vendored/pydevd/tests/test_pydev_ipython_011.py
--- a/ptvsd/_vendored/pydevd/tests/test_pydev_ipython_011.py
+++ b/ptvsd/_vendored/pydevd/tests/test_pydev_ipython_011.py
@@ -145,7 +145,9 @@ def testQuestionMark_1(self):
         self.redirect_stdout()
         try:
             self.add_exec('?')
-            assert len(sys.stdout.getvalue()) > 1000, 'IPython help should be pretty big'
+            found = sys.stdout.getvalue()
+            if len(found) < 1000:
+                raise AssertionError('Expected IPython help to be big. Found: %s' % (found,))
         finally:
             self.restore_stdout()
 
@@ -154,7 +156,9 @@ def testQuestionMark_2(self):
         self.redirect_stdout()
         try:
             self.add_exec('int?')
-            assert sys.stdout.getvalue().find('Convert') != -1
+            found = sys.stdout.getvalue()
+            if 'Convert' not in found:
+                raise AssertionError('Expected to find "Convert" in %s' % (found,))
         finally:
             self.restore_stdout()
 
diff --git a/ptvsd/_vendored/pydevd/tests_python/_debugger_case_redirect.py b/ptvsd/_vendored/pydevd/tests_python/_debugger_case_redirect.py
new file mode 100644
--- /dev/null
+++ b/ptvsd/_vendored/pydevd/tests_python/_debugger_case_redirect.py
@@ -0,0 +1,24 @@
+# coding:utf-8
+if __name__ == '__main__':
+    import sys
+    for stream_name in ('stdout', 'stderr'):
+        stream = getattr(sys, stream_name)
+        if sys.version_info[0] == 2 and sys.version_info[1] == 6:
+            stream.write('text\n')
+        else:
+            stream.write(u'text\n')  # Can't write unicode on py 2.6
+        stream.write('binary or text\n')
+        stream.write('ação1\n')
+
+        if sys.version_info[0] >= 3:
+            # sys.stdout.buffer is only available on py3.   
+            stream.buffer.write(b'binary\n')
+            # Note: this will be giberish on the receiving side because when writing bytes
+            # we can't be sure what's the encoding and will treat it as utf-8 (i.e.:
+            # uses PYTHONIOENCODING).
+            stream.buffer.write('ação2\n'.encode(encoding='latin1'))
+            
+            # This will be ok
+            stream.buffer.write('ação3\n'.encode(encoding='utf-8'))
+
+    print('TEST SUCEEDED!')
\ No newline at end of file
diff --git a/ptvsd/_vendored/pydevd/tests_python/debugger_unittest.py b/ptvsd/_vendored/pydevd/tests_python/debugger_unittest.py
--- a/ptvsd/_vendored/pydevd/tests_python/debugger_unittest.py
+++ b/ptvsd/_vendored/pydevd/tests_python/debugger_unittest.py
@@ -71,6 +71,7 @@
 CMD_STEP_INTO_MY_CODE = 144
 CMD_GET_CONCURRENCY_EVENT = 145
 
+CMD_REDIRECT_OUTPUT = 200
 CMD_GET_NEXT_STATEMENT_TARGETS = 201
 CMD_SET_PROJECT_ROOTS = 202
 
@@ -237,7 +238,7 @@ def read(stream, buffer):
                     if finish[0]:
                         return
                     if IS_PY3K:
-                        line = line.decode('utf-8')
+                        line = line.decode('utf-8', errors='replace')
 
                     if SHOW_STDOUT:
                         sys.stdout.write('stdout: %s' % (line,))
@@ -418,6 +419,23 @@ def wait_for_new_thread(self):
         thread_id = splitted[3]
         return thread_id
 
+    def wait_for_output(self):
+        # Something as:
+        # <xml><io s="TEST SUCEEDED%2521" ctx="1"/></xml>
+        while True:
+            msg = self.reader_thread.get_next_message('wait_output')
+            if "<xml><io s=" in msg:
+                if 'ctx="1"' in msg:
+                    ctx='stdout'
+                elif 'ctx="2"' in msg:
+                    ctx='stderr'
+                else:
+                    raise AssertionError('IO message without ctx.')
+                    
+                msg = unquote_plus(unquote_plus(msg.split('"')[1]))
+                return msg, ctx
+
+        
     def wait_for_breakpoint_hit(self, *args, **kwargs):
         return self.wait_for_breakpoint_hit_with_suspend_type(*args, **kwargs)[:-1]
 
@@ -573,6 +591,9 @@ def write_set_py_exception_globals(
         )))
         self.log.append('write_set_py_exception_globals')
 
+    def write_start_redirect(self):
+        self.write("%s\t%s\t%s" % (CMD_REDIRECT_OUTPUT, self.next_seq(), 'STDERR STDOUT'))
+
     def write_set_project_roots(self, project_roots):
         self.write("%s\t%s\t%s" % (CMD_SET_PROJECT_ROOTS, self.next_seq(), '\t'.join(str(x) for x in project_roots)))
         
diff --git a/ptvsd/_vendored/pydevd/tests_python/test_debugger.py b/ptvsd/_vendored/pydevd/tests_python/test_debugger.py
--- a/ptvsd/_vendored/pydevd/tests_python/test_debugger.py
+++ b/ptvsd/_vendored/pydevd/tests_python/test_debugger.py
@@ -1,3 +1,4 @@
+#coding: utf-8
 '''
     The idea is that we record the commands sent to the debugger and reproduce them from this script
     (so, this works as the client, which spawns the debugger as a separate process and communicates
@@ -106,7 +107,7 @@ def run(self):
 
         self.write_get_next_statement_targets(thread_id, frame_id)
         targets = self.wait_for_get_next_statement_targets()
-        expected = {2, 3, 5, 8, 9, 10, 12, 13, 14, 15, 17, 18, 19, 21}
+        expected = set((2, 3, 5, 8, 9, 10, 12, 13, 14, 15, 17, 18, 19, 21))
         assert targets == expected, 'Expected targets to be %s, was: %s' % (expected, targets)
 
         self.write_remove_breakpoint(breakpoint_id)
@@ -1489,6 +1490,56 @@ def run(self):
 
         self.finished_ok = True
 
+#=======================================================================================================================
+# WriterThreadCaseRedirectOutput
+#======================================================================================================================
+class WriterThreadCaseRedirectOutput(debugger_unittest.AbstractWriterThread):
+
+    TEST_FILE = debugger_unittest._get_debugger_test_file('_debugger_case_redirect.py')
+
+    def get_environ(self):
+        env = os.environ.copy()
+
+        env["PYTHONIOENCODING"] = 'utf-8'
+        return env
+    
+    def run(self):
+        # Note: writes to stdout and stderr are now synchronous (so, the order
+        # must always be consistent and there's a message for each write).
+        expected = [
+            'text\n',
+            'binary or text\n',
+            'ação1\n',
+        ]
+        
+        if sys.version_info[0] >= 3:
+            expected.extend((
+                'binary\n',
+                'ação2\n'.encode(encoding='latin1').decode('utf-8', 'replace'),
+                'ação3\n',
+            ))
+        
+        new_expected = [(x, 'stdout') for x in expected]
+        new_expected.extend([(x, 'stderr') for x in expected])
+        
+        
+        self.start_socket()
+        self.write_start_redirect()
+            
+        self.write_make_initial_run()
+        msgs = []
+        while len(msgs) < len(new_expected):
+            msg = self.wait_for_output()
+            if msg not in new_expected:
+                continue
+            msgs.append(msg)
+        
+        if msgs != new_expected:
+            print(msgs)
+            print(new_expected)
+        assert msgs == new_expected
+        self.finished_ok = True
+
 
 #=======================================================================================================================
 # Test
@@ -1688,6 +1739,9 @@ def test_case_handled_exceptions2(self):
         
     def test_case_handled_exceptions3(self):
         self.check_case(WriterThreadCaseHandledExceptions3)
+        
+    def test_redirect_output(self):
+        self.check_case(WriterThreadCaseRedirectOutput)
 
 @pytest.mark.skipif(not IS_CPYTHON, reason='CPython only test.')
 class TestPythonRemoteDebugger(unittest.TestCase, debugger_unittest.DebuggerRunner):
diff --git a/tests/helpers/message.py b/tests/helpers/message.py
--- a/tests/helpers/message.py
+++ b/tests/helpers/message.py
@@ -31,3 +31,32 @@ def raw_write_one(write, body, stop=noop, **headers):
         write_header(write, name, value, stop=stop)
     write_all(write, b'\r\n')
     write_all(write, body)
+
+
+def assert_messages_equal(received, expected):
+    if received != expected:
+        try:
+            from itertools import zip_longest
+        except ImportError:
+            from itertools import izip_longest as zip_longest
+
+        msg = ['']
+        msg.append('Received:')
+        for r in received:
+            msg.append(str(r))
+        msg.append('')
+
+        msg.append('Expected:')
+        for r in expected:
+            msg.append(str(r))
+        msg.append('')
+
+        msg.append('Diff by line')
+        for i, (a, b) in enumerate(
+            zip_longest(received, expected, fillvalue=None)):
+            if a == b:
+                msg.append(' %2d:  %s' % (i, a,))
+            else:
+                msg.append('!%2d: %s != %s' % (i, a, b))
+
+        raise AssertionError('\n'.join(msg))
diff --git a/tests/highlevel/__init__.py b/tests/highlevel/__init__.py
--- a/tests/highlevel/__init__.py
+++ b/tests/highlevel/__init__.py
@@ -878,33 +878,11 @@ def _new_fixture(self, new_daemon):
         return self.FIXTURE(new_daemon)
 
     def assert_vsc_received(self, received, expected):
-        try:
-            from itertools import zip_longest
-        except ImportError:
-            from itertools import izip_longest as zip_longest
+        from tests.helpers.message import assert_messages_equal
+
         received = list(self.vsc.protocol.parse_each(received))
         expected = list(self.vsc.protocol.parse_each(expected))
-        if received != expected:
-            msg = ['']
-            msg.append('Received:')
-            for r in received:
-                msg.append(str(r))
-            msg.append('')
-
-            msg.append('Expected:')
-            for r in expected:
-                msg.append(str(r))
-            msg.append('')
-
-            msg.append('Diff by line')
-            for i, (a, b) in enumerate(
-                zip_longest(received, expected, fillvalue=None)):
-                if a == b:
-                    msg.append(' %2d:  %s' % (i, a,))
-                else:
-                    msg.append('!%2d: %s != %s' % (i, a, b))
-
-            self.fail('\n'.join(msg))
+        assert_messages_equal(received, expected)
 
     def assert_vsc_failure(self, received, expected, req):
         received = list(self.vsc.protocol.parse_each(received))
diff --git a/tests/highlevel/test_live_pydevd.py b/tests/highlevel/test_live_pydevd.py
--- a/tests/highlevel/test_live_pydevd.py
+++ b/tests/highlevel/test_live_pydevd.py
@@ -516,9 +516,10 @@ def test_basic(self):
                         },
                     ],
                 })
-            with self.vsc.wait_for_event('output'):
-                with self.vsc.wait_for_event('thread'):
-                    req_config = self.send_request('configurationDone')
+            with self.vsc.wait_for_event('output'):  # 1+2=3
+                with self.vsc.wait_for_event('output'):  # \n
+                    with self.vsc.wait_for_event('thread'):
+                        req_config = self.send_request('configurationDone')
 
             wait()
             received = self.vsc.received
@@ -551,6 +552,10 @@ def test_basic(self):
             self.new_event('thread', reason='started', threadId=1),
             self.new_event('output', **dict(
                 category='stdout',
-                output='1+2=3\n',
+                output='1+2=3',
+            )),
+            self.new_event('output', **dict(
+                category='stdout',
+                output='\n',
             )),
         ])
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
@@ -196,9 +196,10 @@ def handle_msg(msg):
         return handlers, (lambda: wait(reason="event 'output'"))
 
     def assert_received(self, received, expected):
+        from tests.helpers.message import assert_messages_equal
         received = [parse_message(msg) for msg in received]
         expected = [parse_message(msg) for msg in expected]
-        self.assertEqual(received, expected)
+        assert_messages_equal(received, expected)
 
     def new_version_event(self, received):
         version = ptvsd.__version__
@@ -240,14 +241,18 @@ def test_launch_ptvsd_client(self):
                 argv,
                 script,
             )
-            with session.wait_for_event('thread'):
-                (req_initialize, req_launch, req_config
-                 ) = lifecycle_handshake(session, 'launch')
+            with session.wait_for_event('exited'):
+                with session.wait_for_event('thread'):
+                    (req_initialize, req_launch, req_config
+                     ) = lifecycle_handshake(session, 'launch')
 
-            done()
-            adapter.wait()
+                done()
+                adapter.wait()
 
-        self.assert_received(session.received[:9], [
+        # Skipping the 'thread exited' and 'terminated' messages which
+        # may appear randomly in the received list.
+        received = session.received[:8]
+        self.assert_received(received, [
             self.new_version_event(session.received),
             self.new_response(req_initialize, **INITIALIZE_RESPONSE),
             self.new_event('initialized'),
@@ -261,7 +266,6 @@ def test_launch_ptvsd_client(self):
             }),
             self.new_event('thread', reason='started', threadId=1),
             self.new_event('exited', exitCode=0),
-            self.new_event('terminated'),
         ])
 
     def test_launch_ptvsd_server(self):
@@ -501,9 +505,18 @@ def test_nodebug(self):
             self.new_response(req_initialize, **INITIALIZE_RESPONSE),
             self.new_event('initialized'),
             self.new_response(req_launch),
+            self.new_event('output',
+                           output='+ before',
+                           category='stdout'),
+            self.new_event('output',
+                           output='\n',
+                           category='stdout'),
             self.new_response(req_config),
             self.new_event('output',
-                           output='+ before\n+ after\n',
+                           output='+ after',
+                           category='stdout'),
+            self.new_event('output',
+                           output='\n',
                            category='stdout'),
             self.new_event('exited', exitCode=0),
             self.new_event('terminated'),