@@ -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;
18191824let _savedConsoleDescriptor : PropertyDescriptor | undefined ;
18201825let _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).
18221837function isGuestConsoleCall ( ) : boolean {
18231838 const stack = new Error ( ) . stack ?? "" ;
18241839 return / a l i c d n \. c o m | a l i y u n c s \. c o m / 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
19372046function destroyDom ( win ) {
0 commit comments