Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/proxy/captcha-happy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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<string, unknown>).moveBy);
});

test("a new install cancels the pending tombstone", async () => {
const { g, w } = makeSandbox();
const w2: Record<string, unknown> = {
Expand Down
20 changes: 19 additions & 1 deletion src/proxy/captcha-happy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down