Repository: microsoft/ptvsd

Issue:
timeline: compound expectations don't backtrack
Consider timeline:

    A B A

and expectation:

    B >> A

The expectation is supposed to be realized in the timeline, but the current implementation incorrectly says that it's not. The reason is that `>>` first tries to find the occurrence at which its right side is realized, and once found, checks if the ordering holds, and immediately bails out if it doesn't. In this case, there are two occurrences at which side is realized, and the first one (which does not follow B) gets matched first, so the check doesn't pass. A similar problem exists with `&` and `^`.

To implement this properly, `>>` needs to be able to backtrack - i.e. after getting the first occurrence of A, checking its ordering against B, and failing, it shouldn't immediately return False, but rather ask for the *next* occurrence of A, and repeat the check, until all occurrences of A are exhausted.
Spurious output in test runs
Because we create debug session via a pytest fixture, the output when the session gets finalized after test finishes running doesn't get captured as belonging to the test. Since start method is now parametrized separately anyway, there's no need to use the fixture for this anymore - the tests should just invoke `DebugSession(...)` directly, and it can clean up in `__del__`.


Discussion:

Resolving patch:
diff --git a/pytests/conftest.py b/pytests/conftest.py
--- a/pytests/conftest.py
+++ b/pytests/conftest.py
@@ -12,7 +12,6 @@
 
 from . import helpers
 from .helpers.printer import wait_for_output
-from .helpers.session import DebugSession
 
 
 @pytest.hookimpl(hookwrapper=True, tryfirst=True)
@@ -119,7 +118,7 @@ def factory(source):
 
         tmpfile = tmpdir.join(name + '.py')
         assert not tmpfile.check()
-        # NOTE: This is a requirement with using pyfile. Adding this 
+        # NOTE: This is a requirement with using pyfile. Adding this
         # makes it easier to add import start method
         assert 'import_and_enable_debugger' in source
         tmpfile.write(source)
@@ -152,22 +151,6 @@ def factory(source):
     ]
 
 
-@pytest.fixture
-def debug_session(request):
-    session = DebugSession()
-    try:
-        yield session
-        try:
-            failed = request.node.call_result.failed
-        except AttributeError:
-            pass
-        else:
-            if not failed:
-                session.wait_for_exit()
-    finally:
-        session.stop()
-
-
 @pytest.fixture(
     name='run_as',
     params=_RUN_AS_PARAMS
diff --git a/pytests/helpers/session.py b/pytests/helpers/session.py
--- a/pytests/helpers/session.py
+++ b/pytests/helpers/session.py
@@ -26,8 +26,6 @@
 from collections import namedtuple
 
 
-_Hit = namedtuple('_Hit', 'thread_stopped, stacktrace, thread_id, frame_id')
-
 PTVSD_ENABLE_KEY = 'PTVSD_ENABLE_ATTACH'
 PTVSD_HOST_KEY = 'PTVSD_TEST_HOST'
 PTVSD_PORT_KEY = 'PTVSD_TEST_PORT'
@@ -37,6 +35,8 @@ class DebugSession(object):
     WAIT_FOR_EXIT_TIMEOUT = 5
     BACKCHANNEL_TIMEOUT = 15
 
+    StopInfo = namedtuple('StopInfo', 'thread_stopped, stacktrace, thread_id, frame_id')
+
     def __init__(self, start_method='launch', ptvsd_port=None, pid=None):
         assert start_method in ('launch', 'attach_pid', 'attach_socket_cmdline', 'attach_socket_import')
         assert ptvsd_port is None or start_method.startswith('attach_socket_')
@@ -87,6 +87,19 @@ def __init__(self, start_method='launch', ptvsd_port=None, pid=None):
         self.expect_new = self.timeline.expect_new
         self.expect_realized = self.timeline.expect_realized
         self.all_occurrences_of = self.timeline.all_occurrences_of
+        self.observe_all = self.timeline.observe_all
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        was_final = self.timeline.is_final
+        self.close()
+        assert exc_type is not None or was_final, (
+            'Session timeline must be finalized before session goes out of scope at the end of the '
+            'with-statement. Use wait_for_exit(), wait_for_termination(), or wait_for_disconnect() '
+            'as appropriate.'
+        )
 
     def __contains__(self, expectation):
         return expectation in self.timeline
@@ -99,7 +112,7 @@ def ignore_unobserved(self):
     def ignore_unobserved(self, value):
         self.timeline.ignore_unobserved = value
 
-    def stop(self):
+    def close(self):
         if self.socket:
             try:
                 self.socket.shutdown(socket.SHUT_RDWR)
@@ -179,7 +192,8 @@ def _get_target(self):
             pytest.fail()
         return argv
 
-    def prepare_to_run(self):
+
+    def initialize(self, **kwargs):
         """Spawns ptvsd using the configured method, telling it to execute the
         provided Python file, module, or code, and establishes a message channel
         to it.
@@ -188,7 +202,24 @@ def prepare_to_run(self):
 
         If perform_handshake is True, calls self.handshake() before returning.
         """
-        print('Preparing debug session with method %r' % str(self.start_method))
+
+        self.ignore_unobserved += [
+            Event('thread', ANY.dict_with({'reason': 'started'})),
+            Event('module')
+        ] + kwargs.pop('ignore_unobserved', [])
+
+        self.env.update(kwargs.pop('env', {}))
+        self.debug_options += kwargs.pop('debug_options', [])
+        self.program_args += kwargs.pop('program_args', [])
+
+        for k, v in kwargs.items():
+            setattr(self, k, v)
+
+        assert self.start_method in ('launch', 'attach_pid', 'attach_socket_cmdline', 'attach_socket_import')
+        assert len(self.target) == 2
+        assert self.target[0] in ('file', 'module', 'code')
+
+        print('Initializing debug session for ptvsd#%d' % self.ptvsd_port)
         argv = []
         if self.start_method == 'launch':
             self._listen()
@@ -221,6 +252,8 @@ def prepare_to_run(self):
         if self.backchannel_port:
             self.env['PTVSD_BACKCHANNEL_PORT'] = str(self.backchannel_port)
 
+        print('Start method: %s' % self.start_method)
+        print('Target: (%s) %s' % self.target)
         print('Current directory: %s' % os.getcwd())
         print('PYTHONPATH: %s' % self.env['PYTHONPATH'])
         print('Spawning %r' % argv)
@@ -251,12 +284,6 @@ def prepare_to_run(self):
         if self.perform_handshake:
             return self.handshake()
 
-    def __enter__(self):
-        return self
-
-    def __exit__(self, *args):
-        self.wait_for_exit()
-
     def wait_for_disconnect(self, close=True):
         """Waits for the connected ptvsd process to disconnect.
         """
@@ -420,7 +447,7 @@ def start_debugging(self, freeze=True):
         to start running code under debugger.
 
         After this method returns, ptvsd is running the code in the script file or module
-        that was specified in prepare_to_run().
+        that was specified via self.target.
         """
 
         configurationDone_request = self.send_request('configurationDone')
@@ -492,7 +519,6 @@ def write_json(self, value):
         return t
 
     def _capture_output(self, pipe, name):
-
         def _output_worker():
             while True:
                 try:
@@ -516,35 +542,13 @@ def _wait_for_remaining_output(self):
         for thread in self._output_capture_threads:
             thread.join()
 
-    def initialize(
-        self,
-        **kwargs
-    ):
-        self.ignore_unobserved += [
-            Event('thread', ANY.dict_with({'reason': 'started'})),
-            Event('module')
-        ] + kwargs.pop('ignore_events', [])
-
-        self.env.update(kwargs.pop('env', {}))
-        self.debug_options += kwargs.pop('debug_options', [])
-        self.program_args += kwargs.pop('program_args', [])
-
-        for k, v in kwargs.items():
-            setattr(self, k, v)
-
-        assert self.start_method in ('launch', 'attach_pid', 'attach_socket_cmdline', 'attach_socket_import')
-        assert len(self.target) == 2
-        assert self.target[0] in ('file', 'module', 'code')
-
-        self.prepare_to_run()
-
     def set_breakpoints(self, path, lines=()):
         self.send_request('setBreakpoints', arguments={
                 'source': {'path': path},
                 'breakpoints': [{'line': bp_line} for bp_line in lines],
             }).wait_for_response()
 
-    def wait_for_thread_stopped(self, reason='breakpoint'):
+    def wait_for_thread_stopped(self, reason=ANY):
         thread_stopped = self.wait_for_next(Event('stopped', ANY.dict_with({'reason': reason})))
 
         tid = thread_stopped.body['threadId']
@@ -558,4 +562,23 @@ def wait_for_thread_stopped(self, reason='breakpoint'):
 
         fid = frames[0]['id']
 
-        return _Hit(thread_stopped, resp_stacktrace, tid, fid)
+        return self.StopInfo(thread_stopped, resp_stacktrace, tid, fid)
+
+    def connect_to_child_session(self, ptvsd_subprocess):
+        child_port = ptvsd_subprocess.body['port']
+        assert child_port != 0
+
+        child_session = DebugSession(start_method='attach_socket_cmdline', ptvsd_port=child_port)
+        try:
+            child_session.ignore_unobserved = self.ignore_unobserved
+            child_session.debug_options = self.debug_options
+            child_session.connect()
+            child_session.handshake()
+        except:
+            child_session.close()
+        else:
+            return child_session
+
+    def connect_to_next_child_session(self):
+        ptvsd_subprocess = self.wait_for_next(Event('ptvsd_subprocess'))
+        return self.connect_to_child_session(ptvsd_subprocess)
diff --git a/pytests/helpers/timeline.py b/pytests/helpers/timeline.py
--- a/pytests/helpers/timeline.py
+++ b/pytests/helpers/timeline.py
@@ -61,7 +61,7 @@ def history(self):
 
     def __contains__(self, expectation):
         self.expect_frozen()
-        return expectation.is_realized_in(self)
+        return any(expectation.test(self.beginning, self.last))
 
     def all_occurrences_of(self, expectation):
         return tuple(occ for occ in self if occ == expectation)
@@ -152,34 +152,44 @@ def observe_all(self, expectation):
         self.observe(*[occ for occ in self if occ == expectation])
 
     def wait_until(self, condition, freeze=None):
+        freeze = freeze or self.is_frozen
         try:
-            # First, test the condition against the timeline as it currently is.
-            with self.frozen():
-                last_seen = self.last
-                if condition(last_seen):
-                    return last_seen
-
-            # Now keep spinning waiting for new occurrences to come, and test the
-            # condition against every new batch in turn.
-
-            freeze = freeze or self.is_frozen
-            self.unfreeze()
-
             with self._recorded_new:
+                # First, test the condition against the timeline as it currently is.
+                with self.frozen():
+                    result = condition()
+                    if result:
+                        return result
+
+                # Now keep spinning waiting for new occurrences to come, and test the
+                # condition against every new batch in turn.
+                self.unfreeze()
                 while True:
+                    self._recorded_new.wait()
                     with self.frozen():
-                        for occ in last_seen.following():
-                            if condition(occ):
-                                return occ
-                        last_seen = self.last
-
+                        result = condition()
+                        if result:
+                            return result
                     assert not self.is_final
-                    self._recorded_new.wait()
 
         finally:
             if freeze:
                 self.freeze()
 
+    def _wait_until_realized(self, expectation, freeze=None, explain=True, observe=True):
+        def has_been_realized():
+            for reasons in expectation.test(self.beginning, self.last):
+                if observe:
+                    self.expect_realized(expectation, explain=explain, observe=observe)
+                return reasons
+
+        reasons = self.wait_until(has_been_realized, freeze)
+        return latest_of(reasons.values())
+
+    def wait_until_realized(self, expectation, freeze=None, explain=True, observe=True):
+        if explain:
+            print(colors.LIGHT_MAGENTA + 'Waiting for ' + colors.RESET + colors.color_repr(expectation))
+        return self._wait_until_realized(expectation, freeze, explain, observe)
 
     def wait_for(self, expectation, freeze=None, explain=True):
         assert expectation.has_lower_bound, (
@@ -188,57 +198,14 @@ def wait_for(self, expectation, freeze=None, explain=True):
             'or wait_for_next() to wait for the next expectation since the timeline was last '
             'frozen, or wait_until_realized() when a lower bound is really not necessary.'
         )
-        return self.wait_until_realized(expectation, freeze, explain=explain)
-
-    def wait_until_realized(self, expectation, freeze=None, explain=True, observe=True):
         if explain:
             print(colors.LIGHT_MAGENTA + 'Waiting for ' + colors.RESET + colors.color_repr(expectation))
-
-        reasons = {}
-        check_past = [True]
-
-        def has_been_realized(occ):
-            # First time wait_until() calls us, we have to check the whole timeline.
-            # On subsequent calls, we only need to check the newly added occurrence.
-            if check_past:
-                # if explain:
-                #     print(
-                #         colors.LIGHT_MAGENTA + 'Testing ' + colors.RESET +
-                #         colors.color_repr(expectation) +
-                #         colors.LIGHT_MAGENTA + ' against timeline up to and including ' + colors.RESET +
-                #         colors.color_repr(occ)
-                #     )
-                reasons.update(expectation.test_at_or_before(occ) or {})
-                del check_past[:]
-            else:
-                # if explain:
-                #     print(
-                #         colors.LIGHT_MAGENTA + 'Testing ' + colors.RESET +
-                #         colors.color_repr(expectation) +
-                #         colors.LIGHT_MAGENTA + ' against ' + colors.RESET +
-                #         colors.color_repr(occ)
-                #     )
-                reasons.update(expectation.test_at(occ) or {})
-
-            if reasons:
-                if observe:
-                    self.expect_realized(expectation, explain=explain, observe=observe)
-                return True
-            # else:
-            #     if explain:
-            #         print(
-            #             colors.color_repr(occ) +
-            #             colors.LIGHT_MAGENTA + ' did not realize ' + colors.RESET +
-            #             colors.color_repr(expectation)
-            #         )
-
-        self.wait_until(has_been_realized, freeze)
-
-        realized_at = reasons[expectation]
-        return realized_at
+        return self._wait_until_realized(expectation, freeze, explain=explain)
 
     def wait_for_next(self, expectation, freeze=True, explain=True, observe=True):
-        return self.wait_until_realized(self._proceeding_from >> expectation, freeze, explain, observe)
+        if explain:
+            print(colors.LIGHT_MAGENTA + 'Waiting for next ' + colors.RESET + colors.color_repr(expectation))
+        return self._wait_until_realized(self._proceeding_from >> expectation, freeze, explain, observe)
 
     def new(self):
         self.expect_frozen()
@@ -254,33 +221,31 @@ def proceed(self):
         self._proceeding_from = self.last
         self.unfreeze()
 
-    def _expect_realized(self, expectation, occurrences, explain=True, observe=True):
+    def _expect_realized(self, expectation, first, explain=True, observe=True):
         self.expect_frozen()
 
-        occurrences = list(occurrences)
-        reasons = expectation.test_until_realized(occurrences)
-        if reasons is None:
+        try:
+            reasons = next(expectation.test(first, self.last))
+        except StopIteration:
             print(colors.LIGHT_RED + 'No matching ' + colors.RESET + colors.color_repr(expectation))
-
             # The weird always-false assert is to make pytest print occurrences nicely.
-            occurrences = list(occurrences)
+            occurrences = list(first.and_following())
             assert occurrences is ('not matching expectation', expectation)
 
+        occs = tuple(reasons.values())
+        assert occs
         if observe:
-            self.observe(*reasons.values())
-
-        realized_at = reasons[expectation]
+            self.observe(*occs)
         if explain:
-            self._explain_how_realized(realized_at, expectation, reasons)
-        return realized_at
+            self._explain_how_realized(expectation, reasons)
+        return occs if len(occs) > 1 else occs[0]
 
     def expect_realized(self, expectation, explain=True, observe=True):
-        return self._expect_realized(expectation, reversed(self), explain, observe)
+        return self._expect_realized(expectation, self.beginning, explain, observe)
 
     def expect_new(self, expectation, explain=True, observe=True):
-        new = self.new()
-        assert len(new) > 0, 'No new occurrences since last proceed()'
-        return self._expect_realized(expectation, self.new(), explain, observe)
+        assert self._proceeding_from.next is not None, 'No new occurrences since last proceed()'
+        return self._expect_realized(expectation, self._proceeding_from.next, explain, observe)
 
     def expect_not_realized(self, expectation):
         self.expect_frozen()
@@ -290,18 +255,15 @@ def expect_no_new(self, expectation):
         self.expect_frozen()
         assert expectation not in self.new()
 
-    def _explain_how_realized(self, occurrence, expectation, reasons):
+    def _explain_how_realized(self, expectation, reasons):
         message = (
             colors.LIGHT_MAGENTA + 'Realized ' + colors.RESET +
-            colors.color_repr(expectation) +
-            colors.LIGHT_MAGENTA + ' via ' + colors.RESET +
-            colors.color_repr(occurrence)
+            colors.color_repr(expectation)
         )
 
-        # For the breakdown, we want to skip the top-level expectation (since we already
-        # printed that above), and also any expectations that were exact occurrences,
+        # For the breakdown, we want to skip any expectations that were exact occurrences,
         # since there's no point explaining that occurrence was realized by itself.
-        skip = [expectation] + [exp for exp in reasons.keys() if isinstance(exp, Occurrence)]
+        skip = [exp for exp in reasons.keys() if isinstance(exp, Occurrence)]
         for exp in skip:
             reasons.pop(exp, None)
 
@@ -344,6 +306,7 @@ def _recorder_worker(self):
                     self._beginning = occ
                     self._last = occ
                 else:
+                    assert self._last.timestamp <= occ.timestamp
                     occ.previous = self._last
                     self._last._next = occ
                     self._last = occ
@@ -429,7 +392,7 @@ def __new__(cls, timeline, start, stop):
         return result
 
     def __contains__(self, expectation):
-        return expectation.is_realized_in(self)
+        return any(expectation.test(self[0], self[-1])) if len(self) > 0 else False
 
     def all_occurrences_of(self, expectation):
         return tuple(occ for occ in self if occ == expectation)
@@ -458,35 +421,35 @@ class Expectation(object):
     timeline = None
     has_lower_bound = False
 
-    def test_at(self, occurrence):
+    def test(self, first, last):
         raise NotImplementedError()
 
-    def test_before(self, occurrence):
-        return self.test_until_realized(occurrence.preceding())
+    # def test_before(self, occurrence):
+    #     return self.test_until_realized(occurrence.preceding())
 
-    def test_at_or_before(self, occurrence):
-        return self.test_until_realized(occurrence.and_preceding())
+    # def test_at_or_before(self, occurrence):
+    #     return self.test_until_realized(occurrence.and_preceding())
 
-    def test_until_realized(self, occurrences):
-        for occ in occurrences:
-            reasons = self.test_at(occ)
-            if reasons:
-                return reasons
-        return None
+    # def test_until_realized(self, occurrences):
+    #     for occ in occurrences:
+    #         reasons = self.test_at(occ)
+    #         if reasons:
+    #             return reasons
+    #     return None
 
-    def is_realized_at(self, occurrence):
-        return self.test_at(occurrence) is not None
+    # def is_realized_at(self, occurrence):
+    #     return self.test_at(occurrence) is not None
 
-    def is_realized_before(self, occurrence):
-        return self.test_before(occurrence) is not None
+    # def is_realized_before(self, occurrence):
+    #     return self.test_before(occurrence) is not None
 
-    def is_realized_at_or_before(self, occurrence):
-        return self.test_at_or_before(occurrence) is not None
+    # def is_realized_at_or_before(self, occurrence):
+    #     return self.test_at_or_before(occurrence) is not None
 
-    def is_realized_in(self, timeline_or_interval):
-        # Go in reverse on the assumption that we're more likely to be looking
-        # for something that happened recently, to find it quicker.
-        return self.test_until_realized(reversed(timeline_or_interval)) is not None
+    # def is_realized_in(self, timeline_or_interval):
+    #     # Go in reverse on the assumption that we're more likely to be looking
+    #     # for something that happened recently, to find it quicker.
+    #     return self.test_until_realized(reversed(timeline_or_interval)) is not None
 
     def wait(self, freeze=None, explain=True):
         assert self.timeline is not None, 'Expectation must be bound to a timeline to be waited on.'
@@ -498,7 +461,7 @@ def wait_until_realized(self, freeze=None):
     def __eq__(self, other):
         if self is other:
             return True
-        elif isinstance(other, Occurrence) and self.is_realized_at(other):
+        elif isinstance(other, Occurrence) and any(self.test(other, other)):
             return True
         else:
             return NotImplemented
@@ -507,7 +470,7 @@ def __ne__(self, other):
         return not self == other
 
     def after(self, other):
-        return SequencedExpectation(self, only_after=other)
+        return SequencedExpectation(other, self)
 
     def when(self, condition):
         return ConditionalExpectation(self, condition)
@@ -557,49 +520,46 @@ def has_lower_bound(self):
 
 
 class SequencedExpectation(DerivativeExpectation):
-    def __init__(self, expectation, only_after):
-        super(SequencedExpectation, self).__init__(expectation, only_after)
+    def __init__(self, first, second):
+        super(SequencedExpectation, self).__init__(first, second)
 
     @property
-    def expectation(self):
+    def first(self):
         return self.expectations[0]
 
     @property
-    def only_after(self):
+    def second(self):
         return self.expectations[1]
 
-    def test_at(self, occurrence):
-        assert isinstance(occurrence, Occurrence)
-
-        reasons = self.expectation.test_at(occurrence)
-        if not reasons:
-            return None
-
-        bound_reasons = self.only_after.test_before(occurrence)
-        if not bound_reasons:
-            return None
-        reasons.update(bound_reasons)
+    def test(self, first, last):
+        assert isinstance(first, Occurrence)
+        assert isinstance(last, Occurrence)
 
-        reasons[self] = occurrence
-        return reasons
+        for first_reasons in self.first.test(first, last):
+            first_occs = first_reasons.values()
+            lower_bound = latest_of(first_occs).next
+            if lower_bound is not None:
+                for second_reasons in self.second.test(lower_bound, last):
+                    reasons = second_reasons.copy()
+                    reasons.update(first_reasons)
+                    yield reasons
 
     @property
     def has_lower_bound(self):
-        return self.expectation.has_lower_bound or self.only_after.has_lower_bound
+        return self.first.has_lower_bound or self.second.has_lower_bound
 
     def __repr__(self):
-        return '(%r >> %r)' % (self.only_after, self.expectation)
+        return '(%r >> %r)' % (self.first, self.second)
 
 
 class OrExpectation(DerivativeExpectation):
-    def test_at(self, occurrence):
-        assert isinstance(occurrence, Occurrence)
+    def test(self, first, last):
+        assert isinstance(first, Occurrence)
+        assert isinstance(last, Occurrence)
+
         for exp in self.expectations:
-            reasons = exp.test_at(occurrence)
-            if reasons:
-                reasons[self] = occurrence
-                return reasons
-        return None
+            for reasons in exp.test(first, last):
+                yield reasons
 
     def __or__(self, other):
         assert isinstance(other, Expectation)
@@ -610,29 +570,23 @@ def __repr__(self):
 
 
 class AndExpectation(DerivativeExpectation):
-    def test_at(self, occurrence):
-        assert isinstance(occurrence, Occurrence)
-
-        # At least one expectation must be realized at the occurrence.
-        expectations = list(self.expectations)
-        for exp in expectations:
-            reasons = exp.test_at(occurrence)
-            if reasons:
-                break
-        else:
-            return None
-
-        # And then all of the remaining expectations must have been realized
-        # at or sometime before that occurrence.
-        expectations.remove(exp)
-        for exp in expectations:
-            more_reasons = exp.test_at_or_before(occurrence)
-            if not more_reasons:
-                return None
-            reasons.update(more_reasons)
+    def test(self, first, last):
+        assert isinstance(first, Occurrence)
+        assert isinstance(last, Occurrence)
+
+        if len(self.expectations) <= 1:
+            for exp in self.expectations:
+                for reasons in exp.test(first, last):
+                    yield reasons
+            return
 
-        reasons[self] = occurrence
-        return reasons
+        lhs = self.expectations[0]
+        rhs = AndExpectation(*self.expectations[1:])
+        for lhs_reasons in lhs.test(first, last):
+            for rhs_reasons in rhs.test(first, last):
+                reasons = lhs_reasons.copy()
+                reasons.update(rhs_reasons)
+                yield reasons
 
     @property
     def has_lower_bound(self):
@@ -647,26 +601,20 @@ def __repr__(self):
 
 
 class XorExpectation(DerivativeExpectation):
-    def test_at(self, occurrence):
-        assert isinstance(occurrence, Occurrence)
-
-        # At least one expectation must be realized by the occurrence.
-        expectations = list(self.expectations)
-        for exp in expectations:
-            reasons = exp.test_at(occurrence)
-            if reasons:
-                break
-        else:
-            return None
+    def test(self, first, last):
+        assert isinstance(first, Occurrence)
+        assert isinstance(last, Occurrence)
 
-        # And then none of the remaining expectations must have been realized
-        # at or anytime before that occurrence.
-        expectations.remove(exp)
-        if any(exp.is_realized_at_or_before(occurrence) for exp in expectations):
-            return None
+        reasons = None
+        for exp in self.expectations:
+            for exp_reasons in exp.test(first, last):
+                if reasons is None:
+                    reasons = exp_reasons
+                else:
+                    return
 
-        reasons[self] = occurrence
-        return reasons
+        if reasons is not None:
+            yield reasons
 
     @property
     def has_lower_bound(self):
@@ -689,18 +637,14 @@ def __init__(self, expectation, condition):
     def expectation(self):
         return self.expectations[0]
 
-    def test_at(self, occurrence):
-        assert isinstance(occurrence, Occurrence)
+    def test(self, first, last):
+        assert isinstance(first, Occurrence)
+        assert isinstance(last, Occurrence)
 
-        if not self.condition(occurrence):
-            return None
-
-        reasons = self.expectation.test_at(occurrence)
-        if not reasons:
-            return None
-
-        reasons[self] = occurrence
-        return reasons
+        for reasons in self.expectation.test(first, last):
+            occs = reasons.values()
+            if self.condition(*occs):
+                yield reasons
 
     def __repr__(self):
         return '%r?' % self.expectation
@@ -710,9 +654,13 @@ class PatternExpectation(Expectation):
     def __init__(self, *circumstances):
         self.circumstances = pattern.Pattern(circumstances)
 
-    def test_at(self, occurrence):
-        assert isinstance(occurrence, Occurrence)
-        return {self: occurrence} if occurrence.circumstances == self.circumstances else None
+    def test(self, first, last):
+        assert isinstance(first, Occurrence)
+        assert isinstance(last, Occurrence)
+
+        for occ in first.and_following(up_to=last, inclusive=True):
+            if occ.circumstances == self.circumstances:
+                yield {self: occ}
 
     def __repr__(self):
         circumstances = self.circumstances.pattern
@@ -775,7 +723,7 @@ def preceding(self):
         next(it)
         return it
 
-    def and_preceding(self, up_to=None):
+    def and_preceding(self, up_to=None, inclusive=False):
         assert self.timeline is not None
         assert up_to is None or isinstance(up_to, Expectation)
 
@@ -787,12 +735,15 @@ def and_preceding(self, up_to=None):
             yield occ
             occ = occ.previous
 
+        if inclusive:
+            yield occ
+
     def following(self):
         it = self.and_following()
         next(it)
         return it
 
-    def and_following(self, up_to=None):
+    def and_following(self, up_to=None, inclusive=False):
         assert self.timeline is not None
         assert up_to is None or isinstance(up_to, Expectation)
         if up_to is None:
@@ -806,6 +757,9 @@ def and_following(self, up_to=None):
             yield occ
             occ = occ.next
 
+        if inclusive:
+            yield occ
+
     def precedes(self, occurrence):
         assert isinstance(occurrence, Occurrence)
         return any(occ is self for occ in occurrence.preceding())
@@ -817,8 +771,13 @@ def realizes(self, expectation):
         assert isinstance(expectation, Expectation)
         return expectation == self
 
-    def test_at(self, occurrence):
-        return {self: self} if occurrence is self else None
+    def test(self, first, last):
+        assert isinstance(first, Occurrence)
+        assert isinstance(last, Occurrence)
+
+        for occ in first.and_following(up_to=last, inclusive=True):
+            if occ is self:
+                yield {self: self}
 
     def __lt__(self, occurrence):
         return self.precedes(occurrence)
@@ -847,3 +806,11 @@ def __repr__(self):
         if not self.observed:
             s = '*' + s
         return s
+
+
+def earliest_of(occurrences):
+    return min(occurrences, key=lambda occ: occ.index)
+
+
+def latest_of(occurrences):
+    return max(occurrences, key=lambda occ: occ.index)


Test patch:
diff --git a/pytests/func/test_breakpoints.py b/pytests/func/test_breakpoints.py
--- a/pytests/func/test_breakpoints.py
+++ b/pytests/func/test_breakpoints.py
@@ -8,41 +8,52 @@
 import os.path
 import pytest
 import sys
-from pytests.helpers.timeline import Event, Response
+
 from pytests.helpers.pathutils import get_test_root, compare_path
+from pytests.helpers.session import DebugSession
+from pytests.helpers.timeline import Event
+
 
 BP_TEST_ROOT = get_test_root('bp')
 
 
-def test_path_with_ampersand(debug_session, start_method, run_as):
+def test_path_with_ampersand(run_as, start_method):
     bp_line = 4
     testfile = os.path.join(BP_TEST_ROOT, 'a&b', 'test.py')
-    debug_session.initialize(target=(run_as, testfile), start_method=start_method)
-    debug_session.set_breakpoints(testfile, [bp_line])
-    debug_session.start_debugging()
-    hit = debug_session.wait_for_thread_stopped()
-    frames = hit.stacktrace.body['stackFrames']
-    assert compare_path(frames[0]['source']['path'], testfile, show=False)
 
-    continue_request = debug_session.send_request('continue')
-    debug_session.wait_for_next(Response(continue_request) & Event('continued'))
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, testfile),
+            start_method=start_method,
+            ignore_unobserved=[Event('continued')],
+        )
+        session.set_breakpoints(testfile, [bp_line])
+        session.start_debugging()
+        hit = session.wait_for_thread_stopped('breakpoint')
+        frames = hit.stacktrace.body['stackFrames']
+        assert compare_path(frames[0]['source']['path'], testfile, show=False)
 
-    debug_session.wait_for_exit()
+        session.send_request('continue').wait_for_response(freeze=False)
+        session.wait_for_exit()
 
 
 @pytest.mark.skipif(sys.version_info < (3, 0), reason='Paths are not Unicode in Python 2.7')
-def test_path_with_unicode(debug_session, start_method, run_as):
+def test_path_with_unicode(run_as, start_method):
     bp_line = 6
     testfile = os.path.join(BP_TEST_ROOT, u'ನನ್ನ_ಸ್ಕ್ರಿಪ್ಟ್.py')
-    debug_session.initialize(target=(run_as, testfile), start_method=start_method)
-    debug_session.set_breakpoints(testfile, [bp_line])
-    debug_session.start_debugging()
-    hit = debug_session.wait_for_thread_stopped()
-    frames = hit.stacktrace.body['stackFrames']
-    assert compare_path(frames[0]['source']['path'], testfile, show=False)
-    assert u'ಏನಾದರೂ_ಮಾಡು' == frames[0]['name']
-
-    continue_request = debug_session.send_request('continue')
-    debug_session.wait_for_next(Response(continue_request) & Event('continued'))
-
-    debug_session.wait_for_exit()
+
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, testfile),
+            start_method=start_method,
+            ignore_unobserved=[Event('continued')],
+        )
+        session.set_breakpoints(testfile, [bp_line])
+        session.start_debugging()
+        hit = session.wait_for_thread_stopped('breakpoint')
+        frames = hit.stacktrace.body['stackFrames']
+        assert compare_path(frames[0]['source']['path'], testfile, show=False)
+        assert u'ಏನಾದರೂ_ಮಾಡು' == frames[0]['name']
+
+        session.send_request('continue').wait_for_response(freeze=False)
+        session.wait_for_exit()
diff --git a/pytests/func/test_completions.py b/pytests/func/test_completions.py
--- a/pytests/func/test_completions.py
+++ b/pytests/func/test_completions.py
@@ -6,6 +6,7 @@
 
 import pytest
 from pytests.helpers.pattern import ANY
+from pytests.helpers.session import DebugSession
 from pytests.helpers.timeline import Event
 
 
@@ -28,7 +29,7 @@
 }
 
 @pytest.mark.parametrize('bp_line', expected_at_line.keys())
-def test_completions_scope(debug_session, pyfile, bp_line, run_as, start_method):
+def test_completions_scope(pyfile, bp_line, run_as, start_method):
     @pyfile
     def code_to_debug():
         from dbgimporter import import_and_enable_debugger
@@ -46,44 +47,45 @@ def someFunction(someVar):
         print('done')
 
     expected = expected_at_line[bp_line]
-    debug_session.ignore_unobserved += [
-        Event('stopped'),
-        Event('continued')
-    ]
-    debug_session.initialize(target=(run_as, code_to_debug), start_method=start_method)
-    debug_session.set_breakpoints(code_to_debug, [bp_line])
-    debug_session.start_debugging()
-
-    thread_stopped = debug_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'breakpoint'})))
-    assert thread_stopped.body['threadId'] is not None
-    tid = thread_stopped.body['threadId']
-
-    resp_stacktrace = debug_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 0
-    frames = resp_stacktrace.body['stackFrames']
-    assert len(frames) > 0
-
-    fid = frames[0]['id']
-    resp_completions = debug_session.send_request('completions', arguments={
-        'text': 'some',
-        'frameId': fid,
-        'column': 1
-    }).wait_for_response()
-    targets = resp_completions.body['targets']
-
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-
-    targets.sort(key=lambda t: t['label'])
-    expected.sort(key=lambda t: t['label'])
-    assert targets == expected
-
-    debug_session.wait_for_exit()
-
-
-def test_completions(debug_session, pyfile, start_method, run_as):
+
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, code_to_debug),
+            start_method=start_method,
+            ignore_unobserved=[Event('stopped'), Event('continued')],
+        )
+        session.set_breakpoints(code_to_debug, [bp_line])
+        session.start_debugging()
+
+        thread_stopped = session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'breakpoint'})))
+        assert thread_stopped.body['threadId'] is not None
+        tid = thread_stopped.body['threadId']
+
+        resp_stacktrace = session.send_request('stackTrace', arguments={
+            'threadId': tid,
+        }).wait_for_response()
+        assert resp_stacktrace.body['totalFrames'] > 0
+        frames = resp_stacktrace.body['stackFrames']
+        assert len(frames) > 0
+
+        fid = frames[0]['id']
+        resp_completions = session.send_request('completions', arguments={
+            'text': 'some',
+            'frameId': fid,
+            'column': 1
+        }).wait_for_response()
+        targets = resp_completions.body['targets']
+
+        session.send_request('continue').wait_for_response(freeze=False)
+
+        targets.sort(key=lambda t: t['label'])
+        expected.sort(key=lambda t: t['label'])
+        assert targets == expected
+
+        session.wait_for_exit()
+
+
+def test_completions(pyfile, run_as, start_method):
     @pyfile
     def code_to_debug():
         from dbgimporter import import_and_enable_debugger
@@ -95,27 +97,31 @@ def code_to_debug():
 
     bp_line = 6
     bp_file = code_to_debug
-    debug_session.initialize(target=(run_as, bp_file), start_method=start_method)
-    debug_session.set_breakpoints(bp_file, [bp_line])
-    debug_session.start_debugging()
-    hit = debug_session.wait_for_thread_stopped()
-
-    response = debug_session.send_request('completions', arguments={
-        'frameId': hit.frame_id,
-        'text': 'b.'
-    }).wait_for_response()
-
-    labels = set(target['label'] for target in response.body['targets'])
-    assert labels.issuperset(['get', 'items', 'keys', 'setdefault', 'update', 'values'])
-
-    response = debug_session.send_request('completions', arguments={
-        'frameId': hit.frame_id,
-        'text': 'not_there'
-    }).wait_for_response()
-
-    assert not response.body['targets']
-
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
 
-    debug_session.wait_for_exit()
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, bp_file),
+            start_method=start_method,
+            ignore_unobserved=[Event('continued')],
+        )
+        session.set_breakpoints(bp_file, [bp_line])
+        session.start_debugging()
+        hit = session.wait_for_thread_stopped()
+
+        response = session.send_request('completions', arguments={
+            'frameId': hit.frame_id,
+            'text': 'b.'
+        }).wait_for_response()
+
+        labels = set(target['label'] for target in response.body['targets'])
+        assert labels.issuperset(['get', 'items', 'keys', 'setdefault', 'update', 'values'])
+
+        response = session.send_request('completions', arguments={
+            'frameId': hit.frame_id,
+            'text': 'not_there'
+        }).wait_for_response()
+
+        assert not response.body['targets']
+
+        session.send_request('continue').wait_for_response(freeze=False)
+        session.wait_for_exit()
diff --git a/pytests/func/test_django.py b/pytests/func/test_django.py
--- a/pytests/func/test_django.py
+++ b/pytests/func/test_django.py
@@ -21,260 +21,258 @@
 DJANGO_PORT = 8000
 
 
-@pytest.mark.parametrize('bp_file, bp_line, bp_name', [
-  (DJANGO1_MANAGE, 40, 'home'),
-  (DJANGO1_TEMPLATE, 8, 'Django Template'),
-])
+@pytest.mark.parametrize('bp_target', ['code', 'template'])
 @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
 @pytest.mark.skipif(sys.version_info < (3, 0), reason='Bug #923')
 @pytest.mark.timeout(60)
-def test_django_breakpoint_no_multiproc(debug_session, bp_file, bp_line, bp_name, start_method):
-    debug_session.initialize(
-        start_method=start_method,
-        target=('file', DJANGO1_MANAGE),
-        program_args=['runserver', '--noreload', '--nothreading'],
-        debug_options=['Django'],
-        cwd=DJANGO1_ROOT,
-        expected_returncode=ANY.int,  # No clean way to kill Flask server
-    )
-
-    bp_var_content = 'Django-Django-Test'
-    debug_session.set_breakpoints(bp_file, [bp_line])
-    debug_session.start_debugging()
-
-    # wait for Django server to start
-    wait_for_connection(DJANGO_PORT)
-    web_request = get_web_content(DJANGO_LINK, {})
-
-    thread_stopped = debug_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'breakpoint'})))
-    assert thread_stopped.body['threadId'] is not None
-
-    tid = thread_stopped.body['threadId']
-
-    resp_stacktrace = debug_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 1
-    frames = resp_stacktrace.body['stackFrames']
-    assert frames[0] == {
-        'id': 1,
-        'name': bp_name,
-        'source': {
-            'sourceReference': ANY,
-            'path': ANY.such_that(lambda s: compare_path(s, bp_file)),
-        },
-        'line': bp_line,
-        'column': 1,
-    }
-
-    fid = frames[0]['id']
-    resp_scopes = debug_session.send_request('scopes', arguments={
-        'frameId': fid
-    }).wait_for_response()
-    scopes = resp_scopes.body['scopes']
-    assert len(scopes) > 0
-
-    resp_variables = debug_session.send_request('variables', arguments={
-        'variablesReference': scopes[0]['variablesReference']
-    }).wait_for_response()
-    variables = list(v for v in resp_variables.body['variables'] if v['name'] == 'content')
-    assert variables == [{
-            'name': 'content',
-            'type': 'str',
-            'value': repr(bp_var_content),
-            'presentationHint': {'attributes': ['rawString']},
-            'evaluateName': 'content'
-        }]
-
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-
-    web_content = web_request.wait_for_response()
-    assert web_content.find(bp_var_content) != -1
-
-    # shutdown to web server
-    link = DJANGO_LINK + 'exit'
-    get_web_content(link).wait_for_response()
-
-
-@pytest.mark.parametrize('ex_type, ex_line', [
-  ('handled', 50),
-  ('unhandled', 64),
-])
-@pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
-@pytest.mark.skipif(sys.version_info < (3, 0), reason='Bug #923')
-@pytest.mark.timeout(60)
-def test_django_exception_no_multiproc(debug_session, ex_type, ex_line, start_method):
-    debug_session.initialize(
-        start_method=start_method,
-        target=('file', DJANGO1_MANAGE),
-        program_args=['runserver', '--noreload', '--nothreading'],
-        debug_options=['Django'],
-        cwd=DJANGO1_ROOT,
-        expected_returncode=ANY.int,  # No clean way to kill Flask server
-    )
-
-    debug_session.send_request('setExceptionBreakpoints', arguments={
-        'filters': ['raised', 'uncaught'],
-    }).wait_for_response()
-
-    debug_session.start_debugging()
-
-    wait_for_connection(DJANGO_PORT)
-
-    base_link = DJANGO_LINK
-    link = base_link + ex_type if base_link.endswith('/') else ('/' + ex_type)
-    web_request = get_web_content(link, {})
-
-    thread_stopped = debug_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'exception'})))
-    assert thread_stopped == Event('stopped', ANY.dict_with({
-        'reason': 'exception',
-        'text': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
-        'description': 'Hello'
-    }))
-
-    tid = thread_stopped.body['threadId']
-    resp_exception_info = debug_session.send_request(
-        'exceptionInfo',
-        arguments={'threadId': tid, }
-    ).wait_for_response()
-    exception = resp_exception_info.body
-    assert exception == {
-        'exceptionId': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
-        'breakMode': 'always',
-        'description': 'Hello',
-        'details': {
-            'message': 'Hello',
-            'typeName': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
-            'source': ANY.such_that(lambda s: compare_path(s, DJANGO1_MANAGE)),
-            'stackTrace': ANY.such_that(lambda s: True),
+def test_django_breakpoint_no_multiproc(bp_target, start_method):
+    bp_file, bp_line, bp_name = {
+        'code': (DJANGO1_MANAGE, 40, 'home'),
+        'template': (DJANGO1_TEMPLATE, 8, 'Django Template'),
+    }[bp_target]
+
+    with DebugSession() as session:
+        session.initialize(
+            start_method=start_method,
+            target=('file', DJANGO1_MANAGE),
+            program_args=['runserver', '--noreload', '--nothreading'],
+            debug_options=['Django'],
+            cwd=DJANGO1_ROOT,
+            expected_returncode=ANY.int,  # No clean way to kill Flask server
+            ignore_unobserved=[Event('continued')],
+        )
+
+        bp_var_content = 'Django-Django-Test'
+        session.set_breakpoints(bp_file, [bp_line])
+        session.start_debugging()
+
+        # wait for Django server to start
+        wait_for_connection(DJANGO_PORT)
+        web_request = get_web_content(DJANGO_LINK, {})
+
+        thread_stopped = session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'breakpoint'})))
+        assert thread_stopped.body['threadId'] is not None
+
+        tid = thread_stopped.body['threadId']
+
+        resp_stacktrace = session.send_request('stackTrace', arguments={
+            'threadId': tid,
+        }).wait_for_response()
+        assert resp_stacktrace.body['totalFrames'] > 1
+        frames = resp_stacktrace.body['stackFrames']
+        assert frames[0] == {
+            'id': 1,
+            'name': bp_name,
+            'source': {
+                'sourceReference': ANY,
+                'path': ANY.such_that(lambda s: compare_path(s, bp_file)),
+            },
+            'line': bp_line,
+            'column': 1,
         }
-    }
 
-    resp_stacktrace = debug_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 1
-    frames = resp_stacktrace.body['stackFrames']
-    assert frames[0] == {
-        'id': ANY,
-        'name': 'bad_route_' + ex_type,
-        'source': {
-            'sourceReference': ANY,
-            'path': ANY.such_that(lambda s: compare_path(s, DJANGO1_MANAGE)),
-        },
-        'line': ex_line,
-        'column': 1,
-    }
+        fid = frames[0]['id']
+        resp_scopes = session.send_request('scopes', arguments={
+            'frameId': fid
+        }).wait_for_response()
+        scopes = resp_scopes.body['scopes']
+        assert len(scopes) > 0
+
+        resp_variables = session.send_request('variables', arguments={
+            'variablesReference': scopes[0]['variablesReference']
+        }).wait_for_response()
+        variables = list(v for v in resp_variables.body['variables'] if v['name'] == 'content')
+        assert variables == [{
+                'name': 'content',
+                'type': 'str',
+                'value': repr(bp_var_content),
+                'presentationHint': {'attributes': ['rawString']},
+                'evaluateName': 'content'
+            }]
+
+        session.send_request('continue').wait_for_response(freeze=False)
+
+        web_content = web_request.wait_for_response()
+        assert web_content.find(bp_var_content) != -1
+
+        # shutdown to web server
+        link = DJANGO_LINK + 'exit'
+        get_web_content(link).wait_for_response()
 
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
+        session.wait_for_exit()
 
-    # ignore response for exception tests
-    web_request.wait_for_response()
 
-    # shutdown to web server
-    link = base_link + 'exit' if base_link.endswith('/') else '/exit'
-    get_web_content(link).wait_for_response()
+@pytest.mark.parametrize('ex_type', ['handled', 'unhandled'])
+@pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
+@pytest.mark.skipif(sys.version_info < (3, 0), reason='Bug #923')
+@pytest.mark.timeout(60)
+def test_django_exception_no_multiproc(ex_type, start_method):
+    ex_line = {
+        'handled': 50,
+        'unhandled': 64,
+    }[ex_type]
+
+    with DebugSession() as session:
+        session.initialize(
+            start_method=start_method,
+            target=('file', DJANGO1_MANAGE),
+            program_args=['runserver', '--noreload', '--nothreading'],
+            debug_options=['Django'],
+            cwd=DJANGO1_ROOT,
+            expected_returncode=ANY.int,  # No clean way to kill Flask server
+            ignore_unobserved=[Event('continued')],
+        )
+
+        session.send_request('setExceptionBreakpoints', arguments={
+            'filters': ['raised', 'uncaught'],
+        }).wait_for_response()
+
+        session.start_debugging()
+
+        wait_for_connection(DJANGO_PORT)
+
+        base_link = DJANGO_LINK
+        link = base_link + ex_type if base_link.endswith('/') else ('/' + ex_type)
+        web_request = get_web_content(link, {})
+
+        thread_stopped = session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'exception'})))
+        assert thread_stopped == Event('stopped', ANY.dict_with({
+            'reason': 'exception',
+            'text': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
+            'description': 'Hello'
+        }))
+
+        tid = thread_stopped.body['threadId']
+        resp_exception_info = session.send_request(
+            'exceptionInfo',
+            arguments={'threadId': tid, }
+        ).wait_for_response()
+        exception = resp_exception_info.body
+        assert exception == {
+            'exceptionId': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
+            'breakMode': 'always',
+            'description': 'Hello',
+            'details': {
+                'message': 'Hello',
+                'typeName': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
+                'source': ANY.such_that(lambda s: compare_path(s, DJANGO1_MANAGE)),
+                'stackTrace': ANY.such_that(lambda s: True),
+            }
+        }
+
+        resp_stacktrace = session.send_request('stackTrace', arguments={
+            'threadId': tid,
+        }).wait_for_response()
+        assert resp_stacktrace.body['totalFrames'] > 1
+        frames = resp_stacktrace.body['stackFrames']
+        assert frames[0] == {
+            'id': ANY,
+            'name': 'bad_route_' + ex_type,
+            'source': {
+                'sourceReference': ANY,
+                'path': ANY.such_that(lambda s: compare_path(s, DJANGO1_MANAGE)),
+            },
+            'line': ex_line,
+            'column': 1,
+        }
 
+        session.send_request('continue').wait_for_response(freeze=False)
 
-def _wait_for_child_process(debug_session):
-    child_subprocess = debug_session.wait_for_next(Event('ptvsd_subprocess'))
-    assert child_subprocess.body['port'] != 0
+        # ignore response for exception tests
+        web_request.wait_for_response()
 
-    child_port = child_subprocess.body['port']
+        # shutdown to web server
+        link = base_link + 'exit' if base_link.endswith('/') else '/exit'
+        get_web_content(link).wait_for_response()
 
-    child_session = DebugSession(start_method='attach_socket_cmdline', ptvsd_port=child_port)
-    child_session.ignore_unobserved = debug_session.ignore_unobserved
-    child_session.debug_options = debug_session.debug_options
-    child_session.connect()
-    child_session.handshake()
-    return child_session
+        session.wait_for_exit()
 
 
 @pytest.mark.skip()
 @pytest.mark.timeout(120)
 @pytest.mark.parametrize('start_method', ['launch'])
-def test_django_breakpoint_multiproc(debug_session, start_method):
-    debug_session.initialize(
-        start_method=start_method,
-        target=('file', DJANGO1_MANAGE),
-        multiprocess=True,
-        program_args=['runserver', ],
-        debug_options=['Django'],
-        cwd=DJANGO1_ROOT,
-        ignore_events=[Event('stopped'), Event('continued')],
-        expected_returncode=ANY.int,  # No clean way to kill Flask server
-    )
-
-    bp_line = 40
-    bp_var_content = 'Django-Django-Test'
-    debug_session.set_breakpoints(DJANGO1_MANAGE, [bp_line])
-    debug_session.start_debugging()
-
-    child_session = _wait_for_child_process(debug_session)
-
-    child_session.send_request('setBreakpoints', arguments={
-        'source': {'path': DJANGO1_MANAGE},
-        'breakpoints': [{'line': bp_line}, ],
-    }).wait_for_response()
-    child_session.start_debugging()
-
-    # wait for Django server to start
-    while True:
-        child_session.proceed()
-        o = child_session.wait_for_next(Event('output'))
-        if get_url_from_str(o.body['output']) is not None:
-            break
-
-    web_request = get_web_content(DJANGO_LINK, {})
-
-    thread_stopped = child_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'breakpoint'})))
-    assert thread_stopped.body['threadId'] is not None
-
-    tid = thread_stopped.body['threadId']
-
-    resp_stacktrace = child_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 0
-    frames = resp_stacktrace.body['stackFrames']
-    assert frames[0] == {
-        'id': ANY.int,
-        'name': 'home',
-        'source': {
-            'sourceReference': ANY.int,
-            'path': ANY.such_that(lambda s: compare_path(s, DJANGO1_MANAGE)),
-        },
-        'line': bp_line,
-        'column': 1,
-    }
-
-    fid = frames[0]['id']
-    resp_scopes = child_session.send_request('scopes', arguments={
-        'frameId': fid
-    }).wait_for_response()
-    scopes = resp_scopes.body['scopes']
-    assert len(scopes) > 0
-
-    resp_variables = child_session.send_request('variables', arguments={
-        'variablesReference': scopes[0]['variablesReference']
-    }).wait_for_response()
-    variables = list(v for v in resp_variables.body['variables'] if v['name'] == 'content')
-    assert variables == [{
-            'name': 'content',
-            'type': 'str',
-            'value': repr(bp_var_content),
-            'presentationHint': {'attributes': ['rawString']},
-            'evaluateName': 'content'
-        }]
-
-    child_session.send_request('continue').wait_for_response()
-    child_session.wait_for_next(Event('continued'))
-
-    web_content = web_request.wait_for_response()
-    assert web_content.find(bp_var_content) != -1
-
-    # shutdown to web server
-    link = DJANGO_LINK + 'exit'
-    get_web_content(link).wait_for_response()
+def test_django_breakpoint_multiproc(start_method):
+    with DebugSession() as parent_session:
+        parent_session.initialize(
+            start_method=start_method,
+            target=('file', DJANGO1_MANAGE),
+            multiprocess=True,
+            program_args=['runserver'],
+            debug_options=['Django'],
+            cwd=DJANGO1_ROOT,
+            ignore_unobserved=[Event('stopped'), Event('continued')],
+            expected_returncode=ANY.int,  # No clean way to kill Flask server
+        )
+
+        bp_line = 40
+        bp_var_content = 'Django-Django-Test'
+        parent_session.set_breakpoints(DJANGO1_MANAGE, [bp_line])
+        parent_session.start_debugging()
+
+        with parent_session.connect_to_next_child_session() as child_session:
+            child_session.send_request('setBreakpoints', arguments={
+                'source': {'path': DJANGO1_MANAGE},
+                'breakpoints': [{'line': bp_line}, ],
+            }).wait_for_response()
+            child_session.start_debugging()
+
+            # wait for Django server to start
+            while True:
+                child_session.proceed()
+                o = child_session.wait_for_next(Event('output'))
+                if get_url_from_str(o.body['output']) is not None:
+                    break
+
+            web_request = get_web_content(DJANGO_LINK, {})
+
+            thread_stopped = child_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'breakpoint'})))
+            assert thread_stopped.body['threadId'] is not None
+
+            tid = thread_stopped.body['threadId']
+
+            resp_stacktrace = child_session.send_request('stackTrace', arguments={
+                'threadId': tid,
+            }).wait_for_response()
+            assert resp_stacktrace.body['totalFrames'] > 0
+            frames = resp_stacktrace.body['stackFrames']
+            assert frames[0] == {
+                'id': ANY.int,
+                'name': 'home',
+                'source': {
+                    'sourceReference': ANY.int,
+                    'path': ANY.such_that(lambda s: compare_path(s, DJANGO1_MANAGE)),
+                },
+                'line': bp_line,
+                'column': 1,
+            }
+
+            fid = frames[0]['id']
+            resp_scopes = child_session.send_request('scopes', arguments={
+                'frameId': fid
+            }).wait_for_response()
+            scopes = resp_scopes.body['scopes']
+            assert len(scopes) > 0
+
+            resp_variables = child_session.send_request('variables', arguments={
+                'variablesReference': scopes[0]['variablesReference']
+            }).wait_for_response()
+            variables = list(v for v in resp_variables.body['variables'] if v['name'] == 'content')
+            assert variables == [{
+                    'name': 'content',
+                    'type': 'str',
+                    'value': repr(bp_var_content),
+                    'presentationHint': {'attributes': ['rawString']},
+                    'evaluateName': 'content'
+                }]
+
+            child_session.send_request('continue').wait_for_response(freeze=False)
+
+            web_content = web_request.wait_for_response()
+            assert web_content.find(bp_var_content) != -1
+
+            # shutdown to web server
+            link = DJANGO_LINK + 'exit'
+            get_web_content(link).wait_for_response()
+
+            child_session.wait_for_termination()
+            parent_session.wait_for_exit()
diff --git a/pytests/func/test_evaluate.py b/pytests/func/test_evaluate.py
--- a/pytests/func/test_evaluate.py
+++ b/pytests/func/test_evaluate.py
@@ -4,13 +4,12 @@
 
 from __future__ import print_function, with_statement, absolute_import
 
-from pytests.helpers import print
 from pytests.helpers.pattern import ANY
+from pytests.helpers.session import DebugSession
 from pytests.helpers.timeline import Event
 
 
-def test_variables_and_evaluate(debug_session, pyfile, run_as, start_method):
-
+def test_variables_and_evaluate(pyfile, run_as, start_method):
     @pyfile
     def code_to_debug():
         from dbgimporter import import_and_enable_debugger
@@ -22,140 +21,145 @@ def code_to_debug():
 
     bp_line = 6
     bp_file = code_to_debug
-    debug_session.initialize(target=(run_as, bp_file), start_method=start_method)
-    debug_session.set_breakpoints(bp_file, [bp_line])
-    debug_session.start_debugging()
-    hit = debug_session.wait_for_thread_stopped()
-
-    resp_scopes = debug_session.send_request('scopes', arguments={
-        'frameId': hit.frame_id,
-    }).wait_for_response()
-    scopes = resp_scopes.body['scopes']
-    assert len(scopes) > 0
-
-    resp_variables = debug_session.send_request('variables', arguments={
-        'variablesReference': scopes[0]['variablesReference']
-    }).wait_for_response()
-    variables = list(v for v in resp_variables.body['variables'] if v['name'] in ['a', 'b', 'c'])
-    assert len(variables) == 3
-
-    # variables should be sorted alphabetically
-    assert ['a', 'b', 'c'] == list(v['name'] for v in variables)
-
-    # get contents of 'b'
-    resp_b_variables = debug_session.send_request('variables', arguments={
-        'variablesReference': variables[1]['variablesReference']
-    }).wait_for_response()
-    b_variables = resp_b_variables.body['variables']
-    assert len(b_variables) == 3
-    assert b_variables[0] == {
-        'type': 'int',
-        'value': '1',
-        'name': ANY.such_that(lambda x: x.find('one') > 0),
-        'evaluateName': "b['one']"
-    }
-    assert b_variables[1] == {
-        'type': 'int',
-        'value': '2',
-        'name': ANY.such_that(lambda x: x.find('two') > 0),
-        'evaluateName': "b['two']"
-    }
-    assert b_variables[2] == {
-        'type': 'int',
-        'value': '2',
-        'name': '__len__',
-        'evaluateName': "b.__len__"
-    }
-
-    # simple variable
-    resp_evaluate1 = debug_session.send_request('evaluate', arguments={
-        'expression': 'a', 'frameId': hit.frame_id,
-    }).wait_for_response()
-    assert resp_evaluate1.body == ANY.dict_with({
-        'type': 'int',
-        'result': '1'
-    })
-
-    # dict variable
-    resp_evaluate2 = debug_session.send_request('evaluate', arguments={
-        'expression': 'b["one"]', 'frameId': hit.frame_id,
-    }).wait_for_response()
-    assert resp_evaluate2.body == ANY.dict_with({
-        'type': 'int',
-        'result': '1'
-    })
-
-    # expression evaluate
-    resp_evaluate3 = debug_session.send_request('evaluate', arguments={
-        'expression': 'a + b["one"]', 'frameId': hit.frame_id,
-    }).wait_for_response()
-    assert resp_evaluate3.body == ANY.dict_with({
-        'type': 'int',
-        'result': '2'
-    })
-
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-
-    debug_session.wait_for_exit()
-
-
-def test_set_variable(debug_session, pyfile, run_as, start_method):
+
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, bp_file),
+            start_method=start_method,
+            ignore_unobserved=[Event('continued')],
+        )
+        session.set_breakpoints(bp_file, [bp_line])
+        session.start_debugging()
+        hit = session.wait_for_thread_stopped()
+
+        resp_scopes = session.send_request('scopes', arguments={
+            'frameId': hit.frame_id,
+        }).wait_for_response()
+        scopes = resp_scopes.body['scopes']
+        assert len(scopes) > 0
+
+        resp_variables = session.send_request('variables', arguments={
+            'variablesReference': scopes[0]['variablesReference']
+        }).wait_for_response()
+        variables = list(v for v in resp_variables.body['variables'] if v['name'] in ['a', 'b', 'c'])
+        assert len(variables) == 3
+
+        # variables should be sorted alphabetically
+        assert ['a', 'b', 'c'] == list(v['name'] for v in variables)
+
+        # get contents of 'b'
+        resp_b_variables = session.send_request('variables', arguments={
+            'variablesReference': variables[1]['variablesReference']
+        }).wait_for_response()
+        b_variables = resp_b_variables.body['variables']
+        assert len(b_variables) == 3
+        assert b_variables[0] == {
+            'type': 'int',
+            'value': '1',
+            'name': ANY.such_that(lambda x: x.find('one') > 0),
+            'evaluateName': "b['one']"
+        }
+        assert b_variables[1] == {
+            'type': 'int',
+            'value': '2',
+            'name': ANY.such_that(lambda x: x.find('two') > 0),
+            'evaluateName': "b['two']"
+        }
+        assert b_variables[2] == {
+            'type': 'int',
+            'value': '2',
+            'name': '__len__',
+            'evaluateName': "b.__len__"
+        }
+
+        # simple variable
+        resp_evaluate1 = session.send_request('evaluate', arguments={
+            'expression': 'a', 'frameId': hit.frame_id,
+        }).wait_for_response()
+        assert resp_evaluate1.body == ANY.dict_with({
+            'type': 'int',
+            'result': '1'
+        })
+
+        # dict variable
+        resp_evaluate2 = session.send_request('evaluate', arguments={
+            'expression': 'b["one"]', 'frameId': hit.frame_id,
+        }).wait_for_response()
+        assert resp_evaluate2.body == ANY.dict_with({
+            'type': 'int',
+            'result': '1'
+        })
+
+        # expression evaluate
+        resp_evaluate3 = session.send_request('evaluate', arguments={
+            'expression': 'a + b["one"]', 'frameId': hit.frame_id,
+        }).wait_for_response()
+        assert resp_evaluate3.body == ANY.dict_with({
+            'type': 'int',
+            'result': '2'
+        })
+
+        session.send_request('continue').wait_for_response(freeze=False)
+        session.wait_for_exit()
+
+
+def test_set_variable(pyfile, run_as, start_method):
     @pyfile
     def code_to_debug():
+        import backchannel
         from dbgimporter import import_and_enable_debugger
         import_and_enable_debugger()
+        import ptvsd
         a = 1
-        print(a)
-
-    bp_line = 4
-    bp_file = code_to_debug
-    debug_session.initialize(target=(run_as, bp_file), start_method=start_method)
-    debug_session.set_breakpoints(bp_file, [bp_line])
-    debug_session.start_debugging()
-    hit = debug_session.wait_for_thread_stopped()
-
-    resp_scopes = debug_session.send_request('scopes', arguments={
-        'frameId': hit.frame_id
-    }).wait_for_response()
-    scopes = resp_scopes.body['scopes']
-    assert len(scopes) > 0
-
-    resp_variables = debug_session.send_request('variables', arguments={
-        'variablesReference': scopes[0]['variablesReference']
-    }).wait_for_response()
-    variables = list(v for v in resp_variables.body['variables'] if v['name'] == 'a')
-    assert len(variables) == 1
-    assert variables[0] == {
-        'type': 'int',
-        'value': '1',
-        'name': 'a',
-        'evaluateName': "a"
-    }
-
-    resp_set_variable = debug_session.send_request('setVariable', arguments={
-        'variablesReference': scopes[0]['variablesReference'],
-        'name': 'a',
-        'value': '1000'
-    }).wait_for_response()
-    assert resp_set_variable.body == ANY.dict_with({
-        'type': 'int',
-        'value': '1000'
-    })
-
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-
-    debug_session.wait_for_next(Event('output'))
-    output = [e for e in debug_session.all_occurrences_of(Event('output'))
-              if e.body['output'].startswith('1000')]
-    assert any(output)
-
-    debug_session.wait_for_exit()
-
-
-def test_variable_sort(debug_session, pyfile, run_as, start_method):
-
+        ptvsd.break_into_debugger()
+        backchannel.write_json(a)
+
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, code_to_debug),
+            start_method=start_method,
+            ignore_unobserved=[Event('continued')],
+            use_backchannel=True,
+        )
+        session.start_debugging()
+        hit = session.wait_for_thread_stopped()
+
+        resp_scopes = session.send_request('scopes', arguments={
+            'frameId': hit.frame_id
+        }).wait_for_response()
+        scopes = resp_scopes.body['scopes']
+        assert len(scopes) > 0
+
+        resp_variables = session.send_request('variables', arguments={
+            'variablesReference': scopes[0]['variablesReference']
+        }).wait_for_response()
+        variables = list(v for v in resp_variables.body['variables'] if v['name'] == 'a')
+        assert len(variables) == 1
+        assert variables[0] == {
+            'type': 'int',
+            'value': '1',
+            'name': 'a',
+            'evaluateName': "a"
+        }
+
+        resp_set_variable = session.send_request('setVariable', arguments={
+            'variablesReference': scopes[0]['variablesReference'],
+            'name': 'a',
+            'value': '1000'
+        }).wait_for_response()
+        assert resp_set_variable.body == ANY.dict_with({
+            'type': 'int',
+            'value': '1000'
+        })
+
+        session.send_request('continue').wait_for_response(freeze=False)
+
+        assert session.read_json() == 1000
+
+        session.wait_for_exit()
+
+
+def test_variable_sort(pyfile, run_as, start_method):
     @pyfile
     def code_to_debug():
         from dbgimporter import import_and_enable_debugger
@@ -177,50 +181,57 @@ def code_to_debug():
 
     bp_line = 15
     bp_file = code_to_debug
-    debug_session.initialize(target=(run_as, bp_file), start_method=start_method)
-    debug_session.set_breakpoints(bp_file, [bp_line])
-    debug_session.start_debugging()
-    hit = debug_session.wait_for_thread_stopped()
-
-    resp_scopes = debug_session.send_request('scopes', arguments={
-        'frameId': hit.frame_id
-    }).wait_for_response()
-    scopes = resp_scopes.body['scopes']
-    assert len(scopes) > 0
-
-    resp_variables = debug_session.send_request('variables', arguments={
-        'variablesReference': scopes[0]['variablesReference']
-    }).wait_for_response()
-    variable_names = list(v['name'] for v in resp_variables.body['variables']
-                          if v['name'].find('_test') > 0)
-    assert variable_names == [
-            'a_test', 'b_test', 'c_test', '_a_test', '_b_test', '_c_test',
-            '__a_test', '__b_test', '__c_test', '__a_test__', '__b_test__',
-            '__c_test__'
-        ]
-
-    # ensure string dict keys are sorted
-    b_test_variable = list(v for v in resp_variables.body['variables'] if v['name'] == 'b_test')
-    assert len(b_test_variable) == 1
-    resp_dict_variables = debug_session.send_request('variables', arguments={
-        'variablesReference': b_test_variable[0]['variablesReference']
-    }).wait_for_response()
-    variable_names = list(v['name'][1:5] for v in resp_dict_variables.body['variables'])
-    assert len(variable_names) == 4
-    assert variable_names[:3] == ['abcd', 'eggs', 'spam']
-
-    # ensure numeric dict keys are sorted
-    c_test_variable = list(v for v in resp_variables.body['variables'] if v['name'] == 'c_test')
-    assert len(c_test_variable) == 1
-    resp_dict_variables2 = debug_session.send_request('variables', arguments={
-        'variablesReference': c_test_variable[0]['variablesReference']
-    }).wait_for_response()
-    variable_names = list(v['name'] for v in resp_dict_variables2.body['variables'])
-    assert len(variable_names) == 4
-    # NOTE: this is commented out due to sorting bug #213
-    # assert variable_names[:3] == ['1', '2', '10']
-
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-
-    debug_session.wait_for_exit()
+
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, bp_file),
+            start_method=start_method,
+            ignore_unobserved=[Event('continued')],
+        )
+        session.set_breakpoints(bp_file, [bp_line])
+        session.start_debugging()
+        hit = session.wait_for_thread_stopped()
+
+        resp_scopes = session.send_request('scopes', arguments={
+            'frameId': hit.frame_id
+        }).wait_for_response()
+        scopes = resp_scopes.body['scopes']
+        assert len(scopes) > 0
+
+        resp_variables = session.send_request('variables', arguments={
+            'variablesReference': scopes[0]['variablesReference']
+        }).wait_for_response()
+        variable_names = list(
+            v['name']
+            for v in resp_variables.body['variables']
+            if v['name'].find('_test') > 0
+        )
+        assert variable_names == [
+                'a_test', 'b_test', 'c_test', '_a_test', '_b_test', '_c_test',
+                '__a_test', '__b_test', '__c_test', '__a_test__', '__b_test__',
+                '__c_test__'
+            ]
+
+        # ensure string dict keys are sorted
+        b_test_variable = list(v for v in resp_variables.body['variables'] if v['name'] == 'b_test')
+        assert len(b_test_variable) == 1
+        resp_dict_variables = session.send_request('variables', arguments={
+            'variablesReference': b_test_variable[0]['variablesReference']
+        }).wait_for_response()
+        variable_names = list(v['name'][1:5] for v in resp_dict_variables.body['variables'])
+        assert len(variable_names) == 4
+        assert variable_names[:3] == ['abcd', 'eggs', 'spam']
+
+        # ensure numeric dict keys are sorted
+        c_test_variable = list(v for v in resp_variables.body['variables'] if v['name'] == 'c_test')
+        assert len(c_test_variable) == 1
+        resp_dict_variables2 = session.send_request('variables', arguments={
+            'variablesReference': c_test_variable[0]['variablesReference']
+        }).wait_for_response()
+        variable_names = list(v['name'] for v in resp_dict_variables2.body['variables'])
+        assert len(variable_names) == 4
+        # NOTE: this is commented out due to sorting bug #213
+        # assert variable_names[:3] == ['1', '2', '10']
+
+        session.send_request('continue').wait_for_response(freeze=False)
+        session.wait_for_exit()
diff --git a/pytests/func/test_flask.py b/pytests/func/test_flask.py
--- a/pytests/func/test_flask.py
+++ b/pytests/func/test_flask.py
@@ -23,7 +23,7 @@
 FLASK_PORT = 5000
 
 
-def _flask_no_multiproc_common(debug_session, start_method):
+def _initialize_flask_session_no_multiproc(session, start_method):
     env = {
         'FLASK_APP': 'app.py',
         'FLASK_ENV': 'development',
@@ -36,11 +36,11 @@ def _flask_no_multiproc_common(debug_session, start_method):
             'LANG': locale,
         })
 
-    debug_session.initialize(
+    session.initialize(
         start_method=start_method,
         target=('module', 'flask'),
         program_args=['run', '--no-debugger', '--no-reload', '--with-threads'],
-        ignore_events=[Event('stopped'), Event('continued')],
+        ignore_unobserved=[Event('stopped'), Event('continued')],
         debug_options=['Jinja'],
         cwd=FLASK1_ROOT,
         env=env,
@@ -48,169 +48,160 @@ def _flask_no_multiproc_common(debug_session, start_method):
     )
 
 
-@pytest.mark.parametrize('bp_file, bp_line, bp_name', [
-  (FLASK1_APP, 11, 'home'),
-  (FLASK1_TEMPLATE, 8, 'template'),
-])
+@pytest.mark.parametrize('bp_target', ['code', 'template'])
 @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
 @pytest.mark.timeout(60)
-def test_flask_breakpoint_no_multiproc(debug_session, bp_file, bp_line, bp_name, start_method):
-    _flask_no_multiproc_common(debug_session, start_method)
-
-    bp_var_content = 'Flask-Jinja-Test'
-    debug_session.set_breakpoints(bp_file, [bp_line])
-    debug_session.start_debugging()
-
-    # wait for Flask web server to start
-    wait_for_connection(FLASK_PORT)
-    link = FLASK_LINK
-    web_request = get_web_content(link, {})
-
-    thread_stopped = debug_session.wait_for_next(Event('stopped'), ANY.dict_with({'reason': 'breakpoint'}))
-    assert thread_stopped.body['threadId'] is not None
-
-    tid = thread_stopped.body['threadId']
-
-    resp_stacktrace = debug_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 0
-    frames = resp_stacktrace.body['stackFrames']
-    assert frames[0] == {
-        'id': ANY.int,
-        'name': bp_name,
-        'source': {
-            'sourceReference': ANY.int,
-            'path': ANY.such_that(lambda s: compare_path(s, bp_file)),
-        },
-        'line': bp_line,
-        'column': 1,
-    }
-
-    fid = frames[0]['id']
-    resp_scopes = debug_session.send_request('scopes', arguments={
-        'frameId': fid
-    }).wait_for_response()
-    scopes = resp_scopes.body['scopes']
-    assert len(scopes) > 0
-
-    resp_variables = debug_session.send_request('variables', arguments={
-        'variablesReference': scopes[0]['variablesReference']
-    }).wait_for_response()
-    variables = list(v for v in resp_variables.body['variables'] if v['name'] == 'content')
-    assert variables == [{
-            'name': 'content',
-            'type': 'str',
-            'value': repr(bp_var_content),
-            'presentationHint': {'attributes': ['rawString']},
-            'evaluateName': 'content'
-        }]
-
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-
-    web_content = web_request.wait_for_response()
-    assert web_content.find(bp_var_content) != -1
-
-    # shutdown to web server
-    link = FLASK_LINK + 'exit'
-    get_web_content(link).wait_for_response()
-
-    debug_session.wait_for_exit()
-
-
-@pytest.mark.parametrize('ex_type, ex_line', [
-  ('handled', 21),
-  ('unhandled', 33),
-])
-@pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
-@pytest.mark.timeout(60)
-def test_flask_exception_no_multiproc(debug_session, ex_type, ex_line, start_method):
-    _flask_no_multiproc_common(debug_session, start_method)
-
-    debug_session.send_request('setExceptionBreakpoints', arguments={
-        'filters': ['raised', 'uncaught'],
-    }).wait_for_response()
-
-    debug_session.start_debugging()
-
-    # wait for Flask web server to start
-    wait_for_connection(FLASK_PORT)
-    base_link = FLASK_LINK
-    link = base_link + ex_type if base_link.endswith('/') else ('/' + ex_type)
-    web_request = get_web_content(link, {})
-
-    thread_stopped = debug_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'exception'})))
-    assert thread_stopped == Event('stopped', ANY.dict_with({
-        'reason': 'exception',
-        'text': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
-        'description': 'Hello'
-    }))
-
-    tid = thread_stopped.body['threadId']
-    resp_exception_info = debug_session.send_request(
-        'exceptionInfo',
-        arguments={'threadId': tid, }
-    ).wait_for_response()
-    exception = resp_exception_info.body
-    assert exception == {
-        'exceptionId': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
-        'breakMode': 'always',
-        'description': 'Hello',
-        'details': {
-            'message': 'Hello',
-            'typeName': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
-            'source': ANY.such_that(lambda s: compare_path(s, FLASK1_APP)),
-            'stackTrace': ANY.such_that(lambda s: True)
+def test_flask_breakpoint_no_multiproc(bp_target, start_method):
+    bp_file, bp_line, bp_name = {
+        'code': (FLASK1_APP, 11, 'home'),
+        'template': (FLASK1_TEMPLATE, 8, 'template')
+    }[bp_target]
+
+    with DebugSession() as session:
+        _initialize_flask_session_no_multiproc(session, start_method)
+
+        bp_var_content = 'Flask-Jinja-Test'
+        session.set_breakpoints(bp_file, [bp_line])
+        session.start_debugging()
+
+        # wait for Flask web server to start
+        wait_for_connection(FLASK_PORT)
+        link = FLASK_LINK
+        web_request = get_web_content(link, {})
+
+        thread_stopped = session.wait_for_next(Event('stopped'), ANY.dict_with({'reason': 'breakpoint'}))
+        assert thread_stopped.body['threadId'] is not None
+
+        tid = thread_stopped.body['threadId']
+
+        resp_stacktrace = session.send_request('stackTrace', arguments={
+            'threadId': tid,
+        }).wait_for_response()
+        assert resp_stacktrace.body['totalFrames'] > 0
+        frames = resp_stacktrace.body['stackFrames']
+        assert frames[0] == {
+            'id': ANY.int,
+            'name': bp_name,
+            'source': {
+                'sourceReference': ANY.int,
+                'path': ANY.such_that(lambda s: compare_path(s, bp_file)),
+            },
+            'line': bp_line,
+            'column': 1,
         }
-    }
 
-    resp_stacktrace = debug_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 0
-    frames = resp_stacktrace.body['stackFrames']
-    assert frames[0] == {
-        'id': ANY.int,
-        'name': 'bad_route_' + ex_type,
-        'source': {
-            'sourceReference': ANY.int,
-            'path': ANY.such_that(lambda s: compare_path(s, FLASK1_APP)),
-        },
-        'line': ex_line,
-        'column': 1,
-    }
+        fid = frames[0]['id']
+        resp_scopes = session.send_request('scopes', arguments={
+            'frameId': fid
+        }).wait_for_response()
+        scopes = resp_scopes.body['scopes']
+        assert len(scopes) > 0
+
+        resp_variables = session.send_request('variables', arguments={
+            'variablesReference': scopes[0]['variablesReference']
+        }).wait_for_response()
+        variables = list(v for v in resp_variables.body['variables'] if v['name'] == 'content')
+        assert variables == [{
+                'name': 'content',
+                'type': 'str',
+                'value': repr(bp_var_content),
+                'presentationHint': {'attributes': ['rawString']},
+                'evaluateName': 'content'
+            }]
 
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
+        session.send_request('continue').wait_for_response(freeze=False)
 
-    # ignore response for exception tests
-    web_request.wait_for_response()
+        web_content = web_request.wait_for_response()
+        assert web_content.find(bp_var_content) != -1
 
-    # shutdown to web server
-    link = base_link + 'exit' if base_link.endswith('/') else '/exit'
-    get_web_content(link).wait_for_response()
+        # shutdown to web server
+        link = FLASK_LINK + 'exit'
+        get_web_content(link).wait_for_response()
 
-    debug_session.wait_for_exit()
+        session.wait_for_exit()
 
-def _wait_for_child_process(debug_session):
-    child_subprocess = debug_session.wait_for_next(Event('ptvsd_subprocess'))
-    assert child_subprocess.body['port'] != 0
 
-    child_port = child_subprocess.body['port']
+@pytest.mark.parametrize('ex_type', ['handled', 'unhandled'])
+@pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
+@pytest.mark.timeout(60)
+def test_flask_exception_no_multiproc(ex_type, start_method):
+    ex_line = {
+        'handled': 21,
+        'unhandled': 33,
+    }[ex_type]
+
+    with DebugSession() as session:
+        _initialize_flask_session_no_multiproc(session, start_method)
+
+        session.send_request('setExceptionBreakpoints', arguments={
+            'filters': ['raised', 'uncaught'],
+        }).wait_for_response()
+
+        session.start_debugging()
+
+        # wait for Flask web server to start
+        wait_for_connection(FLASK_PORT)
+        base_link = FLASK_LINK
+        link = base_link + ex_type if base_link.endswith('/') else ('/' + ex_type)
+        web_request = get_web_content(link, {})
+
+        thread_stopped = session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'exception'})))
+        assert thread_stopped == Event('stopped', ANY.dict_with({
+            'reason': 'exception',
+            'text': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
+            'description': 'Hello'
+        }))
+
+        tid = thread_stopped.body['threadId']
+        resp_exception_info = session.send_request(
+            'exceptionInfo',
+            arguments={'threadId': tid, }
+        ).wait_for_response()
+        exception = resp_exception_info.body
+        assert exception == {
+            'exceptionId': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
+            'breakMode': 'always',
+            'description': 'Hello',
+            'details': {
+                'message': 'Hello',
+                'typeName': ANY.such_that(lambda s: s.endswith('ArithmeticError')),
+                'source': ANY.such_that(lambda s: compare_path(s, FLASK1_APP)),
+                'stackTrace': ANY.such_that(lambda s: True)
+            }
+        }
+
+        resp_stacktrace = session.send_request('stackTrace', arguments={
+            'threadId': tid,
+        }).wait_for_response()
+        assert resp_stacktrace.body['totalFrames'] > 0
+        frames = resp_stacktrace.body['stackFrames']
+        assert frames[0] == {
+            'id': ANY.int,
+            'name': 'bad_route_' + ex_type,
+            'source': {
+                'sourceReference': ANY.int,
+                'path': ANY.such_that(lambda s: compare_path(s, FLASK1_APP)),
+            },
+            'line': ex_line,
+            'column': 1,
+        }
+
+        session.send_request('continue').wait_for_response(freeze=False)
 
-    child_session = DebugSession(start_method='attach_socket_cmdline', ptvsd_port=child_port)
-    child_session.ignore_unobserved = debug_session.ignore_unobserved
-    child_session.debug_options = debug_session.debug_options
-    child_session.connect()
-    child_session.handshake()
-    return child_session
+        # ignore response for exception tests
+        web_request.wait_for_response()
+
+        # shutdown to web server
+        link = base_link + 'exit' if base_link.endswith('/') else '/exit'
+        get_web_content(link).wait_for_response()
+
+        session.wait_for_exit()
 
 
 @pytest.mark.timeout(120)
 @pytest.mark.parametrize('start_method', ['launch'])
 @pytest.mark.skipif((sys.version_info < (3, 0)) and (platform.system() != 'Windows'), reason='Bug #935')
-def test_flask_breakpoint_multiproc(debug_session, start_method):
+def test_flask_breakpoint_multiproc(start_method):
     env = {
         'FLASK_APP': 'app',
         'FLASK_ENV': 'development',
@@ -223,82 +214,83 @@ def test_flask_breakpoint_multiproc(debug_session, start_method):
             'LANG': locale,
         })
 
-    debug_session.initialize(
-        start_method=start_method,
-        target=('module', 'flask'),
-        multiprocess=True,
-        program_args=['run', ],
-        ignore_events=[Event('stopped'), Event('continued')],
-        debug_options=['Jinja'],
-        cwd=FLASK1_ROOT,
-        env=env,
-        expected_returncode=ANY.int,  # No clean way to kill Flask server
-    )
-
-    bp_line = 11
-    bp_var_content = 'Flask-Jinja-Test'
-    debug_session.set_breakpoints(FLASK1_APP, [bp_line])
-    debug_session.start_debugging()
-    child_session = _wait_for_child_process(debug_session)
-
-    child_session.send_request('setBreakpoints', arguments={
-        'source': {'path': FLASK1_APP},
-        'breakpoints': [{'line': bp_line}, ],
-    }).wait_for_response()
-    child_session.start_debugging()
-
-    # wait for Flask server to start
-    wait_for_connection(FLASK_PORT)
-    web_request = get_web_content(FLASK_LINK, {})
-
-    thread_stopped = child_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'breakpoint'})))
-    assert thread_stopped.body['threadId'] is not None
-
-    tid = thread_stopped.body['threadId']
-
-    resp_stacktrace = child_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 0
-    frames = resp_stacktrace.body['stackFrames']
-    assert frames[0] == {
-        'id': ANY.int,
-        'name': 'home',
-        'source': {
-            'sourceReference': ANY.int,
-            'path': ANY.such_that(lambda s: compare_path(s, FLASK1_APP)),
-        },
-        'line': bp_line,
-        'column': 1,
-    }
-
-    fid = frames[0]['id']
-    resp_scopes = child_session.send_request('scopes', arguments={
-        'frameId': fid
-    }).wait_for_response()
-    scopes = resp_scopes.body['scopes']
-    assert len(scopes) > 0
-
-    resp_variables = child_session.send_request('variables', arguments={
-        'variablesReference': scopes[0]['variablesReference']
-    }).wait_for_response()
-    variables = [v for v in resp_variables.body['variables'] if v['name'] == 'content']
-    assert variables == [{
-            'name': 'content',
-            'type': 'str',
-            'value': repr(bp_var_content),
-            'presentationHint': {'attributes': ['rawString']},
-            'evaluateName': 'content'
-        }]
-
-    child_session.send_request('continue').wait_for_response()
-    child_session.wait_for_next(Event('continued'))
-
-    web_content = web_request.wait_for_response()
-    assert web_content.find(bp_var_content) != -1
-
-    # shutdown to web server
-    link = FLASK_LINK + 'exit'
-    get_web_content(link).wait_for_response()
-
-    debug_session.wait_for_exit()
+    with DebugSession() as parent_session:
+        parent_session.initialize(
+            start_method=start_method,
+            target=('module', 'flask'),
+            multiprocess=True,
+            program_args=['run', ],
+            ignore_unobserved=[Event('stopped'), Event('continued')],
+            debug_options=['Jinja'],
+            cwd=FLASK1_ROOT,
+            env=env,
+            expected_returncode=ANY.int,  # No clean way to kill Flask server
+        )
+
+        bp_line = 11
+        bp_var_content = 'Flask-Jinja-Test'
+        parent_session.set_breakpoints(FLASK1_APP, [bp_line])
+        parent_session.start_debugging()
+
+        with parent_session.connect_to_next_child_session() as child_session:
+            child_session.send_request('setBreakpoints', arguments={
+                'source': {'path': FLASK1_APP},
+                'breakpoints': [{'line': bp_line}, ],
+            }).wait_for_response()
+            child_session.start_debugging()
+
+            # wait for Flask server to start
+            wait_for_connection(FLASK_PORT)
+            web_request = get_web_content(FLASK_LINK, {})
+
+            thread_stopped = child_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'breakpoint'})))
+            assert thread_stopped.body['threadId'] is not None
+
+            tid = thread_stopped.body['threadId']
+
+            resp_stacktrace = child_session.send_request('stackTrace', arguments={
+                'threadId': tid,
+            }).wait_for_response()
+            assert resp_stacktrace.body['totalFrames'] > 0
+            frames = resp_stacktrace.body['stackFrames']
+            assert frames[0] == {
+                'id': ANY.int,
+                'name': 'home',
+                'source': {
+                    'sourceReference': ANY.int,
+                    'path': ANY.such_that(lambda s: compare_path(s, FLASK1_APP)),
+                },
+                'line': bp_line,
+                'column': 1,
+            }
+
+            fid = frames[0]['id']
+            resp_scopes = child_session.send_request('scopes', arguments={
+                'frameId': fid
+            }).wait_for_response()
+            scopes = resp_scopes.body['scopes']
+            assert len(scopes) > 0
+
+            resp_variables = child_session.send_request('variables', arguments={
+                'variablesReference': scopes[0]['variablesReference']
+            }).wait_for_response()
+            variables = [v for v in resp_variables.body['variables'] if v['name'] == 'content']
+            assert variables == [{
+                    'name': 'content',
+                    'type': 'str',
+                    'value': repr(bp_var_content),
+                    'presentationHint': {'attributes': ['rawString']},
+                    'evaluateName': 'content'
+                }]
+
+            child_session.send_request('continue').wait_for_response(freeze=False)
+
+            web_content = web_request.wait_for_response()
+            assert web_content.find(bp_var_content) != -1
+
+            # shutdown to web server
+            link = FLASK_LINK + 'exit'
+            get_web_content(link).wait_for_response()
+
+            child_session.wait_for_termination()
+            parent_session.wait_for_exit()
diff --git a/pytests/func/test_multiproc.py b/pytests/func/test_multiproc.py
--- a/pytests/func/test_multiproc.py
+++ b/pytests/func/test_multiproc.py
@@ -13,11 +13,11 @@
 from pytests.helpers.timeline import Event, Request, Response
 
 
-@pytest.mark.timeout(60)
+@pytest.mark.timeout(30)
 @pytest.mark.skipif(platform.system() != 'Windows',
                     reason='Debugging multiprocessing module only works on Windows')
 @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
-def test_multiprocessing(debug_session, pyfile, run_as, start_method):
+def test_multiprocessing(pyfile, run_as, start_method):
     @pyfile
     def code_to_debug():
         import multiprocessing
@@ -67,79 +67,66 @@ def child(q):
             q.close()
             backchannel.write_json('done')
 
-    debug_session.initialize(multiprocess=True, target=(run_as, code_to_debug), start_method=start_method, use_backchannel=True)
-    debug_session.start_debugging()
-
-    root_start_request, = debug_session.all_occurrences_of(Request('launch') | Request('attach'))
-    root_process, = debug_session.all_occurrences_of(Event('process'))
-    root_pid = int(root_process.body['systemProcessId'])
-
-    child_pid = debug_session.read_json()
-
-    child_subprocess = debug_session.wait_for_next(Event('ptvsd_subprocess'))
-    assert child_subprocess == Event('ptvsd_subprocess', {
-        'rootProcessId': root_pid,
-        'parentProcessId': root_pid,
-        'processId': child_pid,
-        'port': ANY.int,
-        'rootStartRequest': {
-            'seq': ANY.int,
-            'type': 'request',
-            'command': root_start_request.command,
-            'arguments': root_start_request.arguments,
-        }
-    })
-    child_port = child_subprocess.body['port']
-
-    child_session = DebugSession(start_method='attach_socket_cmdline', ptvsd_port=child_port)
-    child_session.ignore_unobserved = debug_session.ignore_unobserved
-    child_session.connect()
-    child_session.handshake()
-    child_session.start_debugging()
-
-    debug_session.proceed()
-    child_child_subprocess = debug_session.wait_for_next(Event('ptvsd_subprocess'))
-    assert child_child_subprocess == Event('ptvsd_subprocess', {
-        'rootProcessId': root_pid,
-        'parentProcessId': child_pid,
-        'processId': ANY.int,
-        'port': ANY.int,
-        'rootStartRequest': {
-            'seq': ANY.int,
-            'type': 'request',
-            'command': root_start_request.command,
-            'arguments': root_start_request.arguments,
-        }
-    })
-    child_child_port = child_child_subprocess.body['port']
-
-    child_child_session = DebugSession(start_method='attach_socket_cmdline', ptvsd_port=child_child_port)
-    child_child_session.ignore_unobserved = debug_session.ignore_unobserved
-    child_child_session.connect()
-    child_child_session.handshake()
-    child_child_session.start_debugging(freeze=False)
-
-    debug_session.write_json('continue')
-
-    if sys.version_info >= (3,):
-        child_child_session.wait_for_termination()
-        child_session.wait_for_termination()
-    else:
-        # These should really be wait_for_termination(), but child processes don't send the
-        # usual sequence of events leading to 'terminate' when they exit for some unclear
-        # reason (ptvsd bug?). So, just wait till they drop connection.
-        child_child_session.wait_for_disconnect()
-        child_session.wait_for_disconnect()
-
-    assert debug_session.read_json() == 'done'
-    debug_session.wait_for_exit()
-
-
-@pytest.mark.timeout(60)
+    with DebugSession() as parent_session:
+        parent_session.initialize(multiprocess=True, target=(run_as, code_to_debug), start_method=start_method, use_backchannel=True)
+        parent_session.start_debugging()
+
+        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'])
+
+        child_pid = parent_session.read_json()
+
+        child_subprocess = parent_session.wait_for_next(Event('ptvsd_subprocess'))
+        assert child_subprocess == Event('ptvsd_subprocess', {
+            'rootProcessId': root_pid,
+            'parentProcessId': root_pid,
+            'processId': child_pid,
+            'port': ANY.int,
+            'rootStartRequest': {
+                'seq': ANY.int,
+                'type': 'request',
+                'command': root_start_request.command,
+                'arguments': root_start_request.arguments,
+            }
+        })
+        parent_session.proceed()
+
+        with parent_session.connect_to_child_session(child_subprocess) as child_session:
+            child_session.start_debugging()
+
+            grandchild_subprocess = parent_session.wait_for_next(Event('ptvsd_subprocess'))
+            assert grandchild_subprocess == Event('ptvsd_subprocess', {
+                'rootProcessId': root_pid,
+                'parentProcessId': child_pid,
+                'processId': ANY.int,
+                'port': ANY.int,
+                'rootStartRequest': {
+                    'seq': ANY.int,
+                    'type': 'request',
+                    'command': root_start_request.command,
+                    'arguments': root_start_request.arguments,
+                }
+            })
+            parent_session.proceed()
+
+            with parent_session.connect_to_child_session(grandchild_subprocess) as grandchild_session:
+                grandchild_session.start_debugging()
+
+                parent_session.write_json('continue')
+
+                grandchild_session.wait_for_termination()
+                child_session.wait_for_termination()
+
+                assert parent_session.read_json() == 'done'
+                parent_session.wait_for_exit()
+
+
+@pytest.mark.timeout(30)
 @pytest.mark.skipif(sys.version_info < (3, 0) and (platform.system() != 'Windows'),
                     reason='Bug #935')
 @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
-def test_subprocess(debug_session, pyfile, start_method, run_as):
+def test_subprocess(pyfile, run_as, start_method):
     @pyfile
     def child():
         import sys
@@ -160,49 +147,45 @@ def parent():
         process = subprocess.Popen(argv, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
         process.wait()
 
-    debug_session.program_args += [child]
-    debug_session.initialize(multiprocess=True, target=(run_as, parent), start_method=start_method, use_backchannel=True)
-    debug_session.start_debugging()
-
-    root_start_request, = debug_session.all_occurrences_of(Request('launch') | Request('attach'))
-    root_process, = debug_session.all_occurrences_of(Event('process'))
-    root_pid = int(root_process.body['systemProcessId'])
-
-    child_subprocess = debug_session.wait_for_next(Event('ptvsd_subprocess'))
-    assert child_subprocess == Event('ptvsd_subprocess', {
-        'rootProcessId': root_pid,
-        'parentProcessId': root_pid,
-        'processId': ANY.int,
-        'port': ANY.int,
-        'rootStartRequest': {
-            'seq': ANY.int,
-            'type': 'request',
-            'command': root_start_request.command,
-            'arguments': root_start_request.arguments,
-        }
-    })
-    child_pid = child_subprocess.body['processId']
-    child_port = child_subprocess.body['port']
-    debug_session.proceed()
-
-    child_session = DebugSession(start_method='attach_socket_cmdline', ptvsd_port=child_port, pid=child_pid)
-    child_session.ignore_unobserved = debug_session.ignore_unobserved
-    child_session.connect()
-    child_session.handshake()
-    child_session.start_debugging()
-
-    child_argv = debug_session.read_json()
-    assert child_argv == [child, '--arg1', '--arg2', '--arg3']
-
-    child_session.wait_for_exit()
-    debug_session.wait_for_exit()
-
-
-@pytest.mark.timeout(60)
+    with DebugSession() as parent_session:
+        parent_session.program_args += [child]
+        parent_session.initialize(multiprocess=True, target=(run_as, parent), start_method=start_method, use_backchannel=True)
+        parent_session.start_debugging()
+
+        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'])
+
+        child_subprocess = parent_session.wait_for_next(Event('ptvsd_subprocess'))
+        assert child_subprocess == Event('ptvsd_subprocess', {
+            'rootProcessId': root_pid,
+            'parentProcessId': root_pid,
+            'processId': ANY.int,
+            'port': ANY.int,
+            'rootStartRequest': {
+                'seq': ANY.int,
+                'type': 'request',
+                'command': root_start_request.command,
+                'arguments': root_start_request.arguments,
+            }
+        })
+        parent_session.proceed()
+
+        with parent_session.connect_to_child_session(child_subprocess) as child_session:
+            child_session.start_debugging()
+
+            child_argv = parent_session.read_json()
+            assert child_argv == [child, '--arg1', '--arg2', '--arg3']
+
+            child_session.wait_for_termination()
+            parent_session.wait_for_exit()
+
+
+@pytest.mark.timeout(30)
 @pytest.mark.skipif(sys.version_info < (3, 0) and (platform.system() != 'Windows'),
                     reason='Bug #935')
 @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
-def test_autokill(debug_session, pyfile, start_method, run_as):
+def test_autokill(pyfile, run_as, start_method):
     @pyfile
     def child():
         from dbgimporter import import_and_enable_debugger
@@ -223,31 +206,23 @@ def parent():
         subprocess.Popen(argv, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
         backchannel.read_json()
 
-    debug_session.program_args += [child]
-    debug_session.initialize(multiprocess=True, target=(run_as, parent), start_method=start_method, use_backchannel=True)
-    debug_session.start_debugging()
-
-    child_subprocess = debug_session.wait_for_next(Event('ptvsd_subprocess'))
-    child_pid = child_subprocess.body['processId']
-    child_port = child_subprocess.body['port']
-
-    debug_session.proceed()
-
-    child_session = DebugSession(start_method='attach_socket_cmdline', ptvsd_port=child_port, pid=child_pid)
-    child_session.expected_returncode = ANY
-    child_session.connect()
-    child_session.handshake()
-    child_session.start_debugging()
-
-    if debug_session.start_method == 'launch':
-        # In launch scenario, terminate the parent process by disconnecting from it.
-        debug_session.expected_returncode = ANY
-        disconnect = debug_session.send_request('disconnect', {})
-        debug_session.wait_for_next(Response(disconnect))
-    else:
-        # In attach scenario, just let the parent process run to completion.
-        debug_session.expected_returncode = 0
-        debug_session.write_json(None)
-
-    debug_session.wait_for_exit()
-    child_session.wait_for_exit()
+    with DebugSession() as parent_session:
+        parent_session.program_args += [child]
+        parent_session.initialize(multiprocess=True, target=(run_as, parent), start_method=start_method, use_backchannel=True)
+        parent_session.start_debugging()
+
+        with parent_session.connect_to_next_child_session() as child_session:
+            child_session.start_debugging()
+
+            if parent_session.start_method == 'launch':
+                # In launch scenario, terminate the parent process by disconnecting from it.
+                parent_session.expected_returncode = ANY
+                disconnect = parent_session.send_request('disconnect', {})
+                parent_session.wait_for_next(Response(disconnect))
+            else:
+                # In attach scenario, just let the parent process run to completion.
+                parent_session.expected_returncode = 0
+                parent_session.write_json(None)
+
+            child_session.wait_for_termination()
+            parent_session.wait_for_exit()
diff --git a/pytests/func/test_run.py b/pytests/func/test_run.py
--- a/pytests/func/test_run.py
+++ b/pytests/func/test_run.py
@@ -11,11 +11,12 @@
 
 from pytests.helpers import print
 from pytests.helpers.pattern import ANY
+from pytests.helpers.session import DebugSession
 from pytests.helpers.timeline import Event
 
 
 @pytest.mark.parametrize('run_as', ['file', 'module', 'code'])
-def test_run(debug_session, pyfile, run_as, start_method):
+def test_run(pyfile, run_as, start_method):
     @pyfile
     def code_to_debug():
         import os
@@ -29,27 +30,28 @@ def code_to_debug():
         backchannel.write_json(os.path.abspath(sys.modules['ptvsd'].__file__))
         print('end')
 
-    debug_session.initialize(target=(run_as, code_to_debug), start_method=start_method, use_backchannel=True)
-    debug_session.start_debugging()
-    assert debug_session.timeline.is_frozen
-
-    process_event, = debug_session.all_occurrences_of(Event('process'))
-    assert process_event == Event('process', ANY.dict_with({
-        'name': ANY if run_as == 'code' else ANY.such_that(lambda name: (
-            # There can be a difference in file extension (.py/.pyc/.pyo) on clean runs.
-            name == code_to_debug or
-            name == code_to_debug + 'c' or
-            name == code_to_debug + 'o'
-        )),
-    }))
-
-    debug_session.write_json('continue')
-    ptvsd_path = debug_session.read_json()
-    expected_ptvsd_path = os.path.abspath(ptvsd.__file__)
-    assert (
-        ptvsd_path == expected_ptvsd_path or
-        ptvsd_path == expected_ptvsd_path + 'c' or
-        ptvsd_path == expected_ptvsd_path + 'o'
-    )
-
-    debug_session.wait_for_exit()
+    with DebugSession() as session:
+        session.initialize(target=(run_as, code_to_debug), start_method=start_method, use_backchannel=True)
+        session.start_debugging()
+        assert session.timeline.is_frozen
+
+        process_event, = session.all_occurrences_of(Event('process'))
+        assert process_event == Event('process', ANY.dict_with({
+            'name': ANY if run_as == 'code' else ANY.such_that(lambda name: (
+                # There can be a difference in file extension (.py/.pyc/.pyo) on clean runs.
+                name == code_to_debug or
+                name == code_to_debug + 'c' or
+                name == code_to_debug + 'o'
+            )),
+        }))
+
+        session.write_json('continue')
+        ptvsd_path = session.read_json()
+        expected_ptvsd_path = os.path.abspath(ptvsd.__file__)
+        assert (
+            ptvsd_path == expected_ptvsd_path or
+            ptvsd_path == expected_ptvsd_path + 'c' or
+            ptvsd_path == expected_ptvsd_path + 'o'
+        )
+
+        session.wait_for_exit()
diff --git a/pytests/func/test_start_stop.py b/pytests/func/test_start_stop.py
--- a/pytests/func/test_start_stop.py
+++ b/pytests/func/test_start_stop.py
@@ -8,168 +8,154 @@
 import pytest
 import sys
 
-from pytests.helpers import print
 from pytests.helpers.pattern import ANY
+from pytests.helpers.session import DebugSession
 from pytests.helpers.timeline import Event
 
 
 @pytest.mark.parametrize('start_method', ['launch'])
-def test_break_on_entry(debug_session, pyfile, run_as, start_method):
+def test_break_on_entry(pyfile, run_as, start_method):
     @pyfile
     def code_to_debug():
+        import backchannel
         from dbgimporter import import_and_enable_debugger
         import_and_enable_debugger()
-        print('one')
-        print('two')
-        print('three')
+        backchannel.write_json('done')
 
-    debug_session.debug_options += ['StopOnEntry']
-    debug_session.initialize(target=(run_as, code_to_debug), start_method=start_method)
-    debug_session.start_debugging()
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, code_to_debug),
+            start_method=start_method,
+            debug_options=['StopOnEntry'],
+            ignore_unobserved=[Event('continued')],
+            use_backchannel=True,
+        )
+        session.start_debugging()
 
-    thread_stopped = debug_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'step'})))
-    assert thread_stopped.body['threadId'] is not None
+        thread_stopped, resp_stacktrace, tid, _ = session.wait_for_thread_stopped()
+        frames = resp_stacktrace.body['stackFrames']
+        assert frames[0]['line'] == 1
 
-    tid = thread_stopped.body['threadId']
+        session.send_request('continue').wait_for_response(freeze=False)
+        session.wait_for_termination()
 
-    resp_stacktrace = debug_session.send_request('stackTrace', arguments={
-        'threadId': tid,
-    }).wait_for_response()
-    assert resp_stacktrace.body['totalFrames'] > 0
-    frames = resp_stacktrace.body['stackFrames']
-    assert frames[0]['line'] == 1
+        assert session.read_json() == 'done'
 
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-
-    debug_session.wait_for_termination()
-
-    output = [e.body['output'] for e in debug_session.all_occurrences_of(Event('output'))
-              if len(e.body['output']) >= 3 and e.body['category'] == 'stdout']
-    assert len(output) == 3
-    assert output == ['one', 'two', 'three']
-
-    debug_session.wait_for_exit()
+        session.wait_for_exit()
 
 
 @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
 @pytest.mark.skipif(sys.version_info < (3, 0) and platform.system() == 'Windows',
                     reason="On Win32 Python2.7, unable to send key strokes to test.")
-def test_wait_on_normal_exit_enabled(debug_session, pyfile, run_as, start_method):
+def test_wait_on_normal_exit_enabled(pyfile, run_as, start_method):
     @pyfile
     def code_to_debug():
+        import backchannel
         from dbgimporter import import_and_enable_debugger
         import_and_enable_debugger()
-        print('one')
-        print('two')
-        print('three')
-
-    debug_session.debug_options += ['WaitOnNormalExit']
-
-    bp_line = 5
-    bp_file = code_to_debug
-    debug_session.initialize(target=(run_as, bp_file), start_method=start_method)
-    debug_session.set_breakpoints(bp_file, [bp_line])
-    debug_session.start_debugging()
-
-    debug_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'breakpoint'})))
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-    debug_session.proceed()
-
-    debug_session.expected_returncode = ANY.int
-    debug_session.wait_for_next(Event('exited'))
-
-    output = [e.body['output'] for e in debug_session.all_occurrences_of(Event('output'))
-              if len(e.body['output']) >= 3 and e.body['category'] == 'stdout']
-    assert len(output) == 3
-    assert output == ['one', 'two', 'three']
-
-    debug_session.process.stdin.write(b' \r\n')
-    debug_session.wait_for_exit()
-
-    def _decode(text):
-        if isinstance(text, bytes):
-            return text.decode('utf-8')
-        return text
-    assert any(l for l in debug_session.output_data['OUT']
-               if _decode(l).startswith('Press'))
+        import ptvsd
+        ptvsd.break_into_debugger()
+        backchannel.write_json('done')
+
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, code_to_debug),
+            start_method=start_method,
+            debug_options=['WaitOnNormalExit'],
+            ignore_unobserved=[Event('continued')],
+            use_backchannel=True,
+        )
+        session.start_debugging()
+
+        session.wait_for_thread_stopped()
+        session.send_request('continue').wait_for_response(freeze=False)
+
+        session.expected_returncode = ANY.int
+        assert session.read_json() == 'done'
+
+        session.process.stdin.write(b' \r\n')
+        session.wait_for_exit()
+
+        def _decode(text):
+            if isinstance(text, bytes):
+                return text.decode('utf-8')
+            return text
+        assert any(
+            l for l in session.output_data['OUT']
+            if _decode(l).startswith('Press')
+        )
 
 
 @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
 @pytest.mark.skipif(sys.version_info < (3, 0) and platform.system() == 'Windows',
                     reason="On windows py2.7 unable to send key strokes to test.")
-def test_wait_on_abnormal_exit_enabled(debug_session, pyfile, run_as, start_method):
+def test_wait_on_abnormal_exit_enabled(pyfile, run_as, start_method):
     @pyfile
     def code_to_debug():
+        import backchannel
         import sys
         from dbgimporter import import_and_enable_debugger
         import_and_enable_debugger()
-        print('one')
-        print('two')
-        print('three')
+        import ptvsd
+        ptvsd.break_into_debugger()
+        backchannel.write_json('done')
         sys.exit(12345)
 
-    debug_session.debug_options += ['WaitOnAbnormalExit']
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, code_to_debug),
+            start_method=start_method,
+            debug_options=['WaitOnAbnormalExit'],
+            ignore_unobserved=[Event('continued')],
+            use_backchannel=True,
+        )
+        session.start_debugging()
 
-    bp_line = 7
-    bp_file = code_to_debug
-    debug_session.initialize(target=(run_as, bp_file), start_method=start_method)
-    debug_session.set_breakpoints(bp_file, [bp_line])
-    debug_session.start_debugging()
+        session.wait_for_thread_stopped()
+        session.send_request('continue').wait_for_response(freeze=False)
 
-    debug_session.wait_for_next(Event('stopped'), ANY.dict_with({'reason': 'breakpoint'}))
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-    debug_session.proceed()
+        session.expected_returncode = ANY.int
+        assert session.read_json() == 'done'
 
-    debug_session.expected_returncode = ANY.int
-    debug_session.wait_for_next(Event('exited'))
+        session.process.stdin.write(b' \r\n')
+        session.wait_for_exit()
 
-    output = [e.body['output'] for e in debug_session.all_occurrences_of(Event('output'))
-              if len(e.body['output']) >= 3 and e.body['category'] == 'stdout']
-    assert len(output) == 3
-    assert output == ['one', 'two', 'three']
-
-    debug_session.process.stdin.write(b' \r\n')
-    debug_session.wait_for_exit()
-
-    def _decode(text):
-        if isinstance(text, bytes):
-            return text.decode('utf-8')
-        return text
-    assert any(l for l in debug_session.output_data['OUT']
-               if _decode(l).startswith('Press'))
+        def _decode(text):
+            if isinstance(text, bytes):
+                return text.decode('utf-8')
+            return text
+        assert any(
+            l for l in session.output_data['OUT']
+            if _decode(l).startswith('Press')
+        )
 
 
 @pytest.mark.parametrize('start_method', ['launch', 'attach_socket_cmdline'])
-def test_exit_normally_with_wait_on_abnormal_exit_enabled(debug_session, pyfile, run_as, start_method):
+def test_exit_normally_with_wait_on_abnormal_exit_enabled(pyfile, run_as, start_method):
     @pyfile
     def code_to_debug():
+        import backchannel
         from dbgimporter import import_and_enable_debugger
         import_and_enable_debugger()
-        print('one')
-        print('two')
-        print('three')
-
-    debug_session.debug_options += ['WaitOnAbnormalExit']
+        import ptvsd
+        ptvsd.break_into_debugger()
+        backchannel.write_json('done')
 
-    bp_line = 5
-    bp_file = code_to_debug
-    debug_session.initialize(target=(run_as, bp_file), start_method=start_method)
-    debug_session.set_breakpoints(bp_file, [bp_line])
-    debug_session.start_debugging()
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, code_to_debug),
+            start_method=start_method,
+            debug_options=['WaitOnAbnormalExit'],
+            ignore_unobserved=[Event('continued')],
+            use_backchannel=True,
+        )
+        session.start_debugging()
 
-    debug_session.wait_for_next(Event('stopped', ANY.dict_with({'reason': 'breakpoint'})))
-    debug_session.send_request('continue').wait_for_response()
-    debug_session.wait_for_next(Event('continued'))
-    debug_session.proceed()
+        session.wait_for_thread_stopped()
+        session.send_request('continue').wait_for_response(freeze=False)
 
-    debug_session.wait_for_termination()
+        session.wait_for_termination()
 
-    output = [e.body['output'] for e in debug_session.all_occurrences_of(Event('output'))
-              if len(e.body['output']) >= 3 and e.body['category'] == 'stdout']
-    assert len(output) == 3
-    assert output == ['one', 'two', 'three']
+        assert session.read_json() == 'done'
 
-    debug_session.wait_for_exit()
+        session.wait_for_exit()
diff --git a/pytests/helpers/test_timeline.py b/pytests/helpers/test_timeline.py
--- a/pytests/helpers/test_timeline.py
+++ b/pytests/helpers/test_timeline.py
@@ -177,6 +177,10 @@ def test_after(make_timeline):
     with timeline.frozen():
         assert second_exp in timeline
 
+    timeline.mark('first')
+    with timeline.frozen():
+        assert Mark('second') >> Mark('first') in timeline
+
 
 def test_before(make_timeline):
     timeline, _ = make_timeline()
@@ -291,12 +295,12 @@ def test_xor(make_timeline):
 
     timeline.mark('ham')
     with timeline.frozen():
-        assert t1 >> (eggs_exp ^ ham_exp) in timeline
-        assert t2 >> (eggs_exp ^ ham_exp) not in timeline
-        assert t1 >> (ham_exp ^ eggs_exp) in timeline
-        assert t2 >> (ham_exp ^ eggs_exp) not in timeline
-        assert t1 >> (cheese_exp ^ ham_exp ^ eggs_exp) in timeline
-        assert t2 >> (cheese_exp ^ ham_exp ^ eggs_exp) not in timeline
+        assert t1 >> (eggs_exp ^ ham_exp) not in timeline
+        assert t2 >> (eggs_exp ^ ham_exp) in timeline
+        assert t1 >> (ham_exp ^ eggs_exp) not in timeline
+        assert t2 >> (ham_exp ^ eggs_exp) in timeline
+        assert t1 >> (cheese_exp ^ ham_exp ^ eggs_exp) not in timeline
+        assert t2 >> (cheese_exp ^ ham_exp ^ eggs_exp) in timeline
 
 
 def test_conditional(make_timeline):
@@ -318,6 +322,18 @@ def is_exciting(occ):
         timeline.expect_realized(t >> something_exciting)
 
 
+def test_lower_bound(make_timeline):
+    timeline, _ = make_timeline()
+    timeline.mark('1')
+    timeline.mark('2')
+    timeline.mark('3')
+    timeline.freeze()
+
+    assert (Mark('2') >> (Mark('1') >> Mark('3'))) not in timeline
+    assert (Mark('2')  >> (Mark('1') & Mark('3'))) not in timeline
+    assert (Mark('2')  >> (Mark('1') ^ Mark('3'))) in timeline
+
+
 @pytest.mark.timeout(3)
 def test_frozen(make_timeline, daemon):
     timeline, initial_history = make_timeline()
@@ -419,6 +435,49 @@ def worker():
     timeline.proceed()
 
 
+def test_new(make_timeline):
+    timeline, _ = make_timeline()
+
+    m1 = timeline.mark('1')
+    timeline.freeze()
+
+    assert timeline.expect_new(Mark('1')) is m1
+    with pytest.raises(Exception):
+        timeline.expect_new(Mark('2'))
+
+    timeline.proceed()
+    m2 = timeline.mark('2')
+    timeline.freeze()
+
+    with pytest.raises(Exception):
+        timeline.expect_new(Mark('1'))
+    assert timeline.expect_new(Mark('2')) is m2
+    with pytest.raises(Exception):
+        timeline.expect_new(Mark('3'))
+
+    timeline.unfreeze()
+    m3 = timeline.mark('3')
+    timeline.freeze()
+
+    with pytest.raises(Exception):
+        timeline.expect_new(Mark('1'))
+    assert timeline.expect_new(Mark('2')) is m2
+    assert timeline.expect_new(Mark('3')) is m3
+
+    timeline.proceed()
+    m4 = timeline.mark('4')
+    timeline.mark('4')
+    timeline.freeze()
+
+    with pytest.raises(Exception):
+        timeline.expect_new(Mark('1'))
+    with pytest.raises(Exception):
+        timeline.expect_new(Mark('2'))
+    with pytest.raises(Exception):
+        timeline.expect_new(Mark('3'))
+    assert timeline.expect_new(Mark('4')) is m4
+
+
 @pytest.mark.timeout(3)
 @pytest.mark.parametrize('order', ['mark_then_wait', 'wait_then_mark'])
 def test_concurrency(make_timeline, daemon, order):