From bb6f79292241f4d9d4121d10f837714c2dd0b07e Mon Sep 17 00:00:00 2001 From: arm_dev Date: Mon, 28 Sep 2026 10:46:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=8D=E4=BD=8D=E5=91=BD=E4=BB=A4=E5=BF=85?= =?UTF-8?q?=E9=A1=BB=E5=B8=A6=20timeout=EF=BC=9A=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=AE=83=E8=87=AA=E5=B7=B1=E4=BC=9A=E6=B0=B8=E4=B9=85=E6=8C=82?= =?UTF-8?q?=E4=BD=8F=EF=BC=8C=E8=BF=98=E7=95=99=E4=B8=8B=E5=8D=A0=E7=9D=80?= =?UTF-8?q?=20USB=20=E7=9A=84=E5=83=B5=E5=B0=B8=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把触觉 wedged 的处置改成「首选 `sudo -n orisys-usb-reset`(免密,无人值守 可直接跑)」。真跑了一次,发现这句话是错的: sudo -n /usr/local/bin/orisys-usb-reset 1-3.4.1 跑了 **155s 都没返回**,进程停在 `S` 状态(卡在写 `/sys/bus/usb/drivers/usb/unbind`),`timeout 90` 都收不掉它 —— 现场留下了三个 残留进程。对无人值守来说这是最坏的结果:那一轮在这里永久卡死,而且这个进程还 占着 USB,后面重试触觉只会继续失败。 复位之后 `1-3.4.1` 直接从 sysfs **消失**了(复位前它还在总线上、只是没有 /dev/video*)—— 也就是设备比复位前掉得更彻底。所以这条命令**不是**"跑一下试试" 那种安全操作,必须当有副作用的操作对待。 改动: · 两处给现场的命令一律写成 `timeout 15 sudo -n /usr/local/bin/orisys-usb-reset `,并把「不加 timeout 会永久卡住 + 留僵尸进程」写进提示原文。 这与 `orisys_tactile.py` 的自动自愈口径一致(那里本来就是 `timeout=15.0`, 所以自动路径不受影响,只有手工执行的建议是错的)。 · 复位后设备仍不在总线上(或消失)→ 明确归到**物理**处置:拔插该相机 / 给这个 Hub 断电重启。 测试:test_v4l_health 新增两条 —— 断言给出的复位命令里含 `timeout 15`, 以及提示里必须写出"会挂住"这件事(只给命令不说风险,人就会裸跑它)。 全仓库 1119 passed / 1 skipped。 Co-authored-by: Cursor --- scripts/lib/real_preflight.py | 4 +++- scripts/lib/v4l_health.py | 22 ++++++++++++++++----- src/arm_application/test/test_v4l_health.py | 12 +++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/scripts/lib/real_preflight.py b/scripts/lib/real_preflight.py index d65b01e..749ee0b 100644 --- a/scripts/lib/real_preflight.py +++ b/scripts/lib/real_preflight.py @@ -335,7 +335,9 @@ def check_v4l(pairs: Sequence[Tuple[str, str]], v4l_root: Optional[Path] = None) actions.append( "触觉相机在内核里卡死(sysfs 还在、/dev/video* 没了):`sudo -n " "/usr/local/bin/orisys-usb-reset ` 就是为这种情况准备的" - "(免密白名单,无人值守可直接跑);它也不行才拔插该 USB 口" + "(免密白名单)。**必须加 `timeout 15`** —— 实测这一句会卡在写 unbind " + "上不返回,无人值守时会永久卡住;它跑完设备仍不在总线上就只剩物理" + "处理了(拔插该相机 / 给这个 Hub 断电重启)" ) elif level == LEVEL_FAIL: actions.append("触觉相机没枚举到:确认插好(by-path 是按口认的,换口就会失配)") diff --git a/scripts/lib/v4l_health.py b/scripts/lib/v4l_health.py index 2e64b8d..91555bb 100644 --- a/scripts/lib/v4l_health.py +++ b/scripts/lib/v4l_health.py @@ -16,11 +16,21 @@ 复位 —— 若 unbind 成功而 bind 卡住,就会变成现在这个状态: ``/sys/bus/usb/devices/1-3.4.1`` 还在、接口还挂在 ``uvcvideo`` 上, 但 ``/dev/video*`` 一个都没有。**软件侧重连已经没救**,只能复位: - ``sudo -n /usr/local/bin/orisys-usb-reset ``。 + + timeout 15 sudo -n /usr/local/bin/orisys-usb-reset + 这条路径**不需要密码** —— sudoers 已对这一个脚本配了 NOPASSWD 白名单 (``orisys_tactile.py`` 里的自动复位也是用 ``sudo -n`` 调的), 所以无人值守时可以直接跑;真要输密码说明白名单被改掉了。 - 复位仍不行(unbind 都不响应)就只剩拔插一次该相机。 + + **必须带 ``timeout``**:2026-09-28 实测 ``sudo -n ... 1-3.4.1`` 跑了 + 155s 都不返回(卡在写 ``/sys/bus/usb/drivers/usb/unbind``,进程停在 ``S`` + 状态),而且复位之后 ``1-3.4.1`` 直接从 sysfs **消失**了 —— 设备比复位前 + 更彻底地掉了。没有 ``timeout`` 的话,无人值守的那一轮会永久卡在这一句上、 + 还留下一个占着 USB 的僵尸进程。``orisys_tactile.py`` 的自动自愈本来就是 + 用 ``timeout=15.0`` 调的,手工也照同一口径。 + 复位走完设备仍不在总线上(或干脆消失)→ 已经是**物理**问题,只剩拔插该 + 相机、或给这个 Hub 断电重启。 3. ``可读但无帧`` —— 节点在、open 得到,但一帧都读不出来 (日志里 ``select() timeout``)。→ 多半是同一条 USB2 总线被别的流占满; @@ -117,9 +127,11 @@ class Check: return ( f"{self.name}:设备在总线上({self.devpath})但**没有 /dev/video* 节点** " "—— 内核级卡死,软件侧重连救不回来。跑 " - f"`sudo -n /usr/local/bin/orisys-usb-reset {self.devpath}` 复位" - "(免密,sudoers 白名单;这条本来就是给无人值守用的)," - "复位仍不行就拔插一次这只相机;然后重启触觉节点。" + f"`timeout 15 sudo -n /usr/local/bin/orisys-usb-reset {self.devpath}` 复位" + "(免密,sudoers 白名单;`timeout` **别省** —— 实测这一句会卡在写 " + "unbind 上不返回,无人值守时会永久卡住并留下占 USB 的僵尸进程)。" + "复位后设备仍不在(或从 sysfs 消失)就是物理问题了:拔插该相机," + "或给这个 Hub 断电重启,然后重启触觉节点。" ) return f"{self.name}:没有插({self.by_path} 不存在)—— 插线/换 USB 口。" diff --git a/src/arm_application/test/test_v4l_health.py b/src/arm_application/test/test_v4l_health.py index 14fe79b..c75761d 100644 --- a/src/arm_application/test/test_v4l_health.py +++ b/src/arm_application/test/test_v4l_health.py @@ -84,6 +84,18 @@ def test_check_devices_reports_wedged_for_the_real_failure(tmp_path: Path) -> No assert c.level == "error" assert "orisys-usb-reset 1-3.4.1" in c.text(), "必须给出可直接执行的复位命令" assert "拔插" in c.text(), "也要给出不需要 root 的办法" + # 复位命令必须带 timeout:2026-09-28 实测这一句会卡在写 unbind 上 155s 不返回, + # 无人值守的那一轮会永久卡住、还留下一个占着 USB 的僵尸进程。 + assert "timeout 15 sudo -n" in c.text(), "给出的复位命令必须是有界的" + + +def test_wedged_hint_says_the_reset_can_hang() -> None: + """复位会挂住这件事必须写在提示里 —— 只给命令不说这件事,人就会裸跑它。""" + import v4l_health + + text = v4l_health.Check("左指", LEFT_BY_PATH, "1-3.4.1", "wedged", []).text() + assert "unbind" in text or "卡住" in text + assert "僵尸" in text or "永久" in text def test_check_devices_reports_unplugged_when_device_is_gone(tmp_path: Path) -> None: