diff --git a/ros2action/ros2action/api/__init__.py b/ros2action/ros2action/api/__init__.py index 42e23171c..2d43e068d 100644 --- a/ros2action/ros2action/api/__init__.py +++ b/ros2action/ros2action/api/__init__.py @@ -13,6 +13,7 @@ # limitations under the License. from rclpy.expand_topic_name import expand_topic_name +from rclpy.node import Node from rclpy.validate_full_topic_name import validate_full_topic_name from ros2cli.node.strategy import NodeStrategy from rosidl_runtime_py import get_action_interfaces @@ -66,6 +67,43 @@ def get_action_names(*, node): return [n for (n, t) in action_names_and_types] +def get_action_class(node: Node, action_name: str): + """ + Load action type module for the given action. + + The action should be running for this function to find the action type. + :param node: The node object of rclpy Node class. + :param action_name: The fully-qualified name of the action. + :return: the action class or None + """ + action_names_and_types = get_action_names_and_types(node=node) + + matched_names_and_types = list(filter(lambda x: x[0] == action_name, action_names_and_types)) + if len(matched_names_and_types) < 1: + raise RuntimeError(f"Cannot find type for '{action_name}'") + if len(matched_names_and_types) > 1: + raise RuntimeError(f"Unexpectedly saw more than one entry for action '{action_name}'") + + # Now check whether there are multiple types associated with this action, which is unsupported + action_name_and_types = matched_names_and_types[0] + + types = action_name_and_types[1] + if len(types) < 1: + raise RuntimeError(f"No types associated with '{action_name}'") + if len(types) > 1: + raise RuntimeError(f"More than one type associated with action '{action_name}'") + + action_type = types[0] + + if action_type is None: + return None + + try: + return get_action(action_type) + except (AttributeError, ModuleNotFoundError, ValueError): + raise RuntimeError(f"The action type '{action_type}' is invalid") + + def action_name_completer(prefix, parsed_args, **kwargs): """Callable returning a list of action names.""" with NodeStrategy(parsed_args) as node: diff --git a/ros2action/ros2action/verb/echo.py b/ros2action/ros2action/verb/echo.py new file mode 100644 index 000000000..685169ac4 --- /dev/null +++ b/ros2action/ros2action/verb/echo.py @@ -0,0 +1,293 @@ +# Copyright 2025 Sony Group Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import OrderedDict +from enum import Enum +import queue +import sys +import threading + +import rclpy +from rclpy.qos import qos_profile_action_status_default +from rclpy.qos import qos_profile_services_default +from rclpy.qos import QoSProfile +from rclpy.subscription import Subscription +from rclpy.type_support import EventMessage +from rclpy.type_support import MsgT + +from ros2action.api import action_name_completer +from ros2action.api import action_type_completer +from ros2action.api import get_action_class +from ros2action.verb import VerbExtension + +from ros2cli.helpers import unsigned_int +from ros2cli.node.strategy import NodeStrategy + +from rosidl_runtime_py import message_to_csv +from rosidl_runtime_py import message_to_ordereddict +from rosidl_runtime_py.utilities import get_action + +from service_msgs.msg import ServiceEventInfo + +import yaml + +DEFAULT_TRUNCATE_LENGTH = 128 + + +class ActionInterfaces(Enum): + GOAL_SERVICE = 'GOAL_SERVICE' + CANCEL_SERVICE = 'CANCEL_SERVICE' + RESULT_SERVICE = 'RESULT_SERVICE' + FEEDBACK_TOPIC = 'FEEDBACK_TOPIC' + STATUS_TOPIC = 'STATUS_TOPIC' + + +class EchoVerb(VerbExtension): + """Echo a action.""" + + # Custom representer for getting clean YAML output that preserves the order in an OrderedDict. + # Inspired by: http://stackoverflow.com/a/16782282/7169408 + def __represent_ordereddict(self, dumper, data): + items = [] + for k, v in data.items(): + items.append((dumper.represent_data(k), dumper.represent_data(v))) + return yaml.nodes.MappingNode(u'tag:yaml.org,2002:map', items) + + def __init__(self): + self._event_number_to_name = {} + for k, v in ServiceEventInfo._Metaclass_ServiceEventInfo__constants.items(): + self._event_number_to_name[v] = k + + yaml.add_representer(OrderedDict, self.__represent_ordereddict) + + def add_arguments(self, parser, cli_name): + arg = parser.add_argument( + 'action_name', + help="Name of the ROS action to echo (e.g. '/fibonacci')") + arg.completer = action_name_completer + arg = parser.add_argument( + 'action_type', nargs='?', + help="Type of the ROS action (e.g. 'example_interfaces/action/Fibonacci')") + arg.completer = action_type_completer + parser.add_argument( + '--interfaces', '-i', type=str, metavar='interfaces_name_list', + help='Specify a action interfaces list are output. "|" is used as a delimiter for ' + 'multiple action interfaces. Action interfaces include "GOAL_SERVICE", ' + '"CANCEL_SERVICE", "RESULT_SERVICE", "FEEDBACK_TOPIC" and "STATUS_TOPIC". ' + 'If this option is not set, output messages from all interfaces of the action.') + parser.add_argument( + '--csv', action='store_true', default=False, + help=( + 'Output all recursive fields separated by commas (e.g. for plotting).' + )) + parser.add_argument( + '--full-length', '-f', action='store_true', + help='Output all elements for arrays, bytes, and string with a ' + "length > '--truncate-length', by default they are truncated " + "after '--truncate-length' elements with '...''") + parser.add_argument( + '--truncate-length', '-l', type=unsigned_int, default=DEFAULT_TRUNCATE_LENGTH, + help='The length to truncate arrays, bytes, and string to ' + '(default: %d)' % DEFAULT_TRUNCATE_LENGTH) + parser.add_argument( + '--no-arr', action='store_true', help="Don't print array fields of messages") + parser.add_argument( + '--no-str', action='store_true', help="Don't print string fields of messages") + parser.add_argument( + '--flow-style', action='store_true', + help='Print collections in the block style (not available with csv format)') + + def main(self, *, args): + output_interface_list = set() + if args.interfaces is not None: + if args.interfaces: # Not empty string + # Fill in the user-specified action interface. + interfaces = args.interfaces.split('|') + for interface in interfaces: + if interface.strip().upper() in ActionInterfaces: + output_interface_list.add(interface.strip().upper()) + else: + return f'"{interface.strip()}" is incorrect interface name.' + else: + return 'Input action interface name is empty.' + else: + # By default, output all the action interfaces. + output_interface_list.update([interface.value for interface in ActionInterfaces]) + + if args.action_type is None: + with NodeStrategy(args) as node: + try: + action_module = get_action_class( + node, args.action_name) + except (AttributeError, ModuleNotFoundError, ValueError): + raise RuntimeError(f"The action name '{args.action_name}' is invalid") + else: + try: + action_module = get_action(args.action_type) + except (AttributeError, ModuleNotFoundError, ValueError): + raise RuntimeError(f"The service type '{args.action_type}' is invalid") + + if action_module is None: + raise RuntimeError(f"Could not load the action type for '{args.action_type}'") + + self.csv = args.csv + self.truncate_length = args.truncate_length if not args.full_length else None + self.flow_style = args.flow_style + self.no_arr = args.no_arr + self.no_str = args.no_str + + send_goal_event_topic = args.action_name + '/_action/send_goal/_service_event' + send_goal_event_msg_type = action_module.Impl.SendGoalService.Event + + cancel_goal_event_topic = args.action_name + '/_action/cancel_goal/_service_event' + cancel_goal_event_msg_type = action_module.Impl.CancelGoalService.Event + + get_result_event_topic = args.action_name + '/_action/get_result/_service_event' + get_result_event_msg_type = action_module.Impl.GetResultService.Event + + feedback_topic = args.action_name + '/_action/feedback' + feedback_topic_type = action_module.Impl.FeedbackMessage + + status_topic = args.action_name + '/_action/status' + status_topic_type = action_module.Impl.GoalStatusMessage + + # Queue for messages from above topic. The queue size is set to 100. + # If the queue is full, the message will be dropped. + self.output_msg_queue = queue.Queue(100) + + run_thread = True + # Create a thread to output message from output_queue + + def message_handler(): + while run_thread: + try: + message = self.output_msg_queue.get(block=True, timeout=0.5) + self.output_msg_queue.task_done() + except queue.Empty: + continue + print(message) + output_msg_thread = threading.Thread(target=message_handler) + output_msg_thread.start() + + with NodeStrategy(args) as node: + send_goal_event_sub = None + # TODO: The QoS for the service event publisher, feedback publisher and status + # publisher can be specified by the user, so new parameters need to be added to allow + # specifying QoS of subscription to replace the current fixed QoS. + if ActionInterfaces.GOAL_SERVICE.value in output_interface_list: + send_goal_event_sub: Subscription[EventMessage] = node.create_subscription( + send_goal_event_msg_type, + send_goal_event_topic, + self._send_goal_subscriber_callback, + qos_profile_services_default) + + cancel_goal_event_sub = None + if ActionInterfaces.CANCEL_SERVICE.value in output_interface_list: + cancel_goal_event_sub: Subscription[EventMessage] = node.create_subscription( + cancel_goal_event_msg_type, + cancel_goal_event_topic, + self._cancel_goal_subscriber_callback, + qos_profile_services_default) + + get_result_event_sub = None + if ActionInterfaces.RESULT_SERVICE.value in output_interface_list: + get_result_event_sub: Subscription[EventMessage] = node.create_subscription( + get_result_event_msg_type, + get_result_event_topic, + self._get_result_subscriber_callback, + qos_profile_services_default) + + feedback_sub = None + if ActionInterfaces.FEEDBACK_TOPIC.value in output_interface_list: + feedback_sub: Subscription[MsgT] = node.create_subscription( + feedback_topic_type, + feedback_topic, + self._feedback_subscriber_callback, + QoSProfile(depth=10)) # QoS setting refers to action client implementation + + status_sub = None + if ActionInterfaces.STATUS_TOPIC.value in output_interface_list: + status_sub: Subscription[MsgT] = node.create_subscription( + status_topic_type, + status_topic, + self._status_subscriber_callback, + qos_profile_action_status_default) + + executor: rclpy.Executor = rclpy.get_global_executor() + try: + executor.add_node(node) + while executor.context.ok(): + executor.spin_once() + except KeyboardInterrupt: + pass + finally: + executor.remove_node(node) + + if send_goal_event_sub: + send_goal_event_sub.destroy() + if cancel_goal_event_sub: + cancel_goal_event_sub.destroy() + if get_result_event_sub: + get_result_event_sub.destroy() + if feedback_sub: + feedback_sub.destroy() + if status_sub: + status_sub.destroy() + + run_thread = False + if output_msg_thread.is_alive(): + output_msg_thread.join(1) + + def _send_goal_subscriber_callback(self, msg): + self._base_subscriber_callback(msg, ActionInterfaces.GOAL_SERVICE.value) + + def _cancel_goal_subscriber_callback(self, msg): + self._base_subscriber_callback(msg, ActionInterfaces.CANCEL_SERVICE.value) + + def _get_result_subscriber_callback(self, msg): + self._base_subscriber_callback(msg, ActionInterfaces.RESULT_SERVICE.value) + + def _feedback_subscriber_callback(self, msg): + self._base_subscriber_callback(msg, ActionInterfaces.FEEDBACK_TOPIC.value) + + def _status_subscriber_callback(self, msg): + self._base_subscriber_callback(msg, ActionInterfaces.STATUS_TOPIC.value) + + def _base_subscriber_callback(self, msg, interface: str): + to_print = 'interface: ' + interface + '\n' + if self.csv: + to_print += message_to_csv(msg, truncate_length=self.truncate_length, + no_arr=self.no_arr, no_str=self.no_str) + else: + # The "easy" way to print out a representation here is to call message_to_yaml(). + # However, the message contains numbers for the event type, but we want to show + # meaningful names to the user. So we call message_to_ordereddict() instead, + # and replace the numbers with meaningful names before dumping to YAML. + msgdict = message_to_ordereddict(msg, truncate_length=self.truncate_length, + no_arr=self.no_arr, no_str=self.no_str) + + if 'info' in msgdict: + info = msgdict['info'] + if 'event_type' in info: + info['event_type'] = self._event_number_to_name[info['event_type']] + + to_print += yaml.dump(msgdict, allow_unicode=True, width=sys.maxsize, + default_flow_style=self.flow_style) + + to_print += '---' + try: + self.output_msg_queue.put(to_print, timeout=0.5) + except queue.Full: + print('Output message is full! Please increase the queue size of output message by ' + '"--queue_size"') diff --git a/ros2action/setup.py b/ros2action/setup.py index 1e204ce7f..f59f59b9f 100644 --- a/ros2action/setup.py +++ b/ros2action/setup.py @@ -45,6 +45,7 @@ 'send_goal = ros2action.verb.send_goal:SendGoalVerb', 'type = ros2action.verb.type:TypeVerb', 'find = ros2action.verb.find:FindVerb', + 'echo = ros2action.verb.echo:EchoVerb', ], } ) diff --git a/ros2action/test/fixtures/fibonacci_action_introspection.py b/ros2action/test/fixtures/fibonacci_action_introspection.py new file mode 100644 index 000000000..5698b66c3 --- /dev/null +++ b/ros2action/test/fixtures/fibonacci_action_introspection.py @@ -0,0 +1,125 @@ +# Copyright 2025 Sony Group Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import rclpy +from rclpy.action import ActionClient +from rclpy.action import ActionServer +from rclpy.executors import ExternalShutdownException, SingleThreadedExecutor +from rclpy.node import Node +from rclpy.qos import qos_profile_system_default +from rclpy.service_introspection import ServiceIntrospectionState + +from test_msgs.action import Fibonacci + + +class FibonacciActionServer(Node): + + def __init__(self): + super().__init__('fibonacci_action_server', namespace='/test') + self._action_server = ActionServer( + self, + Fibonacci, + 'fibonacci', + self.execute_callback) + self._action_server.configure_introspection( + self.get_clock(), qos_profile_system_default, ServiceIntrospectionState.CONTENTS) + + def destroy_node(self): + self._action_server.destroy() + super().destroy_node() + + def execute_callback(self, goal_handle): + feedback = Fibonacci.Feedback() + feedback.sequence = [0, 1] + + for i in range(1, goal_handle.request.order): + feedback.sequence.append(feedback.sequence[i] + feedback.sequence[i-1]) + goal_handle.publish_feedback(feedback) + + goal_handle.succeed() + + result = Fibonacci.Result() + result.sequence = feedback.sequence + return result + + +class FibonacciActionClient(Node): + + def __init__(self): + super().__init__('fibonacci_action_client', namespace='/test') + self._action_client = ActionClient(self, Fibonacci, 'fibonacci') + self._action_client.configure_introspection( + self.get_clock(), qos_profile_system_default, ServiceIntrospectionState.CONTENTS) + self._timer = self.create_timer(2, self._timer_callback) + + def _timer_callback(self): + self._timer.cancel() + if not self._action_client.wait_for_server(): + self.get_logger().info('Action erver is unavailable.') + return + + self.send_goal(2) + + def send_goal(self, order): + goal_msg = Fibonacci.Goal() + goal_msg.order = order + + self._send_goal_future = self._action_client.send_goal_async( + goal_msg, + feedback_callback=self.feedback_callback) + + self._send_goal_future.add_done_callback(self.goal_response_callback) + + def goal_response_callback(self, future): + goal_handle = future.result() + + if not goal_handle.accepted: + self.get_logger().info('Goal rejected.') + return + + self.get_logger().info('Goal accepted.') + + self._get_result_future = goal_handle.get_result_async() + + self._get_result_future.add_done_callback(self.get_result_callback) + + def get_result_callback(self, future): + result = future.result().result + self.get_logger().info('Result: {0}'.format(result.sequence)) + + def feedback_callback(self, feedback_msg): + feedback = feedback_msg.feedback + self.get_logger().info('Received feedback: {0}'.format(feedback.sequence)) + + def destroy_node(self): + self._action_client.destroy() + super().destroy_node() + + +def main(args=None): + try: + with rclpy.init(args=args): + action_server_node = FibonacciActionServer() + action_client_node = FibonacciActionClient() + + executor = SingleThreadedExecutor() + executor.add_node(action_server_node) + executor.add_node(action_client_node) + executor.spin() + except (KeyboardInterrupt, ExternalShutdownException): + print('server stopped cleanly') + + +if __name__ == '__main__': + main() diff --git a/ros2action/test/test_echo.py b/ros2action/test/test_echo.py new file mode 100644 index 000000000..9baca2d00 --- /dev/null +++ b/ros2action/test/test_echo.py @@ -0,0 +1,288 @@ +# Copyright 2025 Sony Group Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import contextlib +import functools +import os +import re +import sys +import unittest + +from launch import LaunchDescription +from launch.actions import ExecuteProcess + +import launch_testing +import launch_testing.actions +import launch_testing.asserts +import launch_testing.markers +import launch_testing.tools + +import launch_testing_ros.tools + +from osrf_pycommon.terminal_color import remove_ansi_escape_sequences + +import pytest + +from rclpy.utilities import get_available_rmw_implementations + + +# Skip cli tests on Windows while they exhibit pathological behavior +# https://github.com/ros2/build_farmer/issues/248 +if sys.platform.startswith('win'): + pytest.skip( + 'CLI tests can block for a pathological amount of time on Windows.', + allow_module_level=True) + + +@pytest.mark.rostest +@launch_testing.parametrize('rmw_implementation', get_available_rmw_implementations()) +def generate_test_description(rmw_implementation): + path_to_action_server_executable = os.path.join( + os.path.dirname(__file__), 'fixtures', 'fibonacci_action_introspection.py' + ) + return LaunchDescription([ + # Always restart daemon to isolate tests. + ExecuteProcess( + cmd=['ros2', 'daemon', 'stop'], + name='daemon-stop', + on_exit=[ + ExecuteProcess( + cmd=['ros2', 'daemon', 'start'], + name='daemon-start', + on_exit=[ + ExecuteProcess( + cmd=[sys.executable, path_to_action_server_executable], + additional_env={'RMW_IMPLEMENTATION': rmw_implementation} + ), + launch_testing.actions.ReadyToTest() + ], + additional_env={'RMW_IMPLEMENTATION': rmw_implementation} + ) + ] + ), + ]) + + +class TestROS2ActionCLI(unittest.TestCase): + + @classmethod + def setUpClass( + cls, + launch_service, + proc_info, + proc_output, + rmw_implementation + ): + @contextlib.contextmanager + def launch_action_command(self, arguments): + action_command_action = ExecuteProcess( + cmd=['ros2', 'action', *arguments], + name='ros2action-cli', output='screen', + additional_env={ + 'RMW_IMPLEMENTATION': rmw_implementation, + 'PYTHONUNBUFFERED': '1' + } + ) + with launch_testing.tools.launch_process( + launch_service, action_command_action, proc_info, proc_output, + output_filter=launch_testing_ros.tools.basic_output_filter( + filtered_rmw_implementation=rmw_implementation + ) + ) as action_command: + yield action_command + cls.launch_action_command = launch_action_command + + # @launch_testing.markers.retry_on_failure(times=5, delay=1) + def test_echo(self): + echo_arguments = [ + 'echo', '/test/fibonacci', 'test_msgs/action/Fibonacci', '--no-arr'] + + # Define the desired output message block. + expected_output = [ + [ + 'interface: GOAL_SERVICE', + 'info:', + ' event_type: REQUEST_SENT', + ' stamp:', + re.compile(r' sec: \d+'), + re.compile(r' nanosec: \d+'), + " client_gid: ''", + re.compile(r' sequence_number: \d+'), + "request: ''", + "response: ''", + '---', + ], + [ + 'interface: GOAL_SERVICE', + 'info:', + ' event_type: REQUEST_RECEIVED', + ' stamp:', + re.compile(r' sec: \d+'), + re.compile(r' nanosec: \d+'), + " client_gid: ''", + re.compile(r' sequence_number: \d+'), + "request: ''", + "response: ''", + '---', + ], + [ + 'interface: GOAL_SERVICE', + 'info:', + ' event_type: RESPONSE_SENT', + ' stamp:', + re.compile(r' sec: \d+'), + re.compile(r' nanosec: \d+'), + " client_gid: ''", + re.compile(r' sequence_number: \d+'), + "request: ''", + "response: ''", + '---', + ], + [ + 'interface: GOAL_SERVICE', + 'info:', + ' event_type: RESPONSE_RECEIVED', + ' stamp:', + re.compile(r' sec: \d+'), + re.compile(r' nanosec: \d+'), + " client_gid: ''", + re.compile(r' sequence_number: \d+'), + "request: ''", + "response: ''", + '---', + ], + [ + 'interface: STATUS_TOPIC', + "status_list: ''", + '---', + ], + [ + 'interface: FEEDBACK_TOPIC', + 'goal_id:', + " uuid: ''", + 'feedback:', + " sequence: ''", + '---', + ], + [ + 'interface: RESULT_SERVICE', + 'info:', + ' event_type: REQUEST_RECEIVED', + ' stamp:', + re.compile(r' sec: \d+'), + re.compile(r' nanosec: \d+'), + " client_gid: ''", + re.compile(r' sequence_number: \d+'), + "request: ''", + "response: ''", + '---', + ], + [ + 'interface: RESULT_SERVICE', + 'info:', + ' event_type: RESPONSE_SENT', + ' stamp:', + re.compile(r' sec: \d+'), + re.compile(r' nanosec: \d+'), + " client_gid: ''", + re.compile(r' sequence_number: \d+'), + "request: ''", + "response: ''", + '---' + ] + ] + + # Check if all parts of the expected_output are present in the output. + def match_expected_output(output, expected_output) -> bool: + output_lines_tmp = output.splitlines() + output_lines = [remove_ansi_escape_sequences(line) for line in output_lines_tmp] + + for msg_block in expected_output: + + # Find the position where the first message of the message block appears. + matched_begin_lines = [] + if isinstance(msg_block[0], str): + for index, line in enumerate(output_lines): + if msg_block[0] == line: + matched_begin_lines.append(index) + + if hasattr(msg_block[0], 'match'): + for index, line in enumerate(output_lines): + if msg_block[0].match(line): + matched_begin_lines.append(index) + + filtered_matched_begin_lines = matched_begin_lines + if matched_begin_lines: + for line in matched_begin_lines: + if len(msg_block) > len(output_lines) - line: + filtered_matched_begin_lines.remove(line) + + if not filtered_matched_begin_lines: + return False + else: + return False + + # filter_matched_begin_lines include all positions where the first message appears. + + msg_block_matched = False + for line in filtered_matched_begin_lines: + matched_index = -1 + for index, (expected_line, output_line) in enumerate( + zip(msg_block, output_lines[line:]), start=1 + ): + if isinstance(expected_line, str): + if expected_line != output_line: + break + else: + matched_index = index + continue + if hasattr(expected_line, 'match'): + if not expected_line.match(output_line): + break + else: + matched_index = index + continue + # A complete match has been found, skipping the rest of the detection. + if (matched_index == len(msg_block)): + msg_block_matched = True + break + + # If no message block is matched throughout the entire output. + if not msg_block_matched: + return False + + return True + + with self.launch_action_command(arguments=echo_arguments) as action_command: + # assert action_command.wait_for_output(timeout=10) + assert action_command.wait_for_output( + functools.partial( + match_expected_output, + expected_output=expected_output, + ), + timeout=5, + ) + assert action_command.wait_for_shutdown(timeout=3)