Mobile Development 21 min read

Root Cause Analysis of OOM Crash in iOS Karaoke App Caused by Swizzled NSMutableArray Protection

An OOM crash in the K‑song iOS karaoke app was traced to a configuration that swizzled several NSMutableArray methods, causing each observer lookup to autorelease objects, rapidly filling autorelease‑pool pages and exhausting memory; converting the protection code to manual reference counting eliminated the leak and stopped the crashes.

Tencent Music Tech Team
Tencent Music Tech Team
Tencent Music Tech Team
Root Cause Analysis of OOM Crash in iOS Karaoke App Caused by Swizzled NSMutableArray Protection

The K‑song iOS app experienced a large number of crash reports on August 31st, all indicating sudden app termination without a crash stack. Initial investigation on the TME FireEye APM platform showed no common crash stacks, suggesting the crashes were caused by watchdog termination or out‑of‑memory (OOM) conditions. MetricKit data confirmed memory warnings, leading to the conclusion that the crashes were OOM‑related.

Memory‑water‑level curves from the APM memory module showed a clear rise in memory usage and warnings across almost all pages. The problematic version was identified as 8.12.38, which had been running for a month.

Further digging revealed a configuration change that enabled an array‑bounds protection hook on the external network. Rolling back this configuration caused memory‑water‑level and warning counts to drop, confirming the configuration as the trigger.

The protection code swizzles several NSArray/NSMutableArray methods. For immutable arrays, the following three methods are swizzled:

initWithObjects:count:
objectAtIndex:
objectAtIndexedSubscript:

For mutable arrays, five methods are swizzled:

objectAtIndex:
addObject:
removeObjectAtIndex:
replaceObjectAtIndex:withObject:
insertObject:atIndex:

The swizzled implementation for kscrash_objectAtIndex: looks like this:

- (id)kscrash_objectAtIndex:(NSUInteger)index {
    if (index < self.count) {
        return [[self kscrash_objectAtIndex:index] autorelease];
    } else {
        return nil;
    }
}

Local reproduction enabled the configuration and, after running the app in live‑room scenarios, memory usage kept rising, especially during animation and foreground/background switches. Memory Graph snapshots showed a growth of @autoreleasepool content objects from ~130k to ~200k, increasing memory consumption by ~250 MB.

Stack logging revealed that the leaked @autoreleasepool content objects were created inside the swizzled kscrash_objectAtIndex: of NSMutableArray . This linked the protection hook directly to the memory leak.

Investigation of the iOS RunLoop showed that __CFRunLoopDoObservers iterates over observers using CFArrayGetObjectAtIndex . Because the protection hook also swizzles NSMutableArray , calls to CFArrayGetObjectAtIndex end up invoking the swizzled method, causing autorelease of observer objects and their accumulation in the autorelease pool.

The autorelease pool is implemented as a linked list of AutoreleasePoolPage objects, each 4 KB in size. When a page fills, a new page is allocated and linked as a child, forming a deep chain. The push/pop mechanism works as follows:

int main(int argc, const char * argv[]) {
    {
        void *atautoreleasepoolobj = objc_autoreleasePoolPush();
        // do whatever you want
        objc_autoreleasePoolPop(atautoreleasepoolobj);
    }
    return 0;
}

The push function inserts a sentinel (nil) into the pool; the add function stores objects until the page is full:

id *add(id obj) {
    assert(!full());
    unprotect();
    id *ret = next;
    *next++ = obj;
    protect();
    return ret;
}

If a page is full, autoreleaseFullPage creates a new page and links it as a child. The pop function walks back from the newest page, releasing objects until it reaches the sentinel, then optionally frees empty child pages.

static inline void pop(void *token) {
    AutoreleasePoolPage *page = pageForPointer(token);
    id *stop = (id *)token;
    page->releaseUntil(nil);
    if (page->child) {
        if (page->lessThanHalfFull()) {
            page->child->kill();
        } else if (page->child->child) {
            page->child->child->kill();
        }
    }
}

Because the swizzled objectAtIndex: autoreleases the returned object, each call adds a reference to the autorelease pool. When the RunLoop repeatedly notifies observers (via __CFRunLoopDoObservers ), the same observer objects are repeatedly autoreleased, causing the pool pages to fill and spawn new pages that are never popped in time. This results in a continuous increase of @autoreleasepool content objects, ultimately exhausting memory and triggering OOM.

Root cause: Swizzling NSMutableArray methods introduces autorelease calls that, when combined with the RunLoop’s observer notifications, generate massive, unreleased @autoreleasepool content objects.

Solution: Convert the protection code from ARC to MRC, removing the compiler‑injected autorelease calls, which stops the creation of the leaking @autoreleasepool content objects. After the code change, the OOM issue no longer appears.

iOSMemory LeakRunLoopoomAutoreleasePoolObjective-CSwizzling
Tencent Music Tech Team
Written by

Tencent Music Tech Team

Public account of Tencent Music's development team, focusing on technology sharing and communication.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.