diff --git a/NativeScript/runtime/ArgConverter.h b/NativeScript/runtime/ArgConverter.h index b8bcfe5a..b01ea5f3 100644 --- a/NativeScript/runtime/ArgConverter.h +++ b/NativeScript/runtime/ArgConverter.h @@ -13,23 +13,34 @@ namespace tns { class ArgConverter; +// What native callers of a JS-implemented method own of the object it answers. +enum class NativeReturnOwnership : uint8_t { + Unowned, + // A +1; the receiver stays the caller's. + Owned, + // A +1 in place of the receiver, whose reference the caller passed in. + ReplacesReceiver, +}; + struct MethodCallbackWrapper { public: - MethodCallbackWrapper(v8::Isolate* isolate, - std::shared_ptr> callback, - const uint8_t initialParamIndex, - const uint8_t paramsCount, - const TypeEncoding* typeEncoding) + MethodCallbackWrapper( + v8::Isolate* isolate, std::shared_ptr> callback, + const uint8_t initialParamIndex, const uint8_t paramsCount, + const TypeEncoding* typeEncoding, + NativeReturnOwnership returnOwnership = NativeReturnOwnership::Unowned) : isolateWrapper_(isolate), callback_(callback), initialParamIndex_(initialParamIndex), paramsCount_(paramsCount), - typeEncoding_(typeEncoding) {} + typeEncoding_(typeEncoding), + returnOwnership_(returnOwnership) {} IsolateWrapper isolateWrapper_; std::shared_ptr> callback_; const uint8_t initialParamIndex_; const uint8_t paramsCount_; const TypeEncoding* typeEncoding_; + const NativeReturnOwnership returnOwnership_; }; class ArgConverter { @@ -71,6 +82,21 @@ class ArgConverter { static const ProtocolMeta* FindProtocolMeta(Protocol* protocol); static void MethodCallback(ffi_cif* cif, void* retValue, void** argValues, void* userData); + // For a JS implementation of the instance method `selector`; `meta` is null + // for methods the metadata does not describe, which are classified by + // their selector's method family. + static NativeReturnOwnership ReturnOwnership(SEL selector, + const MethodMeta* meta); + // Gives the native caller of a JS implementation the reference it owns of + // the object SetValue wrote into retValue. `consumedArgument`, an argument + // the method took over, already carries that reference when it is the + // result. + static void RetainOwnedResult(v8::Isolate* isolate, + NativeReturnOwnership ownership, + void** argValues, void* retValue, + const TypeEncoding* typeEncoding, + v8::Local value, + id consumedArgument = nil); // returnSize is the ABI size of the return slot (cif->rtype->size); an // empty, null or undefined value zeroes all of it. static void SetValue(v8::Local context, void* retValue, diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index f83403f8..d4307091 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -36,45 +36,43 @@ bool IsDeallocating(id obj) { return !((BOOL (*)(id, SEL))imp)(obj, allowsWeakReference); } -// Clang's init method family: optional leading underscores, then "init", then -// the end of the selector's first piece or anything but a lowercase letter. -bool IsInitFamily(SEL selector) { +// Clang's method family rule: optional leading underscores, then the family +// name, then the end of the selector's first piece or anything but a +// lowercase letter. +bool InMethodFamily(SEL selector, const char* family) { const char* name = sel_getName(selector); while (*name == '_') { name++; } - return strncmp(name, "init", 4) == 0 && (name[4] < 'a' || name[4] > 'z'); + size_t length = strlen(family); + return strncmp(name, family, length) == 0 && (name[length] < 'a' || name[length] > 'z'); } -// The native caller of an init-family method owns its result. SetValue hands -// a wrapped object or a string over at +0: for the receiver that is the -// reference the caller passed in, any other object needs its own. The -// receiver is left alone: when a super initializer substituted it, that -// initializer already released it, and nothing here can tell that case apart. -void RetainInitializerResult(Isolate* isolate, void** argValues, void* retValue, - const TypeEncoding* typeEncoding, Local value) { +bool IsObjectEncoding(const TypeEncoding* typeEncoding) { BinaryTypeEncodingType type = typeEncoding->type; - if (type != BinaryTypeEncodingType::IdEncoding && - type != BinaryTypeEncodingType::InterfaceDeclarationReference && - type != BinaryTypeEncodingType::InstanceTypeEncoding) { - return; - } + return type == BinaryTypeEncodingType::IdEncoding || + type == BinaryTypeEncodingType::InterfaceDeclarationReference || + type == BinaryTypeEncodingType::InstanceTypeEncoding; +} - id returned = *static_cast(retValue); - if (returned == nil || returned == *static_cast(argValues[0]) || - !IsInitFamily(*static_cast(argValues[1]))) { - return; +// The argument a JS-implemented method takes over from its native caller, or +// nil. Reads only the native call, so it is usable without the isolate. +id ConsumedArgument(const MethodCallbackWrapper* data, void** argValues) { + if (data->initialParamIndex_ != 2) { + return nil; } - - // A plain JS object becomes a DictionaryAdapter SetValue already returns - // at +1. - value = tns::UnwrapProxy(value); - BaseDataWrapper* wrapper = value->IsObject() ? tns::GetValue(isolate, value) : nullptr; - bool atPlusZero = - value->IsString() || (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject); - if (atPlusZero) { - [returned retain]; + SEL selector = *static_cast(argValues[1]); + for (int i = 0; i < data->paramsCount_; i++) { + if (!Interop::ConsumesArgument(selector, i)) { + continue; + } + const TypeEncoding* typeEncoding = data->typeEncoding_; + for (int j = 0; j <= i; j++) { + typeEncoding = typeEncoding->next(); + } + return IsObjectEncoding(typeEncoding) ? *static_cast(argValues[i + 2]) : nil; } + return nil; } // The borrowed wrapper WrapNativeCallbackSelf made for an object in -dealloc @@ -192,7 +190,7 @@ explicit BorrowedSelfScope(Isolate* isolate) : isolate(isolate) {} target = [allocKlass alloc]; } - ObjCMethodCall methodCall(context, meta, target, klass, args, callSuper); + ObjCMethodCall methodCall(context, meta, target, klass, args, callSuper, allocKlass != nil); return Interop::CallFunction(methodCall); } @@ -364,6 +362,8 @@ explicit BorrowedSelfScope(Isolate* isolate) : isolate(isolate) {} void ArgConverter::MethodCallback(ffi_cif* cif, void* retValue, void** argValues, void* userData) { MethodCallbackWrapper* data = static_cast(userData); + // Owned by this call, which gives it up on every path unless it answers it. + id consumedArgument = ConsumedArgument(data, argValues); Isolate* isolate = data->isolateWrapper_.Isolate(); @@ -380,6 +380,7 @@ explicit BorrowedSelfScope(Isolate* isolate) : isolate(isolate) {} IsolatePin pin = data->isolateWrapper_.Pin(); if (!pin || !data->isolateWrapper_.IsValid()) { memset(retValue, 0, cif->rtype->size); + [consumedArgument release]; return; } @@ -391,6 +392,7 @@ explicit BorrowedSelfScope(Isolate* isolate) : isolate(isolate) {} // what makes this second look reliable. if (!data->isolateWrapper_.IsValid()) { memset(retValue, 0, cif->rtype->size); + [consumedArgument release]; return; } Isolate::Scope isolate_scope(isolate); @@ -513,13 +515,18 @@ explicit BorrowedSelfScope(Isolate* isolate) : isolate(isolate) {} memset(retValue, 0, cif->rtype->size); } else { ArgConverter::SetValue(context, retValue, cif->rtype->size, result, data->typeEncoding_); - if (data->initialParamIndex_ == 2) { - RetainInitializerResult(isolate, argValues, retValue, data->typeEncoding_, result); - } + ArgConverter::RetainOwnedResult(isolate, data->returnOwnership_, argValues, retValue, + data->typeEncoding_, result, consumedArgument); } } else { memset(retValue, 0, cif->rtype->size); } + + // A wrapper JS made for the consumed argument holds a reference of its own. + if (consumedArgument != nil && !(IsObjectEncoding(data->typeEncoding_) && + *static_cast(retValue) == consumedArgument)) { + [consumedArgument release]; + } } // all V8 scopes destruct here if (pendingThrow != nil) { @@ -527,6 +534,52 @@ explicit BorrowedSelfScope(Isolate* isolate) : isolate(isolate) {} } } +// SetValue hands a wrapped object or a string over at +0, so an owned result +// gets a reference of its own, except a receiver answered in place of itself: +// that is the reference the caller passed in. The receiver is never released +// when another object is answered instead: a super initializer that +// substituted it already did, and nothing here can tell that case apart. +void ArgConverter::RetainOwnedResult(Isolate* isolate, NativeReturnOwnership ownership, + void** argValues, void* retValue, + const TypeEncoding* typeEncoding, Local value, + id consumedArgument) { + if (ownership == NativeReturnOwnership::Unowned || !IsObjectEncoding(typeEncoding)) { + return; + } + + id returned = *static_cast(retValue); + if (returned == nil || returned == consumedArgument || + (ownership == NativeReturnOwnership::ReplacesReceiver && + returned == *static_cast(argValues[0]))) { + return; + } + + // A plain JS object becomes a DictionaryAdapter SetValue already returns + // at +1. SetValue writes a string only into an id or interface slot; for + // instancetype it leaves the slot unwritten. + value = tns::UnwrapProxy(value); + BaseDataWrapper* wrapper = value->IsObject() ? tns::GetValue(isolate, value) : nullptr; + bool atPlusZero = + (value->IsString() && typeEncoding->type != BinaryTypeEncodingType::InstanceTypeEncoding) || + (wrapper != nullptr && wrapper->Type() == WrapperType::ObjCObject); + if (atPlusZero) { + [returned retain]; + } +} + +NativeReturnOwnership ArgConverter::ReturnOwnership(SEL selector, const MethodMeta* meta) { + bool isInitializer = meta != nullptr ? meta->isInitializer() : InMethodFamily(selector, "init"); + if (Interop::ConsumesReceiver(selector, isInitializer)) { + return NativeReturnOwnership::ReplacesReceiver; + } + + bool owned = meta != nullptr + ? meta->ownsReturnedCocoaObject() + : InMethodFamily(selector, "copy") || InMethodFamily(selector, "mutableCopy") || + InMethodFamily(selector, "new") || Interop::ReturnsRetained(selector); + return owned ? NativeReturnOwnership::Owned : NativeReturnOwnership::Unowned; +} + void ArgConverter::SetValue(Local context, void* retValue, size_t returnSize, Local value, const TypeEncoding* typeEncoding) { if (typeEncoding->type == BinaryTypeEncodingType::VoidEncoding) { @@ -706,7 +759,7 @@ explicit BorrowedSelfScope(Isolate* isolate) : isolate(isolate) {} // An initializer that answered with an already wrapped object (a singleton, // a tagged pointer, a cached cluster instance) leaves us holding a +1 the // existing entry has no use for. - if (resultIsOwned) { + if (Interop::ReleasesCachedOwnedReturn(resultIsOwned, OwnedReturnReceiver::AllocatedForCall)) { [result release]; } info.GetReturnValue().Set(poInstance->Get(isolate)); diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 3baac770..507879f0 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -690,7 +690,8 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { std::shared_ptr> poCallback = std::make_shared>(isolate, method); MethodCallbackWrapper* userData = - new MethodCallbackWrapper(isolate, poCallback, 2, argsCount, typeEncoding); + new MethodCallbackWrapper(isolate, poCallback, 2, argsCount, typeEncoding, + ArgConverter::ReturnOwnership(selector, nullptr)); IMP methodBody = Interop::CreateMethod(2, argsCount, typeEncoding, ArgConverter::MethodCallback, userData); std::string typeInfo = GetTypeEncoding(typeEncoding, argsCount); @@ -793,9 +794,10 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { std::make_shared>(isolate, method); const TypeEncoding* typeEncoding = methodMeta->encodings()->first(); uint8_t argsCount = methodMeta->encodings()->count - 1; - MethodCallbackWrapper* userData = - new MethodCallbackWrapper(isolate, poCallback, 2, argsCount, typeEncoding); SEL selector = methodMeta->selector(); + MethodCallbackWrapper* userData = + new MethodCallbackWrapper(isolate, poCallback, 2, argsCount, typeEncoding, + ArgConverter::ReturnOwnership(selector, methodMeta)); IMP methodBody = Interop::CreateMethod(2, argsCount, typeEncoding, ArgConverter::MethodCallback, userData); std::string typeInfo = GetTypeEncoding(typeEncoding, argsCount); @@ -931,8 +933,13 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { // Default getter result on a JS throw: zeroed return buffer. memset(retValue, 0, cif->rtype->size); } else { - const TypeEncoding* typeEncoding = context->meta_->getter()->encodings()->first(); + const MethodMeta* getterMeta = context->meta_->getter(); + const TypeEncoding* typeEncoding = getterMeta->encodings()->first(); ArgConverter::SetValue(v8Context, retValue, cif->rtype->size, res, typeEncoding); + ArgConverter::RetainOwnedResult( + isolate, + ArgConverter::ReturnOwnership(*static_cast(argValues[1]), getterMeta), + argValues, retValue, typeEncoding, res); } } if (pendingThrow != nil) { diff --git a/NativeScript/runtime/Interop.h b/NativeScript/runtime/Interop.h index 9dcfed69..de332854 100644 --- a/NativeScript/runtime/Interop.h +++ b/NativeScript/runtime/Interop.h @@ -15,6 +15,21 @@ namespace tns { typedef void (*FFIMethodCallback)(ffi_cif* cif, void* retValue, void** argValues, void* userData); +// The receiver of a call that returns an object, as far as releasing an owned +// result that already has a wrapper is concerned. +enum class OwnedReturnReceiver : uint8_t { + // A class method or a C function. + None, + // An instance method that leaves its receiver to the caller. + Kept, + // An initializer or NS_REPLACES_RECEIVER method sent to a receiver that + // existed before the call. + Consumed, + // An initializer or NS_REPLACES_RECEIVER method sent to a receiver the + // runtime allocated for this call. + AllocatedForCall, +}; + struct MethodCall { MethodCall(v8::Local context, bool isPrimitiveFunction, void* functionPointer, const TypeEncoding* typeEncoding, @@ -50,6 +65,7 @@ struct MethodCall { bool ownsReturnedObject_; bool returnsUnmanaged_; bool isInitializer_; + bool receiverAllocatedForCall_ = false; }; struct CMethodCall : MethodCall { @@ -63,7 +79,8 @@ struct CMethodCall : MethodCall { struct ObjCMethodCall : public MethodCall { ObjCMethodCall(v8::Local context, const MethodMeta* meta, - id target, Class clazz, V8Args& args, bool callSuper) + id target, Class clazz, V8Args& args, bool callSuper, + bool receiverAllocatedForCall = false) : MethodCall(context, false, callSuper ? (void*)objc_msgSendSuper : (void*)objc_msgSend, meta->encodings()->first(), args, target, clazz, @@ -71,7 +88,9 @@ struct ObjCMethodCall : public MethodCall { meta->hasErrorOutParameter() && args.Length() < meta->encodings()->count - 1, meta->ownsReturnedCocoaObject(), false, - meta->isInitializer()) {} + meta->isInitializer()) { + receiverAllocatedForCall_ = receiverAllocatedForCall; + } }; class Interop { @@ -96,19 +115,37 @@ class Interop { Class clazz, V8Args& args); static v8::Local CallFunction(ObjCMethodCall& methodCall); static v8::Local CallFunction(CMethodCall& methodCall); + // Whether a method answers in place of its receiver, consuming the caller's + // reference to it: the init family, and NS_REPLACES_RECEIVER methods, which + // the metadata does not record. + static bool ConsumesReceiver(SEL selector, bool isInitializer); + // Whether a method takes over the caller's reference to its argument at + // `index`, counted from the first after the selector: NS_RELEASES_ARGUMENT + // parameters, which the metadata does not record. + static bool ConsumesArgument(SEL selector, int index); + // Whether a method outside the copy, mutableCopy and new families answers + // an object its caller owns (NS_RETURNS_RETAINED), for implementations the + // metadata does not describe. + static bool ReturnsRetained(SEL selector); + // Whether an owned object result that already has a wrapper carries a +1 + // the caller must drop, the Instances entry holding its own reference. + static bool ReleasesCachedOwnedReturn(bool ownsReturnedObject, + OwnedReturnReceiver receiver); static v8::Local GetResultByType( v8::Local context, BaseDataWrapper* typeWrapper, BaseCall* call, v8::Local parentStruct = v8::Local()); // A non-empty `parentStruct` makes a struct-typed result a view into the - // parent's buffer (`call` points at the field) instead of a copy. + // parent's buffer (`call` points at the field) instead of a copy. The + // default `ownedReturnReceiver` never releases an owned result found already + // wrapped. static v8::Local GetResult( v8::Local context, const TypeEncoding* typeEncoding, BaseCall* call, bool marshalToPrimitive, v8::Local parentStruct = v8::Local(), bool isStructMember = false, bool ownsReturnedObject = false, bool returnsUnmanaged = false, bool isInitializer = false, - bool releaseCachedOwnedReturn = false); + OwnedReturnReceiver ownedReturnReceiver = OwnedReturnReceiver::Consumed); static void SetStructPropertyValue(v8::Local context, StructWrapper* wrapper, const StructField& field, diff --git a/NativeScript/runtime/Interop.mm b/NativeScript/runtime/Interop.mm index 5cbc8f40..ae7be610 100644 --- a/NativeScript/runtime/Interop.mm +++ b/NativeScript/runtime/Interop.mm @@ -168,6 +168,27 @@ return Interop::CallFunctionInternal(methodCall); } +bool Interop::ConsumesReceiver(SEL selector, bool isInitializer) { + return isInitializer || selector == @selector(awakeAfterUsingCoder:); +} + +// -[NSKeyedUnarchiverDelegate unarchiver:didDecodeObject:] is the only SDK +// method with an NS_RELEASES_ARGUMENT parameter; it also returns retained. +bool Interop::ConsumesArgument(SEL selector, int index) { + return index == 1 && selector == @selector(unarchiver:didDecodeObject:); +} + +bool Interop::ReturnsRetained(SEL selector) { return ConsumesArgument(selector, 1); } + +// Balanced only while every owned result other than a consumed receiver +// reaches here at +1, JS-implemented methods included: MethodCallback retains +// what a JS implementation answers at +0, and for initializers anything but +// the receiver. AllocatedForCall does not check that the result is the +// receiver, so narrowing that retain makes this over-release. +bool Interop::ReleasesCachedOwnedReturn(bool ownsReturnedObject, OwnedReturnReceiver receiver) { + return ownsReturnedObject && receiver != OwnedReturnReceiver::Consumed; +} + id Interop::CallInitializer(Local context, const MethodMeta* methodMeta, id target, Class clazz, V8Args& args) { const TypeEncoding* typeEncoding = methodMeta->encodings()->first(); @@ -1029,7 +1050,7 @@ static bool SnapshotArrayLike(Isolate* isolate, Local object, Local Interop::GetResult(Local context, const TypeEncoding* typeEncoding, BaseCall* call, bool marshalToPrimitive, Local parentStruct, bool isStructMember, bool ownsReturnedObject, bool returnsUnmanaged, - bool isInitializer, bool releaseCachedOwnedReturn) { + bool isInitializer, OwnedReturnReceiver ownedReturnReceiver) { Isolate* isolate = v8::Isolate::GetCurrent(); if (returnsUnmanaged) { @@ -1364,12 +1385,7 @@ static bool SnapshotArrayLike(Isolate* isolate, Local object, Local cached = poInstance->Get(isolate); - // The Instances entry holds its own reference, so a returned +1 is - // surplus. Only class methods and C functions qualify: an instance - // method can hand back its receiver without a net retain (initializers, - // NS_REPLACES_RECEIVER) or come from a JS implementation, which returns - // at +0. - if (ownsReturnedObject && releaseCachedOwnedReturn) { + if (ReleasesCachedOwnedReturn(ownsReturnedObject || isInitializer, ownedReturnReceiver)) { [result release]; } return cached; @@ -1830,6 +1846,14 @@ throw NativeScriptException( Interop::SetFFIParams(methodCall.context_, methodCall.typeEncoding_, &call, argsCount, initialParameterIndex, methodCall.args_); + if (initialParameterIndex > 1) { + for (int i = initialParameterIndex; i < argsCount; i++) { + if (ConsumesArgument(methodCall.selector_, i - initialParameterIndex)) { + [*static_cast(call.ArgumentBuffer(i)) retain]; + } + } + } + void* errorRef = nullptr; if (methodCall.provideErrorOutParameter_) { void* dest = call.ArgumentBuffer(argsCount); @@ -1937,10 +1961,21 @@ throw NativeScriptException( } } - Local result = Interop::GetResult( - methodCall.context_, methodCall.typeEncoding_, &call, marshalToPrimitive, Local(), - false, methodCall.ownsReturnedObject_, methodCall.returnsUnmanaged_, - methodCall.isInitializer_, !isInstanceMethod); + OwnedReturnReceiver receiver = OwnedReturnReceiver::None; + if (isInstanceMethod) { + if (!ConsumesReceiver(methodCall.selector_, methodCall.isInitializer_)) { + receiver = OwnedReturnReceiver::Kept; + } else if (methodCall.receiverAllocatedForCall_) { + receiver = OwnedReturnReceiver::AllocatedForCall; + } else { + receiver = OwnedReturnReceiver::Consumed; + } + } + + Local result = + Interop::GetResult(methodCall.context_, methodCall.typeEncoding_, &call, marshalToPrimitive, + Local(), false, methodCall.ownsReturnedObject_, + methodCall.returnsUnmanaged_, methodCall.isInitializer_, receiver); // Owned returns, initializers and class factory methods hand out objects JS // most likely holds the last reference to. Instance getters do not: their diff --git a/NativeScript/runtime/MetadataBuilder.mm b/NativeScript/runtime/MetadataBuilder.mm index eca54cce..8ceedd9e 100644 --- a/NativeScript/runtime/MetadataBuilder.mm +++ b/NativeScript/runtime/MetadataBuilder.mm @@ -1397,7 +1397,8 @@ CMethodCall methodCall(context, item->userData_, typeEncoding, args, std::shared_ptr> poCallback = std::make_shared>(isolate, value); MethodCallbackWrapper* userData = - new MethodCallbackWrapper(isolate, poCallback, 2, argsCount, typeEncoding); + new MethodCallbackWrapper(isolate, poCallback, 2, argsCount, typeEncoding, + ArgConverter::ReturnOwnership(methodMeta->selector(), methodMeta)); IMP methodBody = Interop::CreateMethod(2, argsCount, typeEncoding, ArgConverter::MethodCallback, userData); IMP nativeImp = @@ -1477,8 +1478,12 @@ CMethodCall methodCall(context, item->userData_, typeEncoding, args, } memset(retValue, 0, cif->rtype->size); } else { - const TypeEncoding* typeEncoding = context->meta_->getter()->encodings()->first(); + const MethodMeta* getterMeta = context->meta_->getter(); + const TypeEncoding* typeEncoding = getterMeta->encodings()->first(); ArgConverter::SetValue(v8Context, retValue, cif->rtype->size, res, typeEncoding); + ArgConverter::RetainOwnedResult( + isolate, ArgConverter::ReturnOwnership(*static_cast(argValues[1]), getterMeta), + argValues, retValue, typeEncoding, res); } } if (pendingThrow != nil) { diff --git a/TestFixtures/Marshalling/TNSAllocLog.h b/TestFixtures/Marshalling/TNSAllocLog.h index ef80f9a9..71f43603 100644 --- a/TestFixtures/Marshalling/TNSAllocLog.h +++ b/TestFixtures/Marshalling/TNSAllocLog.h @@ -19,4 +19,55 @@ @end +// Native callers that own what an owned-return message answers and drop it +// straight away. +@interface TNSOwnedReturns : TNSAllocLog + +// Answers a new TNSAllocLog. +- (id)newChild; + +// Its getter answers a new TNSAllocLog, owned by the caller. +@property(nonatomic, readonly) id newItem; + ++ (void)copyAndDrop:(id)object; ++ (void)mutableCopyAndDrop:(id)object; ++ (void)newChildAndDrop:(id)object; ++ (void)awakeAndDrop:(id)object; ++ (void)newItemAndDrop:(id)object; +// Answers `newItem` of `object` without passing ownership on. ++ (id)readNewItemOf:(id)object; + +@end + +// Declares the owned-return members of TNSOwnedReturns itself, so JS can +// replace them on this class alone: metadata leaves out members a subclass +// redeclares. +@interface TNSSwizzledOwnedReturns : TNSAllocLog + +- (id)newChild; + +@property(nonatomic, readonly) id newItem; + +@end + +// A TNSAllocLog that survives a keyed archive. +@interface TNSCodingAllocLog : TNSAllocLog + +@end + +// Implements NSKeyedUnarchiverDelegate: answers the object it is given, or a +// new TNSAllocLog once `substitutes` is set. +@interface TNSDecodeDelegate : NSObject + +@property(nonatomic) BOOL substitutes; + +@end + +// -init sends -initHook to the receiver before returning it. +@interface TNSInitCallsHook : TNSAllocLog + +- (void)initHook; + +@end + #endif /* TNSAllocLog_h */ diff --git a/TestFixtures/Marshalling/TNSAllocLog.m b/TestFixtures/Marshalling/TNSAllocLog.m index e04bb917..1eedb09b 100644 --- a/TestFixtures/Marshalling/TNSAllocLog.m +++ b/TestFixtures/Marshalling/TNSAllocLog.m @@ -29,3 +29,93 @@ - (instancetype)init { } @end + +@implementation TNSOwnedReturns + +- (id)newChild { + return [[TNSAllocLog alloc] init]; +} + ++ (void)copyAndDrop:(id)object { + id result = [object copy]; + (void)result; +} + ++ (void)mutableCopyAndDrop:(id)object { + id result = [object mutableCopy]; + (void)result; +} + ++ (void)newChildAndDrop:(id)object { + id result = [object newChild]; + (void)result; +} + ++ (void)awakeAndDrop:(id)object { + NSCoder* coder = nil; + id result = [object awakeAfterUsingCoder:coder]; + (void)result; +} + +- (id)newItem { + return [[TNSAllocLog alloc] init]; +} + ++ (void)newItemAndDrop:(id)object { + id result = [object newItem]; + (void)result; +} + ++ (id)readNewItemOf:(id)object { + return [object newItem]; +} + +@end + +@implementation TNSSwizzledOwnedReturns + +- (id)newChild { + return [[TNSAllocLog alloc] init]; +} + +- (id)newItem { + return [[TNSAllocLog alloc] init]; +} + +@end + +@implementation TNSCodingAllocLog + ++ (BOOL)supportsSecureCoding { + return YES; +} + +- (instancetype)initWithCoder:(NSCoder*)coder { + return [super init]; +} + +- (void)encodeWithCoder:(NSCoder*)coder { +} + +@end + +@implementation TNSDecodeDelegate + +- (id)unarchiver:(NSKeyedUnarchiver*)unarchiver didDecodeObject:(id)object { + return self.substitutes ? [[TNSAllocLog alloc] init] : object; +} + +@end + +@implementation TNSInitCallsHook + +- (instancetype)init { + self = [super init]; + [self initHook]; + return self; +} + +- (void)initHook { +} + +@end diff --git a/TestRunner/app/tests/NativeCallbackSelfTests.js b/TestRunner/app/tests/NativeCallbackSelfTests.js index 049ae5e2..180485ea 100644 --- a/TestRunner/app/tests/NativeCallbackSelfTests.js +++ b/TestRunner/app/tests/NativeCallbackSelfTests.js @@ -408,4 +408,472 @@ describe("Native callback self wrappers", function () { }); }); }); + + it("leaves the retain count of an immutable object whose -copy answers itself unchanged", function (done) { + var array = NSArray.arrayWithArray([1, 2, 3]); + settle(2, function () { + var before = array.retainCount(); + var same = 0; + for (var i = 0; i < 50; i++) { + if (array.copy() === array) { + same++; + } + } + expect(same).toBe(50); + settle(2, function () { + expect(array.retainCount()).toBe(before); + done(); + }); + }); + }); + + it("frees an object JS alone holds after alloc().init() ran its JS initializer", function (done) { + var obj = JSInit.alloc().init(); + obj.state = { i: 3 }; + settle(2, function () { + expect(initCalls).toBe(1); + expect(obj.state.i).toBe(3); + expect(deallocs()).toBe(0); + obj = null; + settle(3, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + }); + + it("keeps the JS state of a natively owned object whose JS initializer ran from alloc().init(), then frees it once", function (done) { + var owner = NSMutableArray.new(); + (function () { + var obj = JSInit.alloc().init(); + obj.state = { i: 4 }; + owner.addObject(obj); + })(); + settle(3, function () { + var obj = owner.objectAtIndex(0); + expect(initCalls).toBe(1); + expect(obj.initialized).toBe(true); + expect(obj.state && obj.state.i).toBe(4); + obj = null; + expect(deallocs()).toBe(0); + owner.removeAllObjects(); + settle(3, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + }); + + it("keeps a wrapped object a JS initializer returns instead of its receiver (alloc().init())", function (done) { + expectInitializerResultOwnedByCaller(function () { + return createThroughInitReturningOther(function () { + return InitReturnsOther.alloc().init(); + }); + }, done); + }); + + it("keeps the substitute a super initializer returned through a JS initializer (alloc().init())", function (done) { + expectInitializerResultOwnedByCaller(function () { + return InitReturnsSubstitute.alloc().init(); + }, done); + }); + + it("does not release a wrapped receiver that is initialized again", function (done) { + var plain = TNSAllocLog.new(); + var jsInit = JSInit.new(); + expect(plain.init()).toBe(plain); + expect(TNSAllocLog.prototype.init.call(plain)).toBe(plain); + expect(TNSAllocLog.prototype.init.apply(plain, [])).toBe(plain); + expect(jsInit.init()).toBe(jsInit); + expect(JSInit.prototype.init.call(jsInit)).toBe(jsInit); + expect(JSInit.prototype.init.apply(jsInit, [])).toBe(jsInit); + expect(TNSAllocLog.prototype.init.call(jsInit)).toBe(jsInit); + settle(3, function () { + var freed = deallocs(); + expect(freed).toBe(0); + if (freed === 0) { + expect(plain.hash > 0).toBe(true); + expect(jsInit.initialized).toBe(true); + } + plain = null; + jsInit = null; + settle(3, function () { + expect(deallocs()).toBe(2); + done(); + }); + }); + }); + + // Native code that owns what an owned-return method answers drops it. The + // object a JS implementation answered must outlive that, and be freed + // exactly once after JS lets go. TNSOwnedReturns and TNSAllocLog log their + // deallocation; `freedAfterwards` counts those among receiver and result. + function expectKeptThroughNativeDrop(state, drop, freedAfterwards, done) { + drop(state.receiver); + settle(3, function () { + var freed = deallocs(); + expect(freed).toBe(0); + if (freed === 0) { + expect(state.receiver.hash > 0).toBe(true); + expect(state.result.hash > 0).toBe(true); + } + state.receiver = null; + state.result = null; + settle(3, function () { + expect(deallocs()).toBe(freedAfterwards); + done(); + }); + }); + } + + function ownedReturnState(base, name, method, protocols, answersReceiver, extra) { + var state = {}; + var implementation = {}; + var answer = function () { + return answersReceiver ? this : state.result; + }; + // `extra.getter` implements `method` as a property getter; + // `extra.exposedMethods` declares it to a class that has no metadata + // for it. + extra = extra || {}; + if (extra.getter) { + Object.defineProperty(implementation, method, { get: answer, enumerable: true, configurable: true }); + } else { + implementation[method] = answer; + } + var Klass = base.extend(implementation, { name: name, protocols: protocols, exposedMethods: extra.exposedMethods }); + state.receiver = Klass.new(); + state.result = answersReceiver ? state.receiver : TNSAllocLog.new(); + return state; + } + + it("hands a native -copy the receiver a JS copyWithZone answers at +1", function (done) { + var state = ownedReturnState(TNSOwnedReturns, "NativeCallbackSelfCopySelf", "copyWithZone", [NSCopying], true); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.copyAndDrop(receiver); + }, 1, done); + }); + + it("hands a native -copy the other object a JS copyWithZone answers at +1", function (done) { + var state = ownedReturnState(TNSOwnedReturns, "NativeCallbackSelfCopyOther", "copyWithZone", [NSCopying], false); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.copyAndDrop(receiver); + }, 2, done); + }); + + it("hands a native -mutableCopy the object a JS mutableCopyWithZone answers at +1", function (done) { + var state = ownedReturnState(TNSOwnedReturns, "NativeCallbackSelfMutableCopy", "mutableCopyWithZone", [NSMutableCopying], false); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.mutableCopyAndDrop(receiver); + }, 2, done); + }); + + it("hands a native caller the object a JS new-family method answers at +1", function (done) { + var state = ownedReturnState(TNSOwnedReturns, "NativeCallbackSelfNewOther", "newChild", [], false); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.newChildAndDrop(receiver); + }, 2, done); + }); + + it("hands a native caller the receiver a JS new-family method answers at +1", function (done) { + var state = ownedReturnState(TNSOwnedReturns, "NativeCallbackSelfNewSelf", "newChild", [], true); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.newChildAndDrop(receiver); + }, 1, done); + }); + + it("hands a native caller back the receiver a JS awakeAfterUsingCoder answers", function (done) { + var state = ownedReturnState(TNSOwnedReturns, "NativeCallbackSelfAwakeSelf", "awakeAfterUsingCoder", [], true); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.awakeAndDrop(receiver); + }, 1, done); + }); + + // The receiver is not a TNSAllocLog: its reference, consumed by the call, + // is not given back when the JS method answers another object. + it("hands a native caller the other object a JS awakeAfterUsingCoder answers at +1", function (done) { + var state = ownedReturnState(NSObject, "NativeCallbackSelfAwakeOther", "awakeAfterUsingCoder", [], false); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.awakeAndDrop(receiver); + }, 1, done); + }); + + function expectBalancedJsCopies(answersReceiver, name, done) { + var state = ownedReturnState(TNSOwnedReturns, name, "copyWithZone", [NSCopying], answersReceiver); + for (var i = 0; i < 20; i++) { + expect(state.receiver.copy()).toBe(state.result); + } + settle(3, function () { + var freed = deallocs(); + expect(freed).toBe(0); + if (freed === 0) { + expect(state.receiver.hash > 0).toBe(true); + expect(state.result.hash > 0).toBe(true); + } + state.receiver = null; + state.result = null; + settle(3, function () { + expect(deallocs()).toBe(answersReceiver ? 1 : 2); + done(); + }); + }); + } + + it("balances copy() called from JS on a JS copyWithZone that answers its receiver", function (done) { + expectBalancedJsCopies(true, "NativeCallbackSelfJsCopySelf", done); + }); + + it("balances copy() called from JS on a JS copyWithZone that answers another object", function (done) { + expectBalancedJsCopies(false, "NativeCallbackSelfJsCopyOther", done); + }); + + it("hands a native caller the object a JS new-family method without metadata answers at +1", function (done) { + var state = ownedReturnState(TNSAllocLog, "NativeCallbackSelfExposedNew", "newChild", [], false, { + exposedMethods: { newChild: { returns: NSObject, params: [] } } + }); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.newChildAndDrop(receiver); + }, 2, done); + }); + + function ownedGetterState(answersReceiver, name) { + return ownedReturnState(TNSOwnedReturns, name, "newItem", [], answersReceiver, { getter: true }); + } + + it("hands a native caller the other object a JS new-family getter answers at +1", function (done) { + var state = ownedGetterState(false, "NativeCallbackSelfGetterOther"); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.newItemAndDrop(receiver); + }, 2, done); + }); + + it("hands a native caller the receiver a JS new-family getter answers at +1", function (done) { + var state = ownedGetterState(true, "NativeCallbackSelfGetterSelf"); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.newItemAndDrop(receiver); + }, 1, done); + }); + + function expectBalancedGetterReads(answersReceiver, name, done) { + var state = ownedGetterState(answersReceiver, name); + for (var i = 0; i < 20; i++) { + expect(state.receiver.newItem).toBe(state.result); + expect(TNSOwnedReturns.readNewItemOf(state.receiver)).toBe(state.result); + } + expectKeptThroughNativeDrop(state, function () {}, answersReceiver ? 1 : 2, done); + } + + it("balances reads of a JS new-family getter that answers another object", function (done) { + expectBalancedGetterReads(false, "NativeCallbackSelfGetterReadsOther", done); + }); + + it("balances reads of a JS new-family getter that answers its receiver", function (done) { + expectBalancedGetterReads(true, "NativeCallbackSelfGetterReadsSelf", done); + }); + + // The replacements stay installed for the rest of the run, on a class no + // other spec uses, and answer what `swizzled.answer` gives them. + var swizzled = { installed: false, answer: null }; + + function swizzledState(answersReceiver) { + if (!swizzled.installed) { + swizzled.installed = true; + TNSSwizzledOwnedReturns.prototype.newChild = function () { + return swizzled.answer(this); + }; + Object.defineProperty(TNSSwizzledOwnedReturns.prototype, "newItem", { + get: function () { + return swizzled.answer(this); + }, + configurable: true + }); + } + var state = { receiver: TNSSwizzledOwnedReturns.new() }; + state.result = answersReceiver ? state.receiver : TNSAllocLog.new(); + swizzled.answer = function (self) { + return answersReceiver ? self : state.result; + }; + return state; + } + + it("hands a native caller the other object a JS replacement of a new-family method answers at +1", function (done) { + var state = swizzledState(false); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.newChildAndDrop(receiver); + }, 2, done); + }); + + it("hands a native caller the receiver a JS replacement of a new-family method answers at +1", function (done) { + var state = swizzledState(true); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.newChildAndDrop(receiver); + }, 1, done); + }); + + it("hands a native caller the other object a JS replacement of a new-family getter answers at +1", function (done) { + var state = swizzledState(false); + expectKeptThroughNativeDrop(state, function (receiver) { + TNSOwnedReturns.newItemAndDrop(receiver); + expect(TNSOwnedReturns.readNewItemOf(receiver)).toBe(state.result); + }, 2, done); + }); + + it("frees once an object whose native -init ran a JS method on it (alloc().init())", function (done) { + var hooks = 0; + var Hooked = TNSInitCallsHook.extend({ + initHook: function () { + hooks++; + this.hooked = true; + } + }, { name: "NativeCallbackSelfInitHook" }); + var obj = Hooked.alloc().init(); + expect(hooks).toBe(1); + expect(obj.hooked).toBe(true); + settle(3, function () { + expect(deallocs()).toBe(0); + expect(obj.hash > 0).toBe(true); + obj = null; + settle(3, function () { + expect(deallocs()).toBe(1); + done(); + }); + }); + }); + + // -unarchiver:didDecodeObject: takes over the object it is given + // (NS_RELEASES_ARGUMENT) and answers an owned one. + var decodeAnswer = null; + var decodeCalls = 0; + var DecodeDelegate = NSObject.extend({ + unarchiverDidDecodeObject: function (unarchiver, object) { + decodeCalls++; + return decodeAnswer(object); + } + }, { name: "NativeCallbackSelfDecodeDelegate", protocols: [NSKeyedUnarchiverDelegate] }); + + // The same method with no metadata to describe it. + var ExposedDecodeDelegate = NSObject.extend({ + "unarchiver:didDecodeObject:": function (unarchiver, object) { + decodeCalls++; + return decodeAnswer(object); + } + }, { + name: "NativeCallbackSelfExposedDecodeDelegate", + exposedMethods: { + "unarchiver:didDecodeObject:": { returns: NSObject, params: [NSObject, NSObject] } + } + }); + + // Decodes an array of two TNSCodingAllocLogs through a `Delegate` + // answering `answer(object)`, or through none when `answer` is null. + // `freedAfterDecode` TNSAllocLogs are gone once the unarchiver is, + // `freedAtEnd` once JS drops the decoded root too. + function expectBalancedDecode(Delegate, answer, freedAfterDecode, freedAtEnd, checkRoot, done) { + var data = (function () { + var root = NSMutableArray.new(); + root.addObject(TNSCodingAllocLog.new()); + root.addObject(TNSCodingAllocLog.new()); + return NSKeyedArchiver.archivedDataWithRootObjectRequiringSecureCodingError(root, false); + })(); + settle(3, function () { + expect(deallocs()).toBe(2); + TNSClearOutput(); + var state = {}; + (function () { + var delegate = answer ? Delegate.new() : null; + decodeAnswer = answer; + decodeCalls = 0; + var unarchiver = NSKeyedUnarchiver.alloc().initForReadingFromDataError(data); + unarchiver.requiresSecureCoding = false; + unarchiver.delegate = delegate; + state.root = unarchiver.decodeObjectForKey(NSKeyedArchiveRootObjectKey); + unarchiver.finishDecoding(); + unarchiver.delegate = null; + decodeAnswer = null; + })(); + expect(decodeCalls).toBe(answer ? 3 : 0); + settle(3, function () { + expect(deallocs()).toBe(freedAfterDecode); + checkRoot(state.root); + state.root = null; + settle(3, function () { + expect(deallocs()).toBe(freedAtEnd); + done(); + }); + }); + }); + } + + function expectDecodedRoot(elementClass) { + return function (root) { + expect(root instanceof NSArray).toBe(true); + expect(root.count).toBe(2); + for (var i = 0; i < root.count; i++) { + var element = root.objectAtIndex(i); + expect(element instanceof elementClass).toBe(true); + expect(element.hash > 0).toBe(true); + } + }; + } + + it("decodes without an unarchiver delegate", function (done) { + expectBalancedDecode(null, null, 0, 2, expectDecodedRoot(TNSCodingAllocLog), done); + }); + + [ + { Delegate: DecodeDelegate, suffix: "" }, + { Delegate: ExposedDecodeDelegate, suffix: " (exposedMethods, no protocol)" } + ].forEach(function (variant) { + var Delegate = variant.Delegate; + + it("hands an unarchiver back the objects a JS delegate answers unchanged" + variant.suffix, function (done) { + expectBalancedDecode(Delegate, function (object) { + return object; + }, 0, 2, expectDecodedRoot(TNSCodingAllocLog), done); + }); + + it("releases the objects a JS unarchiver delegate replaces" + variant.suffix, function (done) { + expectBalancedDecode(Delegate, function (object) { + return object instanceof TNSCodingAllocLog ? TNSAllocLog.new() : object; + }, 2, 4, function (root) { + expectDecodedRoot(TNSAllocLog)(root); + expect(root.objectAtIndex(0) instanceof TNSCodingAllocLog).toBe(false); + }, done); + }); + + // The unarchiver keeps an object its delegate answers nil for. + it("releases the objects a JS unarchiver delegate answers nil for" + variant.suffix, function (done) { + expectBalancedDecode(Delegate, function (object) { + return null; + }, 0, 2, expectDecodedRoot(TNSCodingAllocLog), done); + }); + }); + + it("gives a native unarchiver delegate called from JS the reference it takes over", function (done) { + var delegate = TNSDecodeDelegate.new(); + var object = TNSAllocLog.new(); + var i; + for (i = 0; i < 20; i++) { + expect(delegate.unarchiverDidDecodeObject(null, object)).toBe(object); + } + delegate.substitutes = true; + for (i = 0; i < 20; i++) { + var other = delegate.unarchiverDidDecodeObject(null, object); + expect(other instanceof TNSAllocLog).toBe(true); + expect(other).not.toBe(object); + } + other = null; + settle(3, function () { + var freed = deallocs(); + expect(freed).toBe(20); + if (freed === 20) { + expect(object.hash > 0).toBe(true); + } + object = null; + settle(3, function () { + expect(deallocs()).toBe(21); + done(); + }); + }); + }); });