Skip to content

fix(captcha): stop aliasing host timers onto globalThis (fixes tui crash + UI freeze root cause) - #37

Closed
LX2000WASD wants to merge 1 commit into
TriDefender:masterfrom
LX2000WASD:fix/host-timer-aliasing
Closed

LX2000WASD wants to merge 1 commit into
TriDefender:masterfrom
LX2000WASD:fix/host-timer-aliasing

Conversation

@LX2000WASD

Copy link
Copy Markdown
Contributor

问题

installGlobalWindowAlias 把 happy-dom 窗口的 setTimeout/setInterval/clearTimeout/clearInterval 挂到宿主 globalThis 上(Bun 下 guest 脚本在宿主 realm 执行,所以需要别名)。这里的动机是对的 —— guest 计时器应随窗口销毁,否则 pe-VM 回调会在 destroyDom 之后触发(window is not defined)。但代价是整个进程的计时器被换掉了,包括 HTTP 服务器依赖的那些。

1. 每次响应结束都可能崩溃

Bun 的 node:http 在 keep-alive 重装 socket 超时时无条件调 timer.unref():

// onResponseFinishHandleSocket -> socket.setTimeout(total)
let timer = setTimeout(onSocketTimeoutTimerExpired, msecs, this);
timer.unref(), this[kSocketTimeoutTimer] = timer;

而 happy-dom 的 BrowserWindow.setTimeout:窗口 closed 后返回裸 {}(BrowserWindow.js:1903),delay === 0 时返回没有 unref 的 Timeout。于是:

TypeError: timer.unref is not a function. (In 'timer.unref()', 'timer.unref' is undefined)
    at setTimeout (node:_http_server:1028:20)
    at onResponseFinishHandleSocket (node:_http_server:1280:24)
    at emitResponseFinish (node:_http_server:1257:35)
    at emit (node:events:97:22)
    at processTicksAndRejections (native:7:39)

触发条件在池并发求解下稳定存在:destroyDom 先 happyDOM.close(),而 removeGlobalWindowAlias 在 _aliasRefCount > 0 时直接 return,所以宿主全局在一段时间内指向一个已关闭的窗口。TUI 的 uncaughtException 处理器会把它变成硬退出(tui crashed);serve 模式下则是连接被丢。

2. 拆除别名时把宿主全局删掉了

removeGlobalWindowAlias 遍历 Object.getOwnPropertyNames(w),凡是宿主上有 getter 的就 delete。Bun 下这些是 own property,没有原型链兜底,所以进程直接失去 setTimeout:

ReferenceError: setTimeout is not defined
    at setTimeout (node:_http_server:1027:31)
    at onResponseFinishHandleSocket (node:_http_server:1280:24)

实测被影子覆盖并会被删除的宿主全局有 24 个:setTimeout, setInterval, clearTimeout, clearInterval, addEventListener, removeEventListener, dispatchEvent, postMessage, atob, btoa, navigator, self, WebSocket, DOMException, CustomEvent, ErrorEvent, MessageEvent, CloseEvent, PerformanceEntry, PerformanceObserver, PerformanceObserverEntryList, onmessage, onerror, undefined。代码里已经为 console 单独做了描述符保存/恢复(_savedConsoleDescriptor),说明这个问题被意识到过,但只补了一个。

3. 宿主计时器被静默取消 —— 这就是 "用久了 UI 会冻住" 的根因

别名生效期间宿主自己调度的计时器,会被注册进窗口的 asyncTaskManager,happyDOM.close() 连带取消。

TUI 渲染循环有守卫(src/tui/app.ts:150-153):

function scheduleRender() {
  if (renderTimer) return;
  renderTimer = setTimeout(renderNow, RENDER_MS);
  ...
}

一次求解结束把 pending 的 render timer 收走 → renderNow 永不执行 → renderTimer 永远非 null → 之后每次 scheduleRender() 都被守卫挡掉 = 永久冻结。用 v4.5.2 的别名代码逐字复现:

renders after window close: 0
renderTimer still latched? true
renders after further input: 0
>>> FROZEN

移除别名后:

renders after window close: 1
renderTimer still latched? false
renders after further input: 2
>>> not frozen

4.5.2 的渲染看门狗能兜住症状,但根因还在,且同样会打到 captcha-pool 的 refill interval、async/bridge 的 keepalive、captcha-cpu-governor、OAuth 超时等所有在求解期间调度的计时器上 —— 那些没有看门狗,只会静默失效。

改动

  • 计时器四个名字加入 HOST_CRITICAL_GLOBALS,永不别名。
  • guest 计时器改为词法注入,保留原本"随窗口销毁"的意图:happy-dom 的每个 guest 脚本都经 window[PropertySymbol.evaluateScript] 且传入的是包装表达式,所以在外面再套一层形参名为 setTimeout/... 的函数,guest 的裸引用和 guest 内 eval() 都解析到 shim,宿主全局不动。//# sourceURL 保留在包装之后,guest 栈帧仍带 CDN URL。
  • shim 用真实宿主计时器调度,句柄按窗口存进 WeakMap,destroyDom 显式回收 —— pe-VM 回调依然不会打到已销毁的窗口上。
  • 返回的句柄始终是真实 Node timer,unref/refresh/_idleTimeout 齐全,包括 delay === 0 和窗口已关闭两种原来返回残缺对象的情况。
  • shim 伪装为 [native code],并对齐 name/length,以 happy-dom 原生的描述符形状装到窗口上。这一步是必需的而非美化:飞邻风控会用 Function.prototype.toString 扫描平台 API,泄露闭包源码会让上游以 {"code":3007,"msg":"captcha verify failed"} 拒绝每一个令牌。原来的 w.setTimeout.bind(w) 恰好满足这点,因为 bound function 按规范永远返回 [native code] —— 这是个容易踩的坑,所以加了专门的测试锁住。
  • removeGlobalWindowAlias 改为恢复保存的描述符而非删除,上面那 24 个宿主全局不再被抹掉;console 特例现在只是这套通用机制的一个实例。
  • requestIdleCallback/requestAnimationFrame polyfill 改用受跟踪的计时器(原来直接 setTimeout,会漏过窗口关闭)。

验证

  • bun test:713 pass / 0 fail;bun x tsc --noEmit 干净。
  • 新增测试覆盖:宿主全局在 install/teardown 前后的同一性、嵌套并发求解、native 伪装(3007 回归)、描述符形状对齐、guest 计时器回收、以及在"已关闭窗口仍被别名"状态下真实跑一次 node:http keep-alive 往返 —— 后者在打补丁前会精确复现上面那个 TypeError。
  • 反向验证:把 bug 逐个塞回去,对应测试全部失败(keep-alive 那条直接复现原始 TypeError)。
  • 真实链路:真的 startServer + 真的 createDom/destroyDom 并发 churn,660 请求 0 uncaught;同样场景下打补丁前会崩溃并把进程卡死。
  • 真实求解连续 5 次全部成功(280 字符 param,securityToken 128 字符)。

环境:Bun 1.4.0 / linux-x64,start-plan + bigmodel。

`installGlobalWindowAlias` exposed the happy-dom window's
setTimeout/setInterval/clearTimeout/clearInterval on the host `globalThis`
so guest SDK scripts (which run in the host realm under Bun) would get
timers tied to the window's lifetime. That repointed the timers the whole
process depends on, with three consequences:

1. Crash on every response finish. Bun's node:http keep-alive path does
   `socket.setTimeout(...)` then `timer.unref()` unconditionally
   (onResponseFinishHandleSocket). happy-dom's BrowserWindow.setTimeout
   returns a bare `{}` once `window.closed`, and a handle with no `unref`
   for delay 0, so:

     TypeError: timer.unref is not a function
       at setTimeout (node:_http_server:1028:20)
       at onResponseFinishHandleSocket (node:_http_server:1280:24)
       at emitResponseFinish (node:_http_server:1257:35)

   With the pool solving concurrently, `destroyDom` closes one window
   while `_aliasRefCount > 0` keeps the aliases installed, so the host
   globals point at a closed window. Under the TUI the uncaughtException
   handler turns this into a hard exit ("tui crashed"); under `serve` it
   drops connections.

2. Teardown removed host globals outright. `removeGlobalWindowAlias`
   deleted every aliased name; under Bun these are own properties with no
   prototype fallback, so the process was left with no `setTimeout` at
   all -> "ReferenceError: setTimeout is not defined". `console` had a
   bespoke save/restore for exactly this reason; nothing else did.

3. Silent cancellation of host timers. Timers scheduled while the aliases
   were installed were registered in the window's asyncTaskManager and
   cancelled by `happyDOM.close()`. This is the root cause of the TUI
   freeze: `scheduleRender` guards on `if (renderTimer) return`, so a
   render timer reaped by a solve leaves `renderTimer` non-null forever
   and the UI never repaints again.

Fixes:

- Timer globals join HOST_CRITICAL_GLOBALS and are never aliased.
- Guest scripts get window-scoped timers LEXICALLY instead: happy-dom
  funnels every guest script through `window[PropertySymbol.evaluateScript]`
  as a wrapper expression, so wrapping it in a function whose parameters
  are named setTimeout/... puts the shims in the guest's scope (bare
  references and guest `eval()` resolve to them) while the host keeps its
  real timers. `//# sourceURL` is preserved, so guest stack frames keep
  their CDN URLs.
- The shims schedule on real host timers and track handles per window in a
  WeakMap; `destroyDom` cancels them, preserving the original intent that
  pe-VM callbacks must not fire against a torn-down window.
- Handles are real Node timers, so `unref`/`refresh`/`_idleTimeout` exist
  in every case, including delay 0 and closed windows.
- The shims are masked as `[native code]` with matching name/arity and
  installed on the window with happy-dom's own descriptor shape. This is
  required, not cosmetic: the FeiLin risk engine sweeps platform APIs with
  Function.prototype.toString, and leaking closure source made the
  upstream reject every token with
  {"code":3007,"msg":"captcha verify failed"}. The previous
  `w.setTimeout.bind(w)` happened to satisfy this because bound functions
  always report [native code].
- `removeGlobalWindowAlias` restores saved descriptors instead of deleting,
  so host globals the window shadowed (atob, navigator, addEventListener,
  postMessage, WebSocket, ...) survive teardown. The console special case
  is now just one instance of this.
- requestIdleCallback/requestAnimationFrame polyfills use the tracked
  timers; they previously leaked bare host timers past window close.

Tests cover host-global identity across install/teardown, nested solves,
native-code masking (the 3007 regression), descriptor parity, guest timer
reaping, and a real node:http keep-alive round trip in the
closed-window-aliased state that reproduced the original TypeError.
@TriDefender

Copy link
Copy Markdown
Owner

疑似和main上的修复duplicate了

@TriDefender TriDefender left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Verdict

问题分析全部属实、测试自证质量很高(本地复跑:713 pass / 0 fail,tsc 干净),但不建议按当前架构合并——本 PR 与 10 分钟后合入 master 的 9affffa(makeDualTimers dual-dispatch)是同一崩溃的竞争修复,当前 CONFLICTING。建议改走最小充分组合:保留 master 的 dual-dispatch,只拆出本 PR 的通用描述符恢复 teardown(约 20–25 行),它修的是 9affffa 留下的一个仍然活着的缺陷,且与 dual-dispatch 完全正交、无冲突。

问题核对(我逐条验证过)

本 PR 列出的子问题 PR 基线 e93fb4e 今天 master (9affffa)
1. keep-alive timer.unref 崩溃 真实 已修(host lane 走原始计时器)
2. teardown delete 抹掉宿主全局 真实 仍然活着(见下)
3. 宿主计时器被 close() 连带取消(UI 冻结节根因) 真实 已修(host lane 不进窗口注册表)
4. guest 句柄缺 unref / 关闭窗口返回 {} 真实 guest lane 仍如此,但无消费者,良性

master 上活着的缺陷(本 PR 第 2 点的残留,实测复现)

在 master HEAD 上跑一轮 installGlobalWindowAlias → removeGlobalWindowAlias → happyDOM.close():

before: {"atob":"function","btoa":"function","navigator":"object","WebSocket":"function",...}
after:  {"atob":"undefined","btoa":"undefined","navigator":"undefined","WebSocket":"undefined",...}
atob() THROWS: atob is not defined

9affffa 只给 console + 4 个计时器做了描述符恢复,removeGlobalWindowAlias 的通用 delete 循环还在。而 src/proxy/client-signing.ts:100 (btoa) 和 :115(base64ToBytes 里的裸 atob)位于 Ed25519 握手/PoW 路径、无本地兜底——start-plan 进程只要跑过一次求解,之后的签名握手就会 ReferenceError,靠 fail-open 阶梯降级。另外 master 的 teardown 不删 __capWindowFor,残留 getter 指向已关闭窗口。

请求的改法(最小充分组合)

保留 dual-dispatch,拆出以下三件,其余(HOST_CRITICAL_GLOBALS 计时器条目、词法注入、makeGuestTimers/cancelGuestTimers 注册表、applyPolyfills 的窗口计时器替换)请撤下:

  1. 通用描述符恢复:把 _savedTimerDescriptors 特例推广为 _savedHostDescriptors: Map<string, PropertyDescriptor | undefined>,在 props 循环里对每个被遮蔽的属性先存后装(首次 install 时捕获即可,嵌套 refcount 场景用 has() 防重捕);
  2. teardown 改恢复不删除:遍历该 Map 恢复描述符,宿主从未拥有的属性才 delete;_savedTimerDescriptors 顶部捕获块随之可以删掉(props 循环里的捕获已覆盖计时器,且发生在 dual getter 覆写之前);_savedConsoleDescriptor 特例保留(console 在 HOST_CRITICAL_GLOBALS 里,不经过通用循环);
  3. delete g.__capWindowFor。

测试建议移植两个(在 master 架构下直接适用):"teardown restores shadowed host globals instead of deleting them"(atob 同一性恢复 + window-only 属性确实移除)和 "nested solves keep the alias until the last window tears down"。keep-alive 崩溃测试在 master 上已由 9affffa 的 alias-lifecycle 测试覆盖,guest-timer 系列测试属于词法架构、随架构一起撤。

关于词法注入架构(不合并 ≠ 否定)

"永不触碰宿主全局 + 词法作用域"在结构上优于 dual-dispatch:宿主路径零逻辑、无每次调用的栈捕获,尤其是不依赖 alicdn|aliyuncs 栈帧正则——CDN 域名轮换会让 dual dispatch 的 guest lane 静默失效,这个风险点是真实存在的。如果 maintainer 认可走这条路线,建议另开 PR:rebase 到当前 master、有意识地移除 makeDualTimers 并改写 9affffa 的 alias-lifecycle 测试,作为一个独立的架构决策来评审,而不是和 teardown 修复捆在一起。native-code 伪装部分(toString 扫描 → 3007)无论哪条路线都必须保留,感谢把这个坑用测试锁住。

@TriDefender

Copy link
Copy Markdown
Owner

其实问题主要是之前主线修过了,调整下仅PR teardown delete 抹掉宿主全局 这一部分就OK了

@TriDefender TriDefender self-assigned this Aug 31, 2026
@TriDefender

Copy link
Copy Markdown
Owner

因为长时间没有回复,并且已经补充了patch,所以关闭这个PR

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