不靠等待碰运气:用可控回调验证浏览器文件读取竞态
作者:lgbisha。本文使用 AI 辅助整理,基于本人公开项目「隐迹 · 离线日志脱敏」1.0.0 的源码,以及 2026 年 10 月 25 日实际执行的六组浏览器实验。输入均为合成文本和无效演示值,不含真实日志或账号凭据。
昨天的文件导入验收检查了 UTF-8、空字节和字节上限,却没有验证“读取期间用户又操作了页面”会怎样。这个缺口不能用重复运行正常文件来补齐:小文件通常很快读完,操作顺序碰巧正确并不说明实现没有竞态。
这次把测试的重点从输入格式换成执行顺序:主动扣住异步回调,再在明确的时刻释放它,检查过期结果有没有污染当前页面。
一、问题不在读取慢,而在读取结果属于哪个版本
设想以下时序:开始读取文件 A,用户在输入框填写文本 B,然后 A 才读完。如果回调不检查归属,就可能用 A 覆盖 B。即使 A 的内容完全合法,也破坏了用户刚刚做出的选择。
项目通过两个状态描述结果:revision 是当前页面输入版本,completedRevision 是最近一次脱敏对应的版本。原文、选项或清空操作发生时,当前版本增加,旧结果清除,导出失效。
文件读取会保存启动版本,异步操作结束后先检查版本是否仍一致。关键逻辑可简化为:
const expectedRevision = revision;
const buffer = await file.arrayBuffer();
if (expectedRevision !== revision) return;
实际代码还包括编码校验、错误处理、输入更新和文件控件清理。错误分支与清理分支也要考虑版本归属,不能只保护成功写入原文的那一行。
二、测试时控制完成顺序,而不是用固定延时猜测
本次从本机回环地址加载未修改的静态项目,在测试页面临时包装 File.prototype.arrayBuffer。包装函数仍读取原文件字节,但返回一个暂不兑现的 Promise;测试脚本为每次调用保存独立的释放函数。
流程是:
- 通过真实文件输入控件选择合成文件,让应用实际进入读取流程。
- 等待测试闸门记录对应读取,而不是盲等固定毫秒数。
- 操作原文框、清空按钮,或再选择另一个文件。
- 按测试需要的顺序释放读取结果。
- 检查原文、结果、状态提示和导出按钮,而不只检查函数有没有抛错。
双文件场景刻意让 B 先完成、A 后完成。这样能稳定构造逆序,而不是期待操作系统偶尔把第二次读取排在第一次前面。
每组场景前重新加载测试页面,避免上一组的原型包装、未完成 Promise 或界面状态串入下一组。产品源文件没有被修改,也没有把测试闸门加入发行代码。
三、四组文件读取实验
| 场景 | 特意构造的顺序 | 本次结果 |
|---|---|---|
| 读取后编辑原文 | 开始 A → 填入 B → 释放 A | B 保留,结果为空,复制和下载禁用 |
| 读取后清空 | 开始 A → 清空 → 释放 A | 原文与结果保持为空,清空提示未被覆盖 |
| 两次读取逆序完成 | 开始 A → 开始 B → 释放 B → 释放 A | 最终仍是 B,提示及导出状态与 B 完成后相同 |
| 过期非法编码输入 | 开始非法 UTF-8 的 A → 填入 B → 释放 A | B 保留,提示仍要求重新处理,没有被旧编码错误覆盖 |
四组均符合预期。最后一组并不是证明非法编码得到了成功修复,而是证明已过期的读取不会再接管当前页面。当前有效文件的编码错误处理仍由昨天那组输入边界实验覆盖。
这些场景只验证指定时序,不代表已经穷尽所有事件交错。尤其不能由几组通过推导出线程安全或形式化正确性证明。
四、剪贴板也是异步操作,成功提示可能来晚
文件读取不是唯一的异步入口。navigator.clipboard.writeText 返回 Promise,页面提交写入请求后,用户可能先修改原文,然后浏览器才返回成功或失败。如果成功提示直接覆盖页面状态,用户可能误以为当前新输入已经处理并复制。
本次增加两组实验,剪贴板接口仅在测试页面使用模拟对象,没有写入或读取用户真实剪贴板。
第一组先生成脱敏结果,模拟剪贴板写入尚未完成,然后改动原文,再兑现复制 Promise。实际记录到模拟接口接收的是旧的脱敏结果,不含演示 password 原值;晚到回调没有把页面改成“当前结果已复制”,新输入仍需重新处理,结果为空,导出继续禁用。
第二组让模拟写入拒绝并抛出 NotAllowedError。界面实际聚焦到脱敏结果框、选中完整结果,提示手动复制。模拟接口只收到脱敏结果,未退回复制原文;当前结果仍有效,因此复制和下载按钮仍可用。
这两组同样符合预期,但它们测试的是前端分支与提示,不是浏览器真实权限策略,也不是一次真实剪贴板写入的端到端验证。
五、版本校验不能撤回已经发出去的操作
这里有一个容易夸大的边界:用户修改输入前,旧脱敏结果可能已经作为参数传给浏览器剪贴板接口。随后版本变化,只能阻止旧回调污染页面,不能保证取消外部系统已经接受的写入。
所以本次结论是“旧复制回调不会把新状态伪装成成功”,不是“修改原文能撤回已复制内容”。同理,清空页面不能删除已经下载或发送的文件。
如果产品需要可取消的操作,应另行定义取消协议;一个版本号不是所有外部副作用的撤销机制。
六、把断言写在结果边界,而不是内部实现细节
本次六组断言围绕用户能观察到的状态:
- 当前原文是否属于最后一次有效操作。
- 过期读取是否清空或覆盖新的结果与提示。
- 无有效结果时能否继续复制或下载。
- 剪贴板失败时是否明确给出手动操作,而不是报成功。
- 模拟剪贴板收到的是否为脱敏文本,而不是原文。
文件读取实验并未要求每个内部变量都必须使用固定名称。版本号只是目前实现方式;如果将来换成请求标识或取消令牌,只要同一组外部契约仍成立,测试仍有价值。
结语
2026 年 10 月 25 日,本次实际执行的六组浏览器场景全部通过,产品两份 JavaScript 源文件在实验前后哈希一致。没有把这六组混算成引擎单元测试,也没有声称完成全部浏览器、真实权限或安全审计。
比“多等几秒再检查”更可靠的,是让测试明确掌控回调顺序,并写清楚结果属于谁、哪些副作用无法撤回。公开项目位于 GitCode:LGBISHA/offline-log-redactor。
- 点赞
- 收藏
- 关注作者
评论(0)