体检判据硬化: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:
+133
-15
@@ -19,11 +19,26 @@ USB3 接口里 SuperSpeed 是一对独立的差分针脚,它们一旦接触不
|
|||||||
|
|
||||||
一开始就报"协商速率不对",能把这一类问题从"查算法"挪回"插紧线"。
|
一开始就报"协商速率不对",能把这一类问题从"查算法"挪回"插紧线"。
|
||||||
|
|
||||||
|
还有一种更彻底的坏法:**Hub 在内核里卡死**
|
||||||
|
--------------------------------------------
|
||||||
|
2026-09-28 又实测到一次:不只是掉速,而是挂在同一个 Hub 下的**四台设备一起消失**
|
||||||
|
(腕部 D405 + 顶置 D435 + 左右两个视触觉)。此时 ``/sys/bus/usb/devices`` 里那个
|
||||||
|
Hub 节点还在,但读它的属性会**永久阻塞**:
|
||||||
|
|
||||||
|
/sys/bus/usb/devices/1-3.4/product ← 读 2 秒、20 秒、永不返回
|
||||||
|
|
||||||
|
原因是内核正在跟这个卡死的设备对话,拿不到应答就不返回。于是本脚本自己也挂住 ——
|
||||||
|
用户看到的是"体检卡死",比报一行错更难判断。所以这里的 sysfs 读取**一律带超时**,
|
||||||
|
超时就当作一条 error 结论报出来(见 ``SysfsReader``):宁可少读到一个字段,
|
||||||
|
也不能让体检本身失去意义。
|
||||||
|
|
||||||
判定口径(这里就是**唯一一处**)
|
判定口径(这里就是**唯一一处**)
|
||||||
--------------------------------
|
--------------------------------
|
||||||
· 5000 Mbps → USB3,OK(D435 可用 1280x720@30)
|
· 5000 Mbps → USB3,OK(D435 可用 1280x720@30)
|
||||||
· 480 Mbps → USB2,RealSense 会掉到 640x480@15 —— WARN 并给出拔插建议
|
· 480 Mbps → USB2,RealSense 会掉到 640x480@15 —— WARN 并给出拔插建议
|
||||||
· 12 / 1.5 → USB 全速/低速,串口转换器正常,不告警
|
· 12 / 1.5 → USB 全速/低速,串口转换器正常,不告警
|
||||||
|
· 读属性超时 → Hub/设备内核级卡死,ERROR(软件复位要 root,现场断电重启该 Hub)
|
||||||
|
· 一台都没有 → ERROR
|
||||||
|
|
||||||
用法::
|
用法::
|
||||||
|
|
||||||
@@ -36,6 +51,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -65,6 +81,70 @@ USB_RESEAT_HINT = (
|
|||||||
"核对速率:lsusb -t(设备后面写 480M 还是 5000M)。"
|
"核对速率:lsusb -t(设备后面写 480M 还是 5000M)。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: 读 sysfs 属性的超时 [s]。正常读取是微秒级,1 秒足够宽松。
|
||||||
|
DEFAULT_READ_TIMEOUT_S = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class SysfsReader:
|
||||||
|
"""带超时的 sysfs 读取 —— 卡死的 USB 设备会让读取**永久阻塞**。
|
||||||
|
|
||||||
|
为什么不能直接用 ``Path.read_text``:内核在跟卡死的设备对话时,
|
||||||
|
``/sys/bus/usb/devices/1-3.4/product`` 这类属性读下去不返回,Python 就
|
||||||
|
卡在系统调用里。对体检工具来说这是最坏的结果 —— 本该报出故障的那一次
|
||||||
|
恰恰什么都不输出。
|
||||||
|
|
||||||
|
超时后读属性用的线程会一直留在后台(Python 没法强杀阻塞在系统调用里的
|
||||||
|
线程),所以同一个路径**只尝试一次**并记入 ``blocked``,后续直接跳过。
|
||||||
|
这样 ``--watch`` 反复采样也不会攒出几十个卡住的线程。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, timeout_s: float = DEFAULT_READ_TIMEOUT_S) -> None:
|
||||||
|
self.timeout_s = timeout_s
|
||||||
|
#: 读取超时的 sysfs 路径(按出现顺序,去重)
|
||||||
|
self.blocked: List[str] = []
|
||||||
|
self._tried_blocked: set[str] = set()
|
||||||
|
|
||||||
|
def read(self, path: Path) -> str:
|
||||||
|
key = str(path)
|
||||||
|
if key in self._tried_blocked:
|
||||||
|
return ""
|
||||||
|
done = threading.Event()
|
||||||
|
box: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def _worker() -> None:
|
||||||
|
try:
|
||||||
|
box["v"] = path.read_text(encoding="utf-8", errors="replace").strip()
|
||||||
|
except OSError:
|
||||||
|
box["v"] = ""
|
||||||
|
finally:
|
||||||
|
done.set()
|
||||||
|
|
||||||
|
threading.Thread(target=_worker, daemon=True).start()
|
||||||
|
if not done.wait(self.timeout_s):
|
||||||
|
self._tried_blocked.add(key)
|
||||||
|
if key not in self.blocked:
|
||||||
|
self.blocked.append(key)
|
||||||
|
return ""
|
||||||
|
return box.get("v", "")
|
||||||
|
|
||||||
|
|
||||||
|
def blocked_devices(blocked: Sequence[str]) -> List[str]:
|
||||||
|
"""把阻塞的 sysfs 路径归到 USB 设备路径上(``/sys/.../1-3.4/product`` → ``1-3.4``)。
|
||||||
|
|
||||||
|
归类是为了给出**该动哪一个**:报"1-3.4/product 读不了"没用,
|
||||||
|
要能说出"1-3.4 这个 Hub 卡死了,挂在它下面的设备一起没了"。
|
||||||
|
"""
|
||||||
|
devs: List[str] = []
|
||||||
|
for raw in blocked:
|
||||||
|
parts = Path(raw).parts
|
||||||
|
if "devices" in parts:
|
||||||
|
name = parts[parts.index("devices") + 1]
|
||||||
|
else:
|
||||||
|
name = Path(raw).parent.name
|
||||||
|
if name and name not in devs:
|
||||||
|
devs.append(name)
|
||||||
|
return devs
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class UsbDevice:
|
class UsbDevice:
|
||||||
@@ -131,37 +211,63 @@ def _parse_speed(raw: str) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def read_devices(root: Path = SYS_USB) -> List[UsbDevice]:
|
def read_devices(
|
||||||
"""列出当前 USB 设备(不需要 root)。"""
|
root: Path = SYS_USB, reader: Optional[SysfsReader] = None
|
||||||
|
) -> List[UsbDevice]:
|
||||||
|
"""列出当前 USB 设备(不需要 root)。
|
||||||
|
|
||||||
|
``reader`` 传入时,读取超时的路径会记在它上面(见 :class:`SysfsReader`)——
|
||||||
|
调用方据此把"Hub 卡死"报出来,而不是默默少列几台设备。
|
||||||
|
"""
|
||||||
|
rd = reader or SysfsReader()
|
||||||
out: List[UsbDevice] = []
|
out: List[UsbDevice] = []
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return out
|
return out
|
||||||
for entry in sorted(root.iterdir()):
|
for entry in sorted(root.iterdir()):
|
||||||
vid = _read(entry / "idVendor")
|
vid = rd.read(entry / "idVendor")
|
||||||
if not vid:
|
if not vid:
|
||||||
continue # 不是设备节点(接口/端口目录没有 idVendor)
|
continue # 不是设备节点(接口/端口目录没有 idVendor)
|
||||||
out.append(
|
out.append(
|
||||||
UsbDevice(
|
UsbDevice(
|
||||||
path=entry.name,
|
path=entry.name,
|
||||||
vid=vid,
|
vid=vid,
|
||||||
pid=_read(entry / "idProduct"),
|
pid=rd.read(entry / "idProduct"),
|
||||||
product=_read(entry / "product"),
|
product=rd.read(entry / "product"),
|
||||||
speed_mbps=_parse_speed(_read(entry / "speed")),
|
speed_mbps=_parse_speed(rd.read(entry / "speed")),
|
||||||
serial=_read(entry / "serial"),
|
serial=rd.read(entry / "serial"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def findings_for(devices: Sequence[UsbDevice]) -> List[Finding]:
|
def findings_for(
|
||||||
"""对一批设备给出体检结论(纯函数,便于单测)。"""
|
devices: Sequence[UsbDevice], blocked: Sequence[str] = ()
|
||||||
|
) -> List[Finding]:
|
||||||
|
"""对一批设备给出体检结论(纯函数,便于单测)。
|
||||||
|
|
||||||
|
``blocked`` 是读取超时的 sysfs 路径(``SysfsReader.blocked``)。
|
||||||
|
"""
|
||||||
out: List[Finding] = []
|
out: List[Finding] = []
|
||||||
|
# 卡死的 Hub 要**排在最前面**:它是根因,后面"一台相机都没枚举到"只是症状。
|
||||||
|
for dev in blocked_devices(blocked):
|
||||||
|
out.append(
|
||||||
|
Finding(
|
||||||
|
"error",
|
||||||
|
f"USB 设备 {dev} 的 sysfs 属性读取超时 —— 内核正卡在跟它对话,"
|
||||||
|
f"这一台**及其下游设备**会一起从系统里消失"
|
||||||
|
f"(相机/触觉都挂它下面时,现象是「相机和触觉同时没了」)。"
|
||||||
|
f"软件复位需要 root;现场处置是给这个 Hub 断电重启:"
|
||||||
|
f"拔插它的上游线,或把设备改插到主板后面板直连的 USB 口"
|
||||||
|
f"(两个 RealSense 都走同一个总线供电的小 Hub 很容易把它拖垮)。",
|
||||||
|
)
|
||||||
|
)
|
||||||
cams = [d for d in devices if d.is_realsense]
|
cams = [d for d in devices if d.is_realsense]
|
||||||
if not cams:
|
if not cams:
|
||||||
out.append(
|
out.append(
|
||||||
Finding(
|
Finding(
|
||||||
"error",
|
"error",
|
||||||
"一台 RealSense 都没枚举到。拔插一次;确认没有 "
|
"一台 RealSense 都没枚举到。先看上面有没有「sysfs 读取超时」那条"
|
||||||
|
"(有就是 Hub 卡死,不是相机的问题);否则拔插一次,并确认没有 "
|
||||||
"realsense-viewer / 其它进程占着相机。",
|
"realsense-viewer / 其它进程占着相机。",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -214,13 +320,17 @@ def flap_events(snapshots: Sequence[Sequence[str]]) -> List[str]:
|
|||||||
|
|
||||||
|
|
||||||
def watch_realsense(
|
def watch_realsense(
|
||||||
seconds: float, interval_s: float = 1.0, root: Path = SYS_USB
|
seconds: float,
|
||||||
|
interval_s: float = 1.0,
|
||||||
|
root: Path = SYS_USB,
|
||||||
|
reader: Optional[SysfsReader] = None,
|
||||||
) -> List[List[str]]:
|
) -> List[List[str]]:
|
||||||
"""在 ``seconds`` 秒内反复采样 RealSense 的设备路径。"""
|
"""在 ``seconds`` 秒内反复采样 RealSense 的设备路径。"""
|
||||||
|
rd = reader or SysfsReader()
|
||||||
snaps: List[List[str]] = []
|
snaps: List[List[str]] = []
|
||||||
deadline = time.monotonic() + max(0.0, seconds)
|
deadline = time.monotonic() + max(0.0, seconds)
|
||||||
while True:
|
while True:
|
||||||
snaps.append(sorted(d.path for d in read_devices(root) if d.is_realsense))
|
snaps.append(sorted(d.path for d in read_devices(root, rd) if d.is_realsense))
|
||||||
if time.monotonic() >= deadline:
|
if time.monotonic() >= deadline:
|
||||||
break
|
break
|
||||||
time.sleep(interval_s)
|
time.sleep(interval_s)
|
||||||
@@ -233,9 +343,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||||||
ap.add_argument("--json", action="store_true", help="输出 JSON")
|
ap.add_argument("--json", action="store_true", help="输出 JSON")
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
devices = read_devices()
|
# 整轮共用一个 reader:某条路径一旦确认卡死就不再重读,
|
||||||
findings = findings_for(devices)
|
# 否则 --watch 每次采样都会再攒一个卡在内核里的线程。
|
||||||
snaps = watch_realsense(args.watch) if args.watch > 0 else []
|
reader = SysfsReader()
|
||||||
|
devices = read_devices(reader=reader)
|
||||||
|
findings = findings_for(devices, reader.blocked)
|
||||||
|
snaps = watch_realsense(args.watch, reader=reader) if args.watch > 0 else []
|
||||||
flaps = flap_events(snaps) if snaps else []
|
flaps = flap_events(snaps) if snaps else []
|
||||||
if flaps:
|
if flaps:
|
||||||
findings.append(
|
findings.append(
|
||||||
@@ -252,6 +365,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||||||
"devices": [asdict(d) for d in devices if d.is_realsense],
|
"devices": [asdict(d) for d in devices if d.is_realsense],
|
||||||
"findings": [asdict(f) for f in findings],
|
"findings": [asdict(f) for f in findings],
|
||||||
"flaps": flaps,
|
"flaps": flaps,
|
||||||
|
"blocked_sysfs": list(reader.blocked),
|
||||||
}
|
}
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
@@ -262,6 +376,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||||||
f" {d.model or d.product:8} {d.path:8} "
|
f" {d.model or d.product:8} {d.path:8} "
|
||||||
f"{str(d.speed_mbps) + ' Mbps':>10} vid:pid={d.vid}:{d.pid}"
|
f"{str(d.speed_mbps) + ' Mbps':>10} vid:pid={d.vid}:{d.pid}"
|
||||||
)
|
)
|
||||||
|
if reader.blocked:
|
||||||
|
# 单独打一行:它意味着"下面那些设备根本没被列出来",
|
||||||
|
# 不写清楚的话,上面的列表看起来就像"设备没了"而没有原因。
|
||||||
|
print(f" ({len(reader.blocked)} 条 sysfs 属性读取超时,详见下面的 FAIL)")
|
||||||
for f in findings:
|
for f in findings:
|
||||||
mark = {"ok": "OK ", "warn": "WARN", "error": "FAIL"}[f.level]
|
mark = {"ok": "OK ", "warn": "WARN", "error": "FAIL"}[f.level]
|
||||||
print(f" [{mark}] {f.text}")
|
print(f" [{mark}] {f.text}")
|
||||||
|
|||||||
@@ -15,8 +15,12 @@
|
|||||||
``sudo /usr/local/bin/orisys-usb-reset <devpath>``(unbind/bind 白名单脚本)
|
``sudo /usr/local/bin/orisys-usb-reset <devpath>``(unbind/bind 白名单脚本)
|
||||||
复位 —— 若 unbind 成功而 bind 卡住,就会变成现在这个状态:
|
复位 —— 若 unbind 成功而 bind 卡住,就会变成现在这个状态:
|
||||||
``/sys/bus/usb/devices/1-3.4.1`` 还在、接口还挂在 ``uvcvideo`` 上,
|
``/sys/bus/usb/devices/1-3.4.1`` 还在、接口还挂在 ``uvcvideo`` 上,
|
||||||
但 ``/dev/video*`` 一个都没有。**软件侧已经没救了**,只能:
|
但 ``/dev/video*`` 一个都没有。**软件侧重连已经没救**,只能复位:
|
||||||
在真终端里带密码跑一次 ``orisys-usb-reset``,或拔插一次该相机。
|
``sudo -n /usr/local/bin/orisys-usb-reset <devpath>``。
|
||||||
|
这条路径**不需要密码** —— sudoers 已对这一个脚本配了 NOPASSWD 白名单
|
||||||
|
(``orisys_tactile.py`` 里的自动复位也是用 ``sudo -n`` 调的),
|
||||||
|
所以无人值守时可以直接跑;真要输密码说明白名单被改掉了。
|
||||||
|
复位仍不行(unbind 都不响应)就只剩拔插一次该相机。
|
||||||
|
|
||||||
3. ``可读但无帧`` —— 节点在、open 得到,但一帧都读不出来
|
3. ``可读但无帧`` —— 节点在、open 得到,但一帧都读不出来
|
||||||
(日志里 ``select() timeout``)。→ 多半是同一条 USB2 总线被别的流占满;
|
(日志里 ``select() timeout``)。→ 多半是同一条 USB2 总线被别的流占满;
|
||||||
@@ -112,9 +116,10 @@ class Check:
|
|||||||
if self.state == "wedged":
|
if self.state == "wedged":
|
||||||
return (
|
return (
|
||||||
f"{self.name}:设备在总线上({self.devpath})但**没有 /dev/video* 节点** "
|
f"{self.name}:设备在总线上({self.devpath})但**没有 /dev/video* 节点** "
|
||||||
"—— 内核级卡死,软件侧救不回来。到真终端里跑 "
|
"—— 内核级卡死,软件侧重连救不回来。跑 "
|
||||||
f"`sudo /usr/local/bin/orisys-usb-reset {self.devpath}`(要密码),"
|
f"`sudo -n /usr/local/bin/orisys-usb-reset {self.devpath}` 复位"
|
||||||
"或把这只相机拔插一次;然后重启触觉节点。"
|
"(免密,sudoers 白名单;这条本来就是给无人值守用的),"
|
||||||
|
"复位仍不行就拔插一次这只相机;然后重启触觉节点。"
|
||||||
)
|
)
|
||||||
return f"{self.name}:没有插({self.by_path} 不存在)—— 插线/换 USB 口。"
|
return f"{self.name}:没有插({self.by_path} 不存在)—— 插线/换 USB 口。"
|
||||||
|
|
||||||
@@ -148,7 +153,11 @@ def check_devices(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
def main(
|
||||||
|
argv: Optional[Sequence[str]] = None,
|
||||||
|
v4l_root: Path = V4L_BY_PATH,
|
||||||
|
usb_root: Path = SYS_USB,
|
||||||
|
) -> int:
|
||||||
ap = argparse.ArgumentParser(description="V4L2 触觉相机存在性体检")
|
ap = argparse.ArgumentParser(description="V4L2 触觉相机存在性体检")
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--dev",
|
"--dev",
|
||||||
@@ -164,7 +173,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|||||||
for item in args.dev:
|
for item in args.dev:
|
||||||
name, _, path = str(item).partition("=")
|
name, _, path = str(item).partition("=")
|
||||||
pairs.append((name.strip() or "v4l", path.strip()))
|
pairs.append((name.strip() or "v4l", path.strip()))
|
||||||
checks = check_devices(pairs)
|
checks = check_devices(pairs, v4l_root=v4l_root, usb_root=usb_root)
|
||||||
|
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps([asdict(c) for c in checks], ensure_ascii=False, indent=2))
|
print(json.dumps([asdict(c) for c in checks], ensure_ascii=False, indent=2))
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ lib 的提示必须给出**同一个核对手段**(lsusb -t),否则两边
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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:
|
def test_finding_levels_are_from_a_fixed_vocabulary(level: str) -> None:
|
||||||
"""级别只能是这三个词 —— 脚本按它们判定成败,多一个词就没人处理。"""
|
"""级别只能是这三个词 —— 脚本按它们判定成败,多一个词就没人处理。"""
|
||||||
assert level in {"ok", "warn", "error"}
|
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:
|
def test_main_exit_code_is_nonzero_on_error(tmp_path: Path, capsys) -> None:
|
||||||
"""CLI 退出码必须能表示失败 —— 否则脚本里的 it 会被忽略掉。"""
|
"""CLI 退出码必须能表示失败 —— 否则脚本里的 it 会被忽略掉。
|
||||||
code = v4l_health.main(["--dev", "左指=" + LEFT_BY_PATH])
|
|
||||||
|
这里必须**注入临时 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 code == 1
|
||||||
assert "orisys-usb-reset" in capsys.readouterr().out
|
assert "orisys-usb-reset" in capsys.readouterr().out
|
||||||
|
|||||||
Reference in New Issue
Block a user