ROS 2 Interview Questions: QoS Compatibility, Executors, and Callback Deadlocks
Quick Overview
Prepare for ROS 2 technical interviews with two complete Python experiments executed on Jazzy, rclpy 7.1.11, and Cyclone DDS. Compare all four reliability combinations, inspect discovered endpoints, reproduce a blocked synchronous service call, and verify how callback groups, worker counts, and asynchronous return affect progress. Separates official semantics, a limited candidate report, and measured local results.
A ROS 2 node can be visible in the graph yet receive no messages. A service can execute its server callback while the caller still times out. Explaining those two failures requires more than knowing what a publisher or executor is: you need to identify which condition prevents progress and prove that your change removes it.
This guide uses two original, executed Python experiments. They complement software-engineering interview practice on PracHub with ROS-specific evidence. The examples are preparation exercises, not recalled employer questions.

What evidence supports this preparation scope?
Official technical facts come from the ROS 2 Jazzy documentation and versioned rclpy source. Jazzy is the baseline here; we do not describe it as the newest distribution.
Candidate report: one Honda Research Institute USA account on Glassdoor describes writing a Python node with two subscriptions and one publisher. Its interview date was not established. It does not show that Honda asks the QoS or deadlock exercises below.
Recruiter guidance: CalTek's own robotics interview rubric includes QoS choices and diagnosing stalls. That supports the relevance of these skills, not a claim about interview frequency or a universal hiring process.
Editorial judgment: practice a small working system, introduce one controlled failure, and explain the evidence that distinguishes it from neighboring failures. This is a narrower and more useful goal than memorizing a long robotics glossary.
What does a complete minimal node need?
Before coding, state the interface: topic or service name, message type, expected input, output, and shutdown behavior. For a subscription task, explain what happens before the first input arrives and whether each message is independent or updates stored state.
Our first program creates separate publisher and subscriber nodes in one process. A timer publishes a short string every 0.1 seconds; the subscription records received strings. A finite spin loop drives callbacks, then the executor and nodes are shut down. Keeping the payload trivial makes communication behavior easier to isolate.
Execution environment: Python 3.12.14, ROS 2 Jazzy, rclpy 7.1.11, rmw_cyclonedds_cpp 2.2.3, and Cyclone DDS 0.10.5 on macOS arm64, installed through RoboStack. This is a native local ROS execution, not a robot deployment, lossy-network benchmark, or validation of every RMW implementation.
Use an activated environment containing rclpy, std_msgs, std_srvs, and the selected RMW. We restricted discovery to localhost and used a separate domain:
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
export ROS_DOMAIN_ID=173
export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST
Every terminal inspecting the same experiment must use matching settings. Otherwise, you may accidentally test domain isolation instead of the intended failure.
Why can matching topics still produce no messages?
Official rule: subscriptions request QoS and publishers offer it. A best-effort publisher cannot satisfy a reliable subscription; a reliable publisher can satisfy a best-effort subscription. Compatibility also depends on other policies. See the Jazzy QoS documentation.
Save this complete program as qos_probe.py:
import argparse
import json
import time
import rclpy
from rclpy.node import Node
from rclpy.executors import SingleThreadedExecutor
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
p = argparse.ArgumentParser()
p.add_argument('offered', choices=['best', 'reliable'])
p.add_argument('requested', choices=['best', 'reliable'])
p.add_argument('--seconds', type=float, default=4.0)
a = p.parse_args()
rclpy.init()
tx, rx = Node('probe_tx'), Node('probe_rx')
def profile(value):
return QoSProfile(depth=10, durability=DurabilityPolicy.VOLATILE,
reliability=(ReliabilityPolicy.RELIABLE if value == 'reliable'
else ReliabilityPolicy.BEST_EFFORT))
from std_msgs.msg import String
pub = tx.create_publisher(String, '/prachub_probe', profile(a.offered))
received = []
sub = rx.create_subscription(String, '/prachub_probe',
lambda m: received.append(m.data), profile(a.requested))
timer = tx.create_timer(0.1, lambda: pub.publish(String(data='probe')))
executor = SingleThreadedExecutor()
executor.add_node(tx)
executor.add_node(rx)
end = time.monotonic() + a.seconds
try:
while time.monotonic() < end:
executor.spin_once(timeout_sec=0.1)
print(json.dumps({'offered': a.offered, 'requested': a.requested,
'received': len(received)}), flush=True)
finally:
executor.shutdown()
tx.destroy_node()
rx.destroy_node()
rclpy.shutdown()
Run the incompatible pair, then change only the subscription's reliability:
python qos_probe.py best reliable
python qos_probe.py best best
Observed results: in four-second runs, the incompatible pair received zero messages and logged RELIABILITY as the incompatible policy. The other three reliability combinations each received 39 messages in this run:
| Offered | Requested | Received |
|---|---|---|
| Best effort | Best effort | 39 |
| Best effort | Reliable | 0 |
| Reliable | Best effort | 39 |
| Reliable | Reliable | 39 |
Treat 39 as an observation, not an exact-count contract. Startup and scheduling can change the count. The diagnostic assertion is zero for the incompatible pair and actual delivery for compatible controls.
For an interview, predict the outcome before running the program. If you say both endpoints must use identical settings, the reliable-to-best-effort control disproves that claim. If you say increasing queue depth will fix this mismatch, identify which compatibility requirement that change would satisfy. In this experiment, it does not change reliability.
How do you distinguish discovery from delivery?
While a longer probe is running, inspect its endpoints from another identically configured terminal:
python qos_probe.py best reliable --seconds 10
# In the second terminal:
ros2 topic info /prachub_probe --verbose --no-daemon
Observed diagnostic output: the command showed one publisher and one subscription, both using std_msgs/msg/String. It also showed publisher reliability BEST_EFFORT and subscription reliability RELIABLE; both used volatile durability and keep-last depth 10. Discovery succeeded even though our subscriber received nothing.
That distinction prevents a common debugging mistake: interpreting an endpoint count as proof that application data is flowing. Record both endpoint configuration and a received-message observation.
If the compatible control also fails, widen the investigation. Check domain and discovery settings, topic namespace, message type, whether the publisher is actually publishing, and whether the subscriber's executor is spinning. Change one variable at a time so a successful rerun has an interpretable cause.
Do not downgrade reliability simply to silence a warning. First clarify whether the application can tolerate loss or whether the publisher must offer stronger delivery behavior. The experiment establishes compatibility direction; it does not choose the right business requirement for a camera stream or command channel.
What do executors and callback groups control?
Official semantics: a mutually exclusive group prevents overlap among its callbacks. A reentrant group permits overlap, including multiple invocations of a callback. Entities without an explicit group use the node's default mutually exclusive group. The Jazzy callback-group guide explains these rules and the hazards of synchronous calls inside callbacks.
The versioned rclpy executor implementation supplies another part of the picture: a multi-threaded executor uses worker threads, while callback-group eligibility still controls which work may execute.
In the next experiment, the timer and service client initially share one group. The timer calls the service synchronously and keeps that group occupied. Receiving the response requires client-side work that cannot enter the occupied group. Another worker alone does not remove that restriction.
The server is a separate node. Its callback can run while the caller is stuck, which helps distinguish server availability from client-side completion. A timeout therefore does not, by itself, prove that the server failed to perform an operation.
Can you reproduce the blocked call and its fixes?
Save this second complete program as callback_probe.py. The two-second timeout deliberately bounds the blocked case so the exercise can finish:
import argparse
import json
import time
import rclpy
from rclpy.node import Node
from rclpy.executors import MultiThreadedExecutor
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup
from std_srvs.srv import Trigger
p = argparse.ArgumentParser()
p.add_argument('mode', choices=['same', 'separate', 'async'])
p.add_argument('--workers', type=int, default=2)
a = p.parse_args()
rclpy.init()
server, caller = Node('probe_server'), Node('probe_caller')
state = {'mode': a.mode, 'workers': a.workers, 'server_calls': 0}
finished = False
def respond(request, response):
state['server_calls'] += 1
response.success = True
return response
service = server.create_service(Trigger, '/prachub_trigger', respond)
client_group = MutuallyExclusiveCallbackGroup() if a.mode == 'separate' else None
client = caller.create_client(Trigger, '/prachub_trigger', callback_group=client_group)
if not client.wait_for_service(timeout_sec=5.0):
raise RuntimeError('Service discovery failed')
def done(future):
global finished
state['success'] = future.result().success
finished = True
def invoke():
global finished
timer.cancel()
started = time.monotonic()
if a.mode == 'async':
future = client.call_async(Trigger.Request())
future.add_done_callback(done)
return
response = client.call(Trigger.Request(), timeout_sec=2.0)
if response is None:
state['timeout'] = True
else:
state['success'] = response.success
state['wait_seconds'] = round(time.monotonic() - started, 3)
finished = True
timer = caller.create_timer(0.1, invoke)
executor = MultiThreadedExecutor(num_threads=a.workers)
executor.add_node(server)
executor.add_node(caller)
end = time.monotonic() + 6.0
try:
while not finished and time.monotonic() < end:
executor.spin_once(timeout_sec=0.1)
drain_until = time.monotonic() + 0.3
while time.monotonic() < drain_until:
executor.spin_once(timeout_sec=0.05)
executor.shutdown()
print(json.dumps(state), flush=True)
finally:
server.destroy_node()
caller.destroy_node()
rclpy.shutdown()
Run each configuration separately:
python callback_probe.py same --workers 2
python callback_probe.py same --workers 4
python callback_probe.py separate --workers 2
python callback_probe.py separate --workers 1
python callback_probe.py async --workers 1
Observed results: all five final runs exited successfully. Their returned application outcomes differed:
| Configuration | Outcome |
|---|---|
| Same group, 2 workers | Timeout after about 2 seconds |
| Same group, 4 workers | Timeout after about 2 seconds |
| Separate groups, 2 workers | Successful response |
| Separate groups, 1 worker | Timeout after about 2 seconds |
| Async, same group, 1 worker | Successful response |
The one-worker configurations intentionally use MultiThreadedExecutor(num_threads=1) to hold the executor class constant. rclpy warns that a single-threaded executor would normally be preferable for that configuration. This is a controlled comparison, not a recommended production setting.
In rclpy 7.1.11, a timed-out Client.call() returns None; the program handles that explicitly. Its versioned client implementation documents the return behavior. Do not assume an exception contract from a different release.
After the bounded test, the program briefly continues spinning before destroying nodes. This lets already queued work settle in this small fixture. It is not a general shutdown guarantee for arbitrary long-running callbacks.
Why do the successful changes work?

Inference from the controlled runs: both execution capacity and callback eligibility matter. Four workers cannot admit the response into a group still occupied by its waiting timer. Separate groups permit overlap, but one occupied worker still cannot execute the remaining work. Separate groups plus two workers remove both obstacles in this fixture.
The asynchronous version changes the dependency differently: the timer submits a request, attaches a completion callback, and returns. It releases execution capacity and the occupied group before the response needs processing. The same-group, one-worker control then succeeds.
Calling call_async() and immediately entering a blocking wait inside the timer would undo the important part of that change. The useful property is that the callback returns so response processing can progress. For recurring requests, also define how many may be outstanding, how late results are handled, and what cancellation means.
A reentrant group is another possible design, but it allows more overlap. Before choosing it, identify shared state and whether concurrent invocations are safe. We did not run a reentrant variant here, so the results table makes no claim about one.
The synchronous experiment demonstrates a circular scheduling dependency with a timeout escape. It does not leave an unbounded deadlock running. Removing the timeout would remove that escape under the same dependency, but an infinite hang is not necessary to make the diagnosis clear.
How should you explain the result in an interview?
Use an evidence chain: symptom, competing explanations, discriminating observation, minimal change, verification. For the silent topic, endpoint discovery plus an explicit reliability warning narrows the problem. For the stalled call, the same-group and separate-group controls separate group eligibility from worker capacity.
Explain the limit of each observation. A successful local message exchange does not establish latency under packet loss. A successful service response does not prove freedom from all deadlocks. A two-second timeout limits this waiting call; it does not undo any server-side effect already performed.
Keep Python and C++ APIs distinct. The Jazzy callback-group guide notes that rclcpp does not provide the same synchronous client method as rclpy; its C++ example waits on a future to demonstrate a similar dependency. Translate the scheduling argument, then verify the actual client API.
A useful closing answer is specific: “I reproduced the failure with the pinned client and RMW, changed one scheduling condition, and checked the negative control. Next I would test the real workload, shared-state safety, repeated requests, and shutdown.” That is more defensible than claiming that more threads or reliable QoS always fixes a ROS system.
Practice related concurrency and pub/sub questions
These verified PracHub records exercise transferable skills. They are not all ROS-specific questions, and their company labels do not establish that an employer uses our Jazzy experiments.
| PracHub question | Connection to this lab |
|---|---|
| Design resource loader and ROS-like pub/sub | Define discovery, messaging, and delivery requirements. |
| Explain deadlock cases and how to prevent them | Draw the dependency that prevents progress. |
| Implement a Bounded Per-Key Ordered Task Executor | Separate worker capacity from execution eligibility. |
| Implement a Stoppable Producer–Consumer System in C++ | Explain waiting, ownership, and bounded shutdown. |
| Build a Thread-Safe Video Playback Loop at 25 FPS | Distinguish scheduling targets from verified timing behavior. |
Continue with software-engineering interview questions, then return to the two programs and explain each result without reading the table.
Sources and Further Reading
- ROS 2 Jazzy: QoS settings and compatibility
- ROS 2 Jazzy: using callback groups
- rclpy 7.1.11: service-client implementation
- rclpy 7.1.11: executor implementation
- RoboStack: getting started
- Candidate report: Honda Research Institute USA Python ROS 2 node exercise
- CalTek: robotics interview questions and its evaluation rubrics
Comments (0)