Skip to content

TB-312: 修复扩展连接回收、OHOS 稳定身份和示例书删除竞态 - #141

Merged
hehetoshang merged 1 commit into
mainfrom
fix/TB-312-quality-remediation
Oct 11, 2026
Merged

hehetoshang merged 1 commit into
mainfrom
fix/TB-312-quality-remediation

Conversation

@hehetoshang

Copy link
Copy Markdown
Collaborator

修复 #140 合并后由 TB-309 唯一清单确认的五项问题。例如,无凭据且不发送声明正文的连接现在会收到拒绝并关闭;跨窗口删除确认后,先前发起的迟到示例书导入会报错,刷新保持删除结果。

  • U-01 / SOL-001 / GROK-001 与 U-05 / GROK-005:REST 在 accept 后、解析前分配最多 32 个槽位;头部 16 KiB / 64 条,正文 1 MiB,头部与正文共享 5 秒绝对 deadline。过载只进行一次非阻塞 503 写入,拒绝关闭连接且不排空正文。保留认证后 100-continue 和正常请求;tiny_http 只格式化响应。
  • U-02 / SOL-002 / GROK-002:OHOS stable 恢复 org.houheya.moke,HAP 身份断言同步,删除无需再保留的 CLI 下划线例外。现有源 Preview Profile 绑定 org.houheya.moke_openharmony,未修改签名/Profile/权限。
  • U-03 / SOL-003 / GROK-003:Upgrade 和 hello/auth 的每次底层读写、Ping/Pong 循环使用同一绝对 deadline;认证成功后转回正常非阻塞事件循环。
  • U-04 / SOL-004 / GROK-004:同源 Web Locks 串行化存储操作,持久删除代次阻止旧导入覆盖删除;覆盖设置和离线书库删除入口。下载在锁外,新显式导入可立即开始。未提供 Web Locks 的环境会明确报错,避免未经协调写入。

验证:固定递归 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 head 44523f201506b6634d902d958d06e264dd5f190f。稳定版本、Reader/vendor gitlink 保持;Preview 代码及 #115 的 Host/Origin/CORS/token 整改不在此次范围。本 PR 引用 TB-312,不关闭 TB-300。

Comment on lines +6 to +11
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

硬失败会波及普通(非跨窗口)场景。 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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

crypto.randomUUID() 同样要求 secure context,这里没有回退。虽然当前调用点都在 withSampleBookOperation 内部(暗示锁可用),但 navigator.locks 与 Crypto.randomUUID 的可用性并不保证由同一条件决定(不同 WebView 对二者的门控不完全一致)。一旦不可用,这里抛出的 TypeError 会让整个删除流程失败,而且错误信息和真实原因无关。

建议加一个不依赖安全上下文的回退,例如 `g${Date.now()}-${Math.random()}` 或模块内自增计数——代次只需要单调变化,不需要密码学随机性。另外 localStorage 的读写也应包一层 try/catch(隐私模式/配额异常时 setItem 会抛错)。

Comment thread src/lib/offline-books.ts
return withSampleBookOperation(async () => {
// Also cover deletion through the library, not just developer settings.
// A successful deletion invalidates earlier imports across reloads.
invalidateSampleBookImports();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalidateSampleBookImports() 在真正删除发生之前就推进了代次,而 deleteOfflineBookUncoordinated 之后仍可能抛错(deleteRecordOnly 的 IndexedDB 失败、Tauri 侧 removeStoredBookFile/文件删除失败等)。

后果:删除并没有发生,但所有窗口里正在进行的导入都会以「示例书籍已在另一窗口删除,请重新导入。」失败——提示与事实相反;删除失败后原记录还在,用户再导入又会被这条陈旧代次挡住(下一次导入会因为读到新代次而成功,但那一瞬间的失败信息是误导性的)。

建议把代次推进放在本地记录删除成功之后(deleteRecordOnly 是真正的分界点),或在 deleteOfflineBookUncoordinated 抛错时恢复原代次。

Comment on lines +200 to +201
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\"}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里用 Write::write 而不是 write_all:write 允许部分写入,也可能直接返回 WouldBlock 而一个字节都没写出。前者会让客户端收到被截断的响应头,后者只收到一个 FIN、没有任何状态码——都偏离了「过载返回 503」的目标(测试里之所以稳定通过,是因为 loopback 的发送缓冲区几乎总是空的)。

另外这条响应相比旧实现丢了两点,外部拓展会看到行为变化:

  1. body 从 {"code":"SERVER_BUSY","error":"拓展 API 请求过多"} 变成 {"code":"SERVER_BUSY"},解析 error 字段的客户端会拿到 undefined;
  2. 没有 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个用例依赖 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,让断言验证「行为」而不是「机器速度」。

Comment on lines +90 to +96
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个可移植测试 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 顶部明确标注「仅手动运行,不纳入门禁」,避免后来者误以为它受保护。

Comment on lines +111 to +118
// 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个用例用 document.fonts.ready + 两次 requestAnimationFrame 去「排空」卡片的滚动/布局事件,注释本身也承认菜单会在滚动时关闭;PR 正文提到 bundled Chromium 下需要单独重跑才通过(6/7 → 重跑 1/1)。

既然该 spec 现在被加进了 ci.yml 的 browser smoke 入口,这就是一个已知的偶发失败源:CI 会出现与本 PR 无关的红灯,而且掩盖真实回归。建议修根因(例如在打开菜单后、布局稳定前,不要因为程序化滚动/字体变化而关闭菜单;或让 scrollIntoViewIfNeeded 在打开菜单前完成并稳定),而不是在测试里等待;如果短期无法修,至少把这条用例标成 test.fixme/单独分组,避免污染信号。

Comment on lines +743 to +744
installSampleCoordination();
const indexedDB = createFakeIndexedDb();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

缩进被这次改动带歪了(installSampleCoordination(); 缩进比同一块里的 requestedVersions/const indexedDB 深两层),虽然不影响语义,但会让后面的读者以为是另一个作用域。

更值得注意的是这种「必须记得手工调用」的模式:installWebOfflineStore/installTauriOfflineStore 内部已经调用了 installSampleCoordination(),而第 532、670、702、743 行这几个自行搭建 IndexedDB 的用例又要单独再调一次。漏掉时的失败信息是「不支持安全的多窗口示例书操作。」,与真实原因(缺少 fake lock/localStorage)完全无关,新用例很容易踩坑。建议改成统一的 beforeEach 或在 createFakeIndexedDb() 里一并注入。

@hehetoshang
hehetoshang marked this pull request as ready for review October 11, 2026 00:30
@hehetoshang
hehetoshang merged commit eba924d into main Oct 11, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant