diff --git a/source/units/Goccia.Values.FunctionBase.pas b/source/units/Goccia.Values.FunctionBase.pas index e3f0006fa..7e38cb9d3 100644 --- a/source/units/Goccia.Values.FunctionBase.pas +++ b/source/units/Goccia.Values.FunctionBase.pas @@ -521,6 +521,8 @@ function OrdinaryHasInstance(const AConstructor, AInstance: TGocciaValue): Boole ConstructorPrototype: TGocciaValue; CurrentObject: TGocciaObjectValue; CurrentPrototype: TGocciaValue; + Roots: TGocciaActiveRootFrame; + HopRoots: TGocciaActiveRootFrame; begin if not IsCallableForHasInstance(AConstructor) then Exit(False); @@ -541,18 +543,37 @@ function OrdinaryHasInstance(const AConstructor, AInstance: TGocciaValue): Boole ThrowTypeError('Function has non-object prototype', 'set the constructor prototype property to an object'); - CurrentObject := TGocciaObjectValue(AInstance); - while True do - begin - CurrentPrototype := GetPrototypeOfObject(CurrentObject); - if (CurrentPrototype = nil) or - (CurrentPrototype is TGocciaNullLiteralValue) then - Exit(False); - if CurrentPrototype = ConstructorPrototype then - Exit(True); - if not (CurrentPrototype is TGocciaObjectValue) then - Exit(False); - CurrentObject := TGocciaObjectValue(CurrentPrototype); + // GetPrototypeOfObject can invoke a proxy getPrototypeOf trap — guest code + // that may force a collection. The walked prototype is held only in a Pascal + // local across that trap, and DispatchProxyGetPrototype dereferences the + // proxy's internal target for the post-trap invariant check, so an + // intermediate prototype reachable only through this walk (e.g. a fresh proxy + // returned by an outer trap) would be swept before use. Root the target + // prototype for the whole walk and the current object across each hop. + Roots.Initialize; + Roots.Add(ConstructorPrototype); + try + CurrentObject := TGocciaObjectValue(AInstance); + while True do + begin + HopRoots.Initialize; + HopRoots.Add(CurrentObject); + try + CurrentPrototype := GetPrototypeOfObject(CurrentObject); + finally + HopRoots.Clear; + end; + if (CurrentPrototype = nil) or + (CurrentPrototype is TGocciaNullLiteralValue) then + Exit(False); + if CurrentPrototype = ConstructorPrototype then + Exit(True); + if not (CurrentPrototype is TGocciaObjectValue) then + Exit(False); + CurrentObject := TGocciaObjectValue(CurrentPrototype); + end; + finally + Roots.Clear; end; end; diff --git a/tests/language/expressions/instanceof/prototype-chain-gc-roots.js b/tests/language/expressions/instanceof/prototype-chain-gc-roots.js new file mode 100644 index 000000000..03a06b620 --- /dev/null +++ b/tests/language/expressions/instanceof/prototype-chain-gc-roots.js @@ -0,0 +1,87 @@ +/*--- +description: instanceof keeps the walked prototype reachable across a proxy getPrototypeOf trap +features: [Goccia.gc, Proxy, Reflect] +---*/ + +const hasGoccia = typeof Goccia !== "undefined"; + +const range = (n) => Array.from({ length: n }, (_, i) => i); + +// A bare gc() usually leaves a freed slot readable; allocating a run of objects +// of the same GC class as the collected temporary is what overwrites the slot +// and makes a dangling read observable. The walked prototype is a Proxy, so the +// churn allocates proxies. +const churn = () => { + Goccia.gc(); + Goccia.gc(); + let sink = null; + for (const i of range(200)) { + sink = new Proxy({ a: i, b: i + 1 }, { get(t, k) { return t[k]; } }); + } + return sink; +}; + +describe.runIf(hasGoccia)("instanceof prototype-chain GC roots", () => { + // OrdinaryHasInstance walks the instance's prototype chain with + // GetPrototypeOfObject, which invokes a proxy getPrototypeOf trap — guest code + // that can force a collection. An intermediate prototype reachable only through + // the walk (here, a fresh proxy returned by an outer trap) is held solely in + // the loop variable; DispatchProxyGetPrototype dereferences the proxy's + // internal target for its post-trap invariant check, so without a root the + // proxy is swept mid-walk and the invariant check reads freed memory. + test("an engine-only intermediate proxy survives its getPrototypeOf trap", () => { + let outerRuns = 0; + let innerRuns = 0; + class C {} + const obj = {}; + + Reflect.setPrototypeOf( + obj, + new Proxy( + {}, + { + getPrototypeOf() { + outerRuns += 1; + const innerTarget = Object.preventExtensions(Object.create(null)); + return new Proxy(innerTarget, { + getPrototypeOf(t) { + innerRuns += 1; + churn(); + return Reflect.getPrototypeOf(t); + }, + }); + }, + } + ) + ); + + const result = obj instanceof C; + + expect(outerRuns).toBe(1); + expect(innerRuns).toBe(1); + expect(result).toBe(false); + }); + + // The direct case: the walked proxy is orphaned by the trap that runs on it and + // must survive to the post-trap extensibility invariant check. + test("a directly walked proxy survives a collecting getPrototypeOf trap", () => { + let trapRuns = 0; + class C {} + const obj = {}; + const target = Object.preventExtensions(Object.create(null)); + + Reflect.setPrototypeOf( + obj, + new Proxy(target, { + getPrototypeOf(t) { + trapRuns += 1; + churn(); + return Reflect.getPrototypeOf(t); + }, + }) + ); + + expect(obj instanceof C).toBe(false); + expect(trapRuns).toBe(1); + }); +});