Skip to content

Commit 54605c0

Browse files
camrothmeta-codesync[bot]
authored andcommitted
Fix heap corruption crash from concurrent array mutation during diff (#1578)
Summary: Fixes #1578 Callers of `IGListDiff`/`IGListDiffPaths` may pass `NSMutableArray` instances backed by collections that are mutated on other threads. Because the diffing algorithm uses `__unsafe_unretained` pointers internally for performance, concurrent mutation can cause use-after-free heap corruption — typically manifesting as: ``` malloc: Incorrect checksum for freed object: probably modified after being freed. ``` inside `std::deque::push_back` during the entry `oldIndexes` stack growth. This change adds `[oldArray copy]` and `[newArray copy]` at the top of `IGListDiffing()`. For immutable `NSArray` inputs this is a no-op retain with zero overhead. For `NSMutableArray` inputs it creates an immutable snapshot, narrowing the race window from the entire O(n+m) diff to just the `-copy` call. This is a best-effort mitigation — callers are still responsible for not mutating the source array concurrently since `-[NSMutableArray copy]` itself is not atomic. Differential Revision: D101205956 fbshipit-source-id: 514ebbef1903c796c10fee23f4efb4dd9b3073bd
1 parent e3facbe commit 54605c0

3 files changed

Lines changed: 254 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ The changelog for `IGListKit`. Also see the [releases](https://github.com/instag
1111

1212
- An infinite recursion crash when VoiceOver is enabled and `scrollViewDelegate` or `collectionViewDelegate` is set to the adapter's own `UICollectionView`. [Cameron Roth](https://github.com/camroth) [(#1658)](https://github.com/Instagram/IGListKit/issues/1658)
1313

14+
- A heap corruption crash in `IGListDiff`/`IGListDiffPaths` caused by concurrent mutation of `NSMutableArray` inputs during the diff. Input arrays are now defensively copied to create immutable snapshots before diffing. [Cameron Roth](https://github.com/camroth) [(#1578)](https://github.com/Instagram/IGListKit/issues/1578)
15+
1416
5.2.0
1517
-----
1618

Source/IGListDiffKit/IGListDiff.mm

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ static id IGListDiffing(BOOL returnIndexPaths,
9797
NSArray<id<IGListDiffable>> *oldArray,
9898
NSArray<id<IGListDiffable>> *newArray,
9999
IGListDiffOption option) {
100+
// Best-effort snapshot: narrows the race window from the entire diff to just the copy call.
101+
// For immutable NSArray this is a no-op retain; for NSMutableArray it creates an immutable copy.
102+
// NOTE: Callers are still responsible for not mutating the source array concurrently, since
103+
// -[NSMutableArray copy] itself is not atomic.
104+
oldArray = [oldArray copy];
105+
newArray = [newArray copy];
106+
100107
const NSInteger newCount = newArray.count;
101108
const NSInteger oldCount = oldArray.count;
102109

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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

Comments
 (0)