diff --git a/src/proxy/captcha-happy.test.ts b/src/proxy/captcha-happy.test.ts index be615d9..7ecb411 100644 --- a/src/proxy/captcha-happy.test.ts +++ b/src/proxy/captcha-happy.test.ts @@ -225,6 +225,25 @@ describe("alias lifecycle (install/remove descriptor contract)", () => { expect(g.navigator).toBe(hostNavigator); }); + test("an expired tombstone leaves callable stubs, never a ReferenceError", async () => { + // The grace period ends, but guest stragglers can still be running: the + // FeiLin heartbeat `tE` re-arms itself every 2s and dereferences `moveBy` + // (feilin008.js:169658). A hard `delete` turned that read into an + // uncaught ReferenceError — fatal in the host realm, and the field crash + // this guards. The name must stay resolvable and callable. + const { g, w } = makeSandbox(); + (w as Record).moveBy = () => {}; + installGlobalWindowAlias(g, w, 15); + removeGlobalWindowAlias(g, w); + // Real elapsed time is the mechanism under test: the tombstone is a + // wall-clock grace period armed on the pristine host setTimeout. + await Bun.sleep(70); + expect(typeof g.moveBy).toBe("function"); + expect(() => (g.moveBy as () => void)()).not.toThrow(); + // The stub must not keep the closed window reachable from globalThis. + expect(g.moveBy).not.toBe((w as Record).moveBy); + }); + test("a new install cancels the pending tombstone", async () => { const { g, w } = makeSandbox(); const w2: Record = { diff --git a/src/proxy/captcha-happy.ts b/src/proxy/captcha-happy.ts index c82f300..79e8a18 100644 --- a/src/proxy/captcha-happy.ts +++ b/src/proxy/captcha-happy.ts @@ -1807,6 +1807,12 @@ const EXTRA_WINDOW_PROPS = [ "prompt", "getSelection", "find", ]; +// Subset of the above that a real browser implements as no-op-ish window +// methods. When the tombstone expires these become harmless stubs instead of +// being deleted, so a straggling guest callback that still calls `moveBy()` +// completes silently rather than raising a fatal ReferenceError. +const INERT_WINDOW_METHODS = new Set(EXTRA_WINDOW_PROPS); + // Ref-count: the pool solves in parallel waves; each window must keep the // aliases alive until the LAST concurrent window is destroyed, otherwise one // destroyDom() pulls `window` out from under a sibling mid-solve. @@ -2124,7 +2130,19 @@ export function removeGlobalWindowAlias(g, w) { if (generation !== _aliasGeneration || _aliasRefCount > 0) return; for (const name of names) { try { - if (Object.getOwnPropertyDescriptor(g, name)?.get) delete g[name]; + if (!Object.getOwnPropertyDescriptor(g, name)?.get) continue; + // Do NOT `delete`: a straggler still reading the name would get a + // ReferenceError, which is fatal in the host realm. Leave an inert + // value instead — the reference resolves, the call is a no-op, and + // nothing keeps the closed window alive. The window methods guest + // fingerprint code probes (moveBy/scrollTo/...) are no-ops in a real + // browser anyway, so `undefined` is a faithful stand-in for the rest. + const stub = INERT_WINDOW_METHODS.has(name) ? () => {} : undefined; + Object.defineProperty(g, name, { + value: stub, + configurable: true, + writable: true, + }); } catch (_) {} } }, _tombstoneMs);