From 27d39bbe20fa7bb986ff41089ba3831f1d8e8c69 Mon Sep 17 00:00:00 2001 From: eDO Team Date: Fri, 13 Nov 2026 15:10:17 -0800 Subject: [PATCH] Add host per-vend reference counting for vended remote proxies. PiperOrigin-RevId: 1021361857 --- Service/Sources/EDOClientService.m | 17 +- Service/Sources/EDOHostService+Private.h | 12 +- Service/Sources/EDOHostService.m | 59 +- Service/Sources/EDOObject+Private.h | 10 + Service/Sources/EDOObject.m | 19 +- Service/Sources/EDOObjectReleaseMessage.h | 8 + Service/Sources/EDOObjectReleaseMessage.m | 21 +- Service/Tests/UnitTests/EDOMessageTest.m | 682 +++++++++++++++++++++- Service/Tests/UnitTests/EDOServiceTest.m | 18 + 9 files changed, 824 insertions(+), 22 deletions(-) diff --git a/Service/Sources/EDOClientService.m b/Service/Sources/EDOClientService.m index 86e8d34..d91eb92 100644 --- a/Service/Sources/EDOClientService.m +++ b/Service/Sources/EDOClientService.m @@ -255,9 +255,11 @@ + (nullable id)cachedEDOFromObjectUpdateIfNeeded:(nullable id)object { resolvedObject = localObject; } else if (localObject && [edoObject.servicePort match:localEDO.servicePort]) { // Verify the service in case the old address is overwritten by a new service. - // Since we already have the EDOObject in the cache, the new decoded EDOObject is - // taken as a temporary local object, which does not send release message. - edoObject.local = YES; + // Only suppress release for legacy uncounted proxies; counted proxies must send a + // counted release on dealloc to balance the host's per-vend increment. + if (!edoObject.countedByHost) { + edoObject.local = YES; + } resolvedObject = localObject; } else { // Track the new remote object. @@ -312,6 +314,11 @@ + (EDOServiceResponse *)sendSynchronousRequest:(EDOServiceRequest *)request [stats reportConnectionDuration:EDOGetMillisecondsSinceMachTime(connectionStartTime)]; if (connectionError) { + if ([request class] == [EDOObjectReleaseRequest class] && errorOut == NULL) { + // Release requests are best-effort cleanup (e.g. a temporary or stopped host service may + // already be gone), so do not report errors or invoke the global client error handler. + return nil; + } [stats reportError]; NSDictionary *userInfo = @{ EDOErrorPortKey : port, @@ -380,6 +387,10 @@ + (EDOServiceResponse *)sendSynchronousRequest:(EDOServiceRequest *)request return response; } else { // Cleanup broken channels before retry. + // Retrying a request after attempt 1 already ran on the receiver is non-idempotent: + // (1) the host leaks one counted vend for any vended return object whose response was lost, + // and (2) invocation arguments were boxed once by the caller and re-decoded on retry, + // causing the receiver to emit two counted releases for one caller vend. [EDOChannelPool.sharedChannelPool removeChannelsWithPort:port]; currentAttempt += 1; } diff --git a/Service/Sources/EDOHostService+Private.h b/Service/Sources/EDOHostService+Private.h index 08d98eb..bf5075a 100644 --- a/Service/Sources/EDOHostService+Private.h +++ b/Service/Sources/EDOHostService+Private.h @@ -65,7 +65,17 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)isObjectAliveWithPort:(EDOServicePort *)port remoteAddress:(EDOPointerType)remoteAddress; /** - * Removes an EDOObject with the specified address in the host cache. + * Decrements the per-vend reference count for the object at @c remoteAddress and removes it from + * the host cache when the count reaches zero. + * + * @param remoteAddress The @c EDOPointerType containing the object address. + * @return @c YES if a tracked entry existed (and was decremented or removed); @c NO otherwise. + */ +- (BOOL)releaseObjectWithAddress:(EDOPointerType)remoteAddress; + +/** + * Unconditionally removes an EDOObject with the specified address in the host cache, clearing + * any outstanding per-vend reference count. * * @param remoteAddress The @c EDOPointerType containing the object address. * @return @c YES if an object was removed; @c NO otherwise. diff --git a/Service/Sources/EDOHostService.m b/Service/Sources/EDOHostService.m index 0a8c33a..f870c32 100644 --- a/Service/Sources/EDOHostService.m +++ b/Service/Sources/EDOHostService.m @@ -97,6 +97,11 @@ @interface EDOHostService () * the object. */ @property(nonatomic, readonly) NSMutableDictionary *localObjects; +/** + * Per-vend reference counts for tracked entries in @c localObjects. + * The key is the object address as an @c NSNumber and the value is the active vend count. + */ +@property(nonatomic, readonly) NSMutableDictionary *localObjectsCounts; /** The queue to update local objects atomically. */ @property(nonatomic, readonly) dispatch_queue_t localObjectsSyncQueue; /** @@ -219,6 +224,7 @@ - (instancetype)initWithPort:(UInt16)port if (self) { _registeredToDevice = NO; _localObjects = [[NSMutableDictionary alloc] init]; + _localObjectsCounts = [[NSMutableDictionary alloc] init]; _localObjectsSyncQueue = dispatch_queue_create("com.google.edo.service.localObjects", DISPATCH_QUEUE_SERIAL); @@ -372,22 +378,30 @@ - (EDOObject *)distantObjectForLocalObject:(id)object hostPort:(EDOHostPort *)ho } NSNumber *objectKey = [NSNumber numberWithLongLong:(EDOPointerType)object]; - if (object != self.rootLocalObject) { + BOOL isCountedVend = (object != self.rootLocalObject); + if (isCountedVend) { dispatch_sync(_localObjectsSyncQueue, ^{ if (![self.localObjects objectForKey:objectKey]) { [self.localObjects setObject:object forKey:objectKey]; } + NSUInteger count = self.localObjectsCounts[objectKey].unsignedIntegerValue; + self.localObjectsCounts[objectKey] = @(count + 1); }); } hostPort = hostPort ?: self.port.hostPort; EDOServicePort *port = [EDOServicePort servicePortWithPort:self.port hostPort:hostPort]; + EDOObject *distantObject = nil; if (isObjectBlock) { - return [EDOBlockObject edo_remoteProxyFromUnderlyingObject:object withPort:port]; + distantObject = [EDOBlockObject edo_remoteProxyFromUnderlyingObject:object withPort:port]; } else { - return [EDOObject edo_remoteProxyFromUnderlyingObject:object withPort:port]; + distantObject = [EDOObject edo_remoteProxyFromUnderlyingObject:object withPort:port]; } + if (isCountedVend) { + [distantObject edo_enableHostCountedVend]; + } + return distantObject; } - (BOOL)isObjectAliveWithPort:(EDOServicePort *)port remoteAddress:(EDOPointerType)remoteAddress { @@ -404,13 +418,48 @@ - (BOOL)isObjectAliveWithPort:(EDOServicePort *)port remoteAddress:(EDOPointerTy return isAlive; } +- (BOOL)releaseObjectWithAddress:(EDOPointerType)remoteAddress { + NSNumber *edoKey = [NSNumber numberWithLongLong:remoteAddress]; + __block id removedObject = nil; + __block BOOL found = NO; + + dispatch_sync(_localObjectsSyncQueue, ^{ + id trackedObject = self.localObjects[edoKey]; + if (!trackedObject) { + return; + } + found = YES; + NSUInteger count = self.localObjectsCounts[edoKey].unsignedIntegerValue; + if (count > 1) { + self.localObjectsCounts[edoKey] = @(count - 1); + } else { + removedObject = trackedObject; + [self.localObjects removeObjectForKey:edoKey]; + [self.localObjectsCounts removeObjectForKey:edoKey]; + } + }); + // Release outside the sync queue so object dealloc cannot re-enter localObjectsSyncQueue. + removedObject = nil; + return found; +} + - (BOOL)removeObjectWithAddress:(EDOPointerType)remoteAddress { NSNumber *edoKey = [NSNumber numberWithLongLong:remoteAddress]; + __block id removedObject = nil; + __block BOOL found = NO; dispatch_sync(_localObjectsSyncQueue, ^{ - [self.localObjects removeObjectForKey:edoKey]; + id trackedObject = self.localObjects[edoKey]; + if (trackedObject) { + found = YES; + removedObject = trackedObject; + [self.localObjects removeObjectForKey:edoKey]; + } + [self.localObjectsCounts removeObjectForKey:edoKey]; }); - return YES; + // Release outside the sync queue so object dealloc cannot re-enter localObjectsSyncQueue. + removedObject = nil; + return found; } - (BOOL)removeWeakObjectWithAddress:(EDOPointerType)remoteAddress { diff --git a/Service/Sources/EDOObject+Private.h b/Service/Sources/EDOObject+Private.h index 9d397a8..55024d7 100644 --- a/Service/Sources/EDOObject+Private.h +++ b/Service/Sources/EDOObject+Private.h @@ -42,6 +42,8 @@ typedef int64_t EDOPointerType; @property(readonly) BOOL isLocalEdo; /** Whether the @c EDOObject is weakly referenced. */ @property(readonly) BOOL weaklyReferenced; +/** Whether this remote proxy represents a counted vend tracked on the host service. */ +@property(nonatomic, readonly, getter=isCountedByHost) BOOL countedByHost; /** Create an object with the given local target and port the session listens on. */ + (instancetype)objectWithTarget:(id)target port:(EDOServicePort *)port; @@ -55,6 +57,14 @@ typedef int64_t EDOPointerType; /** Create an EDOObject distant object based on an underlying object. */ + (EDOObject *)edo_remoteProxyFromUnderlyingObject:(id)object withPort:(EDOServicePort *)port; +/** + * Marks this local proxy as eligible to encode @c edoCountedByHost when archived. + * + * Host services call this on newly vended proxies for counted objects (non-root) so clients send + * a counted release rather than tearing down all outstanding vends. + */ +- (void)edo_enableHostCountedVend; + /** * The method to forward invocation. * diff --git a/Service/Sources/EDOObject.m b/Service/Sources/EDOObject.m index 515f9a5..fad9e9c 100644 --- a/Service/Sources/EDOObject.m +++ b/Service/Sources/EDOObject.m @@ -34,6 +34,7 @@ static NSString *const kEDOObjectCoderRemoteClassKey = @"edoRemoteClass"; static NSString *const kEDOObjectCoderClassNameKey = @"edoClassName"; static NSString *const kEDOObjectCoderProcessUUIDKey = @"edoProcessUUID"; +static NSString *const kEDOObjectCoderCountedByHostKey = @"edoCountedByHost"; /** Returns the boolean idicating if the two objects are from the same process. */ static BOOL IsFromSameProcess(id object1, id object2); @@ -49,6 +50,10 @@ @interface EDOObject () @property(nonatomic, readonly) NSString *className; /** The process unique identifier for the remote object. */ @property(nonatomic, readonly) NSString *processUUID; +/** Whether this remote proxy represents a counted vend tracked on the host service. */ +@property(nonatomic, readonly, getter=isCountedByHost) BOOL countedByHost; +/** Whether this local proxy represents a fresh host vend eligible to encode @c edoCountedByHost. */ +@property(nonatomic) BOOL hostCountedVend; @end @implementation EDOObject @@ -72,6 +77,8 @@ - (instancetype)initWithCoder:(NSCoder *)aDecoder { _local = NO; _processUUID = [aDecoder decodeObjectOfClass:[NSString class] forKey:kEDOObjectCoderProcessUUIDKey]; + _countedByHost = [aDecoder decodeBoolForKey:kEDOObjectCoderCountedByHostKey]; + _hostCountedVend = NO; return self; } @@ -117,9 +124,15 @@ - (instancetype)edo_initWithLocalObject:(id)target port:(EDOServicePort *)port { _className = NSStringFromClass(object_getClass(target)); _processUUID = [EDOObject edo_processUUID]; _local = YES; + _countedByHost = NO; + _hostCountedVend = NO; return self; } +- (void)edo_enableHostCountedVend { + self.hostCountedVend = YES; +} + #pragma mark - Class method proxy - (instancetype)alloc { @@ -158,7 +171,8 @@ - (void)dealloc { [EDOClientService removeDistantObjectReferenceForObject:self]; @try { EDOObjectReleaseRequest *request = - [EDOObjectReleaseRequest requestWithRemoteAddress:_remoteAddress]; + _countedByHost ? [EDOObjectReleaseRequest requestWithCountedRemoteAddress:_remoteAddress] + : [EDOObjectReleaseRequest requestWithRemoteAddress:_remoteAddress]; [EDOClientService sendSynchronousRequest:request onPort:_servicePort.hostPort]; } @catch (NSException *e) { // There's an error with the service or most likely it's dead. @@ -179,6 +193,9 @@ - (void)encodeWithCoder:(NSCoder *)aCoder { [aCoder encodeInt64:self.remoteClass forKey:kEDOObjectCoderRemoteClassKey]; [aCoder encodeObject:self.className forKey:kEDOObjectCoderClassNameKey]; [aCoder encodeObject:self.processUUID forKey:kEDOObjectCoderProcessUUIDKey]; + if (self.hostCountedVend) { + [aCoder encodeBool:YES forKey:kEDOObjectCoderCountedByHostKey]; + } } - (BOOL)isEqual:(id)object { diff --git a/Service/Sources/EDOObjectReleaseMessage.h b/Service/Sources/EDOObjectReleaseMessage.h index de117d2..11267d6 100644 --- a/Service/Sources/EDOObjectReleaseMessage.h +++ b/Service/Sources/EDOObjectReleaseMessage.h @@ -35,6 +35,14 @@ NS_ASSUME_NONNULL_BEGIN */ + (instancetype)requestWithRemoteAddress:(EDOPointerType)remoteAddress; +/** + * Creates an object release request that decrements one counted vend on the host. + * + * @param remoteAddress The remote object address to decrement. + * @return An instance of @c EDOObjectReleaseRequest. + */ ++ (instancetype)requestWithCountedRemoteAddress:(EDOPointerType)remoteAddress; + /** * Creates an EDOObjectReleaseRequest for weak EDOObjects. * diff --git a/Service/Sources/EDOObjectReleaseMessage.m b/Service/Sources/EDOObjectReleaseMessage.m index ea752f1..857b11d 100644 --- a/Service/Sources/EDOObjectReleaseMessage.m +++ b/Service/Sources/EDOObjectReleaseMessage.m @@ -24,6 +24,7 @@ static NSString *const kEDOObjectReleaseCoderWeaklyReferencedKey = @"weaklyReferenced"; static NSString *const kEDOObjectReleaseCoderRemoteAddressKey = @"remoteAddress"; +static NSString *const kEDOObjectReleaseCoderCountedReleaseKey = @"countedRelease"; @interface EDOObjectReleaseRequest () @@ -31,6 +32,8 @@ @interface EDOObjectReleaseRequest () /** Indicates whether the object to be released is a weakly referenced object. */ @property(nonatomic, readonly, getter=isWeaklyReferenced) BOOL weaklyReferenced; +/** Indicates whether this request decrements one counted vend rather than force-removing. */ +@property(nonatomic, readonly, getter=isCountedRelease) BOOL countedRelease; @end @@ -41,21 +44,27 @@ + (BOOL)supportsSecureCoding { } - (instancetype)initWithRemoteAddress:(EDOPointerType)remoteAddress - weaklyReferenced:(BOOL)weaklyReferenced { + weaklyReferenced:(BOOL)weaklyReferenced + countedRelease:(BOOL)countedRelease { self = [super init]; if (self) { _remoteAddress = remoteAddress; _weaklyReferenced = weaklyReferenced; + _countedRelease = countedRelease; } return self; } + (instancetype)requestWithRemoteAddress:(EDOPointerType)remoteAddress { - return [[self alloc] initWithRemoteAddress:remoteAddress weaklyReferenced:NO]; + return [[self alloc] initWithRemoteAddress:remoteAddress weaklyReferenced:NO countedRelease:NO]; +} + ++ (instancetype)requestWithCountedRemoteAddress:(EDOPointerType)remoteAddress { + return [[self alloc] initWithRemoteAddress:remoteAddress weaklyReferenced:NO countedRelease:YES]; } + (instancetype)requestWithWeakRemoteAddress:(EDOPointerType)remoteAddress { - return [[self alloc] initWithRemoteAddress:remoteAddress weaklyReferenced:YES]; + return [[self alloc] initWithRemoteAddress:remoteAddress weaklyReferenced:YES countedRelease:NO]; } - (instancetype)initWithCoder:(NSCoder *)aDecoder { @@ -63,6 +72,7 @@ - (instancetype)initWithCoder:(NSCoder *)aDecoder { if (self) { _remoteAddress = [aDecoder decodeInt64ForKey:kEDOObjectReleaseCoderRemoteAddressKey]; _weaklyReferenced = [aDecoder decodeBoolForKey:kEDOObjectReleaseCoderWeaklyReferencedKey]; + _countedRelease = [aDecoder decodeBoolForKey:kEDOObjectReleaseCoderCountedReleaseKey]; } return self; } @@ -71,6 +81,9 @@ - (void)encodeWithCoder:(NSCoder *)aCoder { [super encodeWithCoder:aCoder]; [aCoder encodeInt64:self.remoteAddress forKey:kEDOObjectReleaseCoderRemoteAddressKey]; [aCoder encodeBool:self.weaklyReferenced forKey:kEDOObjectReleaseCoderWeaklyReferencedKey]; + if (self.countedRelease) { + [aCoder encodeBool:YES forKey:kEDOObjectReleaseCoderCountedReleaseKey]; + } } + (EDORequestHandler)requestHandler { @@ -79,6 +92,8 @@ + (EDORequestHandler)requestHandler { EDOPointerType edoRemoteAddress = releaseRequest.remoteAddress; if (releaseRequest.weaklyReferenced) { [service removeWeakObjectWithAddress:edoRemoteAddress]; + } else if (releaseRequest.countedRelease) { + [service releaseObjectWithAddress:edoRemoteAddress]; } else { [service removeObjectWithAddress:edoRemoteAddress]; } diff --git a/Service/Tests/UnitTests/EDOMessageTest.m b/Service/Tests/UnitTests/EDOMessageTest.m index fd7eb87..8d7a837 100644 --- a/Service/Tests/UnitTests/EDOMessageTest.m +++ b/Service/Tests/UnitTests/EDOMessageTest.m @@ -18,6 +18,7 @@ #import +#import "Channel/Sources/EDOChannelPool.h" #import "Channel/Sources/EDOHostPort.h" #import "Service/Sources/EDOBlockObject.h" #import "Service/Sources/EDOClassMessage.h" @@ -30,6 +31,7 @@ #import "Service/Sources/EDOObject+Private.h" #import "Service/Sources/EDOObject.h" #import "Service/Sources/EDOObjectMessage.h" +#import "Service/Sources/EDOObjectReleaseMessage.h" #import "Service/Sources/EDOParameter.h" // IWYU pragma: keep #import "Service/Sources/EDORemoteException.h" #import "Service/Sources/EDOServicePort.h" @@ -37,10 +39,22 @@ #import "Service/Sources/NSKeyedUnarchiver+EDOAdditions.h" #import "Service/Tests/TestsBundle/EDOTestDummy.h" +#import // IWYU pragma: no_include "OCMockObject.h" static const char kDeallocSentinelKey = '\0'; static const char kIsProxyHookKey = '\0'; +@interface EDOHostService (EDOTestRefCounting) +@property(nonatomic, readonly) NSMutableDictionary *localObjects; +@property(nonatomic, readonly) NSMutableDictionary *localObjectsCounts; +@property(nonatomic, readonly) dispatch_queue_t localObjectsSyncQueue; +@property(nonatomic, readonly, nullable) dispatch_queue_t executionQueue; +@end + +@interface EDOObjectReleaseRequest (EDOTestRefCounting) +@property(nonatomic, readonly, getter=isCountedRelease) BOOL countedRelease; +@end + /** Helper that invokes a callback when its associated owner is deallocated. */ @interface EDODeallocSentinel : NSObject - (instancetype)initWithBlock:(dispatch_block_t)block; @@ -718,10 +732,15 @@ - (void)testCachedEDOFromObjectUpdateIfNeededMarksDuplicateLocal { }]; objc_setAssociatedObject(transient, &kDeallocSentinelKey, sentinel, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - EDOObject *vended = [service distantObjectForLocalObject:transient - hostPort:service.port.hostPort]; - address = vended.remoteAddress; - archive = [self edo_archiveProxy:vended withProcessUUID:@"com.google.edotest.remote"]; + // Register transient in service.localObjects, then archive an uncounted proxy to verify the + // legacy duplicate-local suppression path. + [service distantObjectForLocalObject:transient hostPort:service.port.hostPort]; + EDOServicePort *servicePort = [EDOServicePort servicePortWithPort:service.port + hostPort:service.port.hostPort]; + EDOObject *legacy = [EDOObject edo_remoteProxyFromUnderlyingObject:transient + withPort:servicePort]; + address = legacy.remoteAddress; + archive = [self edo_archiveProxy:legacy withProcessUUID:@"com.google.edotest.remote"]; } @autoreleasepool { @@ -774,9 +793,11 @@ - (void)testCachedProxyHitPortMismatchAndLiveRemoval { XCTAssertEqual([EDOClientService cachedEDOFromObjectUpdateIfNeeded:proxyA1], proxyA1); XCTAssertFalse(edoA1.isLocal); - // Same-port duplicate hit must unwrap localEDO and mark duplicate local. + // Same-port duplicate hit must unwrap localEDO and leave counted duplicate remote so dealloc + // balances the host's counted vend. XCTAssertEqual([EDOClientService cachedEDOFromObjectUpdateIfNeeded:proxyA2], proxyA1); - XCTAssertTrue(edoA2.isLocal); + XCTAssertTrue(edoA2.countedByHost); + XCTAssertFalse(edoA2.isLocal); // Mismatched port must replace proxyA1 instead of keeping stale entry. XCTAssertEqual([EDOClientService cachedEDOFromObjectUpdateIfNeeded:proxyB], proxyB); @@ -790,6 +811,12 @@ - (void)testCachedProxyHitPortMismatchAndLiveRemoval { proxyB); [EDOClientService removeDistantObjectReferenceForObject:edoB]; XCTAssertNil([EDOClientService distantObjectReferenceForRemoteAddress:sharedAddress]); + + // Suppress async teardown release writes after verifying cache state since this test isolates + // client cache eviction rather than host release transport. + edoA1.local = YES; + edoA2.local = YES; + edoB.local = YES; } XCTAssertNil([EDOClientService distantObjectReferenceForRemoteAddress:sharedAddress]); } @@ -802,10 +829,28 @@ - (void)testCachedProxyHitPortMismatchAndLiveRemoval { - (void)testCachedEDOFromObjectUpdateIfNeededSerializesConcurrentLookups { for (NSNumber *isBlockValue in @[ @NO, @YES ]) { BOOL isBlock = isBlockValue.boolValue; + EDOHostService *serviceA = [self + edo_hostServiceWithName:[NSString + stringWithFormat:@"com.google.edo.service.cacheConcurrent.%d", + isBlock]]; + id target = [self edo_testTargetForBlock:isBlock]; NSData *archiveA = nil; - EDOPointerType sharedAddress = [self edo_createArchivesForBlock:isBlock - archiveA:&archiveA - archiveB:NULL]; + EDOPointerType sharedAddress = 0; + @autoreleasepool { + // Register target once on serviceA, then archive an uncounted proxy to verify legacy + // concurrent duplicate-local suppression without tripping per-vend refcounting. + [serviceA distantObjectForLocalObject:target hostPort:serviceA.port.hostPort]; + id copiedTarget = isBlock ? [target copy] : target; + EDOServicePort *servicePort = [EDOServicePort servicePortWithPort:serviceA.port + hostPort:serviceA.port.hostPort]; + EDOObject *legacyVended = + isBlock + ? [EDOBlockObject edo_remoteProxyFromUnderlyingObject:copiedTarget + withPort:servicePort] + : [EDOObject edo_remoteProxyFromUnderlyingObject:copiedTarget withPort:servicePort]; + sharedAddress = legacyVended.remoteAddress; + archiveA = [self edo_archiveProxy:legacyVended withProcessUUID:@"com.google.edotest.remoteA"]; + } dispatch_queue_t concurrentQueue = dispatch_queue_create( "com.google.edo.test.clientCacheConcurrent", DISPATCH_QUEUE_CONCURRENT); const size_t workerCount = 4; @@ -837,9 +882,12 @@ - (void)testCachedEDOFromObjectUpdateIfNeededSerializesConcurrentLookups { } } XCTAssertEqual(nonLocalCount, 1U); + // Suppress async release writes after asserting non-local winner selection. + [self edo_distantObjectFromProxy:winner isBlock:isBlock].local = YES; } XCTAssertNil([EDOClientService distantObjectReferenceForRemoteAddress:sharedAddress]); } + [EDOChannelPool.sharedChannelPool removeChannelsWithPort:serviceA.port.hostPort]; } } @@ -1065,6 +1113,622 @@ - (void)testCachedEDOFromObjectUpdateIfNeededHandlesEdgePartitionsAndNilPort { XCTAssertNil([EDOClientService distantObjectReferenceForRemoteAddress:address]); } +/** + * Verifies repeated vending increments per-vend counts and requires matching counted releases + * before dropping the underlying object from the host cache. + * + */ +- (void)testRepeatedVendingIncrementsAndRequiresMatchingCountedReleases { + EDOHostService *service = [self edo_hostServiceWithName:@"com.google.edo.service.refcount1"]; + XCTestExpectation *deallocExpectation = [self expectationWithDescription:@"target deallocated"]; + __weak EDOTestDummy *weakTarget = nil; + EDOPointerType address = 0; + + @autoreleasepool { + EDOTestDummy *target = [[EDOTestDummy alloc] initWithValue:42]; + weakTarget = target; + EDODeallocSentinel *sentinel = [[EDODeallocSentinel alloc] initWithBlock:^{ + [deallocExpectation fulfill]; + }]; + objc_setAssociatedObject(target, &kDeallocSentinelKey, sentinel, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + EDOObject *vended1 = [service distantObjectForLocalObject:target hostPort:nil]; + EDOObject *vended2 = [service distantObjectForLocalObject:target hostPort:nil]; + XCTAssertTrue(vended1.isLocal); + XCTAssertTrue(vended2.isLocal); + address = vended1.remoteAddress; + XCTAssertEqual(address, vended2.remoteAddress); + } + + XCTAssertNotNil(weakTarget); + __block id trackedEntry = nil; + __block NSNumber *trackedCount = nil; + dispatch_sync(service.localObjectsSyncQueue, ^{ + trackedEntry = service.localObjects[@(address)]; + trackedCount = service.localObjectsCounts[@(address)]; + }); + XCTAssertNotNil(trackedEntry); + XCTAssertEqualObjects(trackedCount, @2); + trackedEntry = nil; + + XCTAssertTrue([service releaseObjectWithAddress:address]); + XCTAssertNotNil(weakTarget); + dispatch_sync(service.localObjectsSyncQueue, ^{ + trackedEntry = service.localObjects[@(address)]; + trackedCount = service.localObjectsCounts[@(address)]; + }); + XCTAssertNotNil(trackedEntry); + XCTAssertEqualObjects(trackedCount, @1); + trackedEntry = nil; + + XCTAssertTrue([service releaseObjectWithAddress:address]); + [self waitForExpectations:@[ deallocExpectation ] timeout:5.0]; + XCTAssertNil(weakTarget); + dispatch_sync(service.localObjectsSyncQueue, ^{ + trackedEntry = service.localObjects[@(address)]; + trackedCount = service.localObjectsCounts[@(address)]; + }); + XCTAssertNil(trackedEntry); + XCTAssertNil(trackedCount); + XCTAssertFalse([service releaseObjectWithAddress:address]); +} + +/** + * Verifies legacy @c removeObjectWithAddress: and uncounted @c EDOObjectReleaseRequest messages + * force-clear all outstanding vends immediately. + * + */ +- (void)testLegacyRemoveObjectForceClearsMultipleVendsAndLegacyReleaseRequest { + EDOHostService *service = [self edo_hostServiceWithName:@"com.google.edo.service.refcount2"]; + + // Direct removeObjectWithAddress: clears count = 2 immediately. + XCTestExpectation *deallocA = [self expectationWithDescription:@"target A deallocated"]; + __weak EDOTestDummy *weakA = nil; + EDOPointerType addressA = 0; + @autoreleasepool { + EDOTestDummy *targetA = [[EDOTestDummy alloc] initWithValue:1]; + weakA = targetA; + EDODeallocSentinel *sentinel = [[EDODeallocSentinel alloc] initWithBlock:^{ + [deallocA fulfill]; + }]; + objc_setAssociatedObject(targetA, &kDeallocSentinelKey, sentinel, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + EDOObject *vended1 = [service distantObjectForLocalObject:targetA hostPort:nil]; + [service distantObjectForLocalObject:targetA hostPort:nil]; + addressA = vended1.remoteAddress; + } + XCTAssertTrue([service removeObjectWithAddress:addressA]); + [self waitForExpectations:@[ deallocA ] timeout:5.0]; + XCTAssertNil(weakA); + dispatch_sync(service.localObjectsSyncQueue, ^{ + XCTAssertNil(service.localObjects[@(addressA)]); + XCTAssertNil(service.localObjectsCounts[@(addressA)]); + }); + XCTAssertFalse([service removeObjectWithAddress:addressA]); + + // Legacy uncounted EDOObjectReleaseRequest force-removes despite count = 2. + XCTestExpectation *deallocB = [self expectationWithDescription:@"target B deallocated"]; + __weak EDOTestDummy *weakB = nil; + EDOPointerType addressB = 0; + @autoreleasepool { + EDOTestDummy *targetB = [[EDOTestDummy alloc] initWithValue:2]; + weakB = targetB; + EDODeallocSentinel *sentinel = [[EDODeallocSentinel alloc] initWithBlock:^{ + [deallocB fulfill]; + }]; + objc_setAssociatedObject(targetB, &kDeallocSentinelKey, sentinel, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + EDOObject *vended1 = [service distantObjectForLocalObject:targetB hostPort:nil]; + [service distantObjectForLocalObject:targetB hostPort:nil]; + addressB = vended1.remoteAddress; + } + EDOObjectReleaseRequest *legacyRequest = + [EDOObjectReleaseRequest requestWithRemoteAddress:addressB]; + NSData *archivedRequest = [NSKeyedArchiver edo_archivedDataWithObject:legacyRequest]; + EDOObjectReleaseRequest *decodedRequest = + [NSKeyedUnarchiver edo_unarchiveObjectWithData:archivedRequest]; + EDOObjectReleaseRequest.requestHandler(decodedRequest, service); + [self waitForExpectations:@[ deallocB ] timeout:5.0]; + XCTAssertNil(weakB); + dispatch_sync(service.localObjectsSyncQueue, ^{ + XCTAssertNil(service.localObjects[@(addressB)]); + XCTAssertNil(service.localObjectsCounts[@(addressB)]); + }); +} + +/** + * Verifies duplicate incoming counted proxies send counted releases on dealloc to balance the + * host's per-vend increment while preserving the cached client proxy for both objects and blocks. + * + */ +- (void)testDuplicateCountedProxyRetainsHostCountAndPreservesCachedProxy { + for (NSNumber *isBlockValue in @[ @NO, @YES ]) { + BOOL isBlock = isBlockValue.boolValue; + NSString *serviceName = + [NSString stringWithFormat:@"com.google.edo.service.dupCounted.%d", isBlock]; + EDOHostService *service = [self edo_hostServiceWithName:serviceName]; + XCTestExpectation *deallocExpectation = [self expectationWithDescription:@"target deallocated"]; + __weak id weakTarget = nil; + EDOPointerType address = 0; + + @autoreleasepool { + NSData *archive1 = nil; + NSData *archive2 = nil; + @autoreleasepool { + id target; + if (isBlock) { + EDOTestDummy *capturedDummy = [[EDOTestDummy alloc] initWithValue:99]; + // Capture a heap object so -copy allocates a distinct __NSMallocBlock__ per call. + target = (id)[^int { + return capturedDummy.value; + } copy]; + } else { + target = [[EDOTestDummy alloc] initWithValue:99]; + } + weakTarget = target; + EDODeallocSentinel *sentinel = [[EDODeallocSentinel alloc] initWithBlock:^{ + [deallocExpectation fulfill]; + }]; + objc_setAssociatedObject(target, &kDeallocSentinelKey, sentinel, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + EDOObject *vended1 = [service distantObjectForLocalObject:target + hostPort:service.port.hostPort]; + EDOObject *vended2 = [service distantObjectForLocalObject:target + hostPort:service.port.hostPort]; + address = vended1.remoteAddress; + archive1 = [self edo_archiveProxy:vended1 withProcessUUID:@"com.google.edotest.remote"]; + archive2 = [self edo_archiveProxy:vended2 withProcessUUID:@"com.google.edotest.remote"]; + } + + __block NSNumber *initialCount = nil; + dispatch_sync(service.localObjectsSyncQueue, ^{ + initialCount = service.localObjectsCounts[@(address)]; + }); + XCTAssertEqualObjects(initialCount, @2); + + XCTestExpectation *dupReleaseExpectation = + [self expectationWithDescription:@"duplicate counted release processed"]; + Method releaseMethod = + class_getInstanceMethod([EDOHostService class], @selector(releaseObjectWithAddress:)); + typedef BOOL (*ReleaseIMP)(id, SEL, EDOPointerType); + ReleaseIMP originalIMP = (ReleaseIMP)method_getImplementation(releaseMethod); + IMP wrapperIMP = imp_implementationWithBlock(^BOOL(id instance, EDOPointerType addr) { + BOOL result = originalIMP(instance, @selector(releaseObjectWithAddress:), addr); + if (instance == service && addr == address) { + [dupReleaseExpectation fulfill]; + } + return result; + }); + method_setImplementation(releaseMethod, wrapperIMP); + + @try { + @autoreleasepool { + id proxy1 = [NSKeyedUnarchiver edo_unarchiveObjectWithData:archive1]; + XCTAssertEqual([EDOClientService cachedEDOFromObjectUpdateIfNeeded:proxy1], proxy1); + + @autoreleasepool { + id proxy2 = [NSKeyedUnarchiver edo_unarchiveObjectWithData:archive2]; + EDOObject *distantObject2 = [self edo_distantObjectFromProxy:proxy2 isBlock:isBlock]; + XCTAssertTrue(distantObject2.countedByHost); + id resolved = [EDOClientService cachedEDOFromObjectUpdateIfNeeded:proxy2]; + XCTAssertEqual(resolved, proxy1); + // Counted duplicates stay remote so dealloc emits one counted release. + XCTAssertFalse(distantObject2.isLocal); + } + + // Duplicate counted release should decrement count from 2 to 1 while cached proxy stays + // usable. Wait deterministically for the async release request on the host queue. + [self waitForExpectations:@[ dupReleaseExpectation ] timeout:5.0]; + method_setImplementation(releaseMethod, (IMP)originalIMP); + + __block NSNumber *midCount = nil; + __block id midTracked = nil; + dispatch_sync(service.localObjectsSyncQueue, ^{ + midCount = service.localObjectsCounts[@(address)]; + midTracked = service.localObjects[@(address)]; + }); + XCTAssertEqualObjects(midCount, @1); + XCTAssertNotNil(midTracked); + XCTAssertNotNil(weakTarget); + if (isBlock) { + XCTAssertEqual(((int (^)(void))proxy1)(), 99); + } else { + XCTAssertEqual([(EDOTestDummy *)proxy1 returnInt], 99); + } + XCTAssertEqual([EDOClientService distantObjectReferenceForRemoteAddress:address], proxy1); + } + + // First proxy's dealloc releases the remaining host vend count. + [self waitForExpectations:@[ deallocExpectation ] timeout:5.0]; + } @finally { + method_setImplementation(releaseMethod, (IMP)originalIMP); + if (service.executionQueue) { + // Drain the execution queue so pending async releases finish before imp_removeBlock. + dispatch_sync(service.executionQueue, ^{ + }); + } + imp_removeBlock(wrapperIMP); + } + XCTAssertNil(weakTarget); + __block NSNumber *finalCount = nil; + __block id finalTracked = nil; + dispatch_sync(service.localObjectsSyncQueue, ^{ + finalCount = service.localObjectsCounts[@(address)]; + finalTracked = service.localObjects[@(address)]; + }); + XCTAssertNil(finalCount); + XCTAssertNil(finalTracked); + XCTAssertNil([EDOClientService distantObjectReferenceForRemoteAddress:address]); + } + [EDOChannelPool.sharedChannelPool removeChannelsWithPort:service.port.hostPort]; + } +} + +/** + * Verifies legacy uncounted duplicate proxies are marked local to suppress release, and + * re-encoding a decoded counted proxy drops @c edoCountedByHost so it falls back to legacy + * release. + * + */ +- (void)testLegacyProxyMarksDuplicateLocalAndReencodedCountedProxyFallsBackToLegacy { + EDOHostService *service = [self edo_hostServiceWithName:@"com.google.edo.service.compat"]; + EDOServicePort *servicePort = [EDOServicePort servicePortWithPort:service.port + hostPort:service.port.hostPort]; + + // Part A: legacy proxy without edoCountedByHost marks duplicate local. + XCTestExpectation *deallocA = [self expectationWithDescription:@"target A deallocated"]; + __weak EDOTestDummy *weakA = nil; + EDOPointerType addressA = 0; + @autoreleasepool { + NSData *legacyArchive1 = nil; + NSData *legacyArchive2 = nil; + @autoreleasepool { + EDOTestDummy *targetA = [[EDOTestDummy alloc] initWithValue:10]; + weakA = targetA; + EDODeallocSentinel *sentinel = [[EDODeallocSentinel alloc] initWithBlock:^{ + [deallocA fulfill]; + }]; + objc_setAssociatedObject(targetA, &kDeallocSentinelKey, sentinel, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + // Put 2 counted vends on the host, then create uncounted archives simulating an older host. + [service distantObjectForLocalObject:targetA hostPort:nil]; + [service distantObjectForLocalObject:targetA hostPort:nil]; + EDOObject *legacy1 = [EDOObject edo_remoteProxyFromUnderlyingObject:targetA + withPort:servicePort]; + EDOObject *legacy2 = [EDOObject edo_remoteProxyFromUnderlyingObject:targetA + withPort:servicePort]; + addressA = legacy1.remoteAddress; + legacyArchive1 = [self edo_archiveProxy:legacy1 withProcessUUID:@"com.google.edotest.remote"]; + legacyArchive2 = [self edo_archiveProxy:legacy2 withProcessUUID:@"com.google.edotest.remote"]; + } + + @autoreleasepool { + EDOObject *decoded1 = [NSKeyedUnarchiver edo_unarchiveObjectWithData:legacyArchive1]; + XCTAssertFalse(decoded1.countedByHost); + XCTAssertEqual([EDOClientService cachedEDOFromObjectUpdateIfNeeded:decoded1], decoded1); + + @autoreleasepool { + EDOObject *decoded2 = [NSKeyedUnarchiver edo_unarchiveObjectWithData:legacyArchive2]; + XCTAssertFalse(decoded2.countedByHost); + XCTAssertEqual([EDOClientService cachedEDOFromObjectUpdateIfNeeded:decoded2], decoded1); + XCTAssertTrue(decoded2.isLocal); + } + + // Duplicate legacy proxy was marked local, so host count remains 2. + __block NSNumber *countAfterDup = nil; + dispatch_sync(service.localObjectsSyncQueue, ^{ + countAfterDup = service.localObjectsCounts[@(addressA)]; + }); + XCTAssertEqualObjects(countAfterDup, @2); + } + + // Primary legacy proxy sends uncounted release, force-clearing host entry. + [self waitForExpectations:@[ deallocA ] timeout:5.0]; + XCTAssertNil(weakA); + } + + // Part B: re-encoding a decoded counted proxy drops edoCountedByHost and falls back to legacy + // release. + XCTestExpectation *deallocB = [self expectationWithDescription:@"target B deallocated"]; + __weak EDOTestDummy *weakB = nil; + EDOPointerType addressB = 0; + @autoreleasepool { + NSData *archive1 = nil; + @autoreleasepool { + EDOTestDummy *targetB = [[EDOTestDummy alloc] initWithValue:20]; + weakB = targetB; + EDODeallocSentinel *sentinel = [[EDODeallocSentinel alloc] initWithBlock:^{ + [deallocB fulfill]; + }]; + objc_setAssociatedObject(targetB, &kDeallocSentinelKey, sentinel, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + EDOObject *vended1 = [service distantObjectForLocalObject:targetB + hostPort:service.port.hostPort]; + [service distantObjectForLocalObject:targetB hostPort:nil]; + addressB = vended1.remoteAddress; + archive1 = [self edo_archiveProxy:vended1 withProcessUUID:@"com.google.edotest.remote1"]; + } + + EDOObject *decoded = [NSKeyedUnarchiver edo_unarchiveObjectWithData:archive1]; + XCTAssertTrue(decoded.countedByHost); + NSData *reencodedArchive = [self edo_archiveProxy:decoded + withProcessUUID:@"com.google.edotest.remote2"]; + // Mark first decoded proxy local so only the re-encoded copy drives host release below. + decoded.local = YES; + + @autoreleasepool { + EDOObject *reencoded = [NSKeyedUnarchiver edo_unarchiveObjectWithData:reencodedArchive]; + XCTAssertFalse(reencoded.countedByHost); + } + + // Re-encoded proxy sent a legacy release, force-removing despite host count = 2. + [self waitForExpectations:@[ deallocB ] timeout:5.0]; + XCTAssertNil(weakB); + dispatch_sync(service.localObjectsSyncQueue, ^{ + XCTAssertNil(service.localObjects[@(addressB)]); + XCTAssertNil(service.localObjectsCounts[@(addressB)]); + }); + } + [EDOChannelPool.sharedChannelPool removeChannelsWithPort:service.port.hostPort]; +} + +/** + * Verifies @c EDOObjectReleaseRequest counted factory preserves its flag across archive round + * trips and decrements host vend count cleanly via @c requestHandler. + * + */ +- (void)testCountedReleaseRequestArchiveRoundTripDecrementsCount { + EDOHostService *service = [self edo_hostServiceWithName:@"com.google.edo.service.reqRoundTrip"]; + XCTestExpectation *deallocExpectation = [self expectationWithDescription:@"target deallocated"]; + __weak EDOTestDummy *weakTarget = nil; + EDOPointerType address = 0; + + @autoreleasepool { + EDOTestDummy *target = [[EDOTestDummy alloc] initWithValue:77]; + weakTarget = target; + EDODeallocSentinel *sentinel = [[EDODeallocSentinel alloc] initWithBlock:^{ + [deallocExpectation fulfill]; + }]; + objc_setAssociatedObject(target, &kDeallocSentinelKey, sentinel, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + EDOObject *vended = [service distantObjectForLocalObject:target hostPort:nil]; + [service distantObjectForLocalObject:target hostPort:nil]; + address = vended.remoteAddress; + } + + EDOObjectReleaseRequest *countedRequest = + [EDOObjectReleaseRequest requestWithCountedRemoteAddress:address]; + NSData *archived1 = [NSKeyedArchiver edo_archivedDataWithObject:countedRequest]; + EDOObjectReleaseRequest *decoded1 = [NSKeyedUnarchiver edo_unarchiveObjectWithData:archived1]; + XCTAssertTrue(decoded1.countedRelease); + EDOObjectReleaseRequest.requestHandler(decoded1, service); + + XCTAssertNotNil(weakTarget); + dispatch_sync(service.localObjectsSyncQueue, ^{ + XCTAssertNotNil(service.localObjects[@(address)]); + XCTAssertEqualObjects(service.localObjectsCounts[@(address)], @1); + }); + + NSData *archived2 = [NSKeyedArchiver edo_archivedDataWithObject:countedRequest]; + EDOObjectReleaseRequest *decoded2 = [NSKeyedUnarchiver edo_unarchiveObjectWithData:archived2]; + EDOObjectReleaseRequest.requestHandler(decoded2, service); + + [self waitForExpectations:@[ deallocExpectation ] timeout:5.0]; + XCTAssertNil(weakTarget); + dispatch_sync(service.localObjectsSyncQueue, ^{ + XCTAssertNil(service.localObjects[@(address)]); + XCTAssertNil(service.localObjectsCounts[@(address)]); + }); +} + +/** + * Verifies vending @c rootLocalObject stays uncounted, never encodes @c edoCountedByHost, and + * survives release requests. + * + */ +- (void)testRootObjectVendIsUncountedAndSurvivesRelease { + EDOTestDummy *root = [[EDOTestDummy alloc] initWithValue:123]; + __weak EDOTestDummy *weakRoot = root; + dispatch_queue_t queue = + dispatch_queue_create("com.google.edo.service.rootTest", DISPATCH_QUEUE_SERIAL); + EDOHostService *service = [EDOHostService serviceWithPort:0 rootObject:root queue:queue]; + [self addTeardownBlock:^{ + (void)queue; + (void)root; + [service invalidate]; + }]; + + EDOObject *vendedRoot = [service distantObjectForLocalObject:root hostPort:service.port.hostPort]; + EDOPointerType rootAddress = vendedRoot.remoteAddress; + dispatch_sync(service.localObjectsSyncQueue, ^{ + XCTAssertNil(service.localObjects[@(rootAddress)]); + XCTAssertNil(service.localObjectsCounts[@(rootAddress)]); + }); + + NSData *rootArchive = [self edo_archiveProxy:vendedRoot + withProcessUUID:@"com.google.edotest.remoteRoot"]; + @autoreleasepool { + EDOObject *decodedRoot = [NSKeyedUnarchiver edo_unarchiveObjectWithData:rootArchive]; + XCTAssertFalse(decodedRoot.countedByHost); + } + XCTAssertFalse([service releaseObjectWithAddress:rootAddress]); + XCTAssertFalse([service removeObjectWithAddress:rootAddress]); + XCTAssertNotNil(weakRoot); + [EDOChannelPool.sharedChannelPool removeChannelsWithPort:service.port.hostPort]; +} + +/** + * Concurrent stress test with two host services on separate queues vending objects and executing + * counted releases in parallel. + * + * @note The lost-update race on host refcounts only manifests reliably under ThreadSanitizer + * (@c ServiceUnitTests_sim --features=tsan). + * + */ +- (void)testConcurrentVendAndCountedReleaseMaintainsExactCount { + EDOHostService *serviceA = [self edo_hostServiceWithName:@"com.google.edo.service.concurrentA"]; + EDOHostService *serviceB = [self edo_hostServiceWithName:@"com.google.edo.service.concurrentB"]; + NSArray *services = @[ serviceA, serviceB ]; + const NSInteger kIterationsPerService = 40; + + XCTestExpectation *deallocA = [self expectationWithDescription:@"serviceA target deallocated"]; + XCTestExpectation *deallocB = [self expectationWithDescription:@"serviceB target deallocated"]; + dispatch_queue_t workerQueue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + dispatch_group_t group = dispatch_group_create(); + __block EDOPointerType addressA = 0; + __block EDOPointerType addressB = 0; + __weak EDOTestDummy *weakTargetA = nil; + __weak EDOTestDummy *weakTargetB = nil; + + @autoreleasepool { + EDOTestDummy *targetA = [[EDOTestDummy alloc] initWithValue:1]; + EDOTestDummy *targetB = [[EDOTestDummy alloc] initWithValue:2]; + weakTargetA = targetA; + weakTargetB = targetB; + EDODeallocSentinel *sentinelA = [[EDODeallocSentinel alloc] initWithBlock:^{ + [deallocA fulfill]; + }]; + EDODeallocSentinel *sentinelB = [[EDODeallocSentinel alloc] initWithBlock:^{ + [deallocB fulfill]; + }]; + objc_setAssociatedObject(targetA, &kDeallocSentinelKey, sentinelA, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(targetB, &kDeallocSentinelKey, sentinelB, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + NSArray *targets = @[ targetA, targetB ]; + addressA = (EDOPointerType)(__bridge void *)targetA; + addressB = (EDOPointerType)(__bridge void *)targetB; + + for (NSUInteger idx = 0; idx < services.count; idx++) { + EDOHostService *service = services[idx]; + EDOTestDummy *target = targets[idx]; + for (NSInteger iter = 0; iter < kIterationsPerService; iter++) { + dispatch_group_async(group, workerQueue, ^{ + @autoreleasepool { + EDOObject *vended = [service distantObjectForLocalObject:target + hostPort:service.port.hostPort]; + NSString *uuid = [NSString stringWithFormat:@"com.google.edotest.worker.%lu.%ld", + (unsigned long)idx, (long)iter]; + NSData *archive = [self edo_archiveProxy:vended withProcessUUID:uuid]; + @autoreleasepool { + EDOObject *remoteProxy = [NSKeyedUnarchiver edo_unarchiveObjectWithData:archive]; + XCTAssertTrue(remoteProxy.countedByHost); + (void)remoteProxy; + } + } + }); + } + } + } + + XCTAssertEqual(dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC)), + 0); + [self waitForExpectations:@[ deallocA, deallocB ] timeout:10.0]; + XCTAssertNil(weakTargetA); + XCTAssertNil(weakTargetB); + + dispatch_sync(serviceA.localObjectsSyncQueue, ^{ + XCTAssertNil(serviceA.localObjects[@(addressA)]); + XCTAssertNil(serviceA.localObjectsCounts[@(addressA)]); + }); + dispatch_sync(serviceB.localObjectsSyncQueue, ^{ + XCTAssertNil(serviceB.localObjects[@(addressB)]); + XCTAssertNil(serviceB.localObjectsCounts[@(addressB)]); + }); + [EDOChannelPool.sharedChannelPool removeChannelsWithPort:serviceA.port.hostPort]; + [EDOChannelPool.sharedChannelPool removeChannelsWithPort:serviceB.port.hostPort]; +} + +/** + * Verifies concurrent @c cachedEDOFromObjectUpdateIfNeeded: lookups on counted proxies resolve to + * one cached winner without marking duplicates local, balancing all host vends on release. + */ +- (void)testConcurrentCountedProxyCacheLookupPreservesCountUntilWinnerDealloc { + for (NSNumber *isBlockValue in @[ @NO, @YES ]) { + BOOL isBlock = isBlockValue.boolValue; + EDOHostService *service = + [self edo_hostServiceWithName: + [NSString stringWithFormat:@"com.google.edo.service.concurrentCountedCache.%d", + isBlock]]; + XCTestExpectation *deallocExpectation = [self expectationWithDescription:@"target deallocated"]; + __weak id weakTarget = nil; + __block EDOPointerType address = 0; + const size_t kWorkerCount = 8; + + @autoreleasepool { + NSMutableArray *archives = [NSMutableArray arrayWithCapacity:kWorkerCount]; + @autoreleasepool { + id target; + if (isBlock) { + EDOTestDummy *capturedDummy = [[EDOTestDummy alloc] initWithValue:99]; + target = (id)[^int { + return capturedDummy.value; + } copy]; + } else { + target = [[EDOTestDummy alloc] initWithValue:99]; + } + weakTarget = target; + EDODeallocSentinel *sentinel = [[EDODeallocSentinel alloc] initWithBlock:^{ + [deallocExpectation fulfill]; + }]; + objc_setAssociatedObject(target, &kDeallocSentinelKey, sentinel, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + for (size_t i = 0; i < kWorkerCount; ++i) { + EDOObject *vended = [service distantObjectForLocalObject:target + hostPort:service.port.hostPort]; + address = vended.remoteAddress; + NSString *uuid = [NSString stringWithFormat:@"com.google.edotest.cache.%zu", i]; + [archives addObject:[self edo_archiveProxy:vended withProcessUUID:uuid]]; + } + } + + dispatch_queue_t concurrentQueue = dispatch_queue_create( + "com.google.edo.test.countedCacheConcurrent", DISPATCH_QUEUE_CONCURRENT); + NSMutableArray *proxies = [NSMutableArray arrayWithCapacity:kWorkerCount]; + NSMutableArray *results = [NSMutableArray arrayWithCapacity:kWorkerCount]; + for (size_t i = 0; i < kWorkerCount; ++i) { + [proxies addObject:[NSKeyedUnarchiver edo_unarchiveObjectWithData:archives[i]]]; + [results addObject:[NSNull null]]; + } + + dispatch_apply(kWorkerCount, concurrentQueue, ^(size_t idx) { + @autoreleasepool { + id resolved = [EDOClientService cachedEDOFromObjectUpdateIfNeeded:proxies[idx]]; + @synchronized(results) { + results[idx] = resolved; + } + } + }); + + id winner = results[0]; + XCTAssertNotEqual(winner, (id)[NSNull null]); + for (size_t i = 0; i < kWorkerCount; ++i) { + XCTAssertEqual(results[i], winner); + EDOObject *distantObject = [self edo_distantObjectFromProxy:proxies[i] isBlock:isBlock]; + XCTAssertTrue(distantObject.countedByHost); + XCTAssertFalse(distantObject.isLocal); + } + // Exercise cached winner while still retained before dropping all references together. + if (isBlock) { + XCTAssertEqual(((int (^)(void))winner)(), 99); + } else { + XCTAssertEqual([(EDOTestDummy *)winner returnInt], 99); + } + } + + [self waitForExpectations:@[ deallocExpectation ] timeout:10.0]; + XCTAssertNil(weakTarget); + dispatch_sync(service.localObjectsSyncQueue, ^{ + XCTAssertNil(service.localObjects[@(address)]); + XCTAssertNil(service.localObjectsCounts[@(address)]); + }); + XCTAssertNil([EDOClientService distantObjectReferenceForRemoteAddress:address]); + [EDOChannelPool.sharedChannelPool removeChannelsWithPort:service.port.hostPort]; + } +} + #pragma mark - Helper methods - (id)edo_testTargetForBlock:(BOOL)isBlock { diff --git a/Service/Tests/UnitTests/EDOServiceTest.m b/Service/Tests/UnitTests/EDOServiceTest.m index 3b387a7..1a9e69e 100644 --- a/Service/Tests/UnitTests/EDOServiceTest.m +++ b/Service/Tests/UnitTests/EDOServiceTest.m @@ -25,6 +25,7 @@ #import "Service/Sources/EDOObject+Private.h" #import "Service/Sources/EDOObject.h" #import "Service/Sources/EDOObjectMessage.h" +#import "Service/Sources/EDOObjectReleaseMessage.h" #import "Service/Sources/EDORemoteException.h" #import "Service/Sources/EDOServiceError.h" #import "Service/Sources/EDOServicePort.h" @@ -840,6 +841,23 @@ - (void)testClientErrorHandlerInvoked { [self waitForExpectationsWithTimeout:1 handler:nil]; } +- (void)testObjectReleaseRequestDoesNotInvokeClientErrorHandler { + __block BOOL errorHandlerInvoked = NO; + EDOClientErrorHandler previousHandler = EDOSetClientErrorHandler(^(NSError *error) { + errorHandlerInvoked = YES; + }); + @try { + // Port 0 always fails to connect; release requests are best-effort and must not trigger the + // global client error handler. + [EDOClientService + sendSynchronousRequest:[EDOObjectReleaseRequest requestWithCountedRemoteAddress:0x1234] + onPort:[EDOHostPort hostPortWithLocalPort:0]]; + XCTAssertFalse(errorHandlerInvoked); + } @finally { + EDOSetClientErrorHandler(previousHandler); + } +} + - (void)testServicePopulateExecutorHandlingError { EDOHostService *hostService = [EDOHostService serviceWithPort:0 rootObject:self queue:nil]; EDOHostPort *port = hostService.port.hostPort;