Skip to content

Commit 9affffa

Browse files
committed
Fix: Updated to bun 1.4.0
Fixed bun 1.4.0 issues Pinned CI and build to bun 1.4.0
1 parent e93fb4e commit 9affffa

4 files changed

Lines changed: 268 additions & 14 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ jobs:
2828

2929
- uses: oven-sh/setup-bun@v2
3030
with:
31-
bun-version: "1"
31+
# PINNED: a floating "latest" silently drifted the release runtime
32+
# away from local dev (v4.5.2 exe = Bun 1.4.0 keep-alive timer
33+
# semantics local 1.3.14 never exercised — see AGENTS.md #37).
34+
# Bump deliberately, together with local dev environments.
35+
bun-version: "1.4.0"
3236

3337
- name: Install dependencies
3438
run: bun install --frozen-lockfile

‎.github/workflows/release.yml‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ jobs:
5454
- uses: actions/checkout@v7
5555
- uses: oven-sh/setup-bun@v2
5656
with:
57-
bun-version: "1"
57+
# PINNED (keep all 3 jobs + ci.yml in sync): the release runtime
58+
# must not drift from dev/CI — a floating "latest" shipped v4.5.2
59+
# on Bun 1.4.0 semantics local 1.3.14 never exercised (AGENTS.md #37).
60+
bun-version: "1.4.0"
5861
- name: Install dependencies
5962
run: bun install --frozen-lockfile
6063
- name: Type check
@@ -83,7 +86,7 @@ jobs:
8386
- uses: actions/checkout@v7
8487
- uses: oven-sh/setup-bun@v2
8588
with:
86-
bun-version: "1"
89+
bun-version: "1.4.0" # pinned — see verify job comment
8790
- name: Install dependencies
8891
run: bun install --frozen-lockfile
8992
- name: Build binary
@@ -105,7 +108,7 @@ jobs:
105108
- uses: actions/checkout@v7
106109
- uses: oven-sh/setup-bun@v2
107110
with:
108-
bun-version: "1"
111+
bun-version: "1.4.0" # pinned — see verify job comment
109112
- name: Set up JDK 17
110113
uses: actions/setup-java@v5
111114
with:

‎src/proxy/captcha-happy.test.ts‎

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, test } from "bun:test";
2-
import { makeDualConsole } from "./captcha-happy.js";
2+
import {
3+
installGlobalWindowAlias,
4+
makeDualConsole,
5+
makeDualTimers,
6+
removeGlobalWindowAlias,
7+
} from "./captcha-happy.js";
38

49
describe("dual console (guest spam guard)", () => {
510
test("drops every console method's call while the guest predicate holds", () => {
@@ -34,3 +39,136 @@ describe("dual console (guest spam guard)", () => {
3439
}
3540
});
3641
});
42+
43+
describe("dual timers (host unref contract vs guest window registry)", () => {
44+
type Fn = (...args: unknown[]) => unknown;
45+
46+
function makeFixture() {
47+
const hostCalls: string[] = [];
48+
const winCalls: string[] = [];
49+
const hostTimer = { unref() {}, ref() {} };
50+
const host: Record<string, Fn> = {
51+
setTimeout: (...a: unknown[]) => { hostCalls.push("setTimeout:" + a[1]); return hostTimer; },
52+
setInterval: (...a: unknown[]) => { hostCalls.push("setInterval:" + a[1]); return hostTimer; },
53+
clearTimeout: (...a: unknown[]) => { hostCalls.push("clearTimeout"); },
54+
clearInterval: (...a: unknown[]) => { hostCalls.push("clearInterval"); },
55+
};
56+
const win: Record<string, unknown> = {
57+
setTimeout: (...a: unknown[]) => { winCalls.push("setTimeout:" + a[1]); return 0; },
58+
setInterval: (...a: unknown[]) => { winCalls.push("setInterval:" + a[1]); return 0; },
59+
clearTimeout: (...a: unknown[]) => { winCalls.push("clearTimeout"); },
60+
clearInterval: (...a: unknown[]) => { winCalls.push("clearInterval"); },
61+
};
62+
return { host, win, hostCalls, winCalls, hostTimer };
63+
}
64+
65+
test("host-stack calls keep the host timer objects (the .unref() contract)", () => {
66+
const { host, win, hostCalls, winCalls, hostTimer } = makeFixture();
67+
const dual = makeDualTimers(win, host, () => false);
68+
const t = dual.setTimeout(() => {}, 500) as { unref(): void };
69+
expect(hostCalls).toEqual(["setTimeout:500"]);
70+
expect(winCalls).toEqual([]);
71+
expect(t).toBe(hostTimer);
72+
expect(typeof t.unref).toBe("function");
73+
dual.clearTimeout(t);
74+
expect(hostCalls).toEqual(["setTimeout:500", "clearTimeout"]);
75+
});
76+
77+
test("guest-stack calls land on the window registry, reading it live", () => {
78+
const { host, win, hostCalls, winCalls } = makeFixture();
79+
const dual = makeDualTimers(win, host, () => true);
80+
expect(dual.setTimeout(() => {}, 16)).toBe(0);
81+
// Guest hooks of window.setTimeout apply to guest callers (pe-VM
82+
// scheduler behavior), including hooks installed AFTER the dual was
83+
// built — the window function is resolved per call.
84+
win.setTimeout = () => 42;
85+
expect(dual.setTimeout(() => {}, 1)).toBe(42);
86+
expect(hostCalls).toEqual([]);
87+
expect(winCalls).toEqual(["setTimeout:16"]);
88+
});
89+
90+
test("the crash shape: a window timer without unref never reaches host callers", () => {
91+
const { host, win } = makeFixture();
92+
// Simulate the v4.5.2 field crash preconditions: the pe-VM hooks the
93+
// window's setTimeout into a wrapper that loses the handle, and Bun's
94+
// node:_http_server finishes a response and arms its keep-alive timer
95+
// through the aliased global setTimeout, then calls .unref() on the
96+
// result.
97+
win.setTimeout = (() => 0) as unknown;
98+
const dual = makeDualTimers(win, host, () => false);
99+
const timer = dual.setTimeout(() => {}, 5000) as { unref?: () => void };
100+
expect(typeof timer?.unref).toBe("function");
101+
expect(() => timer.unref!()).not.toThrow();
102+
});
103+
104+
test("dispatchers look native to guest toString sweeps", () => {
105+
const { host, win } = makeFixture();
106+
const dual = makeDualTimers(win, host, () => false);
107+
expect(dual.setTimeout.name).toBe("setTimeout");
108+
expect(String(dual.setTimeout)).toBe("function setTimeout() { [native code] }");
109+
expect(String(dual.clearInterval)).toBe("function clearInterval() { [native code] }");
110+
});
111+
});
112+
113+
describe("alias lifecycle (install/remove descriptor contract)", () => {
114+
const TIMER_PROPS = ["setTimeout", "setInterval", "clearTimeout", "clearInterval"];
115+
116+
function makeSandbox() {
117+
// Sentinel the pristine host fns return — the dual's host lane must
118+
// surface it untouched even when the window's timers are hostile.
119+
const pristine = { marker: "pristine-host-timer" };
120+
const g: Record<string, unknown> = {};
121+
for (const p of TIMER_PROPS) {
122+
g[p] = () => pristine;
123+
}
124+
// Window stub whose timers are hooked/lossy (pe-VM scheduler shape).
125+
const w: Record<string, unknown> = {
126+
setTimeout: () => 0,
127+
setInterval: () => 0,
128+
clearTimeout: () => {},
129+
clearInterval: () => {},
130+
};
131+
return { g, w, pristine };
132+
}
133+
134+
test("host lane serves the PRISTINE timers, not the generic-alias getters (capture-order regression)", () => {
135+
const { g, w, pristine } = makeSandbox();
136+
installGlobalWindowAlias(g, w);
137+
// A host-stack call (this test's stack has no guest CDN frames) through
138+
// the aliased global must return the pristine fn's return value. Under
139+
// the capture-order bug the host lane fell back to g[prop], which the
140+
// generic props loop had already turned into a window-forwarding
141+
// accessor — this would read 0 from the hooked window stub instead.
142+
const hostLane = g.setTimeout as (...a: unknown[]) => unknown;
143+
expect(hostLane(() => {}, 1)).toBe(pristine);
144+
removeGlobalWindowAlias(g, w);
145+
});
146+
147+
test("remove restores the pre-install descriptors byte-identically", () => {
148+
const { g, w } = makeSandbox();
149+
const before = Object.fromEntries(
150+
TIMER_PROPS.map((p) => [p, Object.getOwnPropertyDescriptor(g, p)]),
151+
);
152+
installGlobalWindowAlias(g, w);
153+
expect(Object.getOwnPropertyDescriptor(g, "setTimeout")?.get).toBeDefined();
154+
removeGlobalWindowAlias(g, w);
155+
for (const p of TIMER_PROPS) {
156+
expect(Object.getOwnPropertyDescriptor(g, p)).toEqual(before[p]);
157+
expect(g[p]).toBe(before[p]!.value);
158+
}
159+
});
160+
161+
test("concurrent installs restore exactly once, on the last remove", () => {
162+
const { g } = makeSandbox();
163+
const w1: Record<string, unknown> = { setTimeout: () => 1, setInterval: () => 1, clearTimeout: () => {}, clearInterval: () => {} };
164+
const w2: Record<string, unknown> = { setTimeout: () => 2, setInterval: () => 2, clearTimeout: () => {}, clearInterval: () => {} };
165+
const orig = g.setTimeout;
166+
installGlobalWindowAlias(g, w1);
167+
installGlobalWindowAlias(g, w2);
168+
removeGlobalWindowAlias(g, w1); // refcount 2→1: no restore yet
169+
expect(typeof Object.getOwnPropertyDescriptor(g, "setTimeout")?.get).toBe("function");
170+
expect(g.setTimeout).not.toBe(orig);
171+
removeGlobalWindowAlias(g, w2); // refcount 1→0: restore
172+
expect(g.setTimeout).toBe(orig);
173+
});
174+
});

‎src/proxy/captcha-happy.ts‎

Lines changed: 118 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -924,8 +924,11 @@ function applyPolyfills(w) {
924924
interval = 16;
925925
};
926926

927-
w.requestIdleCallback = w.requestIdleCallback || ((cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 10 }), 1));
928-
w.cancelIdleCallback = w.cancelIdleCallback || ((id) => clearTimeout(id));
927+
// Window-registry timers explicitly: these callbacks only touch the window
928+
// and must die with it (they'd otherwise survive destroyDom via the dual
929+
// dispatcher's host lane).
930+
w.requestIdleCallback = w.requestIdleCallback || ((cb) => w.setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 10 }), 1));
931+
w.cancelIdleCallback = w.cancelIdleCallback || ((id) => w.clearTimeout(id));
929932

930933
w.matchMedia =
931934
w.matchMedia ||
@@ -1216,8 +1219,8 @@ function applyPolyfills(w) {
12161219
}
12171220
};
12181221

1219-
w.requestAnimationFrame = w.requestAnimationFrame || ((cb) => setTimeout(() => cb(Date.now()), 16));
1220-
w.cancelAnimationFrame = w.cancelAnimationFrame || ((id) => clearTimeout(id));
1222+
w.requestAnimationFrame = w.requestAnimationFrame || ((cb) => w.setTimeout(() => cb(Date.now()), 16));
1223+
w.cancelAnimationFrame = w.cancelAnimationFrame || ((id) => w.clearTimeout(id));
12211224

12221225
try {
12231226
Object.defineProperty(w.document, "hidden", { value: false, configurable: true });
@@ -1541,7 +1544,9 @@ function simulateBehavior(w, durationMs = 600) {
15411544
i += 1;
15421545
const done = Date.now() - start >= durationMs;
15431546
if (i <= steps && !done) {
1544-
setTimeout(moveStep, 26 + Math.floor(Math.random() * 32));
1547+
// Window-registry timer: the drag chain only touches the window and
1548+
// must die with it, not ride the dual dispatcher's host lane.
1549+
w.setTimeout(moveStep, 26 + Math.floor(Math.random() * 32));
15451550
} else {
15461551
fire("mousedown", MouseEvent, { clientX: Math.round(x), clientY: Math.round(y), button: 0, buttons: 1 });
15471552
fire("mouseup", MouseEvent, { clientX: Math.round(x), clientY: Math.round(y), button: 0, buttons: 0 });
@@ -1819,6 +1824,16 @@ let _aliasRefCount = 0;
18191824
let _savedConsoleDescriptor: PropertyDescriptor | undefined;
18201825
let _dualConsole: Record<string, unknown> | null = null;
18211826

1827+
// Same save/restore contract as the console descriptor, for the four global
1828+
// timers that the alias pass replaces with dual dispatchers (see
1829+
// makeDualTimers). Captured on the FIRST install (globals are pristine then),
1830+
// restored on the last destroyDom.
1831+
let _savedTimerDescriptors: Record<string, PropertyDescriptor> | undefined;
1832+
1833+
const TIMER_PROPS = ["setTimeout", "setInterval", "clearTimeout", "clearInterval"];
1834+
1835+
// Guest-stack detector shared by the dual console and dual timers: guest
1836+
// script frames carry their CDN source URL (o/g.alicdn.com, aliyuncs APIs).
18221837
function isGuestConsoleCall(): boolean {
18231838
const stack = new Error().stack ?? "";
18241839
return /alicdn\.com|aliyuncs\.com/i.test(stack);
@@ -1845,7 +1860,58 @@ export function makeDualConsole(isGuest: () => boolean = isGuestConsoleCall): Re
18451860
return dual;
18461861
}
18471862

1848-
function installGlobalWindowAlias(g, w) {
1863+
function isGuestTimerCall(): boolean {
1864+
// Same evidence as the dual console — one shared predicate shape.
1865+
return isGuestConsoleCall();
1866+
}
1867+
1868+
/**
1869+
* Build dual setTimeout/setInterval/clearTimeout/clearInterval bound to one
1870+
* solve's window (test seam): calls whose stack originates from guest script
1871+
* URLs land on the window's timer registry (die with the window, so pe-VM
1872+
* callbacks can't outlive destroyDom), everything else — Bun internals like
1873+
* node:_http_server's keep-alive arming, our own modules — keeps the real
1874+
* host timer objects and their ref/unref contract. The window's function is
1875+
* read LIVE at call time so guest-side hooks of window.setTimeout still apply
1876+
* to guest callers; the host functions are captured once, immune to hooks.
1877+
*/
1878+
export function makeDualTimers(
1879+
win: Record<string, unknown>,
1880+
host: Record<string, (...args: unknown[]) => unknown>,
1881+
isGuest: () => boolean = isGuestTimerCall,
1882+
): Record<string, (...args: unknown[]) => unknown> {
1883+
const dual: Record<string, (...args: unknown[]) => unknown> = {};
1884+
for (const prop of TIMER_PROPS) {
1885+
const fn = function (...args: unknown[]) {
1886+
let guest = false;
1887+
try {
1888+
guest = isGuest();
1889+
} catch (_) {}
1890+
if (guest) {
1891+
const wfn = win[prop];
1892+
return typeof wfn === "function" ? wfn.apply(win, args) : undefined;
1893+
}
1894+
return host[prop]?.apply(undefined, args);
1895+
};
1896+
// Look native to guest toString sweeps (same masking technique as
1897+
// installNativeToString): real browsers expose these as native code.
1898+
try {
1899+
Object.defineProperty(fn, "name", { value: prop, configurable: true });
1900+
const nativeStr = `function ${prop}() { [native code] }`;
1901+
Object.defineProperty(fn, "toString", {
1902+
value: () => nativeStr,
1903+
configurable: true,
1904+
writable: true,
1905+
});
1906+
} catch (_) {}
1907+
dual[prop] = fn;
1908+
}
1909+
return dual;
1910+
}
1911+
1912+
// Both take (g, w) explicitly so tests can drive the lifecycle against a
1913+
// sandbox global (same test-seam pattern as makeDualConsole/makeDualTimers).
1914+
export function installGlobalWindowAlias(g, w) {
18491915
_aliasRefCount += 1;
18501916
if (_aliasRefCount === 1 && !process.env.CAPTCHA_DEBUG) {
18511917
_dualConsole = makeDualConsole(); _savedConsoleDescriptor = Object.getOwnPropertyDescriptor(g, "console");
@@ -1861,6 +1927,19 @@ function installGlobalWindowAlias(g, w) {
18611927
});
18621928
} catch (_) {}
18631929
}
1930+
// Capture the pristine timer descriptors BEFORE any aliasing: the generic
1931+
// props loop below aliases the timers as window-forwarding accessors (they
1932+
// are own props of GlobalWindow and not in HOST_CRITICAL_GLOBALS), so a
1933+
// capture taken after it would save those accessors — the dual's host lane
1934+
// would then dispatch into the window, destroyDom's happyDOM.close() would
1935+
// cancel host-lane timers, and the post-remove restore would leave the
1936+
// globals pointing at a closed window (the v4.5.2 unref crash reborn).
1937+
if (_aliasRefCount === 1 && !_savedTimerDescriptors) {
1938+
_savedTimerDescriptors = {};
1939+
for (const prop of TIMER_PROPS) {
1940+
_savedTimerDescriptors[prop] = Object.getOwnPropertyDescriptor(g, prop);
1941+
}
1942+
}
18641943
const props = new Set(Object.getOwnPropertyNames(w));
18651944
for (const name of EXTRA_WINDOW_PROPS) props.add(name);
18661945
// also walk the prototype chain one level (BrowserWindow getters like
@@ -1892,10 +1971,31 @@ function installGlobalWindowAlias(g, w) {
18921971
// Guest timers must live on the window's timer registry (destroyed with
18931972
// the window). The host's setTimeout would let pe-VM callbacks fire after
18941973
// destroyDom, when the `window` alias is gone ("window is not defined").
1895-
for (const prop of ["setTimeout", "setInterval", "clearTimeout", "clearInterval"]) {
1974+
// BUT a bare getter onto w's timers hijacks the HOST's global timers for
1975+
// the whole solve: Bun ≥1.4 arms its node:_http_server keep-alive socket
1976+
// timer through the bare global setTimeout and calls .unref() on the
1977+
// result, and a guest-side hook of window.setTimeout (pe-VM scheduler) or
1978+
// the post-close window stub returns a value without .unref — an uncaught
1979+
// TypeError that kills the whole proxy (v4.5.2 field crash, Bun v1.4.0
1980+
// binaries). So the four globals become DUAL dispatchers instead: calls
1981+
// whose stack originates from guest script URLs land on the window
1982+
// registry (same evidence base as the dual console), everything else
1983+
// keeps the real host timer objects and their ref/unref contract.
1984+
// (Descriptors were captured at the TOP of this function — see there.)
1985+
const hostTimers: Record<string, (...args: unknown[]) => unknown> = {};
1986+
for (const prop of TIMER_PROPS) {
1987+
hostTimers[prop] = _savedTimerDescriptors?.[prop]?.value ?? g[prop];
1988+
}
1989+
const dualTimers = makeDualTimers(w, hostTimers);
1990+
for (const prop of TIMER_PROPS) {
18961991
try {
18971992
Object.defineProperty(g, prop, {
1898-
get() { return w[prop]?.bind(w); },
1993+
get() { return dualTimers[prop]; },
1994+
// Writes through the alias (`setTimeout = x`, guest or host alike)
1995+
// land on the window like a real browser — they must NOT clobber
1996+
// the dispatcher itself; host callers always get the captured
1997+
// pristine functions regardless.
1998+
set(v) { try { w[prop] = v; } catch (_) {} },
18991999
configurable: true,
19002000
});
19012001
} catch (_) {}
@@ -1910,7 +2010,7 @@ function installGlobalWindowAlias(g, w) {
19102010
});
19112011
} catch (_) {}
19122012
}
1913-
function removeGlobalWindowAlias(g, w) {
2013+
export function removeGlobalWindowAlias(g, w) {
19142014
_aliasRefCount -= 1;
19152015
if (_aliasRefCount > 0) return;
19162016
for (const name of Object.getOwnPropertyNames(w)) {
@@ -1932,6 +2032,15 @@ function removeGlobalWindowAlias(g, w) {
19322032
_savedConsoleDescriptor = undefined;
19332033
_dualConsole = null;
19342034
}
2035+
// Restore the pre-alias timer descriptors — a dual dispatcher left behind
2036+
// (bound to a closed window whose setTimeout turns into a stub) would keep
2037+
// routing host calls into it and reopen the unref crash window.
2038+
if (_savedTimerDescriptors) {
2039+
for (const [name, desc] of Object.entries(_savedTimerDescriptors)) {
2040+
try { Object.defineProperty(g, name, desc); } catch (_) {}
2041+
}
2042+
_savedTimerDescriptors = undefined;
2043+
}
19352044
}
19362045

19372046
function destroyDom(win) {

0 commit comments

Comments
 (0)