Skip to content

fix(bridge): 窗口隐藏时不判定桥接失效,修复后台每分钟整页重注入 - #2337

Closed
ViceEye wants to merge 1 commit into
BigPizzaV3:mainfrom
ViceEye:fix/bridge-reinject-hidden-window
Closed

ViceEye wants to merge 1 commit into
BigPizzaV3:mainfrom
ViceEye:fix/bridge-reinject-hidden-window

Conversation

@ViceEye

@ViceEye ViceEye commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

问题

窗口最小化后,Chromium 会把后台定时器限制到大约每分钟一次(实测 5s 定时器被拉长到 22s 甚至更久)。
心跳停止更新 lastSuccessAt/lastAttemptAt,看门狗就判定桥接失效,每分钟重新注入一次 730KB 的脚本。

实测数据:

  • 一次启动里重新注入 350 次
  • reinject_backoff_skipped 为 0 次:修复桥接重注入导致的 CPU 空转与资源累积 #2241 的退避一次都没生效,因为刚注入完那次健康检查必然通过,退避被立即清零
  • 每次重新注入都会清空 codexAppModuleFailures,后台 10 秒内发出 3032 个请求

修改

  1. bridge_health_check_script:document.visibilityState === "hidden" 时直接返回健康;bridge 不存在时仍返回 false,页面重载后桥接确实丢失时照样修复
  2. BridgeReinjectBackoff:持续健康满 60 秒才清零(新增 healthy_since);探测结果不确定时不改变状态;不健康时重新计时;应用实例更换时仍然立即清零
  3. renderer-inject.js:模块失败缓存和 asset URL 查找结果(包括未命中)挂在 window 上,跨重新注入保留,冷却 30 秒

测试

  • cargo test -p codex-plus-core --lib launcher:新增持续健康测试,全部通过
  • cdp_bridge 健康检查:新增 hidden / hidden 且无 bridge / visible 三种情况,全部通过
  • renderer-inject.test.ts:38 个用例全部通过
  • #[cfg(windows)] 代码以本 PR 的 Windows CI 为准

不在本次范围内

旧实例的 listener 和 observer 泄漏(实测 window 上有 65 个 keydown)。修好这次的问题后重新注入会很少,之后单独处理。

Refs #2169 #2241

- 健康检查在 document.visibilityState 为 hidden 时直接视为健康,bridge 缺失仍返回 false
- 重注入退避需持续健康满 60 秒才清零,探测不确定时不改变退避状态
- asset 查找失败缓存与 URL 查找结果挂在 window 上,跨重注入保留

Refs BigPizzaV3#2169 BigPizzaV3#2241

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BigPizzaV3

Copy link
Copy Markdown
Owner

审查了一遍,思路和 Rust 侧实现我都认可,但当前版本不能合 —— 有一个阻断性问题。

阻断:产物与分片不一致

本 PR 只改了 assets/inject/renderer-inject.js(组装产物),没有改对应的分片。这个仓库是分片组装的,分片是唯一真实来源(见 scripts/assemble-renderer-inject.mjs 顶部的说明:漂移由 assemble --check 与 renderer-inject.test.ts 的 drift 用例把守)。

实测结果:

$ node scripts/assemble-renderer-inject.mjs --check
Error: 产物与分片不一致,分片被改过但没重新组装
  产物: c21484c241e3b96bab594d1dfbe61dc6a7c856fc8b77f1bc77f65587597dc44a
  分片: b7487b3b32482090cc59aef1719f9ecb162464852b957e024e6248946213bfb7

前端测试 269 pass / 1 fail,唯一失败的正是 renderer-inject.test.ts:1206:

not ok - 分片按 manifest 顺序拼接后与产物逐字节一致
  产物与分片不一致(分片 15 个)。改分片后请跑 node scripts/assemble-renderer-inject.mjs 重新组装。

这不是格式问题,改动会被静默抹掉。 我在本地跑了一次组装脚本验证:产物里的 window.__codexPlusAppModuleFailures 和 __codexPlusAssetUrlLookups 全部消失,退回成 const codexAppModuleFailures = new Map(); —— 也就是说任何人(包括 CI 后续步骤或其他贡献者)跑一次组装,这个修复就没了。

需要改的位置

把你的改动搬到分片 assets/inject/renderer-inject/20-menu.js:

  1. 第 99 行(codexAppModuleFailures 的定义处)

    const codexAppModuleFailures = new Map();

    这一处要改成挂 window 的形式。

  2. 第 128 行(codexAppAssetUrlFromScriptText 的定义处)

    async function codexAppAssetUrlFromScriptText(namePart) {

    这里要把查找结果缓存的逻辑加进去(拆出 scanCodexAppAssetUrlFromScriptText 也可以)。

  3. 改完跑一次:

node scripts/assemble-renderer-inject.mjs

把产物一起提交。--check 与前端 drift 用例都通过后,这个阻断就解了。

Rust 侧:实现是对的,两点我都认同

1. visibilityState === "hidden" 返回健康 —— 位置很关键,你把它加在存在性检查之后:

if (typeof bridge !== "function" || !health) return false;   // 先判真丢桥接
if (document.visibilityState === "hidden") return true;      // 再判隐藏

页面重载导致桥接真丢失时仍返回 false,不会被隐藏状态掩盖。这个顺序是对的,没有牺牲真实失效的检测能力。

2. 退避需持续健康 60 秒才清零 —— 这条修的是真实缺陷。#2241 的退避实际上从未生效(我刚去核对了代码:重注入后紧接着的健康检查必然通过,因为 lastInjectionAt 在 5 秒内),你补的 observe_healthy 正好堵上这个口子。集成点也正确:

Some(true) => backoff.observe_healthy(Instant::now()),
None => {}                                     // 探测不确定 → 不动状态
Some(false) => backoff.observe_unhealthy(),

本地验证:cargo test -p codex-plus-core --lib launcher 全过(含新增的 reinject_backoff_resets_only_after_sustained_health),cdp_bridge 161 passed。

关于根因

你的解释比我此前在 #2322 里的判断更准确。我当时看到「/backend/status 返回 ok 却判 health failure」,怀疑是探测判定逻辑本身有问题;而你说的是 Chromium 把后台定时器限制到约每分钟一次,心跳根本没机会更新 —— 这更符合证据:不是探测判错了,是探测没跑。你给的「一次启动重注入 350 次、reinject_backoff_skipped 为 0 次」也直接印证了退避从未生效。

一处确认

document.visibilityState 只在窗口隐藏时阻止判定失效。窗口切回可见后,若桥接在此期间真的坏了,心跳恢复即会记录失败、看门狗照常介入 —— 我核对了注入脚本对 visibilitychange 的处理(renderer-inject.js:7669 有监听,但那是线程滚动用的,与桥接无关),确认不存在「切回可见后桥接已死却永远不判失效」的路径。这一点你的改动没有问题,只是想在 review 里记一笔。

#[cfg(windows)] 相关的部分以 CI 为准。

@BigPizzaV3

Copy link
Copy Markdown
Owner

更正我上一条 review 里关于「产物与分片不一致」的批评 —— 那个批评不成立,是我搞错了。

renderer-inject.js 的分片体系是 2026-09-30 01:52 由 ab7e343d("拆包渲染注入脚本…分片是唯一真实来源,单体文件改由 scripts/assemble-renderer-inject.mjs 生成")才引入的。

而本 PR 的分支基点是 09-28 20:20(070be57b)——那时分片体系还不存在,单体 renderer-inject.js 本身就是真实来源,直接改它完全正确。

我把「今天才有的规则」套到了「前天的提交」上,抱歉给你添了无用功。

实际需要的是 rebase,不是改代码

分支落后 main 较多,而 main 上新增了分片体系。rebase 之后,你目前的改动会落在生成物上,需要把它们搬进对应的分片:

你的改动 应落到
codexAppModuleFailures 改挂 window(原单体第 99 行附近) assets/inject/renderer-inject/20-menu.js
codexAppAssetUrlFromScriptText 的缓存逻辑(原第 128 行附近) 同上
bridge_health_check_script 的 hidden 判断 crates/codex-plus-core/src/bridge.rs(不受影响)
BridgeReinjectBackoff 的 observe_healthy crates/codex-plus-core/src/launcher.rs(不受影响)

Rust 部分不受 rebase 影响,你已完成的测试结论仍然有效。

改完分片后跑一次:

node scripts/assemble-renderer-inject.mjs

把产物一起提交,--check 就过了。

不变的部分

Rust 侧的两处实现我依然认可,审查结论不变:

  1. visibilityState === "hidden" 加在存在性检查之后,不牺牲真实失效的检测能力 —— 顺序是对的
  2. 退避需持续健康 60 秒才清零 —— 修复桥接重注入导致的 CPU 空转与资源累积 #2241 的退避此前实际从未生效(重注入后紧接的健康检查必然通过),这个口子堵得准

另外这个 rebase 需求对其他几个同样改 renderer-inject.js 的 PR 一样成立(#2346、#2309、#2291、#2305),不是你一个人的问题。

BigPizzaV3 added a commit that referenced this pull request Oct 2, 2026
采纳 PR #2337 的思路,但把注入层改动落到分片源码而非产物(PR 只改了
`assets/inject/renderer-inject.js`,reviewer 已指出 assemble --check 报产物与
分片不一致;只改产物的话下一次 assemble 会把修复静默抹掉)。

根因(issue #2330 / #2169 / #2267 / #2201 同一链路)
窗口最小化后 Chromium 把后台定时器钳到约每分钟一次,注入脚本的 5s 心跳停摆,
而 `bridge_health_check_script` 只看 lastInjectionAt/lastSuccessAt/lastAttemptAt、
没有 visibilityState 判据,于是每轮探测都判失效;叠加
`Some(true) | None => backoff.reset()`(重注入后紧接的那次探测必然健康,
`lastInjectionAt` 还在 5 秒窗口内)导致退避永远停在第一档。结果是每分钟重发
整份 616KB 脚本,连带全量 asset rescan。

改动
- `bridge.rs`:`bridge_health_check_script` 加 hidden 早返回。放在 bridge/health
  存在性检查**之后**,保证页面重载后真丢桥时仍能修复。
- `launcher.rs`:`BridgeReinjectBackoff` 新增 `healthy_since`,
  `observe_healthy` / `observe_unhealthy`;持续健康满
  `BRIDGE_REINJECT_BACKOFF_RESET_AFTER_SECS`(60s)才清零,探测不确定(None)
  不改变退避状态。`browser_identity_changed` 的 reset 保留。
- `20-menu.js`(分片):`codexAppModuleFailures` 与新增的
  `codexAppAssetUrlLookups` 挂到 window 跨重注入保留;
  `codexAppAssetUrlFromScriptText` 拆出扫描实现并加查找缓存(未命中同样缓存,
  冷却期内不重复扫描)。
- 重组产物(assemble + --check 一致)。

测试
- Rust:新增 `reinject_backoff_resets_only_after_sustained_health`
  (立刻健康不清零 / 未满阈值不清零 / 不健康后重新计时 / 满阈值清零);
  `cargo test -p codex-plus-core --lib` 495 passed。
- 前端:`npm test` 312 passed。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@BigPizzaV3

Copy link
Copy Markdown
Owner

感谢 @ViceEye 的修复。思路完全正确,三点都被采纳并落地——这个 PR 定位的根因(窗口隐藏被误判为桥接失效,叠加退避永远清零)正是 issue #2330 / #2169 / #2267 / #2201 的同一条链路。

已由提交 5bb4f636 合入 main。

采纳的部分(与你的实现语义一致)

  • bridge.rs:bridge_health_check_script 加 hidden 早返回,放在 bridge/health 存在性检查之后(保证页面重载后真丢桥时仍能修复)。
  • launcher.rs:BridgeReinjectBackoff 新增 healthy_since + observe_healthy / observe_unhealthy,持续健康满 BRIDGE_REINJECT_BACKOFF_RESET_AFTER_SECS = 60 才清零;Some(true) → observe_healthy、None → 不改变状态、Some(false) → observe_unhealthy。browser_identity_changed 的 reset 保留。测试 reinject_backoff_resets_only_after_sustained_health 同名保留。
  • 注入层:codexAppModuleFailures 与新增的 codexAppAssetUrlLookups 挂 window 跨重注入保留;codexAppAssetUrlFromScriptText 拆出扫描实现并加查找缓存(未命中同样缓存)。

合入时改掉的一处(原 PR 无法直接合的阻断项)
注入层改动只落在了产物 assets/inject/renderer-inject.js,没有落进分片源码。本仓库的产物由 node scripts/assemble-renderer-inject.mjs 从 assets/inject/renderer-inject/*.js 组装,只改产物的话 assemble --check 会报不一致,且下一次组装会把修复静默抹掉。合入时把同样的改动写进了分片 20-menu.js(codexAppModuleFailures 在第 99 行附近、codexAppAssetUrlFromScriptText 在第 128 行附近)再重跑组装。

顺带一提:你在注释里提到 installExternalApiQuotaGate,而产物有一条契约测试禁止该标识符出现在 bundle 里(renderer-inject.test.ts 的 "rewrites official usage status at the cache boundary")。合入时把注释改成「有调用方会绕过 asset loader 直接调它」,避免为了说明而把禁用词写进产物。

改动同时关掉 #2330 / #2169 / #2267 / #2201。再次感谢这个定位。

@BigPizzaV3 BigPizzaV3 closed this Oct 2, 2026
BigPizzaV3 added a commit that referenced this pull request Oct 2, 2026
审计当时的处置建议有两处被实测推翻、一处性质被更正,记下来:

- **推翻 #2303 的「并发验证阻断项」**:默认并发 5 次全过(未复现 PR 描述的失败);
  32 线程加压下 main 与 PR 分支会挂**同一个**用例
  (upstream_request_returns_when_provider_accepts_but_never_sends_headers,
  墙钟断言 assert!(started.elapsed() < 1s))。是既有 flake,不是 PR 引入。
  真正阻断项是分支落后 34 提交。
  另更正「保留 compaction: bool 单一标记路线」这条约束——加入原生透传后
  compaction 语义已分裂,多字段反而更清晰。
- **推翻 #2313 的「范围过大需拆分」**:冲突只有 1 处且是陈旧基线
  (main 的 04ab629 把 recovery_material 改成 2 参),解完整树全绿 1395/75/331。
  不该因冲突要求改设计。
- **补充 #2333 的真实回归**(审计未发现):实测 model_catalog 测试红,
  默认模型被静默换成 modelList 第一条。附教训:审查贡献者 PR 时必须单独验
  「PR 自带测试是否覆盖整个 workspace」,只信 PR 描述的测试命令会漏跨 target 回归。
- 补记 #2309 两个阻断项的实测证据(版本号撞车、Err(_) 抵消退避修复)。

执行结果:#2366 已合入(ea82bc74);#2337 / #2371 由已落地提交覆盖后关闭;
#2333 / #2309 回复要求修改;#2313 / #2303 回复可合但需先 rebase。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants