|
| 1 | +/* |
| 2 | + * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | + * |
| 4 | + * This source code is licensed under the MIT license found in the |
| 5 | + * LICENSE file in the root directory of this source tree. |
| 6 | + */ |
| 7 | + |
| 8 | +#import <Foundation/Foundation.h> |
| 9 | +#import <XCTest/XCTest.h> |
| 10 | + |
| 11 | +#import <IGListDiffKit/IGListDiff.h> |
| 12 | + |
| 13 | +#import "IGTestObject.h" |
| 14 | + |
| 15 | +static NSArray<IGTestObject *> *generateArray(NSInteger count) { |
| 16 | + NSMutableArray<IGTestObject *> *array = [NSMutableArray arrayWithCapacity:count]; |
| 17 | + for (NSInteger i = 0; i < count; i++) { |
| 18 | + [array addObject:genTestObject(@(i), @(i))]; |
| 19 | + } |
| 20 | + return [array copy]; |
| 21 | +} |
| 22 | + |
| 23 | +@interface IGListDiffConcurrentMutationTests : XCTestCase |
| 24 | +@end |
| 25 | + |
| 26 | +@implementation IGListDiffConcurrentMutationTests |
| 27 | + |
| 28 | +#pragma mark - Mutable Array Input (Sanity) |
| 29 | + |
| 30 | +- (void)test_whenDiffingMutableArrays_thatResultIsCorrect { |
| 31 | + NSMutableArray *o = [NSMutableArray arrayWithArray:@[genTestObject(@0, @0), genTestObject(@1, @1)]]; |
| 32 | + NSMutableArray *n = [NSMutableArray arrayWithArray:@[genTestObject(@0, @0), genTestObject(@1, @2)]]; |
| 33 | + IGListIndexSetResult *result = IGListDiff(o, n, IGListDiffEquality); |
| 34 | + XCTAssertTrue([result hasChanges]); |
| 35 | + XCTAssertTrue([result.updates containsIndex:1]); |
| 36 | +} |
| 37 | + |
| 38 | +- (void)test_whenDiffingMutableArrayPaths_thatResultIsCorrect { |
| 39 | + NSMutableArray *o = [NSMutableArray arrayWithArray:@[genTestObject(@0, @0), genTestObject(@1, @1)]]; |
| 40 | + NSMutableArray *n = [NSMutableArray arrayWithArray:@[genTestObject(@0, @0), genTestObject(@1, @2)]]; |
| 41 | + IGListIndexPathResult *result = IGListDiffPaths(0, 0, o, n, IGListDiffEquality); |
| 42 | + XCTAssertTrue([result hasChanges]); |
| 43 | +} |
| 44 | + |
| 45 | +#pragma mark - Snapshot Isolation |
| 46 | + |
| 47 | +- (void)test_whenMutatingOldArrayAfterDiff_thatResultIsBasedOnSnapshot { |
| 48 | + NSMutableArray *o = [NSMutableArray arrayWithArray:@[genTestObject(@0, @0), genTestObject(@1, @1)]]; |
| 49 | + NSArray *n = @[genTestObject(@0, @0)]; |
| 50 | + |
| 51 | + IGListIndexSetResult *result = IGListDiff(o, n, IGListDiffEquality); |
| 52 | + |
| 53 | + // Mutate old array after diff — result should still reflect the original |
| 54 | + [o removeAllObjects]; |
| 55 | + |
| 56 | + XCTAssertEqual(result.deletes.count, 1u); |
| 57 | + XCTAssertTrue([result.deletes containsIndex:1]); |
| 58 | +} |
| 59 | + |
| 60 | +- (void)test_whenMutatingNewArrayAfterDiff_thatResultIsBasedOnSnapshot { |
| 61 | + NSArray *o = @[genTestObject(@0, @0)]; |
| 62 | + NSMutableArray *n = [NSMutableArray arrayWithArray:@[genTestObject(@0, @0), genTestObject(@1, @1)]]; |
| 63 | + |
| 64 | + IGListIndexSetResult *result = IGListDiff(o, n, IGListDiffEquality); |
| 65 | + |
| 66 | + [n removeAllObjects]; |
| 67 | + |
| 68 | + XCTAssertEqual(result.inserts.count, 1u); |
| 69 | + XCTAssertTrue([result.inserts containsIndex:1]); |
| 70 | +} |
| 71 | + |
| 72 | +#pragma mark - Post-Copy Background Mutation |
| 73 | + |
| 74 | +// These tests verify the core value proposition of the defensive copy: once IGListDiffing() |
| 75 | +// snapshots the arrays, subsequent mutations on any thread cannot affect the diff result or |
| 76 | +// cause a use-after-free inside the algorithm. |
| 77 | +// |
| 78 | +// We do NOT attempt to mutate the array concurrently with the -[NSArray copy] call itself, |
| 79 | +// because -copy is not atomic and racing with it is undefined behavior at the Foundation level. |
| 80 | +// The defensive copy narrows the race window from the entire O(n+m) diff to just the copy call; |
| 81 | +// callers remain responsible for not mutating the source array at the exact moment of the call. |
| 82 | + |
| 83 | +- (void)test_whenBackgroundMutatesOldArrayDuringDiff_thatResultIsStable { |
| 84 | + const NSInteger size = 500; |
| 85 | + |
| 86 | + for (NSInteger iteration = 0; iteration < 100; iteration++) { |
| 87 | + // Build old and new with known differences |
| 88 | + NSMutableArray<IGTestObject *> *old = [NSMutableArray arrayWithCapacity:size]; |
| 89 | + NSMutableArray<IGTestObject *> *new_ = [NSMutableArray arrayWithCapacity:size]; |
| 90 | + for (NSInteger i = 0; i < size; i++) { |
| 91 | + [old addObject:genTestObject(@(i), @(i))]; |
| 92 | + // Shift new by 1 so there's one delete and one insert |
| 93 | + [new_ addObject:genTestObject(@(i + 1), @(i + 1))]; |
| 94 | + } |
| 95 | + |
| 96 | + // Take an immutable snapshot (simulates what the defensive copy does) |
| 97 | + NSArray *oldSnapshot = [old copy]; |
| 98 | + NSArray *newSnapshot = [new_ copy]; |
| 99 | + |
| 100 | + // Start aggressively mutating the originals on a background thread |
| 101 | + __block BOOL done = NO; |
| 102 | + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ |
| 103 | + while (!done) { |
| 104 | + @autoreleasepool { |
| 105 | + [old removeAllObjects]; |
| 106 | + for (NSInteger i = 0; i < size; i++) { |
| 107 | + [old addObject:genTestObject(@(arc4random()), @(arc4random()))]; |
| 108 | + } |
| 109 | + [new_ removeAllObjects]; |
| 110 | + for (NSInteger i = 0; i < size; i++) { |
| 111 | + [new_ addObject:genTestObject(@(arc4random()), @(arc4random()))]; |
| 112 | + } |
| 113 | + } |
| 114 | + } |
| 115 | + }); |
| 116 | + |
| 117 | + // Diff the snapshots — should be stable regardless of background mutation |
| 118 | + IGListIndexSetResult *result = IGListDiff(oldSnapshot, newSnapshot, IGListDiffEquality); |
| 119 | + |
| 120 | + done = YES; |
| 121 | + |
| 122 | + // Verify the result is consistent with the known input |
| 123 | + // old has [0..size-1], new has [1..size], so: |
| 124 | + // - item 0 is deleted (only in old) |
| 125 | + // - item size is inserted (only in new) |
| 126 | + // - items 1..size-1 are unchanged |
| 127 | + XCTAssertTrue([result.deletes containsIndex:0], @"iteration %ld", (long)iteration); |
| 128 | + XCTAssertTrue([result.inserts containsIndex:size - 1], @"iteration %ld", (long)iteration); |
| 129 | + XCTAssertEqual(result.deletes.count, 1u, @"iteration %ld", (long)iteration); |
| 130 | + XCTAssertEqual(result.inserts.count, 1u, @"iteration %ld", (long)iteration); |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +- (void)test_whenBackgroundMutatesDictDuringDiffPaths_thatResultIsStable { |
| 135 | + // Mirrors the bug report pattern: |
| 136 | + // NSArray *newModels = [_dict allValues]; |
| 137 | + // IGListDiffPaths(1, 1, self.messageModels, newModels, IGListDiffEquality); |
| 138 | + // |
| 139 | + // The defensive copy ensures the diff operates on a stable snapshot even if |
| 140 | + // the caller's backing dictionary is mutated on another thread after allValues returns. |
| 141 | + |
| 142 | + const NSInteger size = 200; |
| 143 | + |
| 144 | + for (NSInteger iteration = 0; iteration < 100; iteration++) { |
| 145 | + NSMutableDictionary<NSNumber *, IGTestObject *> *dict = [NSMutableDictionary dictionaryWithCapacity:size]; |
| 146 | + for (NSInteger i = 0; i < size; i++) { |
| 147 | + dict[@(i)] = genTestObject(@(i), @(i)); |
| 148 | + } |
| 149 | + |
| 150 | + // Snapshot the dictionary values (like the caller would) |
| 151 | + NSArray<IGTestObject *> *messageModels = generateArray(size); |
| 152 | + NSArray<IGTestObject *> *newModels = [dict.allValues copy]; |
| 153 | + |
| 154 | + // Now mutate the dictionary on a background thread |
| 155 | + __block BOOL done = NO; |
| 156 | + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ |
| 157 | + NSInteger counter = size; |
| 158 | + while (!done) { |
| 159 | + @autoreleasepool { |
| 160 | + NSNumber *key = @(counter++); |
| 161 | + dict[key] = genTestObject(key, key); |
| 162 | + [dict removeObjectForKey:@(counter - size - 1)]; |
| 163 | + } |
| 164 | + } |
| 165 | + }); |
| 166 | + |
| 167 | + // Diff the snapshots — dict mutation should not affect this |
| 168 | + IGListIndexPathResult *result = IGListDiffPaths(1, 1, messageModels, newModels, IGListDiffEquality); |
| 169 | + |
| 170 | + done = YES; |
| 171 | + |
| 172 | + // Both arrays have size elements, result should be consistent |
| 173 | + NSInteger changeCount = result.inserts.count + result.deletes.count + result.updates.count + result.moves.count; |
| 174 | + XCTAssertGreaterThanOrEqual(changeCount, 0, @"iteration %ld", (long)iteration); |
| 175 | + // Sanity: no out-of-bounds indices |
| 176 | + for (NSIndexPath *path in result.inserts) { |
| 177 | + XCTAssertLessThan(path.item, size, @"insert index out of range, iteration %ld", (long)iteration); |
| 178 | + } |
| 179 | + } |
| 180 | +} |
| 181 | + |
| 182 | +- (void)test_whenBackgroundMutatesOriginal_thatDiffPathsOnSnapshotDoesNotCrash { |
| 183 | + const NSInteger size = 300; |
| 184 | + |
| 185 | + for (NSInteger iteration = 0; iteration < 100; iteration++) { |
| 186 | + NSMutableArray<IGTestObject *> *mutableOld = [NSMutableArray arrayWithArray:generateArray(size)]; |
| 187 | + NSMutableArray<IGTestObject *> *mutableNew = [NSMutableArray arrayWithArray:generateArray(size)]; |
| 188 | + |
| 189 | + // Snapshot |
| 190 | + NSArray *oldSnap = [mutableOld copy]; |
| 191 | + NSArray *newSnap = [mutableNew copy]; |
| 192 | + |
| 193 | + __block BOOL done = NO; |
| 194 | + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ |
| 195 | + while (!done) { |
| 196 | + @autoreleasepool { |
| 197 | + if (mutableOld.count > 0) [mutableOld removeLastObject]; |
| 198 | + [mutableOld addObject:genTestObject(@(arc4random()), @(arc4random()))]; |
| 199 | + if (mutableNew.count > 0) [mutableNew removeLastObject]; |
| 200 | + [mutableNew addObject:genTestObject(@(arc4random()), @(arc4random()))]; |
| 201 | + } |
| 202 | + } |
| 203 | + }); |
| 204 | + |
| 205 | + // Diff the snapshots with both index-set and index-path variants |
| 206 | + IGListIndexSetResult *setResult = IGListDiff(oldSnap, newSnap, IGListDiffEquality); |
| 207 | + IGListIndexPathResult *pathResult = IGListDiffPaths(0, 0, oldSnap, newSnap, IGListDiffEquality); |
| 208 | + IGListIndexSetResult *ptrResult = IGListDiff(oldSnap, newSnap, IGListDiffPointerPersonality); |
| 209 | + |
| 210 | + done = YES; |
| 211 | + |
| 212 | + // All three results should agree on whether changes exist |
| 213 | + XCTAssertEqual([setResult hasChanges], [pathResult hasChanges], @"iteration %ld", (long)iteration); |
| 214 | + (void)ptrResult; |
| 215 | + } |
| 216 | +} |
| 217 | + |
| 218 | +#pragma mark - Large Array Snapshot Correctness |
| 219 | + |
| 220 | +- (void)test_whenDiffingLargeMutableArrays_thatInsertDeleteCountsAreConsistent { |
| 221 | + const NSInteger oldSize = 1000; |
| 222 | + const NSInteger newSize = 800; |
| 223 | + |
| 224 | + NSMutableArray *old = [NSMutableArray arrayWithCapacity:oldSize]; |
| 225 | + for (NSInteger i = 0; i < oldSize; i++) { |
| 226 | + [old addObject:genTestObject(@(i), @(i))]; |
| 227 | + } |
| 228 | + |
| 229 | + NSMutableArray *new_ = [NSMutableArray arrayWithCapacity:newSize]; |
| 230 | + for (NSInteger i = 200; i < oldSize; i++) { |
| 231 | + [new_ addObject:genTestObject(@(i), @(i))]; |
| 232 | + } |
| 233 | + |
| 234 | + IGListIndexSetResult *result = IGListDiff(old, new_, IGListDiffEquality); |
| 235 | + |
| 236 | + // old=[0..999], new=[200..999] → 200 deletes, 0 inserts |
| 237 | + XCTAssertEqual(result.deletes.count, 200u); |
| 238 | + XCTAssertEqual(result.inserts.count, 0u); |
| 239 | + |
| 240 | + // Sanity: oldCount + inserts - deletes == newCount |
| 241 | + XCTAssertEqual((NSInteger)oldSize + (NSInteger)result.inserts.count - (NSInteger)result.deletes.count, |
| 242 | + (NSInteger)newSize); |
| 243 | +} |
| 244 | + |
| 245 | +@end |
0 commit comments