Repository navigation
TB-312: 修复扩展连接回收、OHOS 稳定身份和示例书删除竞态 - #141
Conversation
| export function withSampleBookOperation<T>(operation: () => Promise<T>): Promise<T> { | ||
| if (!globalThis.navigator?.locks) { | ||
| return Promise.reject(new Error('此环境不支持安全的多窗口示例书操作。')); | ||
| } | ||
| return navigator.locks.request(OPERATION_LOCK, operation); | ||
| } |
There was a problem hiding this comment.
硬失败会波及普通(非跨窗口)场景。 navigator.locks 是 secure context 限定 API。Moke 的 web 构建如果部署在 http://<局域网IP>:<port> 这类非安全来源(自建 Talebook 的常见形态),navigator.locks 就是 undefined,于是示例书的导入和删除都会立刻抛出「此环境不支持安全的多窗口示例书操作。」。
改动前的实现只在单文档内用 importing 去重,在非安全来源下是可用的;现在用户既无法导入,也无法删除已经导入的示例书(面板只显示错误,重试无效,记录还在 IndexedDB 里),属于功能回退。
建议:锁不可用时降级为旧的单文档协调(保留 importing 等待 + 代次校验),只让「跨窗口」这一层能力失效,而不是让整个功能不可用;如果确实要硬失败,至少让删除走非协调路径,避免留下删不掉的书籍。
| } | ||
|
|
||
| export function invalidateSampleBookImports(): void { | ||
| localStorage.setItem(DELETION_GENERATION, crypto.randomUUID()); |
There was a problem hiding this comment.
crypto.randomUUID() 同样要求 secure context,这里没有回退。虽然当前调用点都在 withSampleBookOperation 内部(暗示锁可用),但 navigator.locks 与 Crypto.randomUUID 的可用性并不保证由同一条件决定(不同 WebView 对二者的门控不完全一致)。一旦不可用,这里抛出的 TypeError 会让整个删除流程失败,而且错误信息和真实原因无关。
建议加一个不依赖安全上下文的回退,例如 `g${Date.now()}-${Math.random()}` 或模块内自增计数——代次只需要单调变化,不需要密码学随机性。另外 localStorage 的读写也应包一层 try/catch(隐私模式/配额异常时 setItem 会抛错)。
| return withSampleBookOperation(async () => { | ||
| // Also cover deletion through the library, not just developer settings. | ||
| // A successful deletion invalidates earlier imports across reloads. | ||
| invalidateSampleBookImports(); |
There was a problem hiding this comment.
invalidateSampleBookImports() 在真正删除发生之前就推进了代次,而 deleteOfflineBookUncoordinated 之后仍可能抛错(deleteRecordOnly 的 IndexedDB 失败、Tauri 侧 removeStoredBookFile/文件删除失败等)。
后果:删除并没有发生,但所有窗口里正在进行的导入都会以「示例书籍已在另一窗口删除,请重新导入。」失败——提示与事实相反;删除失败后原记录还在,用户再导入又会被这条陈旧代次挡住(下一次导入会因为读到新代次而成功,但那一瞬间的失败信息是误导性的)。
建议把代次推进放在本地记录删除成功之后(deleteRecordOnly 是真正的分界点),或在 deleteOfflineBookUncoordinated 抛错时恢复原代次。
| let _ = stream.set_nonblocking(true); | ||
| let _ = (&stream).write(b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 22\r\n\r\n{\"code\":\"SERVER_BUSY\"}"); |
There was a problem hiding this comment.
这里用 Write::write 而不是 write_all:write 允许部分写入,也可能直接返回 WouldBlock 而一个字节都没写出。前者会让客户端收到被截断的响应头,后者只收到一个 FIN、没有任何状态码——都偏离了「过载返回 503」的目标(测试里之所以稳定通过,是因为 loopback 的发送缓冲区几乎总是空的)。
另外这条响应相比旧实现丢了两点,外部拓展会看到行为变化:
- body 从
{"code":"SERVER_BUSY","error":"拓展 API 请求过多"}变成{"code":"SERVER_BUSY"},解析error字段的客户端会拿到undefined; - 没有
Access-Control-Allow-Origin(而路由内所有响应都带这个头),也没有Content-Type,浏览器来源的调用方拿到的会是 CORS 失败而不是 503。
如果要保持「不阻塞 accept 线程」,可以考虑 set_write_timeout(Some(很小的值)) + write_all,而不是 set_nonblocking(true) + write。
| } | ||
| let started = Instant::now(); | ||
| while active.load(Ordering::Acquire) < MAX_CONNECTIONS { | ||
| assert!(started.elapsed() < Duration::from_millis(400)); |
There was a problem hiding this comment.
这个用例依赖 fixture() 里 600ms 的 deadline(第 252 行)在两次 connect 之前不生效:overflow 和 another 都只建连、不发请求,一旦轮询 32 个槽位的耗时(< 400ms 的断言本身也会先触发)或共享 runner 的负载让 600ms 提前到期,部分槽位会被释放,这两个连接就会被正常接收并进入 parse,阻塞到 deadline 后无响应关闭,reply() 得到空串,starts_with("HTTP/1.1 503") 直接失败。
同类问题也存在于第 308、344、366 行,以及 events.rs 第 440、476 行的 < 400/500/600ms 断言。这些测试会随 CI 的 cargo test --lib 执行(见 .github/workflows/ci.yml:77),是最容易偶发失败的一类断言。建议把 fixture 的 deadline 放宽到 2–5s,让断言验证「行为」而不是「机器速度」。
| fn reject(stream: &mut DeadlineStream, status: u16, code: &str) { | ||
| stream.set_deadline(Instant::now() + RESPONSE_TIMEOUT); | ||
| let response = Response::from_string(format!("{{\"code\":\"{code}\"}}")) | ||
| .with_status_code(status) | ||
| .with_header(Header::from_bytes("Content-Type", "application/json").unwrap()); | ||
| let _ = response.raw_print(stream, HTTPVersion(1, 1), &[], false, None); | ||
| } |
There was a problem hiding this comment.
reject() 生成的错误响应与路由层 respond_api_error 的约定不一致:只有 code、没有 error;没有 Access-Control-Allow-Origin;并且固定回 HTTP/1.1(Request::respond 用的是解析出来的版本)。这些拒绝都发生在认证之前、直接面向外部拓展进程,建议统一成 {code, error} + CORS 头 + 请求版本,免得客户端要为「传输层拒绝」和「路由层拒绝」写两套解析逻辑。
另外第 137 行对 Transfer-Encoding 返回 411(Length Required)语义上更接近 501 Not Implemented 或 400;如果保留 411,至少和 validate_declared_body_length 里「缺少 Content-Length 才回 411」的用法保持一致(目前同一个状态码表示了两件不同的事)。
| tiny_http = "=0.12.0" | ||
| tungstenite = "=0.24.0" | ||
| serde_json = "1.0" | ||
| log = "0.4" |
There was a problem hiding this comment.
这个可移植测试 crate 目前没有任何 CI 入口——.github/workflows/ci.yml 只跑 src-tauri 的 cargo test --lib --locked。README 把它描述成验证传输层的方式,但没有任何东西会执行它,因此:
- 通过
#[path]引入的三个生产模块一旦改名/改签名(例如serve_with_budget、DeadlineStream::check_deadline),这里会先编译失败而 CI 不会发现; - 一起提交的
Cargo.lock会随时间漂移,--locked的承诺无人验证。
考虑到 PR 正文也提到本机 cargo test --lib 因磁盘不足没能跑完,建议补一个 CI 步骤:
- run: cargo test --manifest-path tests/extension-transport/Cargo.toml --locked或者退一步,在 README 顶部明确标注「仅手动运行,不纳入门禁」,避免后来者误以为它受保护。
| // The menu intentionally closes on scroll. Drain the card's automatic | ||
| // scroll/layout events before opening it, as a settled user interaction does. | ||
| await page.evaluate(async () => { | ||
| await document.fonts.ready; | ||
| await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); | ||
| }); | ||
| await book.click({ button: 'right' }); | ||
| await page.getByRole('menuitem', { name: '移出书架' }).click(); |
There was a problem hiding this comment.
这个用例用 document.fonts.ready + 两次 requestAnimationFrame 去「排空」卡片的滚动/布局事件,注释本身也承认菜单会在滚动时关闭;PR 正文提到 bundled Chromium 下需要单独重跑才通过(6/7 → 重跑 1/1)。
既然该 spec 现在被加进了 ci.yml 的 browser smoke 入口,这就是一个已知的偶发失败源:CI 会出现与本 PR 无关的红灯,而且掩盖真实回归。建议修根因(例如在打开菜单后、布局稳定前,不要因为程序化滚动/字体变化而关闭菜单;或让 scrollIntoViewIfNeeded 在打开菜单前完成并稳定),而不是在测试里等待;如果短期无法修,至少把这条用例标成 test.fixme/单独分组,避免污染信号。
| installSampleCoordination(); | ||
| const indexedDB = createFakeIndexedDb(); |
There was a problem hiding this comment.
缩进被这次改动带歪了(installSampleCoordination(); 缩进比同一块里的 requestedVersions/const indexedDB 深两层),虽然不影响语义,但会让后面的读者以为是另一个作用域。
更值得注意的是这种「必须记得手工调用」的模式:installWebOfflineStore/installTauriOfflineStore 内部已经调用了 installSampleCoordination(),而第 532、670、702、743 行这几个自行搭建 IndexedDB 的用例又要单独再调一次。漏掉时的失败信息是「不支持安全的多窗口示例书操作。」,与真实原因(缺少 fake lock/localStorage)完全无关,新用例很容易踩坑。建议改成统一的 beforeEach 或在 createFakeIndexedDb() 里一并注入。
修复 #140 合并后由 TB-309 唯一清单确认的五项问题。例如,无凭据且不发送声明正文的连接现在会收到拒绝并关闭;跨窗口删除确认后,先前发起的迟到示例书导入会报错,刷新保持删除结果。
100-continue和正常请求;tiny_http 只格式化响应。org.houheya.moke,HAP 身份断言同步,删除无需再保留的 CLI 下划线例外。现有源 Preview Profile 绑定org.houheya.moke_openharmony,未修改签名/Profile/权限。验证:固定递归 submodule 初始化后 Node 484/484、lint exit 0(23 个现有 warning)、typecheck exit 0。生产传输/events 模块的独立 crate 真实 TCP 测试 12/12,包含 32 槽满额、连续零散输入、1~1024 字节慢正文、正文上限、认证/事件重放及同一 WS server 到期恢复。该 crate 的 HTTP handler 为虚构认证夹具,不等同于完整 Tauri REST 路由测试。
Playwright MCP 已复现修复前的跨窗口复活,并验证修复后拒绝迟到保存、刷新、新显式导入、桌面/移动/640×480、加载/503 错误重试、键盘导入及小窗口移出书架。系统 Chromium 7/7;bundled Chromium 在调整导航同步后 6/7,小窗口菜单在同步滚动/字体后单项重跑 1/1。示例 spec 已加入 CI mobile-smoke 入口。
尚未通过原生验收:完整
cargo test --lib --locked --offline在依赖编译阶段因磁盘余量 831 MiB 触发资源保护,exit 143;独立 TCP crate 不替代该结果。缺旧身份签名 Profile/旧包及设备,OHOS 升级、数据保留、Preview 共存和 iOS 真机名称仍未验证;原生 Reader 打开/返回、WebView Web Locks 支持及真实 .part/文件清理仍待补证。V-01~09 在 Multica 证据报告逐项保留。双模型最新 SHA 复核待 Mika 冻结并推进,不能宣称 QA 通过。基线
25cc8a1e452923de64876a3909bbf9e9f6527f3e,来源原 #140 / review head44523f201506b6634d902d958d06e264dd5f190f。稳定版本、Reader/vendor gitlink 保持;Preview 代码及 #115 的 Host/Origin/CORS/token 整改不在此次范围。本 PR 引用 TB-312,不关闭 TB-300。