Skip to content

Commit 3743381

Browse files
committed
fix: suppress JS accessor callback reentry
1 parent dc038a4 commit 3743381

6 files changed

Lines changed: 202 additions & 11 deletions

File tree

NativeScript/ffi/shared/bridge/Callbacks.mm

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,7 @@ bool shouldSkipMethodCallback(void* args[], void* ret) {
14591459
for (const auto& key :
14601460
methodPolicy_.skipCallbackIfAssociatedObjectTruthy) {
14611461
if (associatedObjectIsTruthy(receiver, key)) {
1462+
zeroReturnValue(ret);
14621463
applyMethodPolicyAssignments(args);
14631464
storePrimitivePolicyReturnValue(ret);
14641465
return true;
@@ -1469,6 +1470,7 @@ bool shouldSkipMethodCallback(void* args[], void* ret) {
14691470
return false;
14701471
}
14711472

1473+
zeroReturnValue(ret);
14721474
applyMethodPolicyAssignments(args);
14731475
storePrimitivePolicyReturnValue(ret);
14741476
return true;

NativeScript/ffi/shared/bridge/ClassBuilder.mm

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,13 @@ NativeApiMethodCallbackPolicy methodCallbackPolicyForSelector(
376376
return readEngineMethodCallbackPolicyValue(runtime, policyValue);
377377
}
378378

379+
NativeApiMethodCallbackPolicy nativeAccessorCallbackPolicy(
380+
NativeApiMethodCallbackPolicy policy) {
381+
policy.skipCallbackIfAssociatedObjectTruthy.push_back(
382+
"__nativeApiAccessorCallbackState");
383+
return policy;
384+
}
385+
379386
Class dispatchSuperclassForEngineDerivedReceiver(id receiver,
380387
Class defaultSuperclass) {
381388
if (receiver == nil) {
@@ -759,9 +766,9 @@ throw JSError(runtime,
759766
propertyMember->selectorName, propertyMember->signatureOffset,
760767
(propertyMember->flags & metagen::mdMemberReturnOwned) != 0,
761768
getter.asObject(runtime).asFunction(runtime),
762-
methodCallbackPolicyForSelector(runtime, methodPoliciesPtr,
763-
propertyName,
764-
propertyMember->selectorName));
769+
nativeAccessorCallbackPolicy(methodCallbackPolicyForSelector(
770+
runtime, methodPoliciesPtr, propertyName,
771+
propertyMember->selectorName)));
765772
} else if (propertyMember == nullptr && getter.isObject() &&
766773
getter.asObject(runtime).isFunction(runtime)) {
767774
auto overrides = methodOverridesForName(members, propertyName);
@@ -774,9 +781,9 @@ throw JSError(runtime,
774781
member.signatureOffset,
775782
(member.flags & metagen::mdMemberReturnOwned) != 0,
776783
getter.asObject(runtime).asFunction(runtime),
777-
methodCallbackPolicyForSelector(runtime, methodPoliciesPtr,
778-
propertyName,
779-
member.selectorName));
784+
nativeAccessorCallbackPolicy(methodCallbackPolicyForSelector(
785+
runtime, methodPoliciesPtr, propertyName,
786+
member.selectorName)));
780787
}
781788
}
782789

@@ -788,9 +795,10 @@ throw JSError(runtime,
788795
propertyMember->setterSelectorName,
789796
propertyMember->setterSignatureOffset, false,
790797
setter.asObject(runtime).asFunction(runtime),
791-
methodCallbackPolicyForSelector(
792-
runtime, methodPoliciesPtr, propertyName,
793-
propertyMember->setterSelectorName));
798+
nativeAccessorCallbackPolicy(
799+
methodCallbackPolicyForSelector(
800+
runtime, methodPoliciesPtr, propertyName,
801+
propertyMember->setterSelectorName)));
794802
}
795803
}
796804

NativeScript/ffi/shared/bridge/HostObject.mm

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,36 @@ throw JSError(runtime,
346346
return Value::undefined();
347347
});
348348
}
349+
if (property == "__setObjectAccessorCallbackState") {
350+
return Function::createFromHostFunction(
351+
runtime, PropNameID::forAscii(
352+
runtime, "__setObjectAccessorCallbackState"),
353+
2,
354+
[](Runtime& runtime, const Value&, const Value* args,
355+
size_t count) -> Value {
356+
if (count < 1) {
357+
return Value::undefined();
358+
}
359+
id object = NativeApiObjectHostObject::nativeObjectFromValue(
360+
runtime, args[0]);
361+
if (object == nil) {
362+
return Value::undefined();
363+
}
364+
bool active = count >= 2 && args[1].isBool() && args[1].getBool();
365+
SEL key = sel_registerName("__nativeApiAccessorCallbackState");
366+
NSNumber* current = (NSNumber*)objc_getAssociatedObject(object, key);
367+
NSInteger depth = current != nil ? current.integerValue : 0;
368+
if (active) {
369+
depth += 1;
370+
} else if (depth > 0) {
371+
depth -= 1;
372+
}
373+
objc_setAssociatedObject(
374+
object, key, depth > 0 ? @(depth) : nil,
375+
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
376+
return Value::undefined();
377+
});
378+
}
349379
if (property == "CC_SHA256") {
350380
auto bridge = bridge_;
351381
return Function::createFromHostFunction(

NativeScript/ffi/shared/bridge/Install.mm

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,11 +237,51 @@ function findPrototypeDescriptor(className, property) {
237237
return undefined;
238238
}
239239
240+
function setObjectAccessorCallbackState(instance, active) {
241+
try {
242+
if (typeof api.__setObjectAccessorCallbackState === 'function') {
243+
api.__setObjectAccessorCallbackState(instance, !!active);
244+
}
245+
} catch (_) {
246+
}
247+
}
248+
249+
function nativeExtensionAccessorWithCallbackState(fn) {
250+
if (typeof fn !== 'function') {
251+
return fn;
252+
}
253+
return function() {
254+
setObjectAccessorCallbackState(this, true);
255+
try {
256+
var args = Array.prototype.slice.call(arguments);
257+
return fn.apply(this, args);
258+
} finally {
259+
setObjectAccessorCallbackState(this, false);
260+
}
261+
};
262+
}
263+
240264
function nativeExtensionMethodsWithIndexedCollectionAliases(methods) {
241265
if (methods == null || typeof methods !== 'object') {
242266
return methods;
243267
}
244268
269+
var descriptors = Object.getOwnPropertyDescriptors(methods);
270+
var descriptorKeys =
271+
typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function'
272+
? Reflect.ownKeys(descriptors)
273+
: Object.keys(descriptors);
274+
var needsAccessorCallbackState = false;
275+
for (var descriptorIndex = 0; descriptorIndex < descriptorKeys.length; descriptorIndex++) {
276+
var descriptor = descriptors[descriptorKeys[descriptorIndex]];
277+
if (descriptor &&
278+
(typeof descriptor.get === 'function' ||
279+
typeof descriptor.set === 'function')) {
280+
needsAccessorCallbackState = true;
281+
break;
282+
}
283+
}
284+
245285
var hasObjectAtIndex =
246286
Object.prototype.hasOwnProperty.call(methods, 'objectAtIndex');
247287
var hasCount =
@@ -261,12 +301,31 @@ function nativeExtensionMethodsWithIndexedCollectionAliases(methods) {
261301
262302
if (!needsObjectAtIndexedSubscript &&
263303
!needsSetObjectAtIndexedSubscript &&
264-
!needsIndexedCollectionIterator) {
304+
!needsIndexedCollectionIterator &&
305+
!needsAccessorCallbackState) {
265306
return methods;
266307
}
267308
309+
if (needsAccessorCallbackState) {
310+
for (var accessorIndex = 0; accessorIndex < descriptorKeys.length; accessorIndex++) {
311+
var accessorKey = descriptorKeys[accessorIndex];
312+
var accessorDescriptor = descriptors[accessorKey];
313+
if (!accessorDescriptor) {
314+
continue;
315+
}
316+
if (typeof accessorDescriptor.get === 'function') {
317+
accessorDescriptor.get =
318+
nativeExtensionAccessorWithCallbackState(accessorDescriptor.get);
319+
}
320+
if (typeof accessorDescriptor.set === 'function') {
321+
accessorDescriptor.set =
322+
nativeExtensionAccessorWithCallbackState(accessorDescriptor.set);
323+
}
324+
}
325+
}
326+
268327
var prepared = Object.create(Object.getPrototypeOf(methods));
269-
Object.defineProperties(prepared, Object.getOwnPropertyDescriptors(methods));
328+
Object.defineProperties(prepared, descriptors);
270329
271330
if (needsObjectAtIndexedSubscript) {
272331
Object.defineProperty(prepared, 'objectAtIndexedSubscript', {

PROGRESS.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,47 @@ TypeScript/UI worklets.
1717

1818
## Latest Update - 2026-06-30
1919

20+
### 2026-06-30 04:33 EDT - JS prototype accessors suppress native accessor re-entry
21+
22+
- Scope check:
23+
- Still generic NativeScript runtime behavior required by the RN-module
24+
branch. No RNS-specific native implementation and no direct-engine backend
25+
refactor work.
26+
- Simulator-only rule remains active; no physical devices were used.
27+
- CI finding:
28+
- GitHub Actions run `28429299830` on `dc038a4f` compiled and linked the V8
29+
native-interceptor patch, kept macOS tests green, and still failed only in
30+
iOS:
31+
`ApiTests.js SpecialCaseProperty_When_CustomSelector_ImplementedInJS`.
32+
- `ApiTests.js NSMutableArrayMethods` still passed.
33+
- The failure stayed at `ApiTests.js:347` with hundreds of `getter` logs,
34+
so the values were still correct but Objective-C/native accessor re-entry
35+
was repeatedly invoking the JS getter while the JS-owned accessor path was
36+
active.
37+
- Change:
38+
- JS extension accessor descriptors are now wrapped during `.extend(...)`
39+
and TypeScript `NativeClass` materialization so getter/setter bodies run
40+
with a depth-counted native associated-object state.
41+
- The bridge exposes `__setObjectAccessorCallbackState` to maintain that
42+
state on the native object.
43+
- Class-builder generated Objective-C callbacks for JS property accessor
44+
overrides now compose a generic callback policy that skips native callback
45+
re-entry while the JS accessor is executing.
46+
- Skipped native callbacks now zero their return storage before applying an
47+
optional policy return value, matching the construction-skip behavior.
48+
- Verification:
49+
- Focused source guard passed:
50+
`node packages/react-native/test/runtime-instance-selector-base-dispatch.test.js`.
51+
- Runtime RN JS tests passed:
52+
`for f in packages/react-native/test/*.test.js; do node "$f" || exit 1; done`.
53+
- `git diff --check` passed.
54+
- `npm run check:ffi-boundaries` passed.
55+
- Existing generated macOS project compile/link passed with
56+
`xcodebuild -project dist/intermediates/macos/NativeScript.xcodeproj ...`.
57+
- Still next:
58+
- Commit/push this accessor re-entry guard and watch fresh PR CI. If iOS goes
59+
green, resume the RNS simulator parity sweep from this handoff.
60+
2061
### 2026-06-30 03:57 EDT - V8 native instance interceptors honor JS prototype accessors
2162

2263
- Scope check:

packages/react-native/test/runtime-instance-selector-base-dispatch.test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,57 @@ for (const relativePath of [
145145
);
146146
}
147147

148+
{
149+
const installSource = fs.readFileSync(
150+
path.join(repoRoot, "NativeScript/ffi/shared/bridge/Install.mm"),
151+
"utf8",
152+
);
153+
const hostObjectSource = fs.readFileSync(
154+
path.join(repoRoot, "NativeScript/ffi/shared/bridge/HostObject.mm"),
155+
"utf8",
156+
);
157+
const classBuilderSource = fs.readFileSync(
158+
path.join(repoRoot, "NativeScript/ffi/shared/bridge/ClassBuilder.mm"),
159+
"utf8",
160+
);
161+
const callbacksSource = fs.readFileSync(
162+
path.join(repoRoot, "NativeScript/ffi/shared/bridge/Callbacks.mm"),
163+
"utf8",
164+
);
165+
166+
assert(
167+
installSource.includes("function setObjectAccessorCallbackState(instance, active)") &&
168+
installSource.includes("__setObjectAccessorCallbackState(instance, !!active)") &&
169+
installSource.includes("function nativeExtensionAccessorWithCallbackState(fn)") &&
170+
installSource.includes("Object.getOwnPropertyDescriptors(methods)") &&
171+
installSource.includes("Reflect.ownKeys(descriptors)") &&
172+
installSource.includes("fn.apply(this, args)") &&
173+
installSource.includes("setObjectAccessorCallbackState(this, false)"),
174+
"JS extension accessors should run with native callback re-entry suppression state",
175+
);
176+
assert(
177+
hostObjectSource.includes("__setObjectAccessorCallbackState") &&
178+
hostObjectSource.includes('sel_registerName("__nativeApiAccessorCallbackState")') &&
179+
hostObjectSource.includes("depth += 1") &&
180+
hostObjectSource.includes("depth -= 1") &&
181+
hostObjectSource.includes("depth > 0 ? @(depth) : nil"),
182+
"bridge API should expose a depth-counted JS accessor callback state on native objects",
183+
);
184+
assert(
185+
classBuilderSource.includes("NativeApiMethodCallbackPolicy nativeAccessorCallbackPolicy") &&
186+
classBuilderSource.includes('"__nativeApiAccessorCallbackState"') &&
187+
classBuilderSource.includes("nativeAccessorCallbackPolicy(methodCallbackPolicyForSelector") &&
188+
classBuilderSource.match(/nativeAccessorCallbackPolicy\(methodCallbackPolicyForSelector/g)?.length >= 2 &&
189+
classBuilderSource.includes("nativeAccessorCallbackPolicy(\n methodCallbackPolicyForSelector"),
190+
"property accessor overrides should skip native callback re-entry while JS accessors are executing",
191+
);
192+
assert(
193+
callbacksSource.includes("zeroReturnValue(ret);\n applyMethodPolicyAssignments(args);") &&
194+
callbacksSource.includes("zeroReturnValue(ret);\n applyMethodPolicyAssignments(args);"),
195+
"skipped native method callbacks should zero their native return storage before applying policy return values",
196+
);
197+
}
198+
148199
for (const relativePath of [
149200
"NativeScript/ffi/shared/bridge/ClassBuilder.mm",
150201
"packages/react-native/native-api/ffi/shared/bridge/ClassBuilder.mm",

0 commit comments

Comments
 (0)