Skip to content

Reliability: never lose a cancel, no dead windows on fast switching, teleop/autonomy arbitration - #506

Merged
theo-michel merged 10 commits into
mainfrom
theo/stress-test-reliability-fixes
Jul 8, 2026
Merged

Reliability: never lose a cancel, no dead windows on fast switching, teleop/autonomy arbitration#506
theo-michel merged 10 commits into
mainfrom
theo/stress-test-reliability-fixes

Conversation

@theo-michel

Copy link
Copy Markdown
Contributor

Context

A live stress test on R7-27 (2026-07-07) drove the robot through the app's own rosbridge websocket — rapid Run/Stop toggling, cancel-mid-motion, mode switches during navigation, joystick during autonomy — and found five fixable defect classes in this repo (full report: internal artifact stress-report, plus rws bridge segfault tracked separately in innate-rws). This PR fixes all five, each verified live with the same harness that found it.

Fixes & measured results

# Defect (measured pre-fix) Fix Verified post-fix
F2 Cancel in the first ~200 ms of a code skill silently lost (6/20, robot completes full motion, reports success); pattern shared by all 21 fleet skills Base-class _cancelled becomes a platform-owned latch (threading.Event): latches True, ignores the skills' = False reset; server re-arms per run and recovers raced cancels from the goal handle 30/30 immediate cancels honored, results in ~20 ms
F4 Stop→Run hammer always rejected — "Another skill is already running" (11/11; dead window ~60 ms code / ~1.2 s physical) One incoming goal may wait out a cancelling skill's teardown (2 s cap, single waiter, 150 ms beat for the cancel to land); genuinely-busy rejection unchanged 13/13 accepted (code ~40 ms, physical ~1.2 s wait instead of failure)
F7 Physical skills uncancellable for ~1 s (first feedback — which carries the goal id a cancel must bind to — only sent after the behavior handshake) Initial feedback published before the handshake feedback at +67 ms
F3 Mode switch during navigation strands the nav skill forever (no result; robot then rejects every skill until manual Stop) change_mode cancels all active NavigateToPose goals first (cancel-all on the internal bt_navigator action) prompt terminal result, no lockout
F8 Map page can start navigation but not stop it (unreachable goal → ~40 s of recovery motions, no recourse) New /nav/cancel_navigation Trigger service + Stop button on the map page cancel in 50 ms, motion stops 0.48 s
F6 Joystick during navigation: both write /cmd_vel56 source flips in 2.2 s (violent jerking on a powered base) cmd_vel_mux: event-driven priority mux, teleop > skills > nav; producers rerouted (mars_app, smoother remap, cmd_vel_topic launch arg, manipulation playback); zero-stop when all sources go stale 0 flips — teleop only during override, nav resumes on release
F5 Robot keeps moving 0.94–1.45 s after the app shows "cancelled" (smoother max_decel −0.3/−0.5) max_decel → −1.0/−2.0 (accel untouched: driving feel and controller-planned goal approaches unchanged; binds only when the command stream stops/drops) motion tail 0.25–0.35 s

Notes for review

  • No skill files changed: the F2 property setter makes the fleet's self._cancelled = False resets harmless, and the latch self-creates lazily because four skills skip super().__init__(). Unit-tested in test/test_cancel_latch.py (6 tests).
  • Externally-cancelled navigation reports failure, not cancelled: the router can't legally mark its server goal canceled() without a client cancel request (rclpy state machine), so it aborts. Truthful from the skill's perspective; left as-is.
  • F5 needs a powered-base pass: this robot's drivetrain was unpowered (battery telemetry 0 V), so decel was validated at the /cmd_vel stream level only.
  • Sim untouched: brain_client.sim.launch.py keeps /cmd_vel, no mux in sim launch files.
  • Regression sweep on the final build: 10/10 storm cancels, 5/5 Stop→Run accepts (code+physical), wave/head/nav smoke clean, zero node crashes across ~80 additional cycles, skills_server idle CPU unchanged.

Not in this PR

  • rws_server SIGSEGV on client disconnect with an in-flight goal (100% reproducible; crashes the bridge for all clients) — lives in innate-rws, repro + fix task filed separately.

…n grace, early physical feedback

Stress testing showed three ways fast Run/Stop switching breaks:

- A cancel landing in the first ~200ms of a code skill was silently lost:
  skills reset self._cancelled = False as execute()'s first statement,
  wiping a cancel that fired on another executor thread (6/20 lost in a
  storm test; robot completes the full motion while reporting success).
  All 21 fleet skills share the pattern. The base class now owns the flag:
  _cancelled is a property over a threading.Event that latches True and
  ignores False; the server re-arms it per run via _begin_run(), recovering
  raced cancels from the goal handle's persistent cancel status. The latch
  self-creates lazily because four skills skip super().__init__().

- Stop→Run hammering always rejected the new goal ("Another skill is
  already running") while the old one tore down — a ~60ms window for code
  skills, ~1.2s for physical ones. execute_callback now lets one goal wait
  out a cancelling skill's teardown (2s cap, single waiter), including a
  150ms beat for a cancel that arrives just after its follow-up Run.

- Physical skills published their first feedback only after the behavior
  handshake (~1s), and rws can only bind an app cancel once that feedback
  delivers the goal id — Stop was a silent no-op in that window. The
  feedback now goes out before the handshake (+67ms measured).

Verified live on R7-27 over the app websocket: 30/30 immediate cancels
honored (was ~70%), Stop→Run accepted 13/13 (was 0/11).
…vigation, stop 4x faster on cancel

- A mode switch tore down the Nav2 lifecycle under any active
  NavigateToPose goal; the goal never delivered a result, the navigating
  skill hung as 'running' forever, and the one-skill-at-a-time server
  rejected everything until a manual Stop. change_mode now cancels all
  active goals first (zeroed CancelGoal to the internal bt_navigator
  action = cancel-all), so they terminate through their normal result
  path — covers both skill navigation and map-page /goal_pose goals.

- The same helper is exposed as /nav/cancel_navigation (std_srvs/Trigger)
  for the webapp map page's new Stop button: /goal_pose had no cancel
  affordance at all, and an unreachable goal ran spin/backup recoveries
  for ~40s with no way to stop it.

- velocity_smoother max_decel -0.3/-0.5 kept the base moving 0.94-1.45s
  after a cancel result was already delivered. Decel is now -1.0/-2.0
  (accel untouched — driving feel and goal approaches unchanged; the
  limit binds only when the command stream stops or drops). Measured
  tail after cancel: 0.25-0.35s. Validate on a powered base before
  fleet rollout — this robot's drivetrain was unpowered.

Verified live: mode switch mid-navigation now yields a prompt terminal
result; /nav/cancel_navigation cancels a /goal_pose run in 50ms.
…f fighting it

Teleop (mars_app), skills (MobilityInterface, wheeled replay/learned
playback) and Nav2 (velocity_smoother) all published /cmd_vel directly.
A joystick nudge during navigation interleaved conflicting twists at the
combined rate — measured 56 source flips in 2.2s, i.e. the base told
'rotate / forward / rotate / forward' ~25x/s.

Each source now has its own topic and a small event-driven mux forwards
the highest-priority fresh source to /cmd_vel:

  /cmd_vel_teleop  >  /cmd_vel_skills  >  /cmd_vel_nav  ->  /cmd_vel

No re-timing or added latency; a single zero twist is published when all
sources go stale so the base stops deterministically instead of waiting
out its hardware deadman. The mux lives in mode_manager.launch.py so
teleop keeps working with the nav stack down or mid-mode-switch. The
recorder and base driver still see the final /cmd_vel unchanged; sim
launch files are untouched (no mux in sim).

Verified live: joystick during navigation now yields 0 source flips on
/cmd_vel (teleop only), nav resumes when the stick is released, and
teleop-only driving passes through unchanged.
The map page could start navigation (/goal_pose) but not stop it — an
unreachable goal ran recovery motions for ~40s with no user recourse.
Stop calls /nav/cancel_navigation, which cancels every active
NavigateToPose goal server-side (including ones this page didn't start),
then clears the local goal marker and route.
@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR improves robot cancellation, navigation stopping, and velocity arbitration. The main changes are:

  • A platform-owned skill cancel latch and Stop→Run teardown grace.
  • Earlier skill feedback so clients can cancel sooner.
  • Navigation cancel-before-mode-switch handling and a map Stop service.
  • A priority /cmd_vel mux for teleop, skills, and nav commands.
  • Faster velocity smoother deceleration on command stop.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
ros2_ws/src/mars_bot/mars_nav/mars_nav/mode_manager.py Adds terminal-aware navigation cancellation before mode teardown and exposes a map Stop service.
ros2_ws/src/mars_bot/mars_nav/mars_nav/cmd_vel_mux.py Adds the priority mux that forwards teleop, skills, or nav velocity commands to /cmd_vel.
ros2_ws/src/brain/brain_client/brain_client/nodes/skills_server.py Adds initial feedback, slot handoff grace, and per-run cancel latch re-arming.
ros2_ws/src/brain/brain_client/brain_client/skills/types.py Moves skill cancellation state to a lazy threading.Event latch owned by the platform.

Reviews (6): Last reviewed commit: "refactor: flatten the reliability fixes ..." | Re-trigger Greptile

Comment thread ros2_ws/src/mars_bot/mars_nav/mars_nav/mode_manager.py Outdated
Comment thread ros2_ws/src/mars_bot/mars_nav/mars_nav/mode_manager.py Outdated
Comment thread ros2_ws/src/mars_bot/mars_nav/mars_nav/cmd_vel_mux.py Outdated
Comment thread ros2_ws/src/mars_bot/mars_nav/mars_nav/cmd_vel_mux.py
Comment thread ros2_ws/src/mars_bot/mars_nav/mars_nav/mode_manager.py Outdated
Comment thread ros2_ws/src/mars_bot/mars_nav/mars_nav/cmd_vel_mux.py Outdated
@karmanyaahm

Copy link
Copy Markdown
Contributor

plz look at greptile stuff though

@karmanyaahm karmanyaahm left a comment

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.

also integration test is failing


Already have image (with digest): gcr.io/cloud-builders/docker
--
=== /dev/shm ===
Filesystem      Size  Used Avail Use% Mounted on
shm             2.0G     0  2.0G   0% /dev/shm
=== exec-bit guard: install(PROGRAMS) node scripts ===
install(PROGRAMS) node scripts missing the exec bit (chmod +x them):
src/mars_bot/mars_nav/mars_nav/cmd_vel_mux.py

</div></div>

and see format test failing

- chmod +x cmd_vel_mux.py (install(PROGRAMS) exec-bit guard).
- Rename unused loop var to _name (ruff B007); ruff format.
Addresses review (greptile P1s on mode_manager):

- Cancel-ack != terminal: a non-empty goals_canceling only means the cancel
  request was accepted. Tearing Nav2 down then could deactivate bt_navigator
  before it delivered the cancelled goal's result, stranding the router/skill.
  Now subscribe to the bt_navigator action status and wait for the goals to
  reach a terminal state before returning.

- Service-unavailable no longer reports success: 'is nav active' is decided
  from the action status topic (not the cancel service's momentary
  readiness), so a lifecycle-transition blip can't make us skip the cancel
  and silently strand a goal. If a goal is active but the cancel service is
  unreachable, return failure instead.

Also drops an invalid '# noqa' directive in test_cancel_latch.py.

Verified live: mode switch during nav delivers the nav result ~4s before
change_mode returns (was: never); /nav/cancel_navigation reports 'Cancelled
1 navigation goal(s)' in 0.08s with motion stopping in 0.28s; at rest it
returns 'No active navigation goals'.
@theo-michel

Copy link
Copy Markdown
Contributor Author

@karmanyaahm addressed the review — pushed 9ca4c435 (CI) and 01ca577a (greptile P1s):

CI failures

  • Integration test (docker build step 2, exec-bit guard): chmod +x on cmd_vel_mux.py — staged as mode 100755, matching the other install(PROGRAMS) node scripts.
  • Format check: fixed the ruff B007 (unused loop var → _name) and ran ruff format. ruff check + ruff format --check now pass on the full diff.

Greptile P1s (both real, both fixed — replied inline with detail)

  • Cancel ack precedes result: mode switch now waits for the cancelled nav goal to reach a terminal state (via the bt_navigator action status topic) before tearing Nav2 down, so the router/skill isn't stranded. Verified live: nav result now arrives ~4s before change_mode returns.
  • Unavailable cancel reports success: 'is nav active' is decided from the status topic, not service_is_ready(); an active goal with an unreachable cancel service now returns failure instead of false success.
  • Idle teleop (greptile withdrew): confirmed the webapp sends a single release-zero then goes silent, so the 0.5s freshness window handles it — mux left as-is.

All three nav-cancel behaviors re-verified live on R7-27 (base unpowered, so goal-state machinery exercised at the action/topic level).

Comment thread ros2_ws/src/mars_bot/mars_nav/mars_nav/mode_manager.py Outdated
…ability-fixes

# Conflicts:
#	ros2_ws/src/brain/brain_client/brain_client/nodes/skills_server.py
Comment thread ros2_ws/src/mars_bot/mars_nav/mars_nav/mode_manager.py Outdated
@theo-michel

Copy link
Copy Markdown
Contributor Author

Merged latest main (resolved conflicts in skills_server.py — my teardown-grace Condition composed with #505's retired-skill disposal; both share _skill_execution_lock). All checks green now (Format, Integration Tests, Greptile).

Re-verified the full battery on the merged code, live on R7-27: lost-cancel storm 15/15 cancelled, Stop→Run grace accepted, mode-switch terminal-wait delivers the nav result ~4s before change_mode returns, and a reload-mid-run leaves skills_server alive (#505 disposal + my notify coexist). Ready for another look @karmanyaahm.

greptile follow-up P1: even with the terminal-wait, change_mode only logged
and proceeded when _cancel_active_navigation returned False (cancel service
unreachable, or goals not terminal in time), so Nav2 could still be torn down
under a pending NavigateToPose goal — stranding the router/skill.

Now it aborts the switch (returns failure, leaves nav running) in that case.
The cancel request was still sent, so the goal terminates and delivers its
result; the caller can Stop explicitly and retry once nav is idle.

Verified live via a forced-failure build: with a nav goal active and cancel
deliberately unconfirmable, change_mode returns 'Mode switch aborted: could
not stop active navigation', the nav skill still gets its result (not
stranded), and the mode stays 'navigation' (Nav2 not torn down). Happy path
(cancel confirmed) still switches normally.
No behavior change — restructures the PR's additions for readability:

- skills_server: the one-skill-at-a-time serialization is now a symmetric
  _claim_skill_slot / _release_skill_slot pair with the Stop→Run grace wait
  in its own _await_cancelling_teardown method; execute_callback reads
  linearly (parse → claim → run → release) instead of nesting the condition
  dance five levels deep. The duplicated initial-'running'-feedback block
  becomes _publish_initial_feedback.

- mode_manager: _cancel_active_navigation reuses the file's existing
  call_service helper instead of a hand-rolled Event/future dance; the
  redundant lock around a single atomically-assigned int is gone;
  the cancel client lives in _service_clients like every other client;
  the duplicate local 'import threading' in __init__ is dropped.

- cmd_vel_mux: the closure factory is replaced by a plain _on_twist method
  bound with functools.partial, with the priority check extracted to
  _fresh_source_above.

Verified live after rebuild: cancel storm 12/12 cancelled, Stop→Run 4/4
accepted, mux 1 transition flip (baseline 56), mode-switch terminal-wait
intact, /nav/cancel_navigation cancels an active goal in 0.12s; cancel-latch
unit tests 6/6.
@theo-michel
theo-michel merged commit 58e9878 into main Jul 8, 2026
3 checks passed
theo-michel added a commit that referenced this pull request Jul 8, 2026
…e reentrant map service

Conflicts were both in mode_manager.py: imports (union of #506's QoS types
and this branch's PointCloud2/sensor QoS) and the service-creation block
(kept #506's cancel-all NavigateToPose machinery AND this branch's
map service in the reentrant group + _mode_change_lock serialization).
Semantics after merge: a mode change during a map change returns busy
(#506's non-blocking acquire); a map change waits for a mode change —
either way the two can no longer interleave lifecycle transitions.

Live-verified post-merge: navigation mode boots, map switch + relocalization
succeed, /nav/cancel_navigation is served, map-frame navigate_to_position
reaches its goal.
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.

2 participants