コード例 #1
0
class PluginRuntime(object):

    SUPPORTED_DECORATOR_VERSIONS = {
        'input_status': [1, 2],
        'output_status': [1, 2],
        'shutter_status': [1, 2, 3],
        'thermostat_status': [1],
        'thermostat_group_status': [1],
        'ventilation_status': [1],
        'receive_events': [1],
        'background_task': [1],
        'on_remove': [1]
    }

    def __init__(self, path):
        # type: (str) -> None
        self._stopped = False
        self._path = path.rstrip('/')

        self._decorated_methods = {
            'input_status': [],
            'output_status': [],
            'shutter_status': [],
            'thermostat_status': [],
            'thermostat_group_status': [],
            'ventilation_status': [],
            'receive_events': [],
            'background_task': [],
            'on_remove': []
        }  # type: Dict[str,List[Any]]

        self._name = None
        self._version = None
        self._interfaces = []  # type: List[Any]
        self._exposes = []  # type: List[Any]
        self._metric_definitions = []  # type: List[Any]
        self._metric_collectors = []  # type: List[Any]
        self._metric_receivers = []  # type: List[Any]

        self._plugin = None
        self._writer = PluginIPCWriter(os.fdopen(sys.stdout.fileno(), 'wb', 0))
        self._reader = PluginIPCReader(os.fdopen(sys.stdin.fileno(), 'rb', 0),
                                       self._writer.log_exception)

        config = ConfigParser()
        config.read(constants.get_config_file())
        try:
            http_port = int(config.get('OpenMotics', 'http_port'))
        except (NoSectionError, NoOptionError):
            http_port = 80
        self._webinterface = WebInterfaceDispatcher(self._writer.log,
                                                    port=http_port)

    def _init_plugin(self):
        # type: () -> None
        plugin_root = os.path.dirname(self._path)
        plugin_dir = os.path.basename(self._path)

        # Add the plugin and it's eggs to the python path
        sys.path.insert(0, plugin_root)
        for egg_file in os.listdir(self._path):
            if egg_file.endswith('.egg'):
                sys.path.append(os.path.join(self._path, egg_file))

        # Expose plugins.base to the plugin
        sys.modules['plugins'] = sys.modules['__main__']
        sys.modules["plugins.base"] = base

        # Instanciate the plugin class
        plugin_class = get_plugin_class(plugin_dir)
        check_plugin(plugin_class)

        # Set the name, version, interfaces
        self._name = plugin_class.name
        self._version = plugin_class.version
        self._interfaces = plugin_class.interfaces

        # Initialze the plugin
        self._plugin = plugin_class(self._webinterface, self._writer.log)

        for decorator_name, decorated_methods in six.iteritems(
                self._decorated_methods):
            for decorated_method, decorator_version in get_special_methods(
                    self._plugin, decorator_name):
                # only add if supported, raise if an unsupported version is found
                if decorator_version not in PluginRuntime.SUPPORTED_DECORATOR_VERSIONS[
                        decorator_name]:
                    raise NotImplementedError(
                        'Decorator {} version {} is not supported'.format(
                            decorator_name, decorator_version))
                decorated_methods.append(
                    decorated_method)  # add the decorated method to the list

        # Set the exposed methods
        for decorated_method, _ in get_special_methods(self._plugin,
                                                       'om_expose'):
            self._exposes.append({
                'name':
                decorated_method.__name__,
                'auth':
                decorated_method.om_expose['auth'],
                'content_type':
                decorated_method.om_expose['content_type']
            })

        # Set the metric collectors
        for decorated_method, _ in get_special_methods(self._plugin,
                                                       'om_metric_data'):
            self._metric_collectors.append({
                'name':
                decorated_method.__name__,
                'interval':
                decorated_method.om_metric_data['interval']
            })

        # Set the metric receivers
        for decorated_method, _ in get_special_methods(self._plugin,
                                                       'om_metric_receive'):
            self._metric_receivers.append({
                'name':
                decorated_method.__name__,
                'source':
                decorated_method.om_metric_receive['source'],
                'metric_type':
                decorated_method.om_metric_receive['metric_type'],
                'interval':
                decorated_method.om_metric_receive['interval']
            })

        # Set the metric definitions
        if has_interface(plugin_class, 'metrics', '1.0'):
            if hasattr(plugin_class, 'metric_definitions'):
                self._metric_definitions = plugin_class.metric_definitions

    def _start_background_tasks(self):
        # type: () -> None
        """ Start all background tasks. """
        for decorated_method in self._decorated_methods['background_task']:
            thread = BaseThread(name='plugin{}'.format(
                decorated_method.__name__),
                                target=self._run_background_task,
                                args=(decorated_method, ))
            thread.daemon = True
            thread.start()

    def get_decorators_in_use(self):
        registered_decorators = {}
        for decorator_name, decorated_methods in six.iteritems(
                self._decorated_methods):
            decorator_versions_in_use = set()
            for decorated_method in decorated_methods:
                decorator_version = getattr(decorated_method,
                                            decorator_name).get('version', 1)
                decorator_versions_in_use.add(decorator_version)
            registered_decorators[decorator_name] = list(
                decorator_versions_in_use)

        # something in the form of e.g. {'output_status': [1,2], 'input_status': [1]} where 1,2,... are the versions
        return registered_decorators

    def _run_background_task(self, task):
        # type: (Callable[[],None]) -> None
        running = True
        while running:
            try:
                task()
                running = False  # Stop execution if the task returns without exception
            except Exception as exception:
                self._writer.log_exception('background task', exception)
                time.sleep(30)

    def process_stdin(self):
        # type: () -> None
        self._reader.start()
        while not self._stopped:
            command = self._reader.get(block=True)
            if command is None:
                continue

            action = command['action']
            action_version = command['action_version']
            response = {'cid': command['cid'], 'action': action}
            try:
                ret = None
                if action == 'start':
                    ret = self._handle_start()
                elif action == 'stop':
                    ret = self._handle_stop()
                elif action == 'input_status':
                    ret = self._handle_input_status(command['event'])
                elif action == 'output_status':
                    # v1 = state, v2 = event
                    if action_version == 1:
                        ret = self._handle_output_status(command['status'],
                                                         data_type='status')
                    else:
                        ret = self._handle_output_status(command['event'],
                                                         data_type='event')
                elif action == 'ventilation_status':
                    ret = self._handle_ventilation_status(command['event'])
                elif action == 'thermostat_status':
                    ret = self._handle_thermostat_status(command['event'])
                elif action == 'thermostat_group_status':
                    ret = self._handle_thermostat_group_status(
                        command['event'])
                elif action == 'shutter_status':
                    # v1 = state as list, v2 = state as dict, v3 = event
                    if action_version == 1:
                        ret = self._handle_shutter_status(command['status'],
                                                          data_type='status')
                    elif action_version == 2:
                        ret = self._handle_shutter_status(
                            command['status'], data_type='status_dict')
                    else:
                        ret = self._handle_shutter_status(command['event'],
                                                          data_type='event')
                elif action == 'receive_events':
                    ret = self._handle_receive_events(command['code'])
                elif action == 'get_metric_definitions':
                    ret = self._handle_get_metric_definitions()
                elif action == 'collect_metrics':
                    ret = self._handle_collect_metrics(command['name'])
                elif action == 'distribute_metrics':
                    ret = self._handle_distribute_metrics(
                        command['name'], command['metrics'])
                elif action == 'request':
                    ret = self._handle_request(command['method'],
                                               command['args'],
                                               command['kwargs'])
                elif action == 'remove_callback':
                    ret = self._handle_remove_callback()
                elif action == 'ping':
                    pass  # noop
                else:
                    raise RuntimeError('Unknown action: {0}'.format(action))

                if ret is not None:
                    response.update(ret)
            except Exception as exception:
                response['_exception'] = str(exception)
            self._writer.write(response)

    def _handle_start(self):
        # type: () -> Dict[str,Any]
        """ Handles the start command. Cover exceptions manually to make sure as much metadata is returned as possible. """
        data = {}  # type: Dict[str,Any]
        try:
            self._init_plugin()
            self._start_background_tasks()
        except Exception as exception:
            data['exception'] = str(exception)
        data.update({
            'name': self._name,
            'version': self._version,
            'decorators': self.get_decorators_in_use(),
            'exposes': self._exposes,
            'interfaces': self._interfaces,
            'metric_collectors': self._metric_collectors,
            'metric_receivers': self._metric_receivers
        })
        return data

    def _handle_stop(self):
        def delayed_stop():
            time.sleep(2)
            os._exit(0)

        stop_thread = BaseThread(name='pluginstop', target=delayed_stop)
        stop_thread.daemon = True
        stop_thread.start()

        self._stopped = True

    def _handle_input_status(self, data):
        event = GatewayEvent.deserialize(data)
        # get relevant event details
        input_id = event.data['id']
        status = event.data['status']
        for decorated_method in self._decorated_methods['input_status']:
            decorator_version = decorated_method.input_status.get('version', 1)
            if decorator_version == 1:
                # Backwards compatibility: only send rising edges of the input (no input releases)
                if status:
                    self._writer.with_catch('input status', decorated_method,
                                            [(input_id, None)])
            elif decorator_version == 2:
                # Version 2 will send ALL input status changes AND in a dict format
                self._writer.with_catch('input status', decorated_method,
                                        [{
                                            'input_id': input_id,
                                            'status': status
                                        }])
            else:
                error = NotImplementedError(
                    'Version {} is not supported for input status decorators'.
                    format(decorator_version))
                self._writer.log_exception('input status', error)

    def _handle_output_status(self, data, data_type='status'):
        event = GatewayEvent.deserialize(
            data) if data_type == 'event' else None
        for receiver in self._decorated_methods['output_status']:
            decorator_version = receiver.output_status.get('version', 1)
            if decorator_version not in PluginRuntime.SUPPORTED_DECORATOR_VERSIONS[
                    'output_status']:
                error = NotImplementedError(
                    'Version {} is not supported for output status decorators'.
                    format(decorator_version))
                self._writer.log_exception('output status', error)
            else:
                if decorator_version == 1 and data_type == 'status':
                    self._writer.with_catch('output status', receiver, [data])
                elif decorator_version == 2 and event:
                    self._writer.with_catch('output status', receiver,
                                            [event.data])

    def _handle_ventilation_status(self, data):
        event = GatewayEvent.deserialize(data)
        for receiver in self._decorated_methods['ventilation_status']:
            self._writer.with_catch('ventilation status', receiver,
                                    [event.data])

    def _handle_thermostat_status(self, data):
        event = GatewayEvent.deserialize(data)
        for receiver in self._decorated_methods['thermostat_status']:
            self._writer.with_catch('thermostat status', receiver,
                                    [event.data])

    def _handle_thermostat_group_status(self, data):
        event = GatewayEvent.deserialize(data)
        for receiver in self._decorated_methods['thermostat_group_status']:
            self._writer.with_catch('thermostat group status', receiver,
                                    [event.data])

    def _handle_shutter_status(self, data, data_type='status'):
        event = GatewayEvent.deserialize(
            data) if data_type == 'event' else None
        for receiver in self._decorated_methods['shutter_status']:
            decorator_version = receiver.shutter_status.get('version', 1)
            if decorator_version not in PluginRuntime.SUPPORTED_DECORATOR_VERSIONS[
                    'shutter_status']:
                error = NotImplementedError(
                    'Version {} is not supported for shutter status decorators'
                    .format(decorator_version))
                self._writer.log_exception('shutter status', error)
            else:
                if decorator_version == 1 and data_type == 'status':
                    self._writer.with_catch('shutter status', receiver, [data])
                elif decorator_version == 2 and data_type == 'status_dict':
                    self._writer.with_catch('shutter status', receiver,
                                            [data['status'], data['detail']])
                elif decorator_version == 3 and event:
                    self._writer.with_catch('shutter status', receiver,
                                            [event.data])

    def _handle_receive_events(self, code):
        for receiver in self._decorated_methods['receive_events']:
            decorator_version = receiver.receive_events.get('version', 1)
            if decorator_version == 1:
                self._writer.with_catch('process event', receiver, [code])
            else:
                error = NotImplementedError(
                    'Version {} is not supported for receive events decorators'
                    .format(decorator_version))
                self._writer.log_exception('receive events', error)

    def _handle_remove_callback(self):
        for decorated_method in self._decorated_methods['on_remove']:
            decorator_version = decorated_method.on_remove.get('version', 1)
            if decorator_version == 1:
                try:
                    decorated_method()
                except Exception as exception:
                    self._writer.log_exception('on remove', exception)
            else:
                error = NotImplementedError(
                    'Version {} is not supported for shutter status decorators'
                    .format(decorator_version))
                self._writer.log_exception('on remove', error)

    def _handle_get_metric_definitions(self):
        return {'metric_definitions': self._metric_definitions}

    def _handle_collect_metrics(self, name):
        metrics = []
        collect = getattr(self._plugin, name)
        try:
            metrics.extend(list(collect()))
        except Exception as exception:
            self._writer.log_exception('collect metrics', exception)
        return {'metrics': metrics}

    def _handle_distribute_metrics(self, name, metrics):
        receive = getattr(self._plugin, name)
        for metric in metrics:
            self._writer.with_catch('distribute metric', receive, [metric])

    def _handle_request(self, method, args, kwargs):
        func = getattr(self._plugin, method)
        requested_parameters = set(
            Toolbox.get_parameter_names(func)) - {'self'}
        difference = set(kwargs.keys()) - requested_parameters
        if difference:
            # Analog error message as the default CherryPy behavior
            return {
                'success':
                False,
                'exception':
                'Unexpected query string parameters: {0}'.format(
                    ', '.join(difference))
            }
        difference = requested_parameters - set(kwargs.keys())
        if difference:
            # Analog error message as the default CherryPy behavior
            return {
                'success':
                False,
                'exception':
                'Missing parameters: {0}'.format(', '.join(difference))
            }
        try:
            return {'success': True, 'response': func(*args, **kwargs)}
        except Exception as exception:
            return {
                'success': False,
                'exception': str(exception),
                'stacktrace': traceback.format_exc()
            }
コード例 #2
0
class PluginRunner(object):
    class State(object):
        RUNNING = 'RUNNING'
        STOPPED = 'STOPPED'

    def __init__(self,
                 name,
                 runtime_path,
                 plugin_path,
                 logger,
                 command_timeout=5.0,
                 state_callback=None):
        self.runtime_path = runtime_path
        self.plugin_path = plugin_path
        self.command_timeout = command_timeout

        self._logger = logger
        self._cid = 0
        self._proc = None  # type: Optional[subprocess.Popen[bytes]]
        self._running = False
        self._process_running = False
        self._command_lock = Lock()
        self._response_queue = Queue()  # type: Queue[Dict[str,Any]]
        self._writer = None  # type: Optional[PluginIPCWriter]
        self._reader = None  # type: Optional[PluginIPCReader]
        self._state_callback = state_callback  # type: Optional[Callable[[str, str], None]]

        self.name = name
        self.version = None
        self.interfaces = None

        self._decorators_in_use = {}
        self._exposes = []
        self._metric_collectors = []
        self._metric_receivers = []

        self._async_command_thread = None
        self._async_command_queue = None  # type: Optional[Queue[Optional[Dict[str, Any]]]]

        self._commands_executed = 0
        self._commands_failed = 0

        self.__collector_runs = {}  # type: Dict[str,float]

    def start(self):
        # type: () -> None
        if self._running:
            raise Exception('PluginRunner is already running')

        self.logger('[Runner] Starting')

        python_executable = sys.executable
        if python_executable is None or len(python_executable) == 0:
            python_executable = '/usr/bin/python'

        self._proc = subprocess.Popen([
            python_executable, 'runtime.py', 'start_plugin', self.plugin_path
        ],
                                      stdin=subprocess.PIPE,
                                      stdout=subprocess.PIPE,
                                      stderr=None,
                                      cwd=self.runtime_path,
                                      close_fds=True)
        assert self._proc.stdout, 'Plugin stdout not available'
        self._process_running = True

        self._commands_executed = 0
        self._commands_failed = 0

        assert self._proc.stdin, 'Plugin stdin not defined'
        self._writer = PluginIPCWriter(stream=self._proc.stdin)
        self._reader = PluginIPCReader(stream=self._proc.stdout,
                                       logger=lambda message, ex: self.logger(
                                           '{0}: {1}'.format(message, ex)),
                                       command_receiver=self._process_command,
                                       name=self.name)
        self._reader.start()

        start_out = self._do_command('start', timeout=180)
        self.name = start_out['name']
        self.version = start_out['version']
        self.interfaces = start_out['interfaces']

        self._decorators_in_use = start_out['decorators']
        self._exposes = start_out['exposes']
        self._metric_collectors = start_out['metric_collectors']
        self._metric_receivers = start_out['metric_receivers']

        exception = start_out.get('exception')
        if exception is not None:
            raise RuntimeError(exception)

        self._async_command_queue = Queue(1000)
        self._async_command_thread = BaseThread(
            name='plugincmd{0}'.format(self.plugin_path),
            target=self._perform_async_commands)
        self._async_command_thread.daemon = True
        self._async_command_thread.start()

        self._running = True
        if self._state_callback is not None:
            self._state_callback(self.name, PluginRunner.State.RUNNING)
        self.logger('[Runner] Started')

    def logger(self, message):
        # type: (str) -> None
        self._logger(message)
        logger_.info('Plugin {0} - {1}'.format(self.name, message))

    def get_webservice(self, webinterface):
        # type: (WebInterface) -> Service
        return Service(self, webinterface)

    def is_running(self):
        # type: () -> bool
        return self._running

    def stop(self):
        # type: () -> None
        if self._process_running:
            self._running = False

            self.logger('[Runner] Sending stop command')
            try:
                self._do_command('stop')
            except Exception as exception:
                self.logger(
                    '[Runner] Exception during stopping plugin: {0}'.format(
                        exception))
            time.sleep(0.1)

            if self._reader:
                self._reader.stop()
            self._process_running = False
            if self._async_command_queue is not None:
                self._async_command_queue.put(
                    None)  # Triggers an abort on the read thread

            if self._proc and self._proc.poll() is None:
                self.logger('[Runner] Terminating process')
                try:
                    self._proc.terminate()
                except Exception as exception:
                    self.logger(
                        '[Runner] Exception during terminating plugin: {0}'.
                        format(exception))
                time.sleep(0.5)

                if self._proc.poll() is None:
                    self.logger('[Runner] Killing process')
                    try:
                        self._proc.kill()
                    except Exception as exception:
                        self.logger(
                            '[Runner] Exception during killing plugin: {0}'.
                            format(exception))

            if self._state_callback is not None:
                self._state_callback(self.name, PluginRunner.State.STOPPED)
            self.logger('[Runner] Stopped')

    def process_input_status(self, data, action_version=1):
        if action_version in [1, 2]:
            if action_version == 1:
                payload = {'status': data}
            else:
                event_json = data.serialize()
                payload = {'event': event_json}
            self._do_async(action='input_status',
                           payload=payload,
                           should_filter=True,
                           action_version=action_version)
        else:
            self.logger('Input status version {} not supported.'.format(
                action_version))

    def process_output_status(self, data, action_version=1):
        if action_version in [1, 2]:
            if action_version == 1:
                payload = {'status': data}
            else:
                event_json = data.serialize()
                payload = {'event': event_json}
            self._do_async(action='output_status',
                           payload=payload,
                           should_filter=True,
                           action_version=action_version)
        else:
            self.logger('Output status version {} not supported.'.format(
                action_version))

    def process_shutter_status(self, data, action_version=1):
        if action_version in [1, 2, 3]:
            if action_version == 1:
                payload = {'status': data}
            elif action_version == 2:
                status, detail = data
                payload = {'status': {'status': status, 'detail': detail}}
            else:
                event_json = data.serialize()
                payload = {'event': event_json}
            self._do_async(action='shutter_status',
                           payload=payload,
                           should_filter=True,
                           action_version=action_version)
        else:
            self.logger('Shutter status version {} not supported.'.format(
                action_version))

    def process_ventilation_status(self, data, action_version=1):
        if action_version in [1]:
            event_json = data.serialize()
            payload = {'event': event_json}
            self._do_async(action='ventilation_status',
                           payload=payload,
                           should_filter=True,
                           action_version=action_version)
        else:
            self.logger('Ventilation status version {} not supported.'.format(
                action_version))

    def process_thermostat_status(self, data, action_version=1):
        if action_version in [1]:
            event_json = data.serialize()
            payload = {'event': event_json}
            self._do_async(action='thermostat_status',
                           payload=payload,
                           should_filter=True,
                           action_version=action_version)
        else:
            self.logger('Thermostat status version {} not supported.'.format(
                action_version))

    def process_thermostat_group_status(self, data, action_version=1):
        if action_version in [1]:
            event_json = data.serialize()
            payload = {'event': event_json}
            self._do_async(action='thermostat_group_status',
                           payload=payload,
                           should_filter=True,
                           action_version=action_version)
        else:
            self.logger(
                'Thermostat group status version {} not supported.'.format(
                    action_version))

    def process_event(self, code):
        self._do_async('receive_events', {'code': code}, should_filter=True)

    def collect_metrics(self):
        for mc in self._metric_collectors:
            try:
                now = time.time()
                (name, interval) = (mc['name'], mc['interval'])

                if self.__collector_runs.get(name, 0.0) < now - interval:
                    self.__collector_runs[name] = now
                    metrics = self._do_command('collect_metrics',
                                               {'name': name})['metrics']
                    for metric in metrics:
                        if metric is None:
                            continue
                        metric['source'] = self.name
                        yield metric
            except Exception as exception:
                self.logger(
                    '[Runner] Exception while collecting metrics {0}: {1}'.
                    format(exception, traceback.format_exc()))

    def get_metric_receivers(self):
        return self._metric_receivers

    def distribute_metrics(self, method, metrics):
        self._do_async('distribute_metrics', {
            'name': method,
            'metrics': metrics
        })

    def get_metric_definitions(self):
        return self._do_command('get_metric_definitions')['metric_definitions']

    def request(self, method, args=None, kwargs=None):
        if args is None:
            args = []
        if kwargs is None:
            kwargs = {}
        ret = self._do_command('request', {
            'method': method,
            'args': args,
            'kwargs': kwargs
        })
        if ret['success']:
            return ret['response']
        elif 'stacktrace' in ret:
            raise Exception('{0}: {1}'.format(ret['exception'],
                                              ret['stacktrace']))
        raise Exception(ret['exception'])

    def remove_callback(self):
        # type: () -> None
        self._do_command('remove_callback')

    def _process_command(self, response):
        # type: (Dict[str,Any]) -> None
        if not self._process_running:
            return
        assert self._proc, 'Plugin process not defined'
        exit_code = self._proc.poll()
        if exit_code is not None:
            self.logger(
                '[Runner] Stopped with exit code {0}'.format(exit_code))
            self._process_running = False
            return

        if response['cid'] == 0:
            self._handle_async_response(response)
        elif response['cid'] == self._cid:
            self._response_queue.put(response)
        else:
            self.logger(
                '[Runner] Received message with unknown cid: {0}'.format(
                    response))

    def _handle_async_response(self, response):
        # type: (Dict[str,Any]) -> None
        if response['action'] == 'logs':
            self.logger(response['logs'])
        else:
            self.logger('[Runner] Unkown async message: {0}'.format(response))

    def _do_async(self,
                  action,
                  payload,
                  should_filter=False,
                  action_version=1):
        # type: (str, Dict[str,Any], bool, int) -> None
        has_receiver = False
        for decorator_name, decorator_versions in six.iteritems(
                self._decorators_in_use):
            # the action version is linked to a specific decorator version
            has_receiver |= (action == decorator_name
                             and action_version in decorator_versions)
        if not self._process_running or (should_filter and not has_receiver):
            return
        try:
            assert self._async_command_queue, 'Command Queue not defined'
            self._async_command_queue.put(
                {
                    'action': action,
                    'payload': payload,
                    'action_version': action_version
                },
                block=False)
        except Full:
            self.logger('Async action cannot be queued, queue is full')

    def _perform_async_commands(self):
        # type: () -> None
        while self._process_running:
            try:
                # Give it a timeout in order to check whether the plugin is not stopped.
                assert self._async_command_queue, 'Command Queue not defined'
                command = self._async_command_queue.get(block=True, timeout=10)
                if command is None:
                    continue  # Used to exit this thread
                self._do_command(command['action'],
                                 payload=command['payload'],
                                 action_version=command['action_version'])
            except Empty:
                self._do_async('ping', {})
            except Exception as exception:
                self.logger(
                    '[Runner] Failed to perform async command: {0}'.format(
                        exception))

    def _do_command(self,
                    action,
                    payload=None,
                    timeout=None,
                    action_version=1):
        # type: (str, Dict[str,Any], Optional[float], int) -> Dict[str,Any]
        if payload is None:
            payload = {}
        self._commands_executed += 1
        if timeout is None:
            timeout = self.command_timeout

        if not self._process_running:
            raise Exception('Plugin was stopped')

        with self._command_lock:
            try:
                command = self._create_command(action, payload, action_version)
                assert self._writer, 'Plugin stdin not defined'
                self._writer.write(command)
            except Exception:
                self._commands_failed += 1
                raise

            try:
                response = self._response_queue.get(block=True,
                                                    timeout=timeout)
                while response['cid'] != self._cid:
                    response = self._response_queue.get(block=False)
                exception = response.get('_exception')
                if exception is not None:
                    raise RuntimeError(exception)
                return response
            except Empty:
                metadata = ''
                if action == 'request':
                    metadata = ' {0}'.format(payload['method'])
                if self._running:
                    self.logger(
                        '[Runner] No response within {0}s ({1}{2})'.format(
                            timeout, action, metadata))
                self._commands_failed += 1
                raise Exception('Plugin did not respond')

    def _create_command(self, action, payload=None, action_version=1):
        # type: (str, Dict[str,Any], int) -> Dict[str,Any]
        if payload is None:
            payload = {}
        self._cid += 1
        command = {
            'cid': self._cid,
            'action': action,
            'action_version': action_version
        }
        command.update(payload)
        return command

    def error_score(self):
        # type: () -> float
        if self._commands_executed == 0:
            return 0.0
        else:
            score = float(self._commands_failed) / self._commands_executed
            self._commands_failed = 0
            self._commands_executed = 0
            return score

    def get_queue_length(self):
        # type: () -> int
        if self._async_command_queue is None:
            return 0
        return self._async_command_queue.qsize()