测试的测试:孤儿测试三连败与截帧铁证方法论
WeClaw 技术博客 · 第 106 篇 - 模块九第 6 篇 · 验证手段自身的 Bug · shutdown vs close · 真实节奏测试 · 视觉铁证
📚 专栏信息
《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏 · 模块九【稳定性实战:故障链解剖与自愈工程】(共 8 篇)
本文是模块九第 6 篇,也是桌面侧五篇的收官。前面几篇里每个"实测✅"都有个 没说出口的代价——验证产品之前,测试脚本自己先在坑里躺了三轮。 这是一篇"元测试"文章。
上一篇:《WeClaw_105 故障降级的三级自愈链》 | 下一篇:《WeClaw_107 KeyError 的五级放大故障链》
👨💻 作者与项目
作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"
- 💻 项目地址:https://github.com/wyg5208/weclaw
- 🌐 官网地址:https://weclaw.link
- 📝 作者 CSDN:https://blog.csdn.net/yweng18
📝 摘要
本文结构概览: 以"验证孤儿自退出"这一条测试的三连败开场,逐坑拆解 close 与阻塞 readline 的死锁、tasklist 的 GBK 输出崩掉 UTF-8 脚本、嵌套作用域 global 不穿透;随后给出三条正面方法论:复刻真实使用节奏的跨超时窗口测试、坏源注入与杀进程断言、截帧/抓屏的视觉铁证法。
背景: v11.16.0 收口前必须验证四件事:换台跨窗口可靠、首播失败必报错、子进程死亡自愈、父进程骤死无孤儿。产品代码已就绪,验证脚本连吃三败。
核心问题: 当"测量仪器"本身有 Bug 时,产品到底修没修好?
解决方案:
模拟对端骤死用 shutdown(SHUT_RDWR);跨进程输出一律字节级判断;测试节奏以真实用户行为为基准;一切"能播"的断言最终落在像素上。
关键成果:
- 孤儿自退出验证通过(撕断后 6 秒 tasklist 无残留)
- 换台间隔 10~18s(≫ 旧 1s 死亡窗口)全部起播
- 911KB CCTV-1 全高清截帧 + 桌面抓屏双视觉铁证
适合读者:写集成测试/端到端测试的开发者、被"测试挂了还是产品挂了"困扰的人、桌面应用测试同学
阅读时长:约 11 分钟
关键词:测试死锁、shutdown、GBK编码、真实节奏、截帧验证、坏源注入
一、开场:一条测试的三连败
产品侧的孤儿自退出逻辑(第 104/105 篇)已经上线: 父进程暴死 → 子进程读线程拿到 EOF → 自行退出。 验证它需要一个"扮演父进程暴死"的脚本。就是这么一个 40 行的脚本,连摔三次。
1.1 第一败:close 引发的死锁(v1 卡死 120 秒)
最初的想当然写法:
# ❌ v1:模拟"父进程死了"
conn_f.close() # 关掉读端的文件对象,总该触发对面 EOF 了吧?
# ……脚本从此卡死,120 秒超时被外部杀掉
死锁机理:sock.makefile("r") 返回的是 BufferedReader 包装。
子进程方向的读线程此刻正阻塞在同一个文件对象的 readline 上,
持有 BufferedReader 的内部锁;主线程调 close() 需要先拿到同一把锁
才能安全冲刷——两把锁互等,谁也不让谁。
(而 close socket 对象本身也不行:makefile 持有 socket 引用时,
socket 的引用计数没归零,连接根本不会断。)
正确姿势——问"进程真死时 OS 干了什么":强制回收该进程全部 fd, TCP 栈立即发出 FIN/RST。等价物是:
# ✅ v2:立即撕断 TCP,唤醒一切阻塞读
sock.shutdown(socket.SHUT_RDWR)
sock.close()
shutdown 不抢任何用户态锁,直接作用于协议栈:本端所有阻塞在
read/write 上的线程立即返回(EOF 或错误),对端连接确定性地断。
这是 OS 级"骤死"的最小 faithful 模拟。
沉淀规则:模拟"对端骤死"永远用 shutdown,不用 close。 close 是"礼貌地用完了",shutdown 才是"被人拔了网线"。
1.2 第二败:tasklist 崩掉 UTF-8 脚本
子进程退出与否要用 tasklist 独立确认(不能只信 poll()——测试要外部证人对吧)。
然后脚本炸了:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xce ...
原因:Windows tasklist 的输出编码跟随系统 ANSI 代码页(中文机器 = GBK),
而 subprocess.run(..., text=True) 默认用 UTF-8 解码。
修复不需要解码——PID 是纯 ASCII,直接字节级比对:
# ✅ 字节级判断,绕开一切编码坑
out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}"],
capture_output=True).stdout # 不解码
alive = str(pid).encode() in out
沉淀规则:跨进程取文本,要么显式指定对侧真实编码,要么根本不解码。 Windows 工具链(tasklist/dir/netstat)默认 GBK,PowerShell 管道默认 UTF-8, 两套默认值相撞是常态。
1.3 第三败:global 不穿透嵌套作用域
脚本最后一处低级失误:
old_pid = 0
def scenario():
def hard_drop(): # 嵌套函数里想改全局
old_pid = proc.pid # ❌ 这是局部赋值!
...
print(old_pid) # 永远打印 0,断言莫名挂掉
global 声明不随作用域链穿透——每个需要改全局的嵌套函数内部
都得重新声明:
def hard_drop():
global old_pid # ✅ 在这个函数里再声明一次
old_pid = proc.pid
三连败的共同点:没有一败是产品的错,但每一败都可能让人误判 "产品没修好"(v1 卡死像产品挂死、1.3 的 0 像"根本没 spawn 成功")。 这就是本文标题——测试也需要被测试。
二、正面方法论一:测试节奏 = 真实使用节奏
2.1 soak 快速连切为什么立过反功
修复 TimeoutError 之前,soak 脚本用毫秒级连续换台压测——六次换台全部 "看起来有响应"。修复后的复盘揭穿它:读线程启动后 1 秒内是活的, 快速连切的所有命令恰好都打在 1 秒窗口内,这个测试天然测不到空闲死亡。 换句话说,它不仅能漏测,还主动生产过"基本能用"的假阳性证据。
2.2 跨超时窗口测试
真正的用户是这样看电视的:点开 CCTV-1 看一段,想一想,10 秒后切 CCTV-2。
于是终版测试的换台间隔设定为 10~18 秒(≫ 旧的 1 秒死亡窗口),
每一跳断言 started=True:
| 场景 | 旧 bug 下 | 修复后实测 |
|---|---|---|
| 启动后 0.5s 点播 | ✅ 能播(症状"偶尔能放出中央1台"的来源) | ✅ |
| 间隔 10~18s 逐台切换 | ❌ 全部石沉大海 | ✅ 每跳均起播 |
| 关闭对话框 30s 后再开再点 | ❌ 无反应 | ✅ |
沉淀规则:为"超时/空闲死亡"类缺陷设计测试时,测试的等待时间必须 显著大于缺陷的触发窗口。 压测的"快"在这里是反方向的价值。
三、正面方法论二:故障注入要有"必然失败"的靶子
"首播失败必须报错"(第 105 篇 had_stream 修复)怎么验证? 不能靠碰运气找一个恰好死掉的直播源——需要确定性坏源:
# 127.0.0.1:9 (discard 端口) 上没有任何服务 → 连接必然被拒
widget.play("http://127.0.0.1:9/dead.m3u8")
# 断言:恰好收到一次 playback_error,且 reason 非空
assert errors == 1
同类确定性靶子还有:
- 杀进程:
proc.kill()×2 验证自愈链两分支(首死无感重生 / 再死恰好 1 次回退信号); - 撕连接:
shutdown(SHUT_RDWR)验证孤儿自退(§1.1); - 属性观察:轮询
hwdec_current=='d3d11va' and video_fps==25.0验证硬解真实激活 ——而不是"看起来没卡"。
四、正面方法论三:像素才是终审法官
团队有一条铁律:要验证,不要推断。对播放功能而言,一切 started/ack/fps 数字都只是中间证据,终点证据是画面。本次收口的两枚铁证:
| 证据 | 内容 | 说明 |
|---|---|---|
temp/_fix_frame.png | 911KB,CCTV-1 全高清片尾职员表 + 台标 + 中国IPTV水印 | 由 request_screenshot 走 IPC 让子进程 screenshot-to-file 生成,跨 18 秒窗口换台之后截的——证明"命令送达 + 解码正常 + 画面干净"三件事同时成立 |
temp/_desktop.png | 桌面级抓屏:视频精确铺满播放区 | 证明 SetParent 嵌入方案在真实窗体里无偏移、无黑边、无 Z 序问题 |
截帧链路本身就是个小架构(也是 103 篇行协议里 ack shot <0|1> 的用途):
# 父进程
widget.request_screenshot("temp/_fix_frame.png")
# 50ms 泵内消费回执:
elif head == "ack" and parts[1] == "shot":
self._shot_ok = parts[2] == "1"
# 子进程
m.command("screenshot-to-file", arg, "video")
_out("ack", "shot", "1" if os.path.exists(arg) else "0")
为什么"911KB"也重要:mpv 截图若落在解码失败/黑帧上,文件会显著变小 (纯黑帧 PNG 高度可压缩)。体积是画面的第一道哨兵,肉眼看图是终审。
沉淀规则:UI 渲染类功能的成功断言必须包含"看得见的像素"。 日志说播了、信号说好了、属性说 25fps——都不如你亲眼看见片尾字幕在跑。
五、把四套方法论装进一个 pytest 骨架
桌面侧的自动化回归最终沉淀为 55 个用例(tests/test_watch_tv.py +
tests/test_watch_tv_iptv_mpv.py),关键设计:
# conftest.py:会话级闸门——CI/offline 环境不碰真实 libmpv
os.environ["WECLAW_TV_MPV"] = "0" # is_available() 直接 False,
# 播放内核用例降级为协议/状态机 mock 测试
- 分层:协议解析/状态机/had_stream 逻辑用纯 mock 进 pytest(毫秒级、可 CI); 真渲染/真进程/真像素验证走手工脚本(本文 §一~§四),产出 PNG 证据归档 temp/;
- 纪律:手工脚本放
test_scripts/,不污染tests/的 pytest 根目录; - 口径:负向指标(错误日志增量=0)与正向断言同权重——
_mpv_runner.err在修复前后各采一次样,11 → 11 才算闭环。
六、总结与展望
6.1 核心要点回顾
- 三连败三规则:骤死用 shutdown 不用 close;跨进程输出字节级判断; 嵌套 global 逐函数声明;
- 节奏即正义:测超时缺陷的等待时间必须显著大于触发窗口, soak 快切会系统性漏测空闲死亡;
- 确定性靶子:坏源/杀进程/撕连接——故障要注入,不能守株待兔;
- 像素终审:started 信号、fps 属性、ack 回执都是中间证据, 一张人眼可验的截帧才是终点证据。
1 个核心公式:
可信的修复 = 产品代码 × (真实节奏测试 + 确定性故障注入 + 视觉铁证)
6.2 下一步学习方向
- 📖 下一篇:《WeClaw_107 KeyError 的五级放大故障链》——换个战场,方法论原封不动: 误导性报错、逐层取证、防线分层
- 🔜 再下一篇:《WeClaw_108 别猜你的模型预算》
下期预告:《KeyError 的五级放大故障链》
- 两个长期稳定的定时任务突然全挂,报错却与真相毫无关系
- 五级放大链:大参数 → 动态压缩 → 截断 → 静默吞异常 → 裸下标
- "刻意不自动修复":一个反直觉但正确的工程决定
敬请期待!
附录 A:本篇涉及的代码/证据清单
| 文件路径 | 作用 |
|---|---|
src/ui/watch_tv/mpv_player.py request_screenshot / _drain | 截帧请求与回执消费 |
src/ui/watch_tv/mpv_runner.py screenshot 分支 | 子进程侧 screenshot-to-file |
temp/_fix_frame.png、temp/_desktop.png | 两枚视觉铁证(已归档) |
tests/test_watch_tv.py + tests/test_watch_tv_iptv_mpv.py | 55 用例回归基线 |
conftest.py WECLAW_TV_MPV 闸门 | CI 与真实渲染分层 |
附录 B:参考资料
- 上一篇:《WeClaw_105 故障降级的三级自愈链》
- socket.shutdown 文档:https://docs.python.org/3/library/socket.html#socket.socket.shutdown
- BufferedReader 线程安全语义:https://docs.python.org/3/library/io.html#io.BufferedReader
- WeClaw_12《日志分析实战》、WeClaw_83《Mock Fixture 录制回放》(测试家族姊妹篇)
版权声明:本文为CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。