体检判据硬化: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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user