Format: text/x-python
⬇ Download File (18.9 KB)
   1  """
   2  Makes it possible to do the compiled analysis in a subprocess. This has two
   3  goals:
   4  
   5  1. Making it safer - Segfaults and RuntimeErrors as well as stdout/stderr can
   6     be ignored and dealt with.
   7  2. Make it possible to handle different Python versions as well as virtualenvs.
   8  
   9  The architecture here is briefly:
  10   - For each Jedi `Environment` there is a corresponding subprocess which
  11     operates within the target environment. If the subprocess dies it is replaced
  12     at this level.
  13   - `CompiledSubprocess` manages exactly one subprocess and handles communication
  14     from the parent side.
  15   - `Listener` runs within the subprocess, processing each request and yielding
  16     results.
  17   - `InterpreterEnvironment` provides an API which matches that of `Environment`,
  18     but runs functionality inline rather than within a subprocess. It is thus
  19     used both directly in places where a subprocess is unnecessary and/or
  20     undesirable and also within subprocesses themselves.
  21   - `InferenceStateSubprocess` (or `InferenceStateSameProcess`) provide high
  22     level access to functionality within the subprocess from within the parent.
  23     Each `InterpreterState` has an instance of one of these, provided by its
  24     environment.
  25  """
  26  
  27  import collections
  28  import os
  29  import sys
  30  import queue
  31  import subprocess
  32  import traceback
  33  import weakref
  34  from functools import partial
  35  from threading import Thread
  36  from typing import Dict, TYPE_CHECKING
  37  
  38  from jedi._compatibility import pickle_dump, pickle_load
  39  from jedi import debug
  40  from jedi.cache import memoize_method
  41  from jedi.inference.compiled.subprocess import functions
  42  from jedi.inference.compiled.access import DirectObjectAccess, AccessPath, \
  43      SignatureParam
  44  from jedi.api.exceptions import InternalError
  45  
  46  if TYPE_CHECKING:
  47      from jedi.inference import InferenceState
  48  
  49  
  50  _MAIN_PATH = os.path.join(os.path.dirname(__file__), '__main__.py')
  51  PICKLE_PROTOCOL = 4
  52  
  53  
  54  def _GeneralizedPopen(*args, **kwargs):
  55      if os.name == 'nt':
  56          try:
  57              # Was introduced in Python 3.7.
  58              CREATE_NO_WINDOW = subprocess.CREATE_NO_WINDOW
  59          except AttributeError:
  60              CREATE_NO_WINDOW = 0x08000000
  61          kwargs['creationflags'] = CREATE_NO_WINDOW
  62      # The child process doesn't need file descriptors except 0, 1, 2.
  63      # This is unix only.
  64      kwargs['close_fds'] = 'posix' in sys.builtin_module_names
  65  
  66      return subprocess.Popen(*args, **kwargs)
  67  
  68  
  69  def _enqueue_output(out, queue_):
  70      for line in iter(out.readline, b''):
  71          queue_.put(line)
  72  
  73  
  74  def _add_stderr_to_debug(stderr_queue):
  75      while True:
  76          # Try to do some error reporting from the subprocess and print its
  77          # stderr contents.
  78          try:
  79              line = stderr_queue.get_nowait()
  80              line = line.decode('utf-8', 'replace')
  81              debug.warning('stderr output: %s' % line.rstrip('\n'))
  82          except queue.Empty:
  83              break
  84  
  85  
  86  def _get_function(name):
  87      return getattr(functions, name)
  88  
  89  
  90  def _cleanup_process(process, thread):
  91      try:
  92          process.kill()
  93          process.wait()
  94      except OSError:
  95          # Raised if the process is already killed.
  96          pass
  97      thread.join()
  98      for stream in [process.stdin, process.stdout, process.stderr]:
  99          try:
 100              stream.close()
 101          except OSError:
 102              # Raised if the stream is broken.
 103              pass
 104  
 105  
 106  class _InferenceStateProcess:
 107      def __init__(self, inference_state: 'InferenceState') -> None:
 108          self._inference_state_weakref = weakref.ref(inference_state)
 109          self._handles: Dict[int, AccessHandle] = {}
 110  
 111      def get_or_create_access_handle(self, obj):
 112          id_ = id(obj)
 113          try:
 114              return self.get_access_handle(id_)
 115          except KeyError:
 116              access = DirectObjectAccess(self._inference_state_weakref(), obj)
 117              handle = AccessHandle(self, access, id_)
 118              self.set_access_handle(handle)
 119              return handle
 120  
 121      def get_access_handle(self, id_):
 122          return self._handles[id_]
 123  
 124      def set_access_handle(self, handle):
 125          self._handles[handle.id] = handle
 126  
 127  
 128  class InferenceStateSameProcess(_InferenceStateProcess):
 129      """
 130      Basically just an easy access to functions.py. It has the same API
 131      as InferenceStateSubprocess and does the same thing without using a subprocess.
 132      This is necessary for the Interpreter process.
 133      """
 134      def __getattr__(self, name):
 135          return partial(_get_function(name), self._inference_state_weakref())
 136  
 137  
 138  class InferenceStateSubprocess(_InferenceStateProcess):
 139      """
 140      API to functionality which will run in a subprocess.
 141  
 142      This mediates the interaction between an `InferenceState` and the actual
 143      execution of functionality running within a `CompiledSubprocess`. Available
 144      functions are defined in `.functions`, though should be accessed via
 145      attributes on this class of the same name.
 146  
 147      This class is responsible for indicating that the `InferenceState` within
 148      the subprocess can be removed once the corresponding instance in the parent
 149      goes away.
 150      """
 151  
 152      def __init__(
 153          self,
 154          inference_state: 'InferenceState',
 155          compiled_subprocess: 'CompiledSubprocess',
 156      ) -> None:
 157          super().__init__(inference_state)
 158          self._used = False
 159          self._compiled_subprocess = compiled_subprocess
 160  
 161          # Opaque id we'll pass to the subprocess to identify the context (an
 162          # `InferenceState`) which should be used for the request. This allows us
 163          # to make subsequent requests which operate on results from previous
 164          # ones, while keeping a single subprocess which can work with several
 165          # contexts in the parent process. Once it is no longer needed(i.e: when
 166          # this class goes away), we also use this id to indicate that the
 167          # subprocess can discard the context.
 168          #
 169          # Note: this id is deliberately coupled to this class (and not to
 170          # `InferenceState`) as this class manages access handle mappings which
 171          # must correspond to those in the subprocess. This approach also avoids
 172          # race conditions from successive `InferenceState`s with the same object
 173          # id (as observed while adding support for Python 3.13).
 174          #
 175          # This value does not need to be the `id()` of this instance, we merely
 176          # need to ensure that it enables the (visible) lifetime of the context
 177          # within the subprocess to match that of this class. We therefore also
 178          # depend on the semantics of `CompiledSubprocess.delete_inference_state`
 179          # for correctness.
 180          self._inference_state_id = id(self)
 181  
 182      def __getattr__(self, name):
 183          func = _get_function(name)
 184  
 185          def wrapper(*args, **kwargs):
 186              self._used = True
 187  
 188              result = self._compiled_subprocess.run(
 189                  self._inference_state_id,
 190                  func,
 191                  args=args,
 192                  kwargs=kwargs,
 193              )
 194              # IMO it should be possible to create a hook in pickle.load to
 195              # mess with the loaded objects. However it's extremely complicated
 196              # to work around this so just do it with this call. ~ dave
 197              return self._convert_access_handles(result)
 198  
 199          return wrapper
 200  
 201      def _convert_access_handles(self, obj):
 202          if isinstance(obj, SignatureParam):
 203              return SignatureParam(*self._convert_access_handles(tuple(obj)))
 204          elif isinstance(obj, tuple):
 205              return tuple(self._convert_access_handles(o) for o in obj)
 206          elif isinstance(obj, list):
 207              return [self._convert_access_handles(o) for o in obj]
 208          elif isinstance(obj, AccessHandle):
 209              try:
 210                  # Rewrite the access handle to one we're already having.
 211                  obj = self.get_access_handle(obj.id)
 212              except KeyError:
 213                  obj.add_subprocess(self)
 214                  self.set_access_handle(obj)
 215          elif isinstance(obj, AccessPath):
 216              return AccessPath(self._convert_access_handles(obj.accesses))
 217          return obj
 218  
 219      def __del__(self):
 220          if self._used and not self._compiled_subprocess.is_crashed:
 221              self._compiled_subprocess.delete_inference_state(self._inference_state_id)
 222  
 223  
 224  class CompiledSubprocess:
 225      """
 226      A subprocess which runs inference within a target environment.
 227  
 228      This class manages the interface to a single instance of such a process as
 229      well as the lifecycle of the process itself. See `.__main__` and `Listener`
 230      for the implementation of the subprocess and details of the protocol.
 231  
 232      A single live instance of this is maintained by `jedi.api.environment.Environment`,
 233      so that typically a single subprocess is used at a time.
 234      """
 235  
 236      is_crashed = False
 237  
 238      def __init__(self, executable, env_vars=None):
 239          self._executable = executable
 240          self._env_vars = env_vars
 241          self._inference_state_deletion_queue = collections.deque()
 242          self._cleanup_callable = lambda: None
 243  
 244      def __repr__(self):
 245          pid = os.getpid()
 246          return '<%s _executable=%r, is_crashed=%r, pid=%r>' % (
 247              self.__class__.__name__,
 248              self._executable,
 249              self.is_crashed,
 250              pid,
 251          )
 252  
 253      @memoize_method
 254      def _get_process(self):
 255          debug.dbg('Start environment subprocess %s', self._executable)
 256          parso_path = sys.modules['parso'].__file__
 257          args = (
 258              self._executable,
 259              _MAIN_PATH,
 260              os.path.dirname(os.path.dirname(parso_path)),
 261              '.'.join(str(x) for x in sys.version_info[:3]),
 262          )
 263          process = _GeneralizedPopen(
 264              args,
 265              stdin=subprocess.PIPE,
 266              stdout=subprocess.PIPE,
 267              stderr=subprocess.PIPE,
 268              env=self._env_vars
 269          )
 270          self._stderr_queue = queue.Queue()
 271          self._stderr_thread = t = Thread(
 272              target=_enqueue_output,
 273              args=(process.stderr, self._stderr_queue)
 274          )
 275          t.daemon = True
 276          t.start()
 277          # Ensure the subprocess is properly cleaned up when the object
 278          # is garbage collected.
 279          self._cleanup_callable = weakref.finalize(self,
 280                                                    _cleanup_process,
 281                                                    process,
 282                                                    t)
 283          return process
 284  
 285      def run(self, inference_state_id, function, args=(), kwargs={}):
 286          # Delete old inference_states.
 287          while True:
 288              try:
 289                  delete_id = self._inference_state_deletion_queue.pop()
 290              except IndexError:
 291                  break
 292              else:
 293                  self._send(delete_id, None)
 294  
 295          assert callable(function)
 296          return self._send(inference_state_id, function, args, kwargs)
 297  
 298      def get_sys_path(self):
 299          return self._send(None, functions.get_sys_path, (), {})
 300  
 301      def _kill(self):
 302          self.is_crashed = True
 303          self._cleanup_callable()
 304  
 305      def _send(self, inference_state_id, function, args=(), kwargs={}):
 306          if self.is_crashed:
 307              raise InternalError("The subprocess %s has crashed." % self._executable)
 308  
 309          data = inference_state_id, function, args, kwargs
 310          try:
 311              pickle_dump(data, self._get_process().stdin, PICKLE_PROTOCOL)
 312          except BrokenPipeError:
 313              self._kill()
 314              raise InternalError("The subprocess %s was killed. Maybe out of memory?"
 315                                  % self._executable)
 316  
 317          try:
 318              is_exception, traceback, result = pickle_load(self._get_process().stdout)
 319          except EOFError as eof_error:
 320              try:
 321                  stderr = self._get_process().stderr.read().decode('utf-8', 'replace')
 322              except Exception as exc:
 323                  stderr = '<empty/not available (%r)>' % exc
 324              self._kill()
 325              _add_stderr_to_debug(self._stderr_queue)
 326              raise InternalError(
 327                  "The subprocess %s has crashed (%r, stderr=%s)." % (
 328                      self._executable,
 329                      eof_error,
 330                      stderr,
 331                  ))
 332  
 333          _add_stderr_to_debug(self._stderr_queue)
 334  
 335          if is_exception:
 336              # Replace the attribute error message with a the traceback. It's
 337              # way more informative.
 338              result.args = (traceback,)
 339              raise result
 340          return result
 341  
 342      def delete_inference_state(self, inference_state_id):
 343          """
 344          Indicate that an inference state (in the subprocess) is no longer
 345          needed.
 346  
 347          The state corresponding to the given id will become inaccessible and the
 348          id may safely be re-used to refer to a different context.
 349  
 350          Note: it is not guaranteed that the corresponding state will actually be
 351          deleted immediately.
 352          """
 353          # Warning: if changing the semantics of context deletion see the comment
 354          # in `InferenceStateSubprocess.__init__` regarding potential race
 355          # conditions.
 356  
 357          # Currently we are not deleting the related state instantly. They only
 358          # get deleted once the subprocess is used again. It would probably a
 359          # better solution to move all of this into a thread. However, the memory
 360          # usage of a single inference_state shouldn't be that high.
 361          self._inference_state_deletion_queue.append(inference_state_id)
 362  
 363  
 364  class Listener:
 365      """
 366      Main loop for the subprocess which actually does the inference.
 367  
 368      This class runs within the target environment. It listens to instructions
 369      from the parent process, runs inference and returns the results.
 370  
 371      The subprocess has a long lifetime and is expected to process several
 372      requests, including for different `InferenceState` instances in the parent.
 373      See `CompiledSubprocess` for the parent half of the system.
 374  
 375      Communication is via pickled data sent serially over stdin and stdout.
 376      Stderr is read only if the child process crashes.
 377  
 378      The request protocol is a 4-tuple of:
 379       * inference_state_id | None: an opaque identifier of the parent's
 380         `InferenceState`. An `InferenceState` operating over an
 381         `InterpreterEnvironment` is created within this process for each of
 382         these, ensuring that each parent context has a corresponding context
 383         here. This allows context to be persisted between requests. Unless
 384         `None`, the local `InferenceState` will be passed to the given function
 385         as the first positional argument.
 386       * function | None: the function to run. This is expected to be a member of
 387         `.functions`. `None` indicates that the corresponding inference state is
 388         no longer needed and should be dropped.
 389       * args: positional arguments to the `function`. If any of these are
 390         `AccessHandle` instances they will be adapted to the local
 391         `InferenceState` before being passed.
 392       * kwargs: keyword arguments to the `function`. If any of these are
 393         `AccessHandle` instances they will be adapted to the local
 394         `InferenceState` before being passed.
 395  
 396      The result protocol is a 3-tuple of either:
 397       * (False, None, function result): if the function returns without error, or
 398       * (True, traceback, exception): if the function raises an exception
 399      """
 400  
 401      def __init__(self):
 402          self._inference_states = {}
 403  
 404      def _get_inference_state(self, function, inference_state_id):
 405          from jedi.inference import InferenceState
 406  
 407          try:
 408              inference_state = self._inference_states[inference_state_id]
 409          except KeyError:
 410              from jedi import InterpreterEnvironment
 411              inference_state = InferenceState(
 412                  # The project is not actually needed. Nothing should need to
 413                  # access it.
 414                  project=None,
 415                  environment=InterpreterEnvironment()
 416              )
 417              self._inference_states[inference_state_id] = inference_state
 418          return inference_state
 419  
 420      def _run(self, inference_state_id, function, args, kwargs):
 421          if inference_state_id is None:
 422              return function(*args, **kwargs)
 423          elif function is None:
 424              # Warning: if changing the semantics of context deletion see the comment
 425              # in `InferenceStateSubprocess.__init__` regarding potential race
 426              # conditions.
 427              del self._inference_states[inference_state_id]
 428          else:
 429              inference_state = self._get_inference_state(function, inference_state_id)
 430  
 431              # Exchange all handles
 432              args = list(args)
 433              for i, arg in enumerate(args):
 434                  if isinstance(arg, AccessHandle):
 435                      args[i] = inference_state.compiled_subprocess.get_access_handle(arg.id)
 436              for key, value in kwargs.items():
 437                  if isinstance(value, AccessHandle):
 438                      kwargs[key] = inference_state.compiled_subprocess.get_access_handle(value.id)
 439  
 440              return function(inference_state, *args, **kwargs)
 441  
 442      def listen(self):
 443          stdout = sys.stdout
 444          # Mute stdout. Nobody should actually be able to write to it,
 445          # because stdout is used for IPC.
 446          sys.stdout = open(os.devnull, 'w')
 447          stdin = sys.stdin
 448          stdout = stdout.buffer
 449          stdin = stdin.buffer
 450  
 451          while True:
 452              try:
 453                  payload = pickle_load(stdin)
 454              except EOFError:
 455                  # It looks like the parent process closed.
 456                  # Don't make a big fuss here and just exit.
 457                  exit(0)
 458              try:
 459                  result = False, None, self._run(*payload)
 460              except Exception as e:
 461                  result = True, traceback.format_exc(), e
 462  
 463              pickle_dump(result, stdout, PICKLE_PROTOCOL)
 464  
 465  
 466  class AccessHandle:
 467      def __init__(
 468          self,
 469          subprocess: _InferenceStateProcess,
 470          access: DirectObjectAccess,
 471          id_: int,
 472      ) -> None:
 473          self.access = access
 474          self._subprocess = subprocess
 475          self.id = id_
 476  
 477      def add_subprocess(self, subprocess):
 478          self._subprocess = subprocess
 479  
 480      def __repr__(self):
 481          try:
 482              detail = self.access
 483          except AttributeError:
 484              detail = '#' + str(self.id)
 485          return '<%s of %s>' % (self.__class__.__name__, detail)
 486  
 487      def __getstate__(self):
 488          return self.id
 489  
 490      def __setstate__(self, state):
 491          self.id = state
 492  
 493      def __getattr__(self, name):
 494          if name in ('id', 'access') or name.startswith('_'):
 495              raise AttributeError("Something went wrong with unpickling")
 496  
 497          # print('getattr', name, file=sys.stderr)
 498          return partial(self._workaround, name)
 499  
 500      def _workaround(self, name, *args, **kwargs):
 501          """
 502          TODO Currently we're passing slice objects around. This should not
 503          happen. They are also the only unhashable objects that we're passing
 504          around.
 505          """
 506          if args and isinstance(args[0], slice):
 507              return self._subprocess.get_compiled_method_return(self.id, name, *args, **kwargs)
 508          return self._cached_results(name, *args, **kwargs)
 509  
 510      @memoize_method
 511      def _cached_results(self, name, *args, **kwargs):
 512          return self._subprocess.get_compiled_method_return(self.id, name, *args, **kwargs)
 513