体检判据硬化:sysfs 读取带超时、复位命令不再写成"要密码"

两处都是「体检工具自己失效」——本仓库最贵的那类坑:判据看起来在跑,其实
结论是假的或者干脆没有结论。

scripts/lib/usb_health.py
  Hub 在内核里卡死时,读它的 sysfs 属性会**永久阻塞**(实测
  /sys/bus/usb/devices/1-3.4/product 读 20 秒不返回)。于是体检脚本自己挂住:
  本该报出故障的那一次,恰恰什么都不输出。新增 SysfsReader —— 所有 sysfs 读取
  带 1s 超时,卡死的路径只尝试一次并记入 blocked(Python 杀不掉阻塞在系统调用
  里的线程,不记就会在 --watch 反复采样时攒出几十个),blocked 映射回父设备
  并按 error 报出「给这个 Hub 断电重启 / 改插主板直连」。

scripts/lib/v4l_health.py
  · 提示语写「到真终端里带密码跑 orisys-usb-reset」,与实际不符:sudoers 对这个
    脚本本来就是 NOPASSWD 白名单(`sudo -n -l` 可见;orisys_tactile.py 的自动
    复位用的也是 sudo -n)。现场按这句会以为无人值守时做不了这件事。
    改成 `sudo -n /usr/local/bin/orisys-usb-reset <devpath>` 并说明免密来源。
  · wedged 的处置写反了:以前是「物理重插;reset 只在没完全卡死时有效」,
    而设备还在 sysfs 里正是 unbind/bind 能生效的前提,先说 reset。
  · main() 增加可注入的 v4l_root/usb_root,用例不再读真机 sysfs。

测试:test_main_exit_code_is_nonzero_on_error 以前直接读真 sysfs,本机这只
相机没插(unplugged)时会变红 —— 它本该只在 wedged 下断言复位提示。改为注入
临时 sysfs(设备在总线上、无 /dev/video*),用例变成确定性的。
test_usb_health + test_v4l_health 共 28 项通过。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-09-28 10:42:37 +08:00
parent 98f4277849
commit 0fadd26ca2
4 changed files with 239 additions and 24 deletions
@@ -16,6 +16,8 @@ lib 的提示必须给出**同一个核对手段**(lsusb -t),否则两边
from __future__ import annotations
import sys
import threading
import time
from pathlib import Path
import pytest
@@ -152,3 +154,78 @@ def test_two_sources_of_advice_agree_on_lsusb() -> None:
def test_finding_levels_are_from_a_fixed_vocabulary(level: str) -> None:
"""级别只能是这三个词 —— 脚本按它们判定成败,多一个词就没人处理。"""
assert level in {"ok", "warn", "error"}
# ---------------------------------------------------------------------------
# 卡死的 Hub:读 sysfs 会永久阻塞,体检不能跟着挂
# ---------------------------------------------------------------------------
class _BlockingPath:
"""假装是一个"读下去不返回"的 sysfs 节点(内核跟卡死设备对话时的真实行为)。"""
def __init__(self, name: str = "product") -> None:
self.name = name
self.reads = 0
self._release = threading.Event()
def read_text(self, encoding: str = "utf-8", errors: str = "replace") -> str:
self.reads += 1
self._release.wait(30.0) # 永不 set —— 模拟内核不返回
return "never"
def test_blocked_read_times_out_instead_of_hanging() -> None:
"""这一条是本次实测的核心:体检工具自己不能挂在它要诊断的故障上。"""
reader = usb_health.SysfsReader(timeout_s=0.2)
t0 = time.monotonic()
assert reader.read(_BlockingPath()) == ""
elapsed = time.monotonic() - t0
assert elapsed < 5.0, f"读取超时没生效(耗时 {elapsed:.1f}s)—— 体检会卡死"
assert len(reader.blocked) == 1
def test_blocked_path_is_only_attempted_once() -> None:
"""--watch 会反复采样;确认卡死的路径不再重试,否则会攒一堆卡住的线程。"""
reader = usb_health.SysfsReader(timeout_s=0.2)
path = _BlockingPath()
reader.read(path)
reader.read(path)
reader.read(path)
assert path.reads == 1
assert reader.blocked == [str(path)]
def test_blocked_is_reported_as_error_and_says_what_to_do() -> None:
"""报错要说清"动哪一个"以及现场怎么恢复,而不是只报"读不到"。"""
findings = findings_for(
[_cam("2-3.4.3", D405, 5000)], blocked=["/sys/bus/usb/devices/1-3.4/product"]
)
assert findings[0].level == "error", "卡死的 Hub 必须是最前面的 error"
assert "1-3.4" in findings[0].text
assert "下游" in findings[0].text, "要说清它会带走下游设备"
assert "root" in findings[0].text or "断电" in findings[0].text
assert usb_health.has_errors(findings)
def test_blocked_devices_maps_attr_paths_to_device_paths() -> None:
devs = usb_health.blocked_devices(
[
"/sys/bus/usb/devices/1-3.4/product",
"/sys/bus/usb/devices/1-3.4/speed",
"/sys/bus/usb/devices/2-1.2/serial",
]
)
assert devs == ["1-3.4", "2-1.2"], "同一台设备的多个属性只报一次"
def test_no_camera_error_points_at_the_hub_when_one_is_blocked() -> None:
""""一台相机都没有"有两种原因,处置完全不同,措辞要能区分开。"""
only_hub = findings_for([], blocked=["/sys/bus/usb/devices/1-3.4/product"])
assert len(only_hub) == 2, "根因(Hub 卡死)与症状(没有相机)都要报"
assert "读取超时" in only_hub[0].text
assert "Hub 卡死" in only_hub[1].text
unrelated = findings_for([], blocked=[])
assert "拔插" in unrelated[0].text
+13 -2
View File
@@ -105,7 +105,18 @@ def test_empty_by_path_is_unplugged_not_ok(tmp_path: Path) -> None:
def test_main_exit_code_is_nonzero_on_error(tmp_path: Path, capsys) -> None:
"""CLI 退出码必须能表示失败 —— 否则脚本里的 it 会被忽略掉。"""
code = v4l_health.main(["--dev", "左指=" + LEFT_BY_PATH])
"""CLI 退出码必须能表示失败 —— 否则脚本里的 it 会被忽略掉。
这里必须**注入临时 sysfs**:真机上这只相机可能根本没插(unplugged),
也可能卡死(wedged),让用例去读真 sysfs 就会"换台机器就变红"。
"""
usb = tmp_path / "usb"
usb.mkdir()
(usb / "1-3.4.1").mkdir() # 设备在总线上,但没有 /dev/video* → wedged
v4l = tmp_path / "v4l"
v4l.mkdir()
code = v4l_health.main(
["--dev", "左指=" + LEFT_BY_PATH], v4l_root=v4l, usb_root=usb
)
assert code == 1
assert "orisys-usb-reset" in capsys.readouterr().out