From 45c2e6f582ebb41e43da48b6ca89b1486ad130b3 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sat, 14 Nov 2026 16:28:01 -0300 Subject: [PATCH 1/2] fix(runtime): make this.super aliases resolve their receiver's wrapper at use `this.super` on an extend() class returned an object whose internal field 0 was a copy of the receiver's External, a raw ObjCDataWrapper pointer. The alias held no reference to its receiver and nothing told it when that wrapper was deleted, so an alias JS kept longer than the wrapper read freed memory: after the receiver was collected, after __releaseNativeCounterpart(receiver), or when saved from a JS method run by -dealloc (whose borrowed wrapper is deleted when the call returns). Under AddressSanitizer this is a heap-use-after-free in ArgConverter::Invoke. The alias now holds the receiver itself in internal field 0 (a strong JS reference, so the receiver lives at least as long as the alias), and internal field 1 is still the string "super" that identifies an alias. GetValue follows an alias to its receiver only when field 0 is not an External, so the common path does no extra work. A receiver whose wrapper was released or detached reads as released through the alias, with the existing released-object error. - The accessor resolves a Proxy receiver to its target, and an alias receiver to that alias's receiver, so an alias never points at another alias. A receiver that is not a native instance throws a TypeError instead of failing a V8 internal-field check. - __releaseNativeCounterpart(alias) releases the receiver, exactly as __releaseNativeCounterpart(receiver) does; the alias is never written to. - ObjectManager::DisposeValue skips aliases on final disposal too: an alias owns no wrapper, and disposing it at isolate teardown freed the receiver's. Fixes #568 --- NativeScript/runtime/ClassBuilder.mm | 16 +- NativeScript/runtime/Helpers.h | 6 + NativeScript/runtime/Helpers.mm | 21 ++- NativeScript/runtime/ObjectManager.mm | 14 +- TestRunner/app/tests/SuperAliasTests.js | 190 ++++++++++++++++++++++++ TestRunner/app/tests/index.js | 1 + 6 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 TestRunner/app/tests/SuperAliasTests.js diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 3baac770..59312dc7 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -1002,7 +1002,19 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { void ClassBuilder::SuperAccessorGetterCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); Local context = isolate->GetCurrentContext(); - Local thiz = info.This(); + Local target = tns::UnwrapProxy(info.This()); + if (target.IsEmpty() || !target->IsObject() || target.As()->InternalFieldCount() < 1) { + isolate->ThrowException(Exception::TypeError(tns::ToV8String( + isolate, "Cannot read 'super' of an object that is not a native instance"))); + return; + } + Local thiz = target.As(); + // Field 0 holds the object that carries the wrapper, never another alias, + // so resolving an alias takes one step. + Local receiver = tns::SuperAliasReceiver(thiz); + if (receiver.IsEmpty()) { + receiver = thiz; + } std::shared_ptr> poValue = ArgConverter::CreateEmptyObject(context); Local superValue = poValue->Get(isolate).As(); @@ -1011,7 +1023,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { ->SetPrototype(context, thiz->GetPrototype().As()->GetPrototype().As()->GetPrototype()) .ToChecked(); - superValue->SetInternalField(0, thiz->GetInternalField(0)); + superValue->SetInternalField(0, receiver); superValue->SetInternalField(1, tns::ToV8String(isolate, "super")); info.GetReturnValue().Set(superValue); diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index d4fd42b8..d0a2a42d 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -293,6 +293,12 @@ v8::Local GetCreationContextOrCurrent(v8::Isolate* isolate, void SetValue(v8::Isolate* isolate, const v8::Local& obj, BaseDataWrapper* value); BaseDataWrapper* GetValue(v8::Isolate* isolate, const v8::Local& val); +// The receiver a `this.super` alias (ClassBuilder::SuperAccessorGetterCallback) +// stands for, or empty when obj is not one. An alias keeps its receiver in +// internal field 0 and the string "super" in internal field 1; it owns no +// wrapper, so GetValue reads the receiver's at every use. +v8::Local SuperAliasReceiver(const v8::Local& obj); + // What happens when JS touches a wrapper whose native counterpart has already // been released (its internal field was neutered by DisposeValue) — a state a // resurrected object can expose by outliving a referenced object's native diff --git a/NativeScript/runtime/Helpers.mm b/NativeScript/runtime/Helpers.mm index 41d5361b..8d9e8505 100644 --- a/NativeScript/runtime/Helpers.mm +++ b/NativeScript/runtime/Helpers.mm @@ -305,6 +305,21 @@ throw NativeScriptException( } } +Local tns::SuperAliasReceiver(const Local& obj) { + if (obj->InternalFieldCount() < 2) { + return Local(); + } + Local marker = obj->GetInternalField(1).As(); + if (marker.IsEmpty() || !marker->IsString()) { + return Local(); + } + Local receiver = obj->GetInternalField(0).As(); + if (receiver.IsEmpty() || !receiver->IsObject() || receiver->IsExternal()) { + return Local(); + } + return receiver.As(); +} + tns::BaseDataWrapper* tns::GetValue(Isolate* isolate, const Local& val) { if (val.IsEmpty() || val->IsNullOrUndefined() || !val->IsObject()) { return nullptr; @@ -320,9 +335,13 @@ throw NativeScriptException( } if (obj->InternalFieldCount() > 0) { Local field = obj->GetInternalField(0).As(); - if (field.IsEmpty() || field->IsNullOrUndefined() || !field->IsExternal()) { + if (field.IsEmpty() || field->IsNullOrUndefined()) { return nullptr; } + if (!field->IsExternal()) { + Local receiver = SuperAliasReceiver(obj); + return receiver.IsEmpty() ? nullptr : GetValue(isolate, receiver); + } return static_cast( field.As()->Value(v8::kExternalPointerTypeTagDefault)); diff --git a/NativeScript/runtime/ObjectManager.mm b/NativeScript/runtime/ObjectManager.mm index 793612cd..a433c64c 100644 --- a/NativeScript/runtime/ObjectManager.mm +++ b/NativeScript/runtime/ObjectManager.mm @@ -271,10 +271,11 @@ void DisposeHandle(v8::Isolate* isolate, } Local obj = value.As(); - if (obj->InternalFieldCount() > 1 && !isFinalDisposal) { + if (obj->InternalFieldCount() > 1) { Local superValue = obj->GetInternalField(1).As(); if (!superValue.IsEmpty() && superValue->IsString()) { - // Do not dispose the ObjCWrapper contained in a "super" instance + // A "super" alias owns no wrapper; the one GetValue finds through it is + // its receiver's. return true; } } @@ -431,8 +432,15 @@ void DisposeHandle(v8::Isolate* isolate, } // The lookup, the Instances key and the final SetValue must all address the - // same object, so a Proxy is resolved once here. + // same object, so a Proxy is resolved once here, and a "super" alias to the + // receiver whose wrapper it reads. Local value = tns::UnwrapProxy(info[0]); + if (!value.IsEmpty() && value->IsObject()) { + Local receiver = tns::SuperAliasReceiver(value.As()); + if (!receiver.IsEmpty()) { + value = receiver; + } + } BaseDataWrapper* wrapper = tns::GetValue(isolate, value); if (wrapper == nullptr) { diff --git a/TestRunner/app/tests/SuperAliasTests.js b/TestRunner/app/tests/SuperAliasTests.js new file mode 100644 index 00000000..6b4ab0c2 --- /dev/null +++ b/TestRunner/app/tests/SuperAliasTests.js @@ -0,0 +1,190 @@ +// `this.super` on an extend() class: an alias that dispatches to the +// superclass implementation and reads its receiver's native object, never a +// copy of it. +describe("this.super aliases", function () { + function scrub(n) { + return n > 0 ? scrub(n - 1) + n : 0; + } + + // Collect, then give autorelease pools and posted retain/release work a + // turn of the run loop. + function settle(rounds, callback) { + scrub(300); + __collect(); + __collect(); + setTimeout(function () { + NSObject.new(); + if (rounds > 1) { + settle(rounds - 1, callback); + } else { + callback(); + } + }, 10); + } + + function deallocs() { + return (TNSGetOutput().match(/TNSAllocLog dealloc/g) || []).length; + } + + function expectReleased(obj) { + var value; + var error = null; + try { + value = obj.hash; + } catch (e) { + error = e; + } + if (error !== null) { + expect(error.message).toContain("disposed native object"); + } else { + expect(value).toBeUndefined(); + } + } + + var superInits = 0; + var SuperAliasLog = TNSAllocLog.extend({ + init: function () { + var self = this.super.init(); + superInits++; + return self; + }, + get description() { + return "js:" + this.super.description; + } + }, { name: "SuperAliasLog" }); + + // Objects earlier specs dropped are collected first, so the dealloc log + // only counts this spec's objects. + beforeEach(function (done) { + settle(2, function () { + TNSClearOutput(); + superInits = 0; + done(); + }); + }); + + it("dispatches initializers, methods and properties to the superclass", function (done) { + (function () { + var constructed = new SuperAliasLog(); + var created = SuperAliasLog.new(); + expect(superInits).toBe(2); + [constructed, created].forEach(function (obj) { + expect(obj instanceof SuperAliasLog).toBe(true); + expect(obj.description).toMatch(/^js:$/); + expect(obj.super.description).toMatch(/^$/); + expect(obj.super.hash).toBe(obj.hash); + expect(obj.super.isEqual(obj)).toBe(true); + }); + })(); + settle(3, function () { + expect(deallocs()).toBe(2); + done(); + }); + }); + + it("reports an alias as released once its receiver was released with __releaseNativeCounterpart", function (done) { + var obj = SuperAliasLog.new(); + var alias = obj.super; + expect(alias.hash).toBe(obj.hash); + __releaseNativeCounterpart(obj); + settle(3, function () { + expect(deallocs()).toBe(1); + expectReleased(alias); + expectReleased(obj); + obj = null; + alias = null; + settle(3, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + }); + + it("keeps its receiver alive while the alias is reachable, then frees it once", function (done) { + var watch = NSHashTable.weakObjectsHashTable(); + var alias = null; + var hash = 0; + (function () { + var obj = SuperAliasLog.new(); + hash = obj.hash; + watch.addObject(obj); + alias = obj.super; + })(); + settle(4, function () { + expect(deallocs()).toBe(0); + expect(watch.allObjects.count).toBe(1); + expect(alias.hash).toBe(hash); + settle(2, function () { + expect(deallocs()).toBe(0); + expect(alias.description).toMatch(/^$/); + alias = null; + settle(4, function () { + expect(deallocs()).toBe(1); + expect(watch.allObjects.count).toBe(0); + settle(2, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + }); + }); + }); + + it("reports an alias saved from a JS method run by -dealloc as released", function (done) { + var saved = null; + var Keeper = TNSApi.extend({ + methodCalledInDealloc: function () { + saved = this.super; + } + }, { name: "SuperAliasDeallocKeeper" }); + (function () { + var holder = NSMutableArray.alloc().init(); + holder.addObject(Keeper.alloc().init()); + })(); + settle(4, function () { + expect(saved).not.toBeNull(); + expectReleased(saved); + saved = null; + done(); + }); + }); + + it("releases the receiver when __releaseNativeCounterpart is given an alias", function (done) { + var obj = SuperAliasLog.new(); + var alias = obj.super; + __releaseNativeCounterpart(alias); + settle(3, function () { + expect(deallocs()).toBe(1); + expectReleased(obj); + expectReleased(alias); + expectReleased(obj.super); + obj = null; + alias = null; + settle(3, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + }); + + it("resolves the alias of a Proxy over the receiver to the receiver", function (done) { + (function () { + var obj = SuperAliasLog.new(); + var alias = new Proxy(obj, {}).super; + expect(alias.hash).toBe(obj.hash); + expect(alias.description).toMatch(/^$/); + })(); + settle(3, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + + it("throws a TypeError when the accessor is called on an object that is not a native instance", function () { + var implementation = Object.getPrototypeOf(SuperAliasLog.prototype); + var getter = Object.getOwnPropertyDescriptor(implementation, "super").get; + expect(function () { + getter.call({}); + }).toThrowError(TypeError); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 91589b5a..bb93113c 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -128,6 +128,7 @@ require("./ObjCConstructors"); require("./MetadataTests"); // require("./ApiTests"); +require("./SuperAliasTests"); require("./ProtocolBindingTests"); require("./ProxyReceiverTests"); require("./NsRuntimeTests"); From 04890c9e61d38424d2572c2cd8716fdb3fb055dd Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sat, 14 Nov 2026 19:34:32 -0300 Subject: [PATCH 2/2] fix(runtime): read undefined for `super` on a non-native receiver `super` is an enumerable accessor on an extend() implementation object, so generic code that copies the properties of that object or of the class prototype (Object.assign, spread, clone helpers) calls the getter with a receiver that is not a native instance. Throwing there broke those helpers; the runtime's other native accessors read undefined for such a receiver, and the `super` getter now does the same. The alias's prototype is now taken from the resolved receiver's chain. Called on an alias (getter.call(alias), or through a Proxy over one), the getter walked the alias's own chain, which already starts at the base class prototype, so the alias of an alias got a prototype three levels above the receiver's `super`. Each step of the walk is checked, so a receiver whose chain is shorter than an extended instance's reads undefined instead of dereferencing a null prototype. Helpers.h now states that GetValue follows an alias to its receiver while SetValue and DeleteValue do not, so a write-back to an object reached through GetValue must resolve the alias first. Specs: - worker teardown with an object and a kept alias, and with only the initializer's alias: both abort under AddressSanitizer in BaseDataWrapper::IsGcProtected <- ObjectManager::DisposeValue <- DisposeAllRegistered <- ~Runtime on the worker thread with the runtime sources from before the alias fix; - the alias of an alias resolves to the original receiver, and a second __releaseNativeCounterpart reports "not a native wrapper"; - `super` reads undefined on {}, the implementation object and the class prototype, and Object.assign over the implementation copies it; - reads through a released alias must throw the disposed-object error, and hash comparisons first assert a numeric hash. --- NativeScript/runtime/ClassBuilder.mm | 25 ++++-- NativeScript/runtime/Helpers.h | 4 + TestRunner/app/tests/SuperAliasTests.js | 113 +++++++++++++++++++++--- 3 files changed, 121 insertions(+), 21 deletions(-) diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 59312dc7..2d9f7002 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -1002,27 +1002,34 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { void ClassBuilder::SuperAccessorGetterCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); Local context = isolate->GetCurrentContext(); + // Copying the implementation object or the class prototype (Object.assign, + // spread) reads `super` with a receiver that is no native instance: like the + // other native accessors, it reads undefined. Local target = tns::UnwrapProxy(info.This()); if (target.IsEmpty() || !target->IsObject() || target.As()->InternalFieldCount() < 1) { - isolate->ThrowException(Exception::TypeError(tns::ToV8String( - isolate, "Cannot read 'super' of an object that is not a native instance"))); return; } - Local thiz = target.As(); // Field 0 holds the object that carries the wrapper, never another alias, // so resolving an alias takes one step. - Local receiver = tns::SuperAliasReceiver(thiz); + Local receiver = tns::SuperAliasReceiver(target.As()); if (receiver.IsEmpty()) { - receiver = thiz; + receiver = target.As(); + } + + // receiver -> extended class prototype -> implementation object -> base + // class prototype. + Local superPrototype = receiver; + for (int i = 0; i < 3; i++) { + if (!superPrototype->IsObject()) { + return; + } + superPrototype = superPrototype.As()->GetPrototype(); } std::shared_ptr> poValue = ArgConverter::CreateEmptyObject(context); Local superValue = poValue->Get(isolate).As(); - superValue - ->SetPrototype(context, - thiz->GetPrototype().As()->GetPrototype().As()->GetPrototype()) - .ToChecked(); + superValue->SetPrototype(context, superPrototype).ToChecked(); superValue->SetInternalField(0, receiver); superValue->SetInternalField(1, tns::ToV8String(isolate, "super")); diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index d0a2a42d..75a9df55 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -290,6 +290,10 @@ v8::Local GetCreationContextOrCurrent(v8::Isolate* isolate, // Set/Get/DeleteValue all resolve through proxies: wrapper state is keyed by // the Proxy target, so a proxied wrapper yields its target's wrapper. +// GetValue alone also follows a `this.super` alias to its receiver +// (SuperAliasReceiver), so code that writes back to an object it read with +// GetValue must first resolve an alias, or it overwrites the alias's receiver +// field instead of the wrapper it read. void SetValue(v8::Isolate* isolate, const v8::Local& obj, BaseDataWrapper* value); BaseDataWrapper* GetValue(v8::Isolate* isolate, const v8::Local& val); diff --git a/TestRunner/app/tests/SuperAliasTests.js b/TestRunner/app/tests/SuperAliasTests.js index 6b4ab0c2..26b4b62b 100644 --- a/TestRunner/app/tests/SuperAliasTests.js +++ b/TestRunner/app/tests/SuperAliasTests.js @@ -27,18 +27,17 @@ describe("this.super aliases", function () { } function expectReleased(obj) { - var value; var error = null; try { - value = obj.hash; + obj.hash; } catch (e) { error = e; } - if (error !== null) { - expect(error.message).toContain("disposed native object"); - } else { - expect(value).toBeUndefined(); - } + expect(error === null ? "" : String(error.message)).toContain("disposed native object"); + } + + function superGetter(cls) { + return Object.getOwnPropertyDescriptor(Object.getPrototypeOf(cls.prototype), "super").get; } var superInits = 0; @@ -72,6 +71,7 @@ describe("this.super aliases", function () { expect(obj instanceof SuperAliasLog).toBe(true); expect(obj.description).toMatch(/^js:$/); expect(obj.super.description).toMatch(/^$/); + expect(typeof obj.hash).toBe("number"); expect(obj.super.hash).toBe(obj.hash); expect(obj.super.isEqual(obj)).toBe(true); }); @@ -85,6 +85,7 @@ describe("this.super aliases", function () { it("reports an alias as released once its receiver was released with __releaseNativeCounterpart", function (done) { var obj = SuperAliasLog.new(); var alias = obj.super; + expect(typeof obj.hash).toBe("number"); expect(alias.hash).toBe(obj.hash); __releaseNativeCounterpart(obj); settle(3, function () { @@ -107,6 +108,7 @@ describe("this.super aliases", function () { (function () { var obj = SuperAliasLog.new(); hash = obj.hash; + expect(typeof hash).toBe("number"); watch.addObject(obj); alias = obj.super; })(); @@ -171,6 +173,7 @@ describe("this.super aliases", function () { (function () { var obj = SuperAliasLog.new(); var alias = new Proxy(obj, {}).super; + expect(typeof obj.hash).toBe("number"); expect(alias.hash).toBe(obj.hash); expect(alias.description).toMatch(/^$/); })(); @@ -180,11 +183,97 @@ describe("this.super aliases", function () { }); }); - it("throws a TypeError when the accessor is called on an object that is not a native instance", function () { - var implementation = Object.getPrototypeOf(SuperAliasLog.prototype); - var getter = Object.getOwnPropertyDescriptor(implementation, "super").get; + it("resolves the alias of an alias to the original receiver", function (done) { + var getter = superGetter(SuperAliasLog); + var obj = SuperAliasLog.new(); + var alias = obj.super; + var hash = obj.hash; + expect(typeof hash).toBe("number"); + [getter.call(alias), getter.call(new Proxy(alias, {}))].forEach(function (aliasOfAlias) { + expect(Object.getPrototypeOf(aliasOfAlias)).toBe(Object.getPrototypeOf(alias)); + expect(aliasOfAlias.hash).toBe(hash); + expect(aliasOfAlias.description).toMatch(/^$/); + }); + var aliasOfAlias = getter.call(alias); + __releaseNativeCounterpart(aliasOfAlias); expect(function () { - getter.call({}); - }).toThrowError(TypeError); + __releaseNativeCounterpart(aliasOfAlias); + }).toThrowError(/not a native wrapper/); + settle(3, function () { + expect(deallocs()).toBe(1); + expectReleased(obj); + expectReleased(alias); + expectReleased(aliasOfAlias); + obj = null; + alias = null; + aliasOfAlias = null; + settle(3, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + }); + + // `super` is an enumerable accessor of the implementation object. + it("reads undefined when the accessor's receiver is not a native instance", function () { + var SuperAliasPlain = NSObject.extend({ + init: function () { + return this.super.init(); + } + }, { name: "SuperAliasPlain" }); + var implementation = Object.getPrototypeOf(SuperAliasPlain.prototype); + expect(superGetter(SuperAliasPlain).call({})).toBeUndefined(); + expect(implementation.super).toBeUndefined(); + expect(SuperAliasPlain.prototype.super).toBeUndefined(); + var copy = null; + expect(function () { + copy = Object.assign({}, implementation); + }).not.toThrow(); + expect(Object.prototype.hasOwnProperty.call(copy, "super")).toBe(true); + expect(copy.super).toBeUndefined(); + }); +}); + +// Teardown disposes the worker's registered objects newest first, so an alias +// is disposed before its receiver while both still read the same wrapper. An +// alias that is not kept stays registered until a major collection finalizes +// it, so the one the initializer evaluates counts too. +describe("this.super aliases at worker teardown", function () { + var originalTimeout; + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; + }); + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + function terminateWhenReady(source, done) { + var worker = new Worker("~/shared/Workers/EvalWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("ready"); + worker.terminate(); + setTimeout(done, 600); + }; + worker.onerror = function (e) { + expect(String(e && e.message ? e.message : e)).toBe(""); + done(); + }; + worker.postMessage({ + eval: "var Ext = NSObject.extend({" + + " init: function () { return this.super.init(); }" + + "}, { name: 'SuperAliasTeardownObject' });" + + "globalThis.superAliasObject = Ext.new();" + + source + + "postMessage('ready');" + }); + } + + it("disposes an object and a kept alias of it", function (done) { + terminateWhenReady("globalThis.superAlias = globalThis.superAliasObject.super;", done); + }); + + it("disposes an object whose only alias was its initializer's", function (done) { + terminateWhenReady("", done); }); });