Skip to content

fix(nav): survive controller_server crashes — respawn + lifecycle watchdog - #517

Closed
DavidDobas wants to merge 4 commits into
mainfrom
feat/visualise-plan-in-sim
Closed

fix(nav): survive controller_server crashes — respawn + lifecycle watchdog#517
DavidDobas wants to merge 4 commits into
mainfrom
feat/visualise-plan-in-sim

Conversation

@DavidDobas

Copy link
Copy Markdown
Collaborator

Problem

controller_server crashes in the field and nothing brings it back — navigation is silently dead until reboot. Observed live twice in one sim session, same signature both times:

[controller_server]: Failed to make progress
[controller_server]: [follow_path] [ActionServer] Aborting handle.
[controller_server-6] double free or corruption (!prev)
process has died [exit code -6]

Trigger: repeated progress-checker aborts (the robot stuck/rotating near clutter — a state this stack reaches routinely). After the crash, every navigation turns into recovery theater: the BT times out waiting for follow_path to acknowledge, cycles clear/spin/backup/wait, and aborts — indistinguishable from "navigation mysteriously broken."

Root-cause investigation (summary)

  • It's a thread race in upstream code, not ours: valgrind memcheck observed 21 executions of the abort path with zero invalid memory operations — memcheck serializes threads, and serialized execution can't race; free-running execution crashes. Under gdb the timing shift also hides it.
  • The race surface is nav2_util's SimpleActionServer: the control loop runs in a detached std::async thread and calls terminate_current() on failure while the executor thread concurrently handles preempts/cancels/expiry on the same goal handles.
  • It is not the already-fixed rclcpp action race (fix: Fixed race condition in action server between is_ready and take ros2/rclcpp#2250, backported long ago — we run rclcpp-action 16.0.19 which includes it). This is an additional, unfixed race somewhere in nav2_util / rcl_action / rmw_zenoh.
  • This is a sibling of the crash family already documented in mode_manager.py's skip_cleanup_nodes ("Zenoh RMW crashes during TF unsubscription in cleanup", planner "Segfaults during costmap cleanup").
  • Real-robot exposure is identical: same ARM64 Humble debs (ros-humble-nav2-controller 1.1.20), same rmw_zenoh_cpp 0.1.8, same abort-generating behaviors. An upstream issue with the reproduction recipe is the path to a true fix; this PR makes the crash survivable in the meantime (and for whatever the next such bug is).

Fix

Two parts, both generic:

  1. respawn=True, respawn_delay=2.0 on controller_server (navigation.launch.py) — launch brings the process back.
  2. Lifecycle watchdog in mode_manager — a respawned lifecycle node comes back UNCONFIGURED and useless. A 5 s watchdog checks the current mode's nodes and re-drives anything UNCONFIGURED to where the mode needs it (ACTIVE, or INACTIVE for configure-only nodes). It arms only after the first successful mode startup (never races bringup) and yields to in-flight mode/map operations via the existing _mode_change_lock. It heals any respawn-configured nav node, not just the controller.

Verification (live, full digital twin)

Simulated the exact crash (SIGABRT to controller_server) during an active session:

T0      SIGABRT
T0+2s   launch respawn: "process started with pid [56382]"
T0+6s   watchdog: "controller_server is UNCONFIGURED while navigation mode is
        active -- it likely crashed and respawned; re-driving its lifecycle"
        "controller_server restored to ACTIVE after respawn"
T0+40s  next NavigateToPose goal: SUCCEEDED

A crash that previously cost the rest of the session now costs ~6 seconds and leaves a loud log line.

Real-robot testing notes

  • To exercise the heal path on hardware without waiting for a natural crash: pkill -ABRT -f nav2_controller mid-session, then watch for the watchdog log lines and send a goal.
  • The watchdog acts only on UNCONFIGURED nodes of the current mode, so normal mode switches, map switches and bringup are untouched (they hold _mode_change_lock, which the watchdog defers to).

…ms and failure reasons

Navigation trajectory: both Nav2 planner servers are namespaced, so the map
widget's /plan subscription never received a route -- subscribe
/navigation/plan + /mapfree/plan instead, and draw the same plan as a flat
ribbon on the floor of the 3D sim view (cleared when the planner goes quiet,
same staleness rule as the map).

Agent panel: /brain/skill_status_update now carries the skill's call inputs,
rendered as a compact k=v line under each skill run; failed runs render their
reason expanded instead of hidden behind a chevron. navigate_to_position
returns specific failure detail (unresolvable local goal, no path to goal, or
distance remaining + recovery count) instead of the raw TaskResult.
See why navigation struggles, not just where it's headed. A new "follower"
chip in the 3D view draws the MPPI controller's commanded velocity as an
arrow on the robot (green forward / red reverse + turn indicator, from
/cmd_vel_raw) and the path it's actively tracking (/transformed_global_plan)
as an amber ribbon over the blue global plan -- a near-zero/oscillating arrow
and a gap between the ribbons are the visible signature of a stuck follower.
The 2D map gains the same velocity arrow. Enables MPPI `visualize` so the
controller publishes its tracked path (costs controller CPU).
…me too

The sim driver publishes /clock (extrapolated from the world server at wall
rate, held while paused, monotonic); every other sim node runs
use_sim_time:=true via a launch argument the sim tmux launcher passes
(default false -- real robot unchanged). Pausing the world now freezes
Nav2's progress checkers, BT timers and MPPI with it: a 25s mid-navigation
freeze resumes with zero elapsed ROS time, no recoveries. TF-bearing
publishers hold while paused so identical frozen stamps don't spam
TF_REPEATED_DATA.
…F-aware overlays

Everything nav-debug hides behind ?navdebug (plain sim view opens no
rosbridge connection): the plan/follower ribbons and chips, a 5Hz HUD
reporting what MPPI's critics are gated on (distance to goal, heading error
to the carrot, costmap cost under/ahead, path occupancy, stall/oscillation
detection), a freeze chip driving /virtual_mars/pause, and a set-goal chip
(click the floor, drag the heading). navigate_to_position publishes its
resolved goal on latched /nav/commanded_goal so UIs render the true target
instead of the wiggling plan endpoint. Overlays and goal publishing go
through AMCL's map->odom from /tf both ways, so localization drift can't
shift what you see or where your click lands.

Also duplicates the lidar-height map fix + failure details that shipped via
@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes navigation and simulation more resilient and easier to debug. The main changes are:

  • Controller server respawn and sim-time launch propagation.
  • A simulated /clock source and world pause control.
  • Nav-debug overlays for plans, goals, costmaps, commands, and feedback.
  • Commanded-goal publishing for navigation skills.
  • Extra skill input details in status messages.

Confidence Score: 4/5

The sim pause and navigation overlay paths need fixes before merging.

  • Paused arm trajectories can report success before the simulated arm moves.
  • Map and navdebug goal handling can use the wrong frame and show or send incorrect targets.
  • Two launch files now import launch_ros without declaring that package dependency.

node.py, mapWidget.js, rosbridgeController.ts, console.launch.py, uninavid.launch.py

Important Files Changed

Filename Overview
ros2_ws/src/mars_bot/mars_sim_driver/mars_sim_driver/node.py Adds /clock, pause handling, sim-time stamping, and paused publisher suppression; arm trajectory completion still advances while physics is paused.
ros2_ws/src/mars_bot/mars_sim_driver/mars_sim_driver/world_server.py Adds pause state and stops physics stepping while paused.
sim/viewer/src/physics/rosbridgeController.ts Adds nav-debug subscriptions, frame conversion, costmap decoding, and set-goal publishing.
webapp/js/map/mapWidget.js Adds multi-plan subscriptions, commanded-goal markers, zero-stamp goal publishing, and follower command display.
workspace/innate_skills/navigate_to_position.py Publishes a latched commanded-goal pose in either map or odom frame.
ros2_ws/src/cloud/innate_console/launch/console.launch.py Adds a use_sim_time launch argument using ParameterValue.
ros2_ws/src/cloud/innate_uninavid/launch/uninavid.launch.py Adds a use_sim_time launch argument using ParameterValue.
ros2_ws/src/mars_bot/mars_nav/launch/navigation.launch.py Threads use_sim_time through Nav2 lifecycle nodes and enables controller respawn.
ros2_ws/src/mars_bot/mars_nav/launch/mode_manager.launch.py Propagates use_sim_time into navigation and local navigation helper nodes.

Comments Outside Diff (1)

  1. ros2_ws/src/mars_bot/mars_sim_driver/mars_sim_driver/node.py, line 367-378 (link)

    P1 Paused Trajectories Finish Early

    When /virtual_mars/pause freezes MuJoCo, this loop still advances arm trajectory segments with wall time and sets _traj_req.ok = True. A paused arm command can report success before the simulated arm moves, so the next manipulation step can run against the old pose after unpause.

Reviews (1): Last reviewed commit: "feat(sim): ?navdebug -- MPPI decision HU..." | Re-trigger Greptile

Comment on lines +190 to +199
/** @param {any} msg geometry_msgs/PoseStamped — the skill's exact target */
function onCommandedGoal(msg) {
const pos = msg?.pose?.position;
const q = msg?.pose?.orientation;
if (typeof pos?.x !== "number" || typeof pos?.y !== "number" || !q) return;
goalMarker = { x: pos.x, y: pos.y, yaw: Math.atan2(2 * (q.w * q.z + q.x * q.y), 1 - 2 * (q.y * q.y + q.z * q.z)) };
goalIsCommanded = true;
armNavStale(); // the goal marks an active navigation; expire it like the route
draw();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Odom Goals Drawn As Map

/nav/commanded_goal can be published with frame_id: "odom" for local goals, but this handler ignores the frame and draws raw coordinates on the /map grid. During local navigation, the green goal marker is shifted by the current map↔odom offset and shows the operator the target in the wrong place.

Comment on lines 201 to +223
@@ -179,15 +209,45 @@ export function createMap(root, opts = {}) {
}
if (pts.length) {
plan = pts;
// Fallback goal marker: the route's end. Only used until the skill's
// exact target arrives on /nav/commanded_goal (goalIsCommanded) — the
// endpoint wiggles with every replan, the commanded goal doesn't. Still
// needed for navigations that bypass the skill (e.g. map clicks routed
// straight to bt_navigator).
if (!goalIsCommanded) {
const end = poses[poses.length - 1]?.pose;
const q = end?.orientation;
if (q) {
const yaw = Math.atan2(2 * (q.w * q.z + q.x * q.y), 1 - 2 * (q.y * q.y + q.z * q.z));
goalMarker = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y, yaw };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Mapfree Plans Drawn As Map

This handler now receives both /navigation/plan and /mapfree/plan, but it still renders every path directly on the map grid. The mapfree planner publishes odom-frame paths, so local-navigation routes and fallback goal endpoints appear shifted on the 2D map instead of matching the robot's actual route.

Comment on lines +336 to +348
publishGoalPose(worldX: number, worldY: number, worldYaw: number): void {
// P_map = R(yaw)·P_world + t (world == odom in sim).
const t = this.#mapToOdom;
const c = t ? Math.cos(t.yaw) : 1;
const s = t ? Math.sin(t.yaw) : 0;
const x = t ? worldX * c - worldY * s + t.x : worldX;
const y = t ? worldX * s + worldY * c + t.y : worldY;
const yaw = t ? worldYaw + t.yaw : worldYaw;
this.#send({
op: "publish",
topic: "/goal_pose",
msg: {
header: { stamp: { sec: 0, nanosec: 0 }, frame_id: "map" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing Transform Sends Wrong Goal

If the navdebug set-goal chip is used before the first map -> odom TF arrives, #mapToOdom is null and this publishes world/odom click coordinates with frame_id: "map". Nav2 then interprets the pose in the wrong frame and can drive to a different location than the clicked point.

Comment on lines 8 to +16
from launch_ros.actions import Node
from launch_ros.parameter_descriptions import ParameterValue


def generate_launch_description() -> LaunchDescription:
# ROS time source: false on the real robot (no /clock); the sim launcher
# passes true so this node follows the sim driver's /clock.
use_sim_time_arg = DeclareLaunchArgument("use_sim_time", default_value="false")
use_sim_time = {"use_sim_time": ParameterValue(LaunchConfiguration("use_sim_time"), value_type=bool)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Launch Dependency Missing

This launch file now imports launch_ros.parameter_descriptions.ParameterValue, but the innate_console manifest only declares rclpy and std_msgs. A clean install has no package-level dependency that pulls in launch_ros, so ros2 launch innate_console console.launch.py can fail while parsing the launch file.

Comment on lines 11 to +34
@@ -27,15 +28,20 @@ def generate_launch_description() -> LaunchDescription:
description="Velocity topic UniNavid commands should publish to",
)

# ROS time source: false on the real robot (no /clock); the sim launcher
# passes true so this node follows the sim driver's /clock.
use_sim_time_arg = DeclareLaunchArgument("use_sim_time", default_value="false")
use_sim_time = {"use_sim_time": ParameterValue(LaunchConfiguration("use_sim_time"), value_type=bool)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Launch Dependency Missing

This launch file now imports launch_ros.parameter_descriptions.ParameterValue, but innate_uninavid does not declare launch_ros in its manifest. On a clean install, rosdep may not install that dependency and ros2 launch innate_uninavid uninavid.launch.py can fail before starting the node.

@DavidDobas

Copy link
Copy Markdown
Collaborator Author

Wrong head branch (created from the debug branch by mistake) — superseded by the PR from fix/nav-controller-crash-resilience.

@DavidDobas DavidDobas closed this Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant