真机侧补 stop_real.sh 与域隔离:残留的 mock 栈会把仿真/真机的 TF 盖成"静止"
实测(2026-09-28)在仿真里上料失败,一路查到 IK / TF / 时钟上,全是被冤枉的。 根因是两个真机侧一直缺的东西: 1) **真机侧没有停止脚本**。仿真侧早有 stop_sim_gz.sh(R4.10/R4.11:残留会伪造出 "代码写错了"的现场),真机侧只有 run_real.sh 里两行 pkill。而真机栈里有一族 **独立于 ros2_control_node 存活**的进程 —— 上一轮栈的主体早没了,只剩 real_trajectory_adapter / named_pose_server / arm_status_node / joint_state_publisher 四条,两条 pkill 一条都匹配不到。 危害不是占资源而是**冒名顶替**:残留的 adapter 照样订阅 /trajectory、 残留的 joint_state_publisher 照样发 /joint_states(mock 的静态关节角)。 现象极具误导性:控制器 feedback 与 reference 完全一致(控制器没坏), TF 却显示末端一动不动。 新增 stop_real.sh + lib/real_procs.sh:扫描 = 点名表 + `install/` 前缀兜底, 按 ROS_DOMAIN_ID 判别真机/仿真(两边有同名的 robot_state_publisher, 否则停真机会把正在跑的仿真杀一半),杀完复查残留并非零退出。 run_real.sh 启动前的清理改用它,不再各写一份会漂移的模式表。 真机侧**有意不**用仿真的"进程组"法:真机栈是前台交互跑的,没有独立进程组, 它的 PGID 就是调用它的 shell —— 记下来再 kill 会杀掉自己的终端。 2) **真机侧不强制 ROS_DOMAIN_ID**。sim_env.sh 早就强制 77 并写明"隔离不能靠 『环境里恰好没设』",真机侧没有对应保护:先 source 过 sim_env.sh 的 shell 里 再起真机栈,真机栈就落到**仿真的域**上,节点名撞车、/joint_states 与 /tf 各有两个来源互相盖。补 lib/ros_domain.sh(真机 0 / 覆盖用 REAL_ROS_DOMAIN_ID), env.sh、run_real.sh、run_sim.sh 三处共用一份实现 —— 成对强制,否则一侧的 强制只是把运气挪到了另一侧。 run_sim.sh 特别容易漏:它名为 sim,用的却是**真机的 install/**、节点名与真机 完全一样,而且当时不管域;这次也钉到真机域 0。run_sim_gz.sh 文件头那句 "两者互不影响"是错的,一并改掉。 验证:stop_real.sh --list 在有仿真在跑时为空(REAL_PROC_INCLUDE_SIM=1 能看到 并按"域 77"标出);伪装一个 install/arm_control/lib/arm_control/arm_status_node 的孤儿进程能被点名。新增 6 项单测钉住域的四种输入 + 一个不许误报。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""真机/仿真的 DDS 域隔离与真机进程扫描(scripts/lib/ros_domain.sh、real_procs.sh)。
|
||||
|
||||
为什么这两个 shell 库值得用 pytest 钉住
|
||||
--------------------------------------
|
||||
2026-09-28 无人值守实测踩了一次,代价是"仿真上料失败"查到 IK/TF/时钟上去:
|
||||
先 source 过 sim_env.sh 的 shell 里(ROS_DOMAIN_ID=77 还 export 着)再起真机栈,
|
||||
真机栈就落到**仿真的域**上,两边节点名撞车、/joint_states 与 /tf 各有两份来源
|
||||
互相盖。现象是"控制器 feedback 与 reference 完全一致(控制器没坏),但 TF 显示
|
||||
末端一动不动"。
|
||||
|
||||
仿真侧早就强制 77 并写明了"隔离不能靠『环境里恰好没设』",真机侧没有对应保护。
|
||||
ros_domain.sh 补上了保护;这个文件钉住它的**四种输入 + 一个不许误报**,
|
||||
以及 real_procs.sh 的域判别(否则停真机会顺手杀掉正在跑的仿真)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LIB = ROOT / "scripts" / "lib"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.name != "posix", reason="这两个库是 bash + /proc,只在 Linux 上有意义"
|
||||
)
|
||||
|
||||
#: conda 里的 bash 在 `bash -c` 时会往 stderr 打这一条,和被测逻辑毫无关系。
|
||||
#: 不滤掉的话,"非 77 的域不该有任何提示"那条用例会被它永久污染,最后只能把
|
||||
#: 断言削弱或删掉 —— 而"不许误报"和"必须报"一样是要钉的性质。
|
||||
_LIBTINFO_NOISE = "no version information available"
|
||||
|
||||
|
||||
def _stderr_clean(p: subprocess.CompletedProcess) -> str:
|
||||
return "\n".join(
|
||||
ln for ln in p.stderr.splitlines() if _LIBTINFO_NOISE not in ln
|
||||
).strip()
|
||||
|
||||
|
||||
def _clean_env(**overrides: object) -> dict:
|
||||
"""干净环境:先摘掉三个域变量,再套用 overrides。
|
||||
|
||||
跑测试的那个 shell 里的 ROS_DOMAIN_ID 经常正好是 77(因为常跑仿真),
|
||||
不清掉的话用例会永远落在同一个分支上 —— 这正是被测代码要防的那类错误。
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
for k in ("ROS_DOMAIN_ID", "REAL_ROS_DOMAIN_ID", "SIM_ROS_DOMAIN_ID"):
|
||||
env.pop(k, None)
|
||||
env.update({k: str(v) for k, v in overrides.items()})
|
||||
return env
|
||||
|
||||
|
||||
def _run_sourced(snippet: str, env_overrides: dict) -> subprocess.CompletedProcess:
|
||||
"""source ros_domain.sh 后执行 snippet,返回 CompletedProcess。"""
|
||||
return subprocess.run(
|
||||
["bash", "-c", f'source "{LIB}/ros_domain.sh"\nros_domain_force_real\n{snippet}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_clean_env(**env_overrides),
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
|
||||
def test_sim_domain_leak_is_corrected_to_zero_and_announced() -> None:
|
||||
"""核心用例:环境里带着仿真的 77 → 必须改回 0,并且**喊出来**。
|
||||
|
||||
只改不说不行:现场会以为"我明明设过域"而找不到原因。
|
||||
"""
|
||||
r = _run_sourced("echo -n $ROS_DOMAIN_ID", {"ROS_DOMAIN_ID": 77})
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert r.stdout == "0", f"77 是仿真的域,真机栈不能落在上面;实际 {r.stdout!r}"
|
||||
assert "77" in r.stderr and "仿真" in r.stderr, (
|
||||
"改掉别人的环境必须留一句人话,否则现场不知道域被换过"
|
||||
)
|
||||
|
||||
|
||||
def test_unset_domain_becomes_explicit_zero() -> None:
|
||||
"""没设 → 显式写 0(而不是留空)。
|
||||
|
||||
显式写出来的意义:`env | grep ROS_DOMAIN_ID` 能一眼看出真机跑在哪个域,
|
||||
不用去记"没设等于 0"。
|
||||
"""
|
||||
r = _run_sourced("echo -n $ROS_DOMAIN_ID", {})
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert r.stdout == "0"
|
||||
|
||||
|
||||
def test_explicit_real_domain_override_wins() -> None:
|
||||
"""REAL_ROS_DOMAIN_ID 是换域的唯一出口(名字带 REAL,不会被仿真环境撞上)。"""
|
||||
r = _run_sourced("echo -n $ROS_DOMAIN_ID", {"REAL_ROS_DOMAIN_ID": 5})
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert r.stdout == "5"
|
||||
|
||||
|
||||
def test_other_domain_is_left_alone_and_not_announced() -> None:
|
||||
"""用户自己挑的域(非 77)不许被动、也不许刷警告。
|
||||
|
||||
"不许误报"和"必须报"一样重要:每次 source env.sh 都刷一行吓人的警告,
|
||||
人就会开始无视它 —— 真出事那次也就看不见了。
|
||||
"""
|
||||
r = _run_sourced("echo -n $ROS_DOMAIN_ID", {"ROS_DOMAIN_ID": 3})
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert r.stdout == "3"
|
||||
assert _stderr_clean(r) == "", f"非 77 的域不该有任何提示;实际 {_stderr_clean(r)!r}"
|
||||
|
||||
|
||||
def test_real_procs_patterns_cover_install_space_and_named_nodes() -> None:
|
||||
"""扫描规则要同时覆盖两半:install/ 兜底 + 不在 install/ 下的点名表。
|
||||
|
||||
只留兜底会漏 robot_state_publisher / joint_state_publisher(它们在
|
||||
conda 或 /opt/ros 下,命令行里没有仓库路径);只留点名表会漏
|
||||
arm_control 那一族孤儿(2026-09-28 实测漏的就是它们)。
|
||||
"""
|
||||
r = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
f'source "{LIB}/real_procs.sh"\nreal_proc_patterns',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
assert r.returncode == 0, r.stderr
|
||||
pat = r.stdout
|
||||
# 兜底:仓库的 install/(而不是笼统的 install)
|
||||
assert f"{ROOT}/install/" in pat
|
||||
# 点名表里的关键项
|
||||
for name in ("ros2_control_node", "joint_state_publisher", "robot_state_publisher"):
|
||||
assert name in pat, f"{name} 不在扫描规则里"
|
||||
# 关键性质:install_sim/ **不**该被这条兜底模式匹配到(否则停真机会杀仿真)
|
||||
escaped = f"{ROOT}/install/"
|
||||
assert f"{ROOT}/install_sim/arm_control/lib/arm_control/arm_status_node".find(escaped) == -1
|
||||
|
||||
|
||||
def test_real_procs_domain_of_own_process_is_readable() -> None:
|
||||
"""real_proc_domain 要能从 /proc 读出进程的域(域判别的地基)。
|
||||
|
||||
这条地基不牢,real_proc_is_sim 就会恒为假 —— 于是 stop_real.sh 会把正在跑的
|
||||
仿真的 robot_state_publisher 一起杀掉(两边同名)。那种误杀的表现是"仿真莫名
|
||||
少了大半 TF",比漏杀更难查。
|
||||
|
||||
⚠️ 域必须通过 `env` 在 **exec 时**给:/proc/<pid>/environ 读的是进程启动那一刻
|
||||
的环境块,进去之后再 `export ROS_DOMAIN_ID=42` 是**改不到**它的。
|
||||
(第一版用例就是这么写的,拿到的是父 shell 的值,于是它测的不是被测代码。)
|
||||
"""
|
||||
env = _clean_env()
|
||||
env["ROS_DOMAIN_ID"] = "42"
|
||||
r = subprocess.run(
|
||||
["bash", "-c", f'source "{LIB}/real_procs.sh"\nreal_proc_domain $$'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=20,
|
||||
)
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert r.stdout.strip() == "42", f"读到的域不对:{r.stdout!r}(stderr={_stderr_clean(r)!r})"
|
||||
Reference in New Issue
Block a user