Skip to content
Draft
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
157 changes: 105 additions & 52 deletions apps/example/src/getTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface TestRunner {
// 1) It's a lot of allocations and any VM (JS, JVM) will likely trigger GC
// 2) In JVM, 51_200 is the limit for `jni::global_ref`s, then the app crashes - this intentionally exhausts that
const MEMORY_LEAK_TEST_ALLOCATION_COUNT = 55_000
const MEMORY_LEAK_TEST_TIMEOUT = 120_000
const EXTERNAL_MEMORY_TEST_SIZE = 1024 * 1024
const PARALLEL_HYBRID_OBJECT_TEST_TIMEOUT = 120_000

Expand Down Expand Up @@ -309,6 +310,44 @@ export function getTests(
.equals(true)
),

// Bound HybridObject performance experiment
createTest('HybridObject methods are bound to their native receiver', () =>
it(() => {
const { equals } = testObject
const other = testObject.newTestObject()
return equals(testObject) && !equals.call(other, other)
})
.didNotThrow()
.equals(true)
),
createTest(
'HybridObject accessors are bound to their native receiver',
() =>
it(() => {
const other = testObject.newTestObject()
other.numberValue = 100
const descriptor = Object.getOwnPropertyDescriptor(
testObject,
'numberValue'
)!
descriptor.set!.call(other, 42)
return descriptor.get!.call(other) === 42 && other.numberValue === 100
})
.didNotThrow()
.equals(true)
),
createTest('HybridObject methods have per-instance identity', () =>
it(() => {
const other = testObject.newTestObject()
return (
Object.prototype.hasOwnProperty.call(testObject, 'simpleFunc') &&
testObject.simpleFunc !== other.simpleFunc
)
})
.didNotThrow()
.equals(true)
),

// Test Primitives (getters & setters)
createTest('set numberValue to 13', () =>
it(() => (testObject.numberValue = 13)).didNotThrow()
Expand Down Expand Up @@ -1697,67 +1736,77 @@ export function getTests(
),
createTest(
'HybridObjects do not leak memory when automatically reclaimed by JS GC',
() =>
it(() => {
const baselineAllocations =
NitroModules.debug_getTotalAllocatedHybridObjects()
const BATCH_SIZE = 1000

const objects: Array<TestObjectCpp | TestObjectSwiftKotlin> = []
for (let i = 0; i < MEMORY_LEAK_TEST_ALLOCATION_COUNT; i++) {
const object = testObject.newTestObject()
object.numberValue = i
objects.push(object)

if (objects.length >= BATCH_SIZE) {
objects.length = 0
gc()
async () =>
(
await it(async () => {
const baselineAllocations =
NitroModules.debug_getTotalAllocatedHybridObjects()
const BATCH_SIZE = 1000

const objects: Array<TestObjectCpp | TestObjectSwiftKotlin> = []
for (let i = 0; i < MEMORY_LEAK_TEST_ALLOCATION_COUNT; i++) {
const object = testObject.newTestObject()
object.numberValue = i
objects.push(object)

if (objects.length >= BATCH_SIZE) {
objects.length = 0
gc()
// Binding allocates functions per object; yield so Harness can send heartbeats.
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
}
}

objects.length = 0
gc()
gc()
gc()
objects.length = 0
gc()
gc()
gc()

const currentAllocations =
NitroModules.debug_getTotalAllocatedHybridObjects()
const remainingAllocations = currentAllocations - baselineAllocations
// make sure that less than 10% of the total allocations are remaining, indicating GC ran for most of it.
const didDeleteMostObjects =
remainingAllocations < MEMORY_LEAK_TEST_ALLOCATION_COUNT * 0.1
const result: {
baselineAllocations: number
currentAllocations: number
isEqual?: boolean
} = {
baselineAllocations: baselineAllocations,
currentAllocations: currentAllocations,
isEqual: didDeleteMostObjects,
}
if (!didDeleteMostObjects) {
delete result.isEqual
}
return result
})
const currentAllocations =
NitroModules.debug_getTotalAllocatedHybridObjects()
const remainingAllocations =
currentAllocations - baselineAllocations
// make sure that less than 10% of the total allocations are remaining, indicating GC ran for most of it.
const didDeleteMostObjects =
remainingAllocations < MEMORY_LEAK_TEST_ALLOCATION_COUNT * 0.1
const result: {
baselineAllocations: number
currentAllocations: number
isEqual?: boolean
} = {
baselineAllocations: baselineAllocations,
currentAllocations: currentAllocations,
isEqual: didDeleteMostObjects,
}
if (!didDeleteMostObjects) {
delete result.isEqual
}
return result
}, MEMORY_LEAK_TEST_TIMEOUT)
)
.didNotThrow()
.toContain('isEqual')
),
createTest('HybridObjects dont leak memory with manual dispose()', () =>
it(() => {
const BATCH_SIZE = 1000
createTest(
'HybridObjects dont leak memory with manual dispose()',
async () =>
(
await it(async () => {
const BATCH_SIZE = 1000

for (let i = 0; i < MEMORY_LEAK_TEST_ALLOCATION_COUNT; i++) {
const object = testObject.newTestObject()
object.dispose()
for (let i = 0; i < MEMORY_LEAK_TEST_ALLOCATION_COUNT; i++) {
const object = testObject.newTestObject()
object.dispose()

if ((i + 1) % BATCH_SIZE === 0) {
gc()
}
}
if ((i + 1) % BATCH_SIZE === 0) {
gc()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
}

gc()
}).didNotThrow()
gc()
}, MEMORY_LEAK_TEST_TIMEOUT)
).didNotThrow()
),
createTest('callWithOptional(undefined)', async () =>
(
Expand Down Expand Up @@ -2610,6 +2659,8 @@ export function getTests(
NitroModules.updateMemorySize(testObject)

testObject.stringValue = 'x'.repeat(EXTERNAL_MEMORY_TEST_SIZE)
// Exclude unrelated, unreachable allocations from the global counter delta.
gc()
const externalBytesBefore = getHermesExternalMemorySize()
NitroModules.updateMemorySize(testObject)
const externalBytesAfter = getHermesExternalMemorySize()
Expand Down Expand Up @@ -2640,6 +2691,8 @@ export function getTests(
() =>
it(() => {
const size = EXTERNAL_MEMORY_TEST_SIZE
// Allocation can trigger GC, so collect earlier tests' buffers before taking the baseline.
gc()
const externalBytesBefore = getHermesExternalMemorySize()
const buffer = NitroModules.createNativeArrayBuffer(size)
const externalBytesAfter = getHermesExternalMemorySize()
Expand Down
15 changes: 7 additions & 8 deletions apps/example/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function findPrototypeWhere<T extends object>(
}

const HybridObjectPrototype = findPrototypeWhere(
NitroModules,
Object.getPrototypeOf(NitroModules),
(obj) =>
Object.hasOwn(obj, 'toString') &&
Object.hasOwn(obj, 'equals') &&
Expand All @@ -30,13 +30,12 @@ if (HybridObjectPrototype == null) {
throw new Error(`Failed to find HybridObject root prototype!`)
}
function isHybridObjectSubclass(obj: object): boolean {
if (!(obj.toString instanceof Function)) {
// it doesn't haven have .toString
return false
}
// If its .toString function is the same as the HybridObject prototype's
// .toString function, it means it is inheriting from HybridObject.
return obj.toString === HybridObjectPrototype?.toString
// Bound instances have their own functions. Identify the shared prototype
// by ancestry so we also recognize prototype objects without invoking getters.
return (
obj === HybridObjectPrototype ||
Object.prototype.isPrototypeOf.call(HybridObjectPrototype, obj)
)
}

export function stringify(value: unknown): string {
Expand Down
109 changes: 72 additions & 37 deletions packages/react-native-nitro-modules/cpp/core/HybridFunction.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ using RawInstanceMethod = InstanceMethod<
*/
class HybridFunction final {
private:
using BindFunction = std::function<jsi::HostFunctionType(const std::shared_ptr<jsi::NativeState>&)>;

jsi::HostFunctionType _function;
BindFunction _bindFunction;
size_t _paramCount;
std::string _name;

Expand All @@ -72,9 +75,15 @@ class HybridFunction final {
_function);
}

// Performance experiment: resolve the native type once, then capture it in the HostFunction.
inline jsi::Function toBoundJSFunction(jsi::Runtime& runtime, const std::shared_ptr<jsi::NativeState>& instance) const {
return jsi::Function::createFromHostFunction(runtime, PropNameIDCache::get(runtime, _name), static_cast<unsigned int>(_paramCount),
_bindFunction(instance));
}

private:
HybridFunction(jsi::HostFunctionType&& function, size_t paramCount, const std::string& name)
: _function(std::move(function)), _paramCount(paramCount), _name(name) {}
HybridFunction(jsi::HostFunctionType&& function, BindFunction&& bindFunction, size_t paramCount, const std::string& name)
: _function(std::move(function)), _bindFunction(std::move(bindFunction)), _paramCount(paramCount), _name(name) {}

public:
/**
Expand All @@ -94,43 +103,19 @@ class HybridFunction final {
// 1. Get actual `HybridObject` instance from `thisValue` (it's stored as `NativeState`)
std::shared_ptr<THybrid> hybridInstance = getHybridObjectNativeState<THybrid>(runtime, thisValue, kind, name);

// 2. Make sure the given arguments match, either with a static size, or with potentially optional arguments size.
constexpr size_t optionalArgsCount = trailing_optionals_count_v<Args...>;
constexpr size_t maxArgsCount = sizeof...(Args);
constexpr size_t minArgsCount = maxArgsCount - optionalArgsCount;
bool isWithinArgsRange = (count >= minArgsCount && count <= maxArgsCount);
if (!isWithinArgsRange) [[unlikely]] {
// invalid amount of arguments passed!
std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
if constexpr (minArgsCount == maxArgsCount) {
// min and max args length is the same, so we don't have any optional parameters. fixed count
throw jsi::JSError(runtime, "`" + funcName + "` expected " + std::to_string(maxArgsCount) + " arguments, but received " +
std::to_string(count) + "!");
} else {
// min and max args length are different, so we have optional parameters - variable length arguments.
throw jsi::JSError(runtime, "`" + funcName + "` expected between " + std::to_string(minArgsCount) + " and " +
std::to_string(maxArgsCount) + " arguments, but received " + std::to_string(count) + "!");
}
}
return callWithArguments(hybridInstance.get(), method, kind, name, runtime, args, count);
};

try {
// 3. Actually call the method with JSI values as arguments and return a JSI value again.
// Internally, this method converts the JSI values to C++ values using `JSIConverter<T>`.
return callMethod(hybridInstance.get(), method, runtime, args, count, std::index_sequence_for<Args...>{});
} catch (const std::exception& exception) {
// Some exception was thrown - add method name information and re-throw as `JSError`.
std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
std::string message = exception.what();
throw jsi::JSError(runtime, funcName + ": " + message);
} catch (...) {
// Some unknown exception was thrown - add method name information and re-throw as `JSError`.
std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
std::string errorName = TypeInfo::getCurrentExceptionName();
throw jsi::JSError(runtime, "`" + funcName + "` threw an unknown " + errorName + " error.");
}
BindFunction bindFunction = [name, method, kind](const std::shared_ptr<jsi::NativeState>& instance) -> jsi::HostFunctionType {
auto hybridInstance = std::dynamic_pointer_cast<THybrid>(instance);
return [hybridInstance = std::move(hybridInstance), name, method, kind](jsi::Runtime& runtime, const jsi::Value&,
const jsi::Value* args, size_t count) -> jsi::Value {
// No JS receiver lookup, RTTI, or shared_ptr copy on the call path.
return callWithArguments(hybridInstance.get(), method, kind, name, runtime, args, count);
};
};

return HybridFunction(std::move(hostFunction), sizeof...(Args), name);
return HybridFunction(std::move(hostFunction), std::move(bindFunction), sizeof...(Args), name);
}

/**
Expand All @@ -154,7 +139,57 @@ class HybridFunction final {
return (pointer->*method)(runtime, thisValue, args, count);
};

return HybridFunction(std::move(hostFunction), expectedArgumentsCount, name);
BindFunction bindFunction = [method](const std::shared_ptr<jsi::NativeState>& instance) -> jsi::HostFunctionType {
auto hybridInstance = std::dynamic_pointer_cast<Derived>(instance);
return [hybridInstance = std::move(hybridInstance), method](jsi::Runtime& runtime, const jsi::Value& thisValue,
const jsi::Value* args, size_t count) -> jsi::Value {
// Raw methods still receive the caller's JS thisValue, but use the captured native instance.
return (hybridInstance.get()->*method)(runtime, thisValue, args, count);
};
};

return HybridFunction(std::move(hostFunction), std::move(bindFunction), expectedArgumentsCount, name);
}

private:
template <typename THybrid, typename ReturnType, typename... Args>
static inline jsi::Value callWithArguments(THybrid* NON_NULL hybridInstance, InstanceMethod<THybrid, ReturnType, Args...> method,
FunctionKind kind, const std::string& name, jsi::Runtime& runtime,
const jsi::Value* NON_NULL args, size_t count) {
// 2. Make sure the given arguments match, either with a static size, or with potentially optional arguments size.
constexpr size_t optionalArgsCount = trailing_optionals_count_v<Args...>;
constexpr size_t maxArgsCount = sizeof...(Args);
constexpr size_t minArgsCount = maxArgsCount - optionalArgsCount;
bool isWithinArgsRange = (count >= minArgsCount && count <= maxArgsCount);
if (!isWithinArgsRange) [[unlikely]] {
// invalid amount of arguments passed!
std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance);
if constexpr (minArgsCount == maxArgsCount) {
// min and max args length is the same, so we don't have any optional parameters. fixed count
throw jsi::JSError(runtime, "`" + funcName + "` expected " + std::to_string(maxArgsCount) + " arguments, but received " +
std::to_string(count) + "!");
} else {
// min and max args length are different, so we have optional parameters - variable length arguments.
throw jsi::JSError(runtime, "`" + funcName + "` expected between " + std::to_string(minArgsCount) + " and " +
std::to_string(maxArgsCount) + " arguments, but received " + std::to_string(count) + "!");
}
}

try {
// 3. Actually call the method with JSI values as arguments and return a JSI value again.
// Internally, this method converts the JSI values to C++ values using `JSIConverter<T>`.
return callMethod(hybridInstance, method, runtime, args, count, std::index_sequence_for<Args...>{});
} catch (const std::exception& exception) {
// Some exception was thrown - add method name information and re-throw as `JSError`.
std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance);
std::string message = exception.what();
throw jsi::JSError(runtime, funcName + ": " + message);
} catch (...) {
// Some unknown exception was thrown - add method name information and re-throw as `JSError`.
std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance);
std::string errorName = TypeInfo::getCurrentExceptionName();
throw jsi::JSError(runtime, "`" + funcName + "` threw an unknown " + errorName + " error.");
}
}

private:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,13 @@ jsi::Value HybridObject::toObject(jsi::Runtime& runtime) {
jsi::Object object = CommonGlobals::Object::create(runtime, prototype);

// 4. Assign NativeState to the object so the prototype can resolve the native methods
object.setNativeState(runtime, shared());
auto instance = shared();
object.setNativeState(runtime, instance);

// Performance experiment: shadow every prototype member with a HostFunction bound to this instance.
// Remove this call to compare against the shared-prototype/NativeState dispatch baseline.
// Bound functions retain the native object even after dispose() clears its NativeState.
bindHybridFunctions(runtime, object, instance);

// 5. Set memory size so Hermes GC knows about actual memory
object.setExternalMemoryPressure(runtime, getExternalMemorySize());
Expand Down
Loading
Loading