From 322f694601e343a13275d6a290378f7c2b24a6b5 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Tue, 22 May 2018 16:50:55 -0600 Subject: [PATCH] Support re-attaching to an adapter. (#381) (fixes #208) Currently when ptvsd starts up (as server) it listens for a connection and shuts down when the debug session is done. This prevents re-attach. To fix this we go back to listening for a connection after getting a "disconnect" (or the client closes the connection). --- ptvsd/_main.py | 2 +- ptvsd/_util.py | 241 ++++++++++++++ ptvsd/daemon.py | 437 ++++++++++++++++++-------- ptvsd/exit_handlers.py | 114 +++++++ ptvsd/ipcjson.py | 50 ++- ptvsd/pydevd_hooks.py | 53 +++- ptvsd/runner.py | 6 +- ptvsd/session.py | 132 ++++++++ ptvsd/socket.py | 128 +++++++- ptvsd/wrapper.py | 26 +- tests/helpers/__init__.py | 3 +- tests/helpers/_io.py | 39 ++- tests/helpers/counter.py | 5 +- tests/helpers/debugadapter.py | 96 +++++- tests/helpers/debugclient.py | 50 ++- tests/helpers/debugsession.py | 5 +- tests/helpers/lock.py | 6 +- tests/helpers/proc.py | 42 ++- tests/helpers/protocol.py | 15 + tests/helpers/pydevd/_binder.py | 23 +- tests/helpers/pydevd/_live.py | 15 +- tests/helpers/socket.py | 39 ++- tests/highlevel/test_live_pydevd.py | 34 +- tests/ptvsd/test___main__.py | 35 +-- tests/ptvsd/test_socket.py | 174 ++++++++++ tests/system_tests/test_connection.py | 156 +++++++++ tests/system_tests/test_main.py | 166 +++++++++- 27 files changed, 1805 insertions(+), 287 deletions(-) create mode 100644 ptvsd/_util.py create mode 100644 ptvsd/exit_handlers.py create mode 100644 ptvsd/session.py create mode 100644 tests/ptvsd/test_socket.py create mode 100644 tests/system_tests/test_connection.py diff --git a/ptvsd/_main.py b/ptvsd/_main.py index cbe7ce39f..f5e4597ea 100644 --- a/ptvsd/_main.py +++ b/ptvsd/_main.py @@ -115,7 +115,7 @@ def wait_for_connection(daemon, host, port): debugger.ready_to_run = True server = create_server(host, port) client, _ = server.accept() - daemon.set_connection(client) + daemon.start_session(client, 'ptvsd.Server') daemon.re_build_breakpoints() on_attach() diff --git a/ptvsd/_util.py b/ptvsd/_util.py new file mode 100644 index 000000000..cc001c7bd --- /dev/null +++ b/ptvsd/_util.py @@ -0,0 +1,241 @@ +from __future__ import print_function + +import contextlib +import os +import threading +import sys + + +DEBUG = False +if os.environ.get('PTVSD_DEBUG', ''): + DEBUG = True + + +def debug(*msg, **kwargs): + if not DEBUG: + return + tb = kwargs.pop('tb', False) + assert not kwargs + if tb: + import traceback + traceback.print_exc() + print(*msg, file=sys.stderr) + sys.stderr.flush() + + +@contextlib.contextmanager +def ignore_errors(log=None): + """A context manager that masks any raised exceptions.""" + try: + yield + except Exception as exc: + if log is not None: + log('ignoring error', exc) + + +def call_all(callables, *args, **kwargs): + """Return the result of calling every given object.""" + results = [] + for call in callables: + try: + call(*args, **kwargs) + except Exception as exc: + results.append((call, exc)) + else: + results.append((call, None)) + return results + + +######################## +# closing stuff + +class ClosedError(RuntimeError): + """Indicates that the object is closed.""" + + +def close_all(closeables): + """Return the result of closing every given object.""" + results = [] + for obj in closeables: + try: + obj.close() + except Exception as exc: + results.append((obj, exc)) + else: + results.append((obj, None)) + return results + + +class Closeable(object): + """A base class for types that may be closed.""" + + NAME = None + FAIL_ON_ALREADY_CLOSED = True + + def __init__(self): + super(Closeable, self).__init__() + self._closed = False + self._closedlock = threading.Lock() + self._handlers = [] + + def __del__(self): + try: + self.close() + except ClosedError: + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + @property + def closed(self): + return self._closed + + def add_resource_to_close(self, resource, before=False): + """Add a resource to be closed when closing.""" + close = resource.close + if before: + def handle_closing(before): + if not before: + return + close() + else: + def handle_closing(before): + if before: + return + close() + self.add_close_handler(handle_closing) + + def add_close_handler(self, handle_closing, nodupe=True): + """Add a func to be called when closing. + + The func takes one arg: True if it was called before the main + close func and False if after. + """ + with self._closedlock: + if self._closed: + if self.FAIL_ON_ALREADY_CLOSED: + raise ClosedError('already closed') + return + if nodupe and handle_closing in self._handlers: + raise ValueError('close func already added') + + self._handlers.append(handle_closing) + + def check_closed(self): + """Raise ClosedError if closed.""" + if self._closed: + if self.NAME: + raise ClosedError('{} closed'.format(self.NAME)) + else: + raise ClosedError('closed') + + @contextlib.contextmanager + def while_not_closed(self): + """A context manager under which the object will not be closed.""" + with self._closedlock: + self.check_closed() + yield + + def close(self): + """Release any owned resources and clean up.""" + with self._closedlock: + if self._closed: + if self.FAIL_ON_ALREADY_CLOSED: + raise ClosedError('already closed') + return + self._closed = True + handlers = list(self._handlers) + + results = call_all(handlers, True) + self._log_results(results) + self._close() + results = call_all(handlers, False) + self._log_results(results) + + # implemented by subclasses + + def _close(self): + pass + + # internal methods + + def _log_results(self, results, log=None): + if log is None: + return + for obj, exc in results: + if exc is None: + continue + log('failed to close {!r} ({!r})'.format(obj, exc)) + + +######################## +# running stuff + +class NotRunningError(RuntimeError): + """Something isn't currently running.""" + + +class AlreadyStartedError(RuntimeError): + """Something was already started.""" + + +class AlreadyRunningError(AlreadyStartedError): + """Something is already running.""" + + +class Startable(object): + """A base class for types that may be started.""" + + RESTARTABLE = False + FAIL_ON_ALREADY_STOPPED = True + + def __init__(self): + super(Startable, self).__init__() + self._is_running = None + self._startlock = threading.Lock() + self._numstarts = 0 + + def is_running(self, checkclosed=True): + """Return True if currently running.""" + if checkclosed and hasattr(self, 'check_closed'): + self.check_closed() + is_running = self._is_running + if is_running is None: + return False + return is_running() + + def start(self, *args, **kwargs): + """Begin internal execution.""" + with self._startlock: + if self.is_running(): + raise AlreadyRunningError() + if not self.RESTARTABLE and self._numstarts > 0: + raise AlreadyStartedError() + + self._is_running = self._start(*args, **kwargs) + self._numstarts += 1 + + def stop(self, *args, **kwargs): + """Stop execution and wait until done.""" + with self._startlock: + # TODO: Call self.check_closed() here? + if not self.is_running(checkclosed=False): + if not self.FAIL_ON_ALREADY_STOPPED: + return + raise NotRunningError() + self._is_running = None + + self._stop(*args, **kwargs) + + # implemented by subclasses + + def _start(self, *args, **kwargs): + """Return an "is_running()" func after starting.""" + raise NotImplementedError + + def _stop(self): + raise NotImplementedError diff --git a/ptvsd/daemon.py b/ptvsd/daemon.py index 8d5c6c7a6..7f44e3449 100644 --- a/ptvsd/daemon.py +++ b/ptvsd/daemon.py @@ -1,11 +1,15 @@ -import atexit -import os -import platform -import signal +import contextlib import sys +import threading from ptvsd import wrapper -from ptvsd.socket import close_socket +from ptvsd.socket import ( + close_socket, create_server, create_client, connect, Address) +from .exit_handlers import ( + ExitHandlers, UnsupportedSignalError, + kill_current_proc) +from .session import DebugSession +from ._util import ignore_errors, debug def _wait_on_exit(): @@ -22,11 +26,28 @@ def _wait_on_exit(): msvcrt.getch() -class DaemonClosedError(RuntimeError): +class DaemonError(RuntimeError): + """Indicates that a Daemon had a problem.""" + MSG = 'error' + + def __init__(self, msg=None): + if msg is None: + msg = self.MSG + super(DaemonError, self).__init__(msg) + + +class DaemonClosedError(DaemonError): """Indicates that a Daemon was unexpectedly closed.""" - def __init__(self, msg='closed'): - super(DaemonClosedError, self).__init__(msg) + MSG = 'closed' + + +class DaemonStoppedError(DaemonError): + """Indicates that a Daemon was unexpectedly stopped.""" + MSG = 'stopped' + +# TODO: Inherit from Closeable. +# TODO: Inherit from Startable? class Daemon(object): """The process-level manager for the VSC protocol debug adapter.""" @@ -34,21 +55,24 @@ class Daemon(object): exitcode = 0 def __init__(self, wait_on_exit=_wait_on_exit, - addhandlers=True, killonclose=True): + addhandlers=True, killonclose=True, + hidebadsessions=True): self.wait_on_exit = wait_on_exit self.killonclose = killonclose + self.hidebadsessions = hidebadsessions self._closed = False self._exiting_via_atexit_handler = False self._pydevd = None self._server = None - self._client = None - self._adapter = None + self._numstarts = 0 - self._signal_handlers = None - self._atexit_handlers = None - self._handlers_installed = False + self._session = None + self._sessionlock = None + self._numsessions = 0 + + self._exithandlers = ExitHandlers() if addhandlers: self.install_exit_handlers() @@ -57,165 +81,324 @@ def pydevd(self): return self._pydevd @property - def server(self): - return self._server + def session(self): + """The current session.""" + return self._session - @property - def client(self): - return self._client + def install_exit_handlers(self): + """Set the placeholder handlers.""" + self._exithandlers.install() - @property - def adapter(self): - return self._adapter + try: + self._exithandlers.add_atexit_handler(self._handle_atexit) + except ValueError: + pass + for signum in self._exithandlers.SIGNALS: + try: + self._exithandlers.add_signal_handler(signum, + self._handle_signal) + except ValueError: + # Already added. + pass + except UnsupportedSignalError: + # TODO: This shouldn't happen. + pass + + @contextlib.contextmanager + def started(self, stoponcmexit=True): + """A context manager that starts the daemon. + + If there's a failure then the daemon is stopped. It is also + stopped at the end of the with block if "stoponcmexit" is True + (the default). + """ + pydevd = self.start() + try: + yield pydevd + except Exception: + self._stop_quietly() + raise + else: + if stoponcmexit: + self._stop_quietly() + + def is_running(self): + """Return True if the daemon is running.""" + if self._pydevd is None: + return False + return True - def start(self, server=None): + def start(self): """Return the "socket" to use for pydevd after setting it up.""" if self._closed: raise DaemonClosedError() if self._pydevd is not None: raise RuntimeError('already started') - self._pydevd = wrapper.PydevdSocket( - self._handle_pydevd_message, - self._handle_pydevd_close, - self._getpeername, - self._getsockname, - ) - self._server = server - return self._pydevd - def install_exit_handlers(self): - """Set the placeholder handlers.""" - if self._signal_handlers is not None: - raise RuntimeError('exit handlers already installed') - self._atexit_handlers = [] + return self._start() - if platform.system() == 'Windows': - self._signal_handlers = {} - else: - self._signal_handlers = { - signal.SIGHUP: [], - } + def stop(self): + """Un-start the daemon (i.e. stop the "socket").""" + if self._closed: + raise DaemonClosedError() + if self._pydevd is None: + raise RuntimeError('not started') + + self._stop() + + def start_server(self, addr): + """Return (pydevd "socket", next_session) with a new server socket.""" + addr = Address.from_raw(addr) + with self.started(stoponcmexit=False) as pydevd: + assert self._sessionlock is None + assert self._session is None + self._server = create_server(addr.host, addr.port) + self._sessionlock = threading.Lock() + + def next_session(**kwargs): + if self._closed: + raise DaemonClosedError() + server = self._server + if self._pydevd is None or server is None: + raise DaemonStoppedError() + sessionlock = self._sessionlock + if sessionlock is None: + raise DaemonStoppedError() + + debug('getting next session') + sessionlock.acquire() # Released in _handle_session_closing(). + debug('session lock acquired') + if self._closed: + raise DaemonClosedError() + if self._pydevd is None or self._server is None: + raise DaemonStoppedError() + timeout = kwargs.pop('timeout', None) try: - for sig in self._signal_handlers: - signal.signal(sig, self._signal_handler) - except ValueError: - # Wasn't called in main thread! + debug('getting session socket') + client = connect(server, None, **kwargs) + session = DebugSession.from_raw( + client, + notify_closing=self._handle_session_closing, + ownsock=True, + ) + debug('starting session') + self._start_session(session, 'ptvsd.Server', timeout) + debug('session started') + return session + except Exception as exc: + debug('session exc:', exc, tb=True) + with ignore_errors(): + self._stop_session() + if self.hidebadsessions: + debug('hiding bad session') + # TODO: Log the error? + return None + self._stop_quietly() + raise + + return pydevd, next_session + + def start_client(self, addr): + """Return (pydevd "socket", start_session) with a new client socket.""" + addr = Address.from_raw(addr) + with self.started(stoponcmexit=False) as pydevd: + assert self._session is None + client = create_client() + connect(client, addr) + + def start_session(): + if self._closed: + raise DaemonClosedError() + if self._pydevd is None: + raise DaemonStoppedError() + if self._session is not None: + raise RuntimeError('session already started') + if self._numsessions: + raise RuntimeError('session stopped') + + try: + session = DebugSession.from_raw( + client, + notify_closing=self._handle_session_closing, + ownsock=True, + ) + self._start_session(session, 'ptvsd.Client', None) + return session + except Exception: + self._stop_quietly() raise - atexit.register(self._atexit_handler) - def set_connection(self, client): - """Set the client socket to use for the debug adapter. + return pydevd, start_session + + def start_session(self, session, threadname, timeout=None): + """Start the debug session and remember it. - A VSC message loop is started for the client. + If "session" is a client socket then a session is created + from it. """ if self._closed: raise DaemonClosedError() if self._pydevd is None: raise RuntimeError('not started yet') - if self._client is not None: - raise RuntimeError('connection already set') - self._client = client - - self._adapter = wrapper.VSCodeMessageProcessor( - client, - self._pydevd.pydevd_notify, - self._pydevd.pydevd_request, - self._handle_vsc_disconnect, - self._handle_vsc_close, + if self._server is not None: + raise RuntimeError('running as server') + if self._session is not None: + raise RuntimeError('session already started') + + session = DebugSession.from_raw( + session, + notify_closing=self._handle_session_closing, + ownsock=True, ) - name = 'ptvsd.Client' if self._server is None else 'ptvsd.Server' - self._adapter.start(name) - if self._signal_handlers is not None: - self._add_signal_handlers() - self._add_atexit_handler() - return self._adapter + self._start_session(session, threadname, timeout) + return session def close(self): """Stop all loops and release all resources.""" if self._closed: raise DaemonClosedError('already closed') - self._closed = True - if self._adapter is not None: - normal, abnormal = self._adapter._wait_options() + self._close() + + def re_build_breakpoints(self): + """Restore the breakpoints to their last values.""" + if self._session is None: + return + return self._session.re_build_breakpoints() + + # internal methods + + def _close(self): + self._closed = True + session = self._stop() + if session is not None: + normal, abnormal = session.wait_options() if (normal and not self.exitcode) or (abnormal and self.exitcode): self.wait_on_exit() - if self._pydevd is not None: - close_socket(self._pydevd) - if self._client is not None: - self._release_connection() + def _start(self): + self._numstarts += 1 + self._pydevd = wrapper.PydevdSocket( + self._handle_pydevd_message, + self._handle_pydevd_close, + self._getpeername, + self._getsockname, + ) + return self._pydevd - def re_build_breakpoints(self): - self.adapter.re_build_breakpoints() + def _stop(self): + sessionlock = self._sessionlock + self._sessionlock = None + server = self._server + self._server = None + pydevd = self._pydevd + self._pydevd = None - # internal methods + session = self._session + with ignore_errors(): + self._stop_session() + + if sessionlock is not None: + try: + sessionlock.release() + except Exception: + pass + + if server is not None: + with ignore_errors(): + close_socket(server) - def _signal_handler(self, signum, frame): - for handle_signal in self._signal_handlers.get(signum, ()): - handle_signal(signum, frame) - - def _atexit_handler(self): - for handle_atexit in self._atexit_handlers: - handle_atexit() - - def _add_atexit_handler(self): - def handler(): - self._exiting_via_atexit_handler = True - if not self._closed: - self.close() - if self._adapter is not None: - # TODO: Do this in VSCodeMessageProcessor.close()? - self._adapter._wait_for_server_thread() - self._atexit_handlers.append(handler) - - def _add_signal_handlers(self): - if platform.system() == 'Windows': + if pydevd is not None: + with ignore_errors(): + close_socket(pydevd) + + return session + + def _stop_quietly(self): + if self._closed: return + with ignore_errors(): + self._stop() + + def _start_session(self, session, threadname, timeout): + self._session = session + self._numsessions += 1 + try: + session.start( + threadname, + self._pydevd.pydevd_notify, + self._pydevd.pydevd_request, + timeout=timeout, + ) + except Exception: + assert self._session is session + with ignore_errors(): + self._stop_session() + raise + + def _stop_session(self): + session = self._session + self._session = None - def handler(signum, frame): - if not self._closed: - self.close() - sys.exit(0) - self._signal_handlers[signal.SIGHUP].append(handler) + try: + if session is not None: + session.stop(self.exitcode if self._server is None else None) + session.close() + finally: + sessionlock = self._sessionlock + if sessionlock is not None: + try: + sessionlock.release() + except Exception: # TODO: Make it more specific? + debug('session lock not released') + else: + debug('session lock released') + debug('session stopped') + + def _handle_atexit(self): + self._exiting_via_atexit_handler = True + if not self._closed: + self._close() + # TODO: Is this broken (due to always clearing self._session on close? + if self._session is not None: + self._session.wait_until_stopped() + + def _handle_signal(self, signum, frame): + if not self._closed: + self._close() + sys.exit(0) + + def _handle_session_closing(self, kill=False): + debug('handling closing session') + if self._server is not None and not kill: + self._session = None + self._stop_session() + return - def _release_connection(self): - if self._adapter is not None: - # TODO: This is not correct in the "attach" case. - self._adapter.handle_pydevd_stopped(self.exitcode) - self._adapter.close() - close_socket(self._client) + if not self._closed: + self._close() + if kill and self.killonclose and not self._exiting_via_atexit_handler: + kill_current_proc() # internal methods for PyDevdSocket(). def _handle_pydevd_message(self, cmdid, seq, text): - if self._adapter is not None: - self._adapter.on_pydevd_event(cmdid, seq, text) + if self._session is None: + # TODO: Do more than ignore? + return + self._session.handle_pydevd_message(cmdid, seq, text) def _handle_pydevd_close(self): if self._closed: return - self.close() + self._close() def _getpeername(self): - if self._client is None: + if self._session is None: raise NotImplementedError - return self._client.getpeername() + return self._session.socket.getpeername() def _getsockname(self): - if self._client is None: + if self._session is None: raise NotImplementedError - return self._client.getsockname() - - # internal methods for VSCodeMessageProcessor - - def _handle_vsc_disconnect(self, kill=False): - if not self._closed: - self.close() - if kill and self.killonclose and not self._exiting_via_atexit_handler: - os.kill(os.getpid(), signal.SIGTERM) - - def _handle_vsc_close(self): - if self._closed: - return - self.close() + return self._session.socket.getsockname() diff --git a/ptvsd/exit_handlers.py b/ptvsd/exit_handlers.py new file mode 100644 index 000000000..8a7e33a07 --- /dev/null +++ b/ptvsd/exit_handlers.py @@ -0,0 +1,114 @@ +import atexit +import os +import platform +import signal + + +class AlreadyInstalledError(RuntimeError): + """Exit handlers were already installed.""" + + +class UnsupportedSignalError(RuntimeError): + """A signal is not supported.""" + + +def kill_current_proc(signum=signal.SIGTERM): + """Kill the current process. + + Note that this directly kills the process (with SIGTERM, by default) + rather than using sys.exit(). + """ + os.kill(os.getpid(), signum) + + +class ExitHandlers(object): + """Manages signal and atexit handlers.""" + + if platform.system() == 'Windows': + # TODO: Windows *does* support these signals: + # SIGABRT, SIGFPE, SIGILL, SIGINT, SIGSEGV, SIGTERM, SIGBREAK + SIGNALS = [] + else: + SIGNALS = [ + signal.SIGHUP, + ] + + def __init__(self): + self._signal_handlers = {sig: [] + for sig in self.SIGNALS} + self._atexit_handlers = [] + self._installed = False + + @property + def supported_signals(self): + return set(self.SIGNALS) + + @property + def installed(self): + return self._installed + + def install(self): + """Set the parent handlers. + + This must be called in the main thread. + """ + if self._installed: + raise AlreadyInstalledError('exit handlers already installed') + self._installed = True + self._install_signal_handler() + self._install_atexit_handler() + + # TODO: Add uninstall()? + + def add_atexit_handler(self, handle_atexit, nodupe=True): + """Add an atexit handler to the list managed here.""" + if nodupe and handle_atexit in self._atexit_handlers: + raise ValueError('atexit handler alraedy added') + self._atexit_handlers.append(handle_atexit) + + def add_signal_handler(self, signum, handle_signal, nodupe=True, + ignoreunsupported=False): + """Add a signal handler to the list managed here.""" + # TODO: The initialization of self.SIGNALS should make this + # special-casing unnecessary. + if platform.system() == 'Windows': + return + + try: + handlers = self._signal_handlers[signum] + except KeyError: + if ignoreunsupported: + return + raise UnsupportedSignalError(signum) + if nodupe and handle_signal in handlers: + raise ValueError('signal handler alraedy added') + handlers.append(handle_signal) + + # internal methods + + def _install_signal_handler(self): + # TODO: The initialization of self.SIGNALS should make this + # special-casing unnecessary. + if platform.system() == 'Windows': + return + + orig = {} + try: + for sig in self._signal_handlers: + # TODO: Skip or fail if signal.getsignal() returns None? + orig[sig] = signal.signal(sig, self._signal_handler) + except ValueError: + # Wasn't called in main thread! + raise + + def _signal_handler(self, signum, frame): + for handle_signal in self._signal_handlers.get(signum, ()): + handle_signal(signum, frame) + + def _install_atexit_handler(self): + self._atexit_handlers = [] + atexit.register(self._atexit_handler) + + def _atexit_handler(self): + for handle_atexit in self._atexit_handlers: + handle_atexit() diff --git a/ptvsd/ipcjson.py b/ptvsd/ipcjson.py index 6ce1abdbd..3982e3cba 100644 --- a/ptvsd/ipcjson.py +++ b/ptvsd/ipcjson.py @@ -14,11 +14,15 @@ import errno import itertools import json +import os import os.path -import socket +from socket import create_connection import sys +import time import traceback +from .socket import TimeoutError + _TRACE = None @@ -28,6 +32,10 @@ os.path.abspath(__file__))), ] +TIMEOUT = os.environ.get('PTVSD_SOCKET_TIMEOUT') +if TIMEOUT: + TIMEOUT = float(TIMEOUT) + if sys.version_info[0] >= 3: from encodings import ascii @@ -71,18 +79,24 @@ class SocketIO(object): # TODO: docstring def __init__(self, *args, **kwargs): + port = kwargs.pop('port', None) + socket = kwargs.pop('socket', None) + own_socket = kwargs.pop('own_socket', True) + logfile = kwargs.pop('logfile', None) + if socket is None: + if port is None: + raise ValueError( + "A 'port' or a 'socket' must be passed to SocketIO initializer as a keyword argument.") # noqa + addr = ('127.0.0.1', port) + socket = create_connection(addr) + own_socket = True super(SocketIO, self).__init__(*args, **kwargs) + self.__buffer = to_bytes('') - self.__port = kwargs.get('port') - self.__socket = kwargs.get('socket') - self.__own_socket = kwargs.get('own_socket', True) - self.__logfile = kwargs.get('logfile') - if self.__socket is None and self.__port is None: - raise ValueError( - "A 'port' or a 'socket' must be passed to SocketIO initializer as a keyword argument.") # noqa - if self.__socket is None: - self.__socket = socket.create_connection( - ('127.0.0.1', self.__port)) + self.__port = port + self.__socket = socket + self.__own_socket = own_socket + self.__logfile = logfile def _send(self, **payload): # TODO: docstring @@ -233,6 +247,10 @@ class IpcChannel(object): # TODO: docstring def __init__(self, *args, **kwargs): + timeout = kwargs.pop('timeout', None) + if timeout is None: + timeout = TIMEOUT + super(IpcChannel, self).__init__(*args, **kwargs) # This class is meant to be last in the list of base classes # Don't call super because object's __init__ doesn't take arguments try: @@ -244,6 +262,8 @@ def __init__(self, *args, **kwargs): self.__exit = False self.__lock = thread.allocate_lock() self.__message = [] + self._timeout = timeout + self._fail_after = None def close(self): # TODO: docstring @@ -278,6 +298,8 @@ def set_exit(self): def process_messages(self): # TODO: docstring + if self._timeout is not None: + self._fail_after = time.time() + self._timeout while True: if self.process_one_message(): return @@ -300,7 +322,13 @@ def process_one_message(self): try: msg = self.__message.pop(0) except IndexError: + # No messages received. + if self._fail_after is not None: + if time.time() < self._fail_after: + raise TimeoutError('connection closed?') return self.__exit + if self._fail_after is not None: + self._fail_after = time.time() + self._timeout _trace('Received ', msg) diff --git a/ptvsd/pydevd_hooks.py b/ptvsd/pydevd_hooks.py index c29d69240..0fb00e1bd 100644 --- a/ptvsd/pydevd_hooks.py +++ b/ptvsd/pydevd_hooks.py @@ -1,9 +1,11 @@ import sys +import threading from _pydevd_bundle import pydevd_comm -from ptvsd.socket import create_server, create_client, Address -from ptvsd.daemon import Daemon +from ptvsd.socket import Address +from ptvsd.daemon import Daemon, DaemonStoppedError, DaemonClosedError +from ptvsd._util import debug def start_server(daemon, host, port): @@ -14,11 +16,41 @@ def start_server(daemon, host, port): This is a replacement for _pydevd_bundle.pydevd_comm.start_server. """ - server = create_server(host, port) - client, _ = server.accept() - - pydevd = daemon.start(server) - daemon.set_connection(client) + pydevd, next_session = daemon.start_server((host, port)) + + def handle_next(): + try: + session = next_session() + debug('done waiting') + return session + except (DaemonClosedError, DaemonStoppedError): + # Typically won't happen. + debug('stopped') + raise + except Exception as exc: + # TODO: log this? + debug('failed:', exc, tb=True) + return None + + while True: + debug('waiting on initial connection') + handle_next() + break + + def serve_forever(): + while True: + debug('waiting on next connection') + try: + handle_next() + except (DaemonClosedError, DaemonStoppedError): + break + debug('done') + t = threading.Thread( + target=serve_forever, + name='ptvsd.sessions', + ) + t.daemon = True, + t.start() return pydevd @@ -30,11 +62,8 @@ def start_client(daemon, host, port): This is a replacement for _pydevd_bundle.pydevd_comm.start_client. """ - client = create_client() - client.connect((host, port)) - - pydevd = daemon.start() - daemon.set_connection(client) + pydevd, start_session = daemon.start_client((host, port)) + start_session() return pydevd diff --git a/ptvsd/runner.py b/ptvsd/runner.py index b1946401a..4b9b46d48 100644 --- a/ptvsd/runner.py +++ b/ptvsd/runner.py @@ -101,6 +101,8 @@ def _check_output(self, out, category): traceback.print_exc() +# TODO: Inherit from ptvsd.daemon.Daemon. + class Daemon(object): """The process-level manager for the VSC protocol debug adapter.""" @@ -120,7 +122,7 @@ def __init__(self, self._client = None self._adapter = None - def start(self, server=None): + def start(self): if self._closed: raise DaemonClosedError() @@ -129,7 +131,7 @@ def start(self, server=None): return None - def set_connection(self, client): + def start_session(self, client): """Set the client socket to use for the debug adapter. A VSC message loop is started for the client. diff --git a/ptvsd/session.py b/ptvsd/session.py new file mode 100644 index 000000000..cf6af4704 --- /dev/null +++ b/ptvsd/session.py @@ -0,0 +1,132 @@ +from .socket import is_socket, close_socket +from .wrapper import VSCodeMessageProcessor +from ._util import Closeable, Startable, debug + + +class DebugSession(Startable, Closeable): + """A single DAP session for a network client socket.""" + + NAME = 'debug session' + FAIL_ON_ALREADY_CLOSED = False + FAIL_ON_ALREADY_STOPPED = False + + @classmethod + def from_raw(cls, raw, **kwargs): + """Return a session for the given data.""" + if isinstance(raw, cls): + return raw + if not is_socket(raw): + # TODO: Create a new client socket from a remote address? + #addr = Address.from_raw(raw) + raise NotImplementedError + client = raw + return cls(client, **kwargs) + + @classmethod + def from_server_socket(cls, server, **kwargs): + """Return a session for the next connection to the given socket.""" + client, _ = server.accept() + return cls(client, ownsock=True, **kwargs) + + def __init__(self, sock, notify_closing=None, ownsock=False): + super(DebugSession, self).__init__() + + self._sock = sock + if ownsock: + def handle_closing(before): + if before: + return + close_socket(self._sock) + self.add_close_handler(handle_closing) + + self._killrequested = False + if notify_closing is not None: + def handle_closing(before): + if not before: + return + notify_closing(kill=self._killrequested) + self.add_close_handler(handle_closing) + + self._msgprocessor = None + + @property + def socket(self): + return self._sock + + @property + def msgprocessor(self): + return self._msgprocessor + + def handle_pydevd_message(self, cmdid, seq, text): + if self._msgprocessor is None: + # TODO: Do more than ignore? + return + return self._msgprocessor.on_pydevd_event(cmdid, seq, text) + + def re_build_breakpoints(self): + """Restore the breakpoints to their last values.""" + if self._msgprocessor is None: + return + return self._msgprocessor.re_build_breakpoints() + + def wait_options(self): + """Return (normal, abnormal) based on the session's launch config.""" + if self._msgprocessor is None: + return (False, False) + return self._msgprocessor._wait_options() + + def wait_until_stopped(self): + """Block until all resources (e.g. message processor) have stopped.""" + if self._msgprocessor is None: + return + # TODO: Do this in VSCodeMessageProcessor.close()? + self._msgprocessor._wait_for_server_thread() + + # internal methods + + def _start(self, threadname, pydevd_notify, pydevd_request, timeout=None): + """Start the message handling for the session. + + A VSC message loop is started. + """ + self._msgprocessor = VSCodeMessageProcessor( + self._sock, + pydevd_notify, + pydevd_request, + notify_disconnecting=self._handle_vsc_disconnect, + notify_closing=self._handle_vsc_close, + timeout=timeout, + ) + self.add_resource_to_close(self._msgprocessor) + self._msgprocessor.start(threadname) + return self._msgprocessor_running + + def _stop(self, exitcode=None): + if self._msgprocessor is None: + return + + # TODO: This is not correct in the "attach" case. + self._msgprocessor.handle_session_stopped(exitcode) + self._msgprocessor.close() + self._msgprocessor = None + + def _close(self): + debug('session closing') + pass + + def _msgprocessor_running(self): + if self._msgprocessor is None: + return False + # TODO: Return self._msgprocessor.is_running(). + return True + + # internal methods for VSCodeMessageProcessor + + def _handle_vsc_disconnect(self, kill=False): + if kill: + self._killrequested = kill + self.close() + + def _handle_vsc_close(self): + debug('processor closing') + self.close() diff --git a/ptvsd/socket.py b/ptvsd/socket.py index 36f44dac5..9eada2726 100644 --- a/ptvsd/socket.py +++ b/ptvsd/socket.py @@ -4,6 +4,10 @@ import contextlib import errno import socket +try: + from urllib.parse import urlparse +except ImportError: + from urlparse import urlparse NOT_CONNECTED = ( @@ -12,13 +16,22 @@ ) +class TimeoutError(socket.timeout): + """A socket timeout happened.""" + + +def is_socket(sock): + """Return True if the object can be used as a socket.""" + return isinstance(sock, socket.socket) + + def create_server(host, port): """Return a local server socket listening on the given port.""" if host is None: host = 'localhost' server = _new_sock() server.bind((host, port)) - server.listen(1) + server.listen(0) return server @@ -45,6 +58,83 @@ def ignored_errno(*ignored): raise +class KeepAlive(namedtuple('KeepAlive', 'interval idle maxfails')): + """TCP keep-alive settings.""" + + INTERVAL = 3 # seconds + IDLE = 1 # seconds after idle + MAX_FAILS = 5 + + @classmethod + def from_raw(cls, raw): + """Return the corresponding KeepAlive.""" + if raw is None: + return None + elif isinstance(raw, cls): + return raw + elif isinstance(raw, (str, int, float)): + return cls(raw) + else: + try: + raw = dict(raw) + except TypeError: + return cls(*raw) + else: + return cls(**raw) + + def __new__(cls, interval=None, idle=None, maxfails=None): + self = super(KeepAlive, cls).__new__( + cls, + float(interval) if interval or interval == 0 else cls.INTERVAL, + float(idle) if idle or idle == 0 else cls.IDLE, + float(maxfails) if maxfails or maxfails == 0 else cls.MAX_FAILS, + ) + return self + + def apply(self, sock): + """Set the keepalive values on the socket.""" + sock.setsockopt(socket.SOL_SOCKET, + socket.SO_KEEPALIVE, + 1) + interval = self.interval + idle = self.idle + maxfails = self.maxfails + try: + if interval > 0: + sock.setsockopt(socket.IPPROTO_TCP, + socket.TCP_KEEPINTVL, + interval) + if idle > 0: + sock.setsockopt(socket.IPPROTO_TCP, + socket.TCP_KEEPIDLE, + idle) + if maxfails >= 0: + sock.setsockopt(socket.IPPROTO_TCP, + socket.TCP_KEEPCNT, + maxfails) + except AttributeError: + # mostly linux-only + pass + + +def connect(sock, addr, keepalive=None): + """Return the client socket for the next connection.""" + if addr is None: + if keepalive is None or keepalive is True: + keepalive = KeepAlive() + elif keepalive: + keepalive = KeepAlive.from_raw(keepalive) + client, _ = sock.accept() + if keepalive: + keepalive.apply(client) + return client + else: + if keepalive: + raise NotImplementedError + sock.connect(addr) + return sock + + def shut_down(sock, how=socket.SHUT_RDWR, ignored=NOT_CONNECTED): """Shut down the given socket.""" with ignored_errno(*ignored or ()): @@ -56,6 +146,7 @@ def close_socket(sock): try: shut_down(sock) except Exception: + # TODO: Log errors? pass sock.close() @@ -64,18 +155,35 @@ class Address(namedtuple('Address', 'host port')): """An IP address to use for sockets.""" @classmethod - def from_raw(cls, raw): + def from_raw(cls, raw, defaultport=None): """Return an address corresponding to the given data.""" if isinstance(raw, cls): return raw - if isinstance(raw, str): - raise NotImplementedError - try: - kwargs = dict(**raw) - except TypeError: - return cls(*raw) + elif isinstance(raw, int): + return cls(None, raw) + elif isinstance(raw, str): + if raw == '': + return cls('', defaultport) + parsed = urlparse(raw) + if not parsed.netloc: + if parsed.scheme: + raise ValueError('invalid address {!r}'.format(raw)) + return cls.from_raw('x://' + raw, defaultport=defaultport) + return cls( + parsed.hostname or '', + parsed.port if parsed.port else defaultport, + ) + elif not raw: + return cls(None, defaultport) else: - return cls(**kwargs) + try: + kwargs = dict(**raw) + except TypeError: + return cls(*raw) + else: + kwargs.setdefault('host', None) + kwargs.setdefault('port', defaultport) + return cls(**kwargs) @classmethod def as_server(cls, host, port): @@ -88,6 +196,8 @@ def as_client(cls, host, port): return cls(host, port, isserver=False) def __new__(cls, host, port, **kwargs): + if host == '*': + host = '' isserver = kwargs.pop('isserver', None) if isserver is None: isserver = (host is None or host == '') diff --git a/ptvsd/wrapper.py b/ptvsd/wrapper.py index 696e38421..1c96d87b1 100644 --- a/ptvsd/wrapper.py +++ b/ptvsd/wrapper.py @@ -32,6 +32,7 @@ import _pydevd_bundle.pydevd_extension_utils as pydevd_extutil # noqa import _pydevd_bundle.pydevd_frame as pydevd_frame # noqa #from _pydevd_bundle.pydevd_comm import pydevd_log +from _pydevd_bundle.pydevd_additional_thread_info import PyDBAdditionalThreadInfo # noqa import ptvsd.ipcjson as ipcjson # noqa import ptvsd.futures as futures # noqa @@ -39,7 +40,8 @@ from ptvsd.pathutils import PathUnNormcase # noqa from ptvsd.safe_repr import SafeRepr # noqa from ptvsd.version import __version__ # noqa -from _pydevd_bundle.pydevd_additional_thread_info import PyDBAdditionalThreadInfo # noqa +from ptvsd._util import debug # noqa +from ptvsd.socket import TimeoutError # noqa #def ipcjson_trace(s): @@ -709,10 +711,11 @@ class VSCodeMessageProcessor(ipcjson.SocketIO, ipcjson.IpcChannel): def __init__(self, socket, pydevd_notify, pydevd_request, notify_disconnecting, notify_closing, - logfile=None, + timeout=None, logfile=None, ): super(VSCodeMessageProcessor, self).__init__(socket=socket, own_socket=False, + timeout=timeout, logfile=logfile) self.socket = socket self._pydevd_notify = pydevd_notify @@ -767,7 +770,11 @@ def start(self, threadname): # VSC msg processing loop def process_messages(): self.readylock.acquire() - self.process_messages() + try: + self.process_messages() + except TimeoutError: + debug('client socket closed') + self.close() self.server_thread = threading.Thread( target=process_messages, name=threadname, @@ -776,18 +783,21 @@ def process_messages(): self.server_thread.start() # special initialization + debug('sending output') self.send_event( 'output', category='telemetry', output='ptvsd', data={'version': __version__}, ) + debug('output sent') self.readylock.release() # closing the adapter def close(self): """Stop the message processor and release its resources.""" + debug('raw closing') if self._closed: return self._closed = True @@ -819,14 +829,15 @@ def _wait_options(self): abnormal = self.debug_options.get('WAIT_ON_ABNORMAL_EXIT', False) return normal, abnormal - def handle_pydevd_stopped(self, exitcode): + def handle_session_stopped(self, exitcode=None): """Finalize the protocol connection.""" if self._exited: return self._exited = True - # Notify the editor that the "debuggee" (e.g. script, app) exited. - self.send_event('exited', exitCode=exitcode) + if exitcode is not None: + # Notify the editor that the "debuggee" (e.g. script, app) exited. + self.send_event('exited', exitCode=exitcode) # Notify the editor that the debugger has stopped. self.send_event('terminated') @@ -846,7 +857,7 @@ def _wait_for_disconnect(self, timeout=None): def _handle_disconnect(self, request): self.disconnect_request = request self.disconnect_request_event.set() - self._notify_disconnecting(not self._closed) + self._notify_disconnecting(kill=not self._closed) if not self._closed: # Closing the socket causes pydevd to resume all threads, # so just terminate the process altogether. @@ -1102,6 +1113,7 @@ def on_disconnect(self, request, args): self._handle_disconnect(request) else: self.send_response(request) + self._notify_disconnecting(kill=False) def send_process_event(self, start_method): # TODO: docstring diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 222dd7a3e..dc0843e21 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -9,7 +9,8 @@ def __init__(self): self._closed = False def __del__(self): - self.close() + if not self._closed: + self.close() def __enter__(self): return self diff --git a/tests/helpers/_io.py b/tests/helpers/_io.py index 442113919..f2a5cc641 100644 --- a/tests/helpers/_io.py +++ b/tests/helpers/_io.py @@ -1,6 +1,41 @@ +import contextlib +from io import StringIO, BytesIO +import sys + from . import noop +if sys.version_info < (3,): + Buffer = BytesIO +else: + Buffer = StringIO + + +@contextlib.contextmanager +def captured_stdio(out=None, err=None): + if out is None: + if err is None: + out = err = Buffer() + elif err is False: + out = Buffer() + elif err is None and out is False: + err = Buffer() + if out is False: + out = None + if err is False: + err = None + + orig = sys.stdout, sys.stderr + if out is not None: + sys.stdout = out + if err is not None: + sys.stderr = err + try: + yield out, err + finally: + sys.stdout, sys.stderr = orig + + def iter_lines(read, sep=b'\n', stop=noop): """Yield each sep-delimited line. @@ -62,7 +97,9 @@ def iter_lines_buffered(read, sep=b'\n', initial=b'', stop=noop): try: if stop(): raise EOFError() - # TODO: handle ConnectionResetError (errno 104) + # ConnectionResetError (errno 104) likely means the + # client was never able to establish a connection. + # TODO: Handle ConnectionResetError gracefully. data = read(1024) if not data: raise EOFError() diff --git a/tests/helpers/counter.py b/tests/helpers/counter.py index b0c74a3b9..343b3d960 100644 --- a/tests/helpers/counter.py +++ b/tests/helpers/counter.py @@ -58,4 +58,7 @@ def reset(self, start=None): """ if start is not None: self._start = int(start) - self._next = self._start + try: + del self._last + except AttributeError: + pass diff --git a/tests/helpers/debugadapter.py b/tests/helpers/debugadapter.py index 42ade8bcb..e1680b433 100644 --- a/tests/helpers/debugadapter.py +++ b/tests/helpers/debugadapter.py @@ -1,3 +1,4 @@ +from ptvsd.socket import Address from . import Closeable from .proc import Proc @@ -9,40 +10,103 @@ class DebugAdapter(Closeable): PORT = 8888 + # generic factories + @classmethod def start(cls, argv, **kwargs): - def new_proc(argv, host, port): + def new_proc(argv, addr): + if cls.VERBOSE: + env = { + 'PTVSD_DEBUG': '1', + 'PTVSD_SOCKET_TIMEOUT': '1', + } + else: + env = {} argv = list(argv) - cls._ensure_addr(argv, host, port) - return Proc.start_python_module('ptvsd', argv) + cls._ensure_addr(argv, addr) + return Proc.start_python_module('ptvsd', argv, env=env) return cls._start(new_proc, argv, **kwargs) @classmethod def start_wrapper_script(cls, filename, argv, **kwargs): - def new_proc(argv, host, port): + def new_proc(argv, addr): return Proc.start_python_script(filename, argv) return cls._start(new_proc, argv, **kwargs) + # specific factory cases + + @classmethod + def start_nodebug(cls, addr, name, kind='script'): + if kind == 'script': + argv = ['--nodebug', name] + elif kind == 'module': + argv = ['--nodebug', '-m', name] + else: + raise NotImplementedError + return cls.start(argv, addr=addr) + + @classmethod + def start_as_server(cls, addr, *args, **kwargs): + addr = Address.as_server(*addr) + return cls._start_as(addr, *args, server=False, **kwargs) + + @classmethod + def start_as_client(cls, addr, *args, **kwargs): + addr = Address.as_client(*addr) + return cls._start_as(addr, *args, server=False, **kwargs) + + @classmethod + def start_for_attach(cls, addr, *args, **kwargs): + addr = Address.as_server(*addr) + return cls._start_as(addr, *args, server=True, **kwargs) + + @classmethod + def _start_as(cls, addr, name, kind='script', extra=None, server=False): + argv = [] + if server: + argv += ['--server'] + if kind == 'script': + argv += [name] + elif kind == 'module': + argv += ['-m', name] + else: + raise NotImplementedError + if extra: + argv += list(extra) + return cls.start(argv, addr=addr) + + @classmethod + def start_embedded(cls, addr, filename, redirect_output=True): + addr = Address.as_server(*addr) + with open(filename, 'r+') as scriptfile: + content = scriptfile.read() + # TODO: Handle this case somehow? + assert 'ptvsd.enable_attach' in content + return cls.start_wrapper_script(filename, argv=[], addr=addr) + @classmethod - def _start(cls, new_proc, argv, host='localhost', port=None): - if port is None: - port = cls.PORT - addr = (host, port) - proc = new_proc(argv, host, port) + def _start(cls, new_proc, argv, addr=None): + addr = Address.from_raw(addr, defaultport=cls.PORT) + proc = new_proc(argv, addr) return cls(proc, addr, owned=True) @classmethod - def _ensure_addr(cls, argv, host, port): + def _ensure_addr(cls, argv, addr): if '--host' in argv: raise ValueError("unexpected '--host' in argv") + if '--server-host' in argv: + raise ValueError("unexpected '--server-host' in argv") if '--port' in argv: raise ValueError("unexpected '--port' in argv") + host, port = addr argv.insert(0, str(port)) argv.insert(0, '--port') - if host and host not in ('localhost', '127.0.0.1'): - argv.insert(0, host) + argv.insert(0, host) + if addr.isserver: + argv.insert(0, '--server-host') + else: argv.insert(0, '--host') def __init__(self, proc, addr, owned=False): @@ -51,6 +115,14 @@ def __init__(self, proc, addr, owned=False): self._proc = proc self._addr = addr + @property + def address(self): + return self._addr + + @property + def pid(self): + return self._proc.pid + @property def output(self): # TODO: Decode here? diff --git a/tests/helpers/debugclient.py b/tests/helpers/debugclient.py index 64cce04ad..f40ab074c 100644 --- a/tests/helpers/debugclient.py +++ b/tests/helpers/debugclient.py @@ -3,6 +3,7 @@ import threading import warnings +from ptvsd.socket import Address from . import Closeable from .debugadapter import DebugAdapter from .debugsession import DebugSession @@ -14,9 +15,10 @@ class _LifecycleClient(Closeable): - def __init__(self, port=8888, breakpoints=None, connecttimeout=1.0): + def __init__(self, addr=None, port=8888, breakpoints=None, + connecttimeout=1.0): super(_LifecycleClient, self).__init__() - self._port = port + self._addr = Address.from_raw(addr, defaultport=port) self._connecttimeout = connecttimeout self._adapter = None self._session = None @@ -51,7 +53,7 @@ def stop_debugging(self): self._adapter.close() self._adapter = None - def attach(self, **kwargs): + def attach_pid(self, pid, **kwargs): if self.closed: raise RuntimeError('debug client closed') if self._adapter is None: @@ -59,15 +61,33 @@ def attach(self, **kwargs): if self._session is not None: raise RuntimeError('already attached') - self._attach(**kwargs) + raise NotImplementedError + + def attach_socket(self, addr=None, adapter=None, **kwargs): + if self.closed: + raise RuntimeError('debug client closed') + if adapter is None: + adapter = self._adapter + elif self._adapter is not None: + raise RuntimeError('already using managed adapter') + if adapter is None: + raise RuntimeError('debugger not running') + if self._session is not None: + raise RuntimeError('already attached') + + if addr is None: + addr = adapter.address + self._attach(addr, **kwargs) return self._session - def detach(self): + def detach(self, adapter=None): if self.closed: raise RuntimeError('debug client closed') if self._session is None: raise RuntimeError('not attached') - assert self._adapter is not None + if adapter is None: + adapter = self._adapter + assert adapter is not None if not self._session.is_client: raise RuntimeError('detach not supported') @@ -89,19 +109,19 @@ def start(*args, **kwargs): *args, **kwargs) else: start = DebugAdapter.start - self._adapter = start( - argv, - host='localhost' if detachable else None, - port=self._port, - ) + new_addr = Address.as_server if detachable else Address.as_client + addr = new_addr(None, self._addr.port) + self._adapter = start(argv, addr=addr) if wait_for_connect: wait_for_connect() else: - self._attach(**kwargs) + self._attach(addr, **kwargs) - def _attach(self, **kwargs): - addr = ('localhost', self._port) + def _attach(self, addr, **kwargs): + if addr is None: + addr = self._addr + assert addr.host == 'localhost' self._session = DebugSession.create_client(addr, **kwargs) def _detach(self): @@ -136,7 +156,7 @@ def host_local_debugger(self, argv, script=None, **kwargs): if self._adapter is not None: raise RuntimeError('debugger already running') assert self._session is None - addr = ('localhost', self._port) + addr = ('localhost', self._addr.port) def run(): self._session = DebugSession.create_server(addr, **kwargs) diff --git a/tests/helpers/debugsession.py b/tests/helpers/debugsession.py index 588a3bfcb..adfda0395 100644 --- a/tests/helpers/debugsession.py +++ b/tests/helpers/debugsession.py @@ -120,7 +120,10 @@ class DebugSession(Closeable): def create_client(cls, addr=None, **kwargs): if addr is None: addr = (cls.HOST, cls.PORT) - conn = DebugSessionConnection.create_client(addr) + conn = DebugSessionConnection.create_client( + addr, + timeout=kwargs.get('timeout'), + ) return cls(conn, owned=True, **kwargs) @classmethod diff --git a/tests/helpers/lock.py b/tests/helpers/lock.py index fe78a5a5d..1ba3c98bc 100644 --- a/tests/helpers/lock.py +++ b/tests/helpers/lock.py @@ -42,6 +42,8 @@ def _release_lockfile(filename): # <- START ACQUIRE LOCKFILE SCRIPT -> import os.path import time +class LockTimeoutError(RuntimeError): + pass %s _acquire_lockfile({!r}, {!r}) # <- END ACQUIRE LOCKFILE SCRIPT -> @@ -76,10 +78,10 @@ def __str__(self): def filename(self): return self._filename - def acquire(self, timeout=1.0): + def acquire(self, timeout=5.0): _acquire_lockfile(self._filename, timeout) - def acquire_script(self, timeout=1.0): + def acquire_script(self, timeout=5.0): return _ACQUIRE_LOCKFILE.format(self._filename, timeout) def release(self): diff --git a/tests/helpers/proc.py b/tests/helpers/proc.py index f848ccf48..25fd1647e 100644 --- a/tests/helpers/proc.py +++ b/tests/helpers/proc.py @@ -11,32 +11,41 @@ class Proc(Closeable): #VERBOSE = True @classmethod - def start_python_script(cls, filename, argv): + def start_python_script(cls, filename, argv, **kwargs): argv = [ sys.executable, filename, ] + argv - return cls.start(argv) + return cls.start(argv, **kwargs) @classmethod - def start_python_module(cls, module, argv): + def start_python_module(cls, module, argv, **kwargs): argv = [ sys.executable, '-m', module, ] + argv - return cls.start(argv) + return cls.start(argv, **kwargs) @classmethod - def start(cls, argv): - proc = cls._start(argv) + def start(cls, argv, env=None, stdout=None, stderr=None): + if env is None: + env = {} + if cls.VERBOSE: + env.setdefault('PTVSD_DEBUG', '1') + proc = cls._start(argv, env, stdout, stderr) return cls(proc, owned=True) @classmethod - def _start(cls, argv): + def _start(cls, argv, env, stdout, stderr): + if stdout is None: + stdout = subprocess.PIPE + if stderr is None: + stderr = subprocess.STDOUT proc = subprocess.Popen( argv, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, + stdout=stdout, + stderr=stderr, + env=env, ) return proc @@ -44,6 +53,8 @@ def __init__(self, proc, owned=False): super(Proc, self).__init__() assert isinstance(proc, subprocess.Popen) self._proc = proc + if proc.stdout is sys.stdout or proc.stdout is None: + self._output = None # TODO: Emulate class-only methods? #def __getattribute__(self, name): @@ -52,6 +63,10 @@ def __init__(self, proc, owned=False): # raise AttributeError(name) # return val + @property + def pid(self): + return self._proc.pid + @property def output(self): try: @@ -78,6 +93,11 @@ def _close(self): except OSError: # Already killed. pass + else: + if self.VERBOSE: + print('proc killed') if self.VERBOSE: - lines = self.output.decode('utf-8').splitlines() - print(' + ' + '\n + '.join(lines)) + out = self.output + if out is not None: + lines = out.decode('utf-8').splitlines() + print(' + ' + '\n + '.join(lines)) diff --git a/tests/helpers/protocol.py b/tests/helpers/protocol.py index a8adf71e3..c00e45a45 100644 --- a/tests/helpers/protocol.py +++ b/tests/helpers/protocol.py @@ -88,6 +88,21 @@ def next_response(self): def next_event(self): return next(self.event) + def reset(self, request=None, response=None, event=None): + if request is None and response is None and event is None: + raise ValueError('missing at least one counter') + if request is not None: + self.request.reset(start=request) + if response is not None: + self.response.reset(start=response) + if event is not None: + self.event.reset(start=event) + + def reset_all(self, start=0): + self.request.reset(start) + self.response.reset(start) + self.event.reset(start) + class DaemonStarted(object): """A simple wrapper around a started protocol daemon.""" diff --git a/tests/helpers/pydevd/_binder.py b/tests/helpers/pydevd/_binder.py index 64d021e95..5cc0b62ce 100644 --- a/tests/helpers/pydevd/_binder.py +++ b/tests/helpers/pydevd/_binder.py @@ -26,8 +26,9 @@ def from_connect_func(cls, connect): killonclose=False, ) client, server = connect() - self.start(server) - self.set_connection(client) + self.start() + self.start_session(client, 'ptvsd.Server') + self.server = server return self @property @@ -36,7 +37,9 @@ def fakesock(self): @property def proc(self): - return self.adapter + if self.session is None: + return None + return self.session.msgprocessor def close(self): """Stop PTVSD and clean up. @@ -135,8 +138,7 @@ def _run_debugger(self): raise NotImplementedError def _wrap_sock(self): - return socket.Connection(self.ptvsd.client, self.ptvsd.server) - #return socket.Connection(self.ptvsd.fakesock, self.ptvsd.server) + return socket.Connection(self.ptvsd.session.socket, self.ptvsd.server) #################### # internal methods @@ -149,12 +151,13 @@ def _start_ptvsd(self): def _run(self): try: - self._run_debugger() + close = self._run_debugger() except SystemExit as exc: self.ptvsd.exitcode = int(exc.code) raise self.ptvsd.exitcode = 0 - self.ptvsd.close() + if close or close is None: + self.ptvsd.close() class Binder(BinderBase): @@ -168,15 +171,15 @@ class Binder(BinderBase): finished. """ - def __init__(self, do_debugging=None): + def __init__(self, do_debugging=None, **kwargs): if do_debugging is None: def do_debugging(external, internal): time.sleep(5) - super(Binder, self).__init__() + super(Binder, self).__init__(**kwargs) self._do_debugging = do_debugging def _run_debugger(self): self._start_ptvsd() - external = self.ptvsd.server + external = self.ptvsd.session.server internal = self.ptvsd.fakesock self._do_debugging(external, internal) diff --git a/tests/helpers/pydevd/_live.py b/tests/helpers/pydevd/_live.py index 01673cea7..9a80d8729 100644 --- a/tests/helpers/pydevd/_live.py +++ b/tests/helpers/pydevd/_live.py @@ -11,12 +11,13 @@ class Binder(BinderBase): - def __init__(self, filename, module): - super(Binder, self).__init__() + def __init__(self, filename, module, **kwargs): + super(Binder, self).__init__(**kwargs) self.filename = filename self.module = module self._lock = threading.Lock() self._lock.acquire() + self._closeondone = True def _run_debugger(self): def new_pydevd_sock(*args): @@ -43,8 +44,10 @@ def new_pydevd_sock(*args): # This shouldn't happen since the timeout on event waiting # is this long. warnings.warn('timeout out waiting for "done"') + return self._closeondone - def done(self): + def done(self, close=True): + self._closeondone = close self._lock.release() @@ -63,11 +66,11 @@ def parse_source(cls, source): # TODO: Write source code to temp module? raise NotImplementedError - def __init__(self, source): + def __init__(self, source, **kwargs): filename, module, owned = self.parse_source(source) self._filename = filename self._owned = owned - self.binder = Binder(filename, module) + self.binder = Binder(filename, module, **kwargs) super(LivePyDevd, self).__init__(self.binder.bind) @@ -80,3 +83,5 @@ def _close(self): if self._owned: os.unlink(self._filename) + if self.binder.ptvsd is not None: + self.binder.ptvsd.close() diff --git a/tests/helpers/socket.py b/tests/helpers/socket.py index f2382d1d0..e724715d6 100644 --- a/tests/helpers/socket.py +++ b/tests/helpers/socket.py @@ -8,9 +8,16 @@ try: + ConnectionError # noqa BrokenPipeError # noqa + ConnectionResetError # noqa except NameError: class BrokenPipeError(Exception): + # EPIPE and ESHUTDOWN + pass + + class ConnectionResetError(Exception): + # ECONNRESET pass @@ -22,11 +29,27 @@ class BrokenPipeError(Exception): CLOSED = ( errno.EPIPE, errno.ESHUTDOWN, + errno.ECONNRESET, ) EOF = NOT_CONNECTED + CLOSED +@contextlib.contextmanager +def convert_eof(): + """A context manager to convert some socket errors into EOFError.""" + try: + yield + except ConnectionResetError: + raise EOFError + except BrokenPipeError: + raise EOFError + except OSError as exc: + if exc.errno in EOF: + raise EOFError + raise + + # TODO: Add timeouts to the functions. def create_server(address): @@ -79,28 +102,16 @@ def connect(): def recv_as_read(sock): """Return a wrapper ardoung sock.read that arises EOFError when closed.""" def read(numbytes, _recv=sock.recv): - try: + with convert_eof(): return _recv(numbytes) - except BrokenPipeError: - raise EOFError - except OSError as exc: - if exc.errno in EOF: - raise EOFError - raise return read def send_as_write(sock): """Return a wrapper ardoung sock.send that arises EOFError when closed.""" def write(data, _send=sock.send): - try: + with convert_eof(): return _send(data) - except BrokenPipeError: - raise EOFError - except OSError as exc: - if exc.errno in EOF: - raise EOFError - raise return write diff --git a/tests/highlevel/test_live_pydevd.py b/tests/highlevel/test_live_pydevd.py index f9d5d271b..a8f621751 100644 --- a/tests/highlevel/test_live_pydevd.py +++ b/tests/highlevel/test_live_pydevd.py @@ -152,7 +152,7 @@ def launched(self, port=8888, **kwargs): kwargs.setdefault('process', False) with self.lifecycle.launched(port=port, hide=True, **kwargs): yield - self.fix.binder.done() + self.fix.binder.done(close=False) self.fix.binder.wait_until_done() @@ -208,6 +208,18 @@ class LogpointTests(TestBase, unittest.TestCase): d = 4 """ + @contextlib.contextmanager + def closing(self, exit=True): + def handle_msg(msg, _): + with self.wait_for_event('output'): + self.req_disconnect = self.send_request('disconnect') + with self.wait_for_event('terminated', handler=handle_msg): + if exit: + with self.wait_for_event('exited'): + yield + else: + yield + @contextlib.contextmanager def running(self): addr = (None, 8888) @@ -215,6 +227,11 @@ def running(self): yield def test_basic(self): + with open(self.filename) as scriptfile: + script = scriptfile.read() + done, waitscript = self.workspace.lockfile().wait_in_script() + with open(self.filename, 'w') as scriptfile: + scriptfile.write(script + waitscript) addr = (None, 8888) with self.fake.start(addr): with self.vsc.wait_for_event('output'): @@ -236,12 +253,14 @@ def test_basic(self): }, ], }) + with self.vsc.wait_for_event('output'): + req_config = self.send_request('configurationDone') + done() - req_config = self.send_request('configurationDone') - - with self.wait_for_events(['exited', 'terminated']): - self.fix.binder.done() + self.fix.binder.done(close=False) self.fix.binder.wait_until_done() + with self.closing(): + self.fix.binder.ptvsd.close() received = self.vsc.received self.assert_vsc_received(received, [ @@ -263,7 +282,10 @@ def test_basic(self): isLocalProcess=True, startMethod='attach', )), + self.new_event('output', **dict( + category='stdout', + output='1+2=3' + os.linesep, + )), self.new_event('exited', exitCode=0), self.new_event('terminated'), - self.new_event('output', **dict(category='stdout', output='1+2=3' + os.linesep)), # noqa ]) diff --git a/tests/ptvsd/test___main__.py b/tests/ptvsd/test___main__.py index 01b4e645e..f371a5946 100644 --- a/tests/ptvsd/test___main__.py +++ b/tests/ptvsd/test___main__.py @@ -1,41 +1,8 @@ -import contextlib -from io import StringIO, BytesIO -import sys import unittest from ptvsd.socket import Address from ptvsd.__main__ import parse_args - - -if sys.version_info < (3,): - Buffer = BytesIO -else: - Buffer = StringIO - - -@contextlib.contextmanager -def captured_stdio(out=None, err=None): - if out is None: - if err is None: - out = err = Buffer() - elif err is False: - out = Buffer() - elif err is None and out is False: - err = Buffer() - if out is False: - out = None - if err is False: - err = None - - orig = sys.stdout, sys.stderr - if out is not None: - sys.stdout = out - if err is not None: - sys.stderr = err - try: - yield out, err - finally: - sys.stdout, sys.stderr = orig +from tests.helpers._io import captured_stdio class ParseArgsTests(unittest.TestCase): diff --git a/tests/ptvsd/test_socket.py b/tests/ptvsd/test_socket.py new file mode 100644 index 000000000..fe92a45e5 --- /dev/null +++ b/tests/ptvsd/test_socket.py @@ -0,0 +1,174 @@ +import contextlib +import sys +import unittest + +from ptvsd.socket import Address + + +class AddressTests(unittest.TestCase): + + if sys.version_info < (3,): + @contextlib.contextmanager + def subTest(self, *args, **kwargs): + yield + + def test_from_raw(self): + serverlocal = Address.as_server('localhost', 9876) + serverremote = Address.as_server('1.2.3.4', 9876) + clientlocal = Address.as_client('localhost', 9876) + clientremote = Address.as_client('1.2.3.4', 9876) + default = Address(None, 1111) + external = Address('', 1111) + values = [ + (serverlocal, serverlocal), + (serverremote, serverremote), + (clientlocal, clientlocal), + (clientremote, clientremote), + (None, default), + ('', external), + ([], default), + ({}, default), + (9876, serverlocal), + ('localhost:9876', clientlocal), + ('1.2.3.4:9876', clientremote), + ('*:9876', Address.as_server('', 9876)), + ('*', external), + (':9876', Address.as_server('', 9876)), + ('localhost', Address('localhost', 1111)), + (':', external), + (dict(host='localhost'), Address('localhost', 1111)), + (dict(port=9876), serverlocal), + (dict(host=None, port=9876), serverlocal), + (dict(host='localhost', port=9876), clientlocal), + (dict(host='localhost', port='9876'), clientlocal), + ] + for value, expected in values: + with self.subTest(value): + addr = Address.from_raw(value, defaultport=1111) + + self.assertEqual(addr, expected) + + def test_as_server_valid_address(self): + for host in ['localhost', '127.0.0.1', '::', '1.2.3.4']: + with self.subTest(host): + addr = Address.as_server(host, 9786) + + self.assertEqual( + addr, + Address(host, 9786, isserver=True), + ) + + def test_as_server_public_host(self): + addr = Address.as_server('', 9786) + + self.assertEqual( + addr, + Address('', 9786, isserver=True), + ) + + def test_as_server_default_host(self): + addr = Address.as_server(None, 9786) + + self.assertEqual( + addr, + Address('localhost', 9786, isserver=True), + ) + + def test_as_server_bad_port(self): + port = None + for host in [None, '', 'localhost', '1.2.3.4']: + with self.subTest((host, port)): + with self.assertRaises(TypeError): + Address.as_server(host, port) + + for port in ['', -1, 0, 65536]: + for host in [None, '', 'localhost', '1.2.3.4']: + with self.subTest((host, port)): + with self.assertRaises(ValueError): + Address.as_server(host, port) + + def test_as_client_valid_address(self): + for host in ['localhost', '127.0.0.1', '::', '1.2.3.4']: + with self.subTest(host): + addr = Address.as_client(host, 9786) + + self.assertEqual( + addr, + Address(host, 9786, isserver=False), + ) + + def test_as_client_public_host(self): + addr = Address.as_client('', 9786) + + self.assertEqual( + addr, + Address('', 9786, isserver=False), + ) + + def test_as_client_default_host(self): + addr = Address.as_client(None, 9786) + + self.assertEqual( + addr, + Address('localhost', 9786, isserver=False), + ) + + def test_as_client_bad_port(self): + port = None + for host in [None, '', 'localhost', '1.2.3.4']: + with self.subTest((host, port)): + with self.assertRaises(TypeError): + Address.as_client(host, port) + + for port in ['', -1, 0, 65536]: + for host in [None, '', 'localhost', '1.2.3.4']: + with self.subTest((host, port)): + with self.assertRaises(ValueError): + Address.as_client(host, port) + + def test_new_valid_address(self): + for host in ['localhost', '127.0.0.1', '::', '1.2.3.4']: + with self.subTest(host): + addr = Address(host, 9786) + + self.assertEqual( + addr, + Address(host, 9786, isserver=False), + ) + + def test_new_public_host(self): + addr = Address('', 9786) + + self.assertEqual( + addr, + Address('', 9786, isserver=True), + ) + + def test_new_default_host(self): + addr = Address(None, 9786) + + self.assertEqual( + addr, + Address('localhost', 9786, isserver=True), + ) + + def test_new_wildcard_host(self): + addr = Address('*', 9786) + + self.assertEqual( + addr, + Address('', 9786, isserver=True), + ) + + def test_new_bad_port(self): + port = None + for host in [None, '', 'localhost', '1.2.3.4']: + with self.subTest((host, port)): + with self.assertRaises(TypeError): + Address(host, port) + + for port in ['', -1, 0, 65536]: + for host in [None, '', 'localhost', '1.2.3.4']: + with self.subTest((host, port)): + with self.assertRaises(ValueError): + Address(host, port) diff --git a/tests/system_tests/test_connection.py b/tests/system_tests/test_connection.py new file mode 100644 index 000000000..3a672fe26 --- /dev/null +++ b/tests/system_tests/test_connection.py @@ -0,0 +1,156 @@ +from __future__ import print_function + +import contextlib +import os +import time +import sys +import unittest + +import ptvsd._util +from ptvsd.socket import create_client, close_socket +from tests.helpers.proc import Proc +from tests.helpers.workspace import Workspace + + +@contextlib.contextmanager +def _retrier(timeout=1, persec=10, max=None, verbose=False): + steps = int(timeout * persec) + 1 + delay = 1.0 / persec + + @contextlib.contextmanager + def attempt(num): + if verbose: + print('*', end='') + sys.stdout.flush() + yield + if verbose: + if num % persec == 0: + print() + elif (num * 2) % persec == 0: + print(' ', end='') + + def attempts(): + # The first attempt always happens. + num = 1 + with attempt(num): + yield num + for num in range(2, steps): + if max is not None and num > max: + raise RuntimeError('too many attempts (max {})'.format(max)) + time.sleep(delay) + with attempt(num): + yield num + else: + raise RuntimeError('timed out') + yield attempts() + if verbose: + print() + + +class RawConnectionTests(unittest.TestCase): + + VERBOSE = False + #VERBOSE = True + + def setUp(self): + super(RawConnectionTests, self).setUp() + self.workspace = Workspace() + self.addCleanup(self.workspace.cleanup) + + def _propagate_verbose(self): + if not self.VERBOSE: + return + + def unset(): + Proc.VERBOSE = False + ptvsd._util.DEBUG = False + self.addCleanup(unset) + Proc.VERBOSE = True + ptvsd._util.DEBUG = True + + def _wait_for_ready(self, rpipe): + if self.VERBOSE: + print('waiting for ready') + line = b'' + while True: + c = os.read(rpipe, 1) + line += c + if c == b'\n': + if self.VERBOSE: + print(line.decode('utf-8'), end='') + if b'getting session socket' in line: + break + line = b'' + + @unittest.skip('there is a race here under travis') + def test_repeated(self): + def debug(msg): + if not self.VERBOSE: + return + print(msg) + + def connect(addr, wait=None, closeonly=False): + sock = create_client() + try: + sock.settimeout(1) + sock.connect(addr) + debug('>connected') + if wait is not None: + debug('>waiting') + time.sleep(wait) + finally: + debug('>closing') + if closeonly: + sock.close() + else: + close_socket(sock) + filename = self.workspace.write('spam.py', content=""" + raise Exception('should never run') + """) + addr = ('localhost', 5678) + self._propagate_verbose() + rpipe, wpipe = os.pipe() + self.addCleanup(lambda: os.close(rpipe)) + self.addCleanup(lambda: os.close(wpipe)) + proc = Proc.start_python_module('ptvsd', [ + '--server', + '--port', '5678', + '--file', filename, + ], env={ + 'PTVSD_DEBUG': '1', + 'PTVSD_SOCKET_TIMEOUT': '1', + }, stdout=wpipe) + with proc: + # Wait for the server to spin up. + debug('>a') + with _retrier(timeout=3, verbose=self.VERBOSE) as attempts: + for _ in attempts: + try: + connect(addr) + break + except Exception: + pass + self._wait_for_ready(rpipe) + debug('>b') + connect(addr) + self._wait_for_ready(rpipe) + # We should be able to handle more connections. + debug('>c') + connect(addr) + self._wait_for_ready(rpipe) + # Give ptvsd long enough to try sending something. + debug('>d') + connect(addr, wait=0.2) + self._wait_for_ready(rpipe) + debug('>e') + connect(addr) + self._wait_for_ready(rpipe) + debug('>f') + connect(addr, closeonly=True) + self._wait_for_ready(rpipe) + debug('>g') + connect(addr) + self._wait_for_ready(rpipe) + debug('>h') + connect(addr) + self._wait_for_ready(rpipe) diff --git a/tests/system_tests/test_main.py b/tests/system_tests/test_main.py index 93af31a12..764fa6c68 100644 --- a/tests/system_tests/test_main.py +++ b/tests/system_tests/test_main.py @@ -1,14 +1,21 @@ import os +from textwrap import dedent import unittest import ptvsd +from ptvsd.socket import Address from ptvsd.wrapper import INITIALIZE_RESPONSE # noqa +from tests.helpers.debugadapter import DebugAdapter from tests.helpers.debugclient import EasyDebugClient as DebugClient from tests.helpers.threading import get_locked_and_waiter from tests.helpers.vsc import parse_message, VSCMessages from tests.helpers.workspace import Workspace, PathEntry +#VERSION = '0+unknown' +VERSION = ptvsd.__version__ + + def _strip_pydevd_output(out): # TODO: Leave relevant lines from before the marker? pre, sep, out = out.partition( @@ -217,7 +224,7 @@ def test_pre_init(self): 'body': { 'output': 'ptvsd', 'data': { - 'version': ptvsd.__version__, + 'version': VERSION, }, 'category': 'telemetry', }, @@ -249,7 +256,7 @@ def test_launch_ptvsd_client(self): 'output', category='telemetry', output='ptvsd', - data={'version': ptvsd.__version__}), + data={'version': VERSION}), self.new_response(req_initialize, **INITIALIZE_RESPONSE), self.new_event('initialized'), self.new_response(req_launch), @@ -273,22 +280,171 @@ def test_launch_ptvsd_server(self): done() adapter.wait() + self.maxDiff = None + self.assert_received(session.received, [ + self.new_event( + 'output', + category='telemetry', + output='ptvsd', + data={'version': VERSION}), + self.new_response(req_initialize, **INITIALIZE_RESPONSE), + self.new_event('initialized'), + self.new_response(req_launch), + self.new_response(req_config), + self.new_event('exited', exitCode=0), + self.new_event('terminated'), + ]) + + def test_attach_started_separately(self): + lockfile = self.workspace.lockfile() + done, waitscript = lockfile.wait_in_script() + filename = self.write_script('spam.py', waitscript) + addr = Address('localhost', 8888) + with DebugAdapter.start_for_attach(addr, filename) as adapter: + with DebugClient() as editor: + session = editor.attach_socket(addr, adapter) + + (req_initialize, req_launch, req_config + ) = lifecycle_handshake(session, 'attach') + done() + adapter.wait() + + self.assert_received(session.received, [ + self.new_event( + 'output', + category='telemetry', + output='ptvsd', + data={'version': VERSION}), + self.new_response(req_initialize, **INITIALIZE_RESPONSE), + self.new_event('initialized'), + self.new_response(req_launch), + self.new_response(req_config), + self.new_event('process', **{ + 'isLocalProcess': True, + 'systemProcessId': adapter.pid, + 'startMethod': 'attach', + 'name': filename, + }), + self.new_event('exited', exitCode=0), + self.new_event('terminated'), + ]) + + def test_attach_embedded(self): + lockfile = self.workspace.lockfile() + done, waitscript = lockfile.wait_in_script() + addr = Address('localhost', 8888) + script = dedent(""" + from __future__ import print_function + import sys + sys.path.insert(0, {!r}) + import ptvsd + ptvsd.enable_attach({}, redirect_output={}) + + %s + + print('success!', end='') + """).format(os.getcwd(), tuple(addr), True) + filename = self.write_script('spam.py', script % waitscript) + with DebugAdapter.start_embedded(addr, filename) as adapter: + with DebugClient() as editor: + session = editor.attach_socket(addr, adapter) + + (req_initialize, req_launch, req_config + ) = lifecycle_handshake(session, 'attach') + done() + adapter.wait() + out = adapter.output.decode('utf-8') + self.assert_received(session.received, [ self.new_event( 'output', category='telemetry', output='ptvsd', - data={'version': ptvsd.__version__}), + data={'version': VERSION}), + self.new_response(req_initialize, **INITIALIZE_RESPONSE), + self.new_event('initialized'), + self.new_response(req_launch), + self.new_response(req_config), + self.new_event('process', **{ + 'isLocalProcess': True, + 'systemProcessId': adapter.pid, + 'startMethod': 'attach', + 'name': filename, + }), + self.new_event('output', output='success!', category='stdout'), + self.new_event('exited', exitCode=0), + self.new_event('terminated'), + ]) + self.assertIn('success!', out) + + def test_reattach(self): + lockfile1 = self.workspace.lockfile() + done1, waitscript1 = lockfile1.wait_in_script(timeout=5) + lockfile2 = self.workspace.lockfile() + done2, waitscript2 = lockfile2.wait_in_script(timeout=5) + filename = self.write_script('spam.py', waitscript1 + waitscript2) + addr = Address('localhost', 8888) + with DebugAdapter.start_for_attach(addr, filename) as adapter: + with DebugClient() as editor: + # Attach initially. + session1 = editor.attach_socket(addr, adapter) + reqs = lifecycle_handshake(session1, 'attach') + done1() + req_disconnect = session1.send_request('disconnect') + editor.detach(adapter) + + # Re-attach + session2 = editor.attach_socket(addr, adapter) + (req_initialize, req_launch, req_config + ) = lifecycle_handshake(session2, 'attach') + done2() + + adapter.wait() + + #self.maxDiff = None + self.assert_received(session1.received, [ + self.new_event( + 'output', + category='telemetry', + output='ptvsd', + data={'version': VERSION}), + self.new_response(reqs[0], **INITIALIZE_RESPONSE), + self.new_event('initialized'), + self.new_response(reqs[1]), + self.new_response(reqs[2]), + self.new_event('process', **{ + 'isLocalProcess': True, + 'systemProcessId': adapter.pid, + 'startMethod': 'attach', + 'name': filename, + }), + self.new_response(req_disconnect), + # TODO: Shouldn't there be a "terminated" event? + #self.new_event('terminated'), + ]) + self.messages.reset_all() + self.assert_received(session2.received, [ + self.new_event( + 'output', + category='telemetry', + output='ptvsd', + data={'version': VERSION}), self.new_response(req_initialize, **INITIALIZE_RESPONSE), self.new_event('initialized'), self.new_response(req_launch), self.new_response(req_config), + self.new_event('process', **{ + 'isLocalProcess': True, + 'systemProcessId': adapter.pid, + 'startMethod': 'attach', + 'name': filename, + }), self.new_event('exited', exitCode=0), self.new_event('terminated'), ]) @unittest.skip('re-attach needs fixing') - def test_attach(self): + def test_attach_unknown(self): lockfile = self.workspace.lockfile() done, waitscript = lockfile.wait_in_script() filename = self.write_script('spam.py', waitscript) @@ -318,7 +474,7 @@ def test_attach(self): 'output', category='telemetry', output='ptvsd', - data={'version': ptvsd.__version__}), + data={'version': VERSION}), self.new_response(req_initialize, **INITIALIZE_RESPONSE), self.new_event('initialized'), self.new_response(req_launch),