Repository navigation
fix(captcha): stop aliasing host timers onto globalThis (fixes tui crash + UI freeze root cause) - #37
LX2000WASD wants to merge 1 commit into
Conversation
`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.
|
疑似和main上的修复duplicate了 |
TriDefender
left a comment
There was a problem hiding this comment.
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 的窗口计时器替换)请撤下:
- 通用描述符恢复:把
_savedTimerDescriptors特例推广为_savedHostDescriptors: Map<string, PropertyDescriptor | undefined>,在 props 循环里对每个被遮蔽的属性先存后装(首次 install 时捕获即可,嵌套 refcount 场景用has()防重捕); - teardown 改恢复不删除:遍历该 Map 恢复描述符,宿主从未拥有的属性才
delete;_savedTimerDescriptors顶部捕获块随之可以删掉(props 循环里的捕获已覆盖计时器,且发生在 dual getter 覆写之前);_savedConsoleDescriptor特例保留(console 在HOST_CRITICAL_GLOBALS里,不经过通用循环); 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)无论哪条路线都必须保留,感谢把这个坑用测试锁住。
|
其实问题主要是之前主线修过了,调整下仅PR teardown delete 抹掉宿主全局 这一部分就OK了 |
|
因为长时间没有回复,并且已经补充了patch,所以关闭这个PR |
问题
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():而 happy-dom 的
BrowserWindow.setTimeout:窗口closed后返回裸{}(BrowserWindow.js:1903),delay === 0时返回没有unref的Timeout。于是:触发条件在池并发求解下稳定存在:
destroyDom先happyDOM.close(),而removeGlobalWindowAlias在_aliasRefCount > 0时直接 return,所以宿主全局在一段时间内指向一个已关闭的窗口。TUI 的uncaughtException处理器会把它变成硬退出(tui crashed);serve模式下则是连接被丢。2. 拆除别名时把宿主全局删掉了
removeGlobalWindowAlias遍历Object.getOwnPropertyNames(w),凡是宿主上有 getter 的就delete。Bun 下这些是 own property,没有原型链兜底,所以进程直接失去setTimeout:实测被影子覆盖并会被删除的宿主全局有 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):一次求解结束把 pending 的 render timer 收走 →
renderNow永不执行 →renderTimer永远非 null → 之后每次scheduleRender()都被守卫挡掉 = 永久冻结。用 v4.5.2 的别名代码逐字复现:移除别名后:
4.5.2 的渲染看门狗能兜住症状,但根因还在,且同样会打到
captcha-pool的 refill interval、async/bridge的 keepalive、captcha-cpu-governor、OAuth 超时等所有在求解期间调度的计时器上 —— 那些没有看门狗,只会静默失效。改动
HOST_CRITICAL_GLOBALS,永不别名。window[PropertySymbol.evaluateScript]且传入的是包装表达式,所以在外面再套一层形参名为setTimeout/...的函数,guest 的裸引用和 guest 内eval()都解析到 shim,宿主全局不动。//# sourceURL保留在包装之后,guest 栈帧仍带 CDN URL。WeakMap,destroyDom显式回收 —— pe-VM 回调依然不会打到已销毁的窗口上。unref/refresh/_idleTimeout齐全,包括delay === 0和窗口已关闭两种原来返回残缺对象的情况。[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/requestAnimationFramepolyfill 改用受跟踪的计时器(原来直接setTimeout,会漏过窗口关闭)。验证
bun test:713 pass / 0 fail;bun x tsc --noEmit干净。node:httpkeep-alive 往返 —— 后者在打补丁前会精确复现上面那个TypeError。TypeError)。startServer+ 真的createDom/destroyDom并发 churn,660 请求 0 uncaught;同样场景下打补丁前会崩溃并把进程卡死。securityToken128 字符)。