fix(ios): guard plugin event listener state against concurrent access
Closes #8157
Root cause
CAPPlugin keeps eventListeners and retainedEventArguments in plain NSMutableDictionary / NSMutableArray instances with no synchronization.
addListener / removeListener / removeAllListeners run on the plugin's dispatch queue, while plugins call notifyListeners:data:retainUntilConsumed: from whatever thread they are on (for example DispatchQueue.global()).
A read racing a mutation of these Foundation collections is undefined behavior and crashes in production with:
-[__NSDictionaryM objectForKey:]
-[CAPPlugin notifyListeners:data:retainUntilConsumed:] (CAPPlugin.m:83)
The same applies to the listener array iterated in notifyListeners while removeEventListener mutates it, to retainedEventArguments in the retain / replay path, and to getListeners / hasListeners.
A second, single-threaded bug falls out of the same code: notifyListeners iterated the live listener array by index, so a listener that removed itself during its callback shifted the array and the next listener was skipped.
Fix
Every access to eventListeners and retainedEventArguments in CAPPlugin.m now goes through one os_unfair_lock ivar.
This follows the approach of #7480, which replaced the unsynchronized storedCalls dictionary with a lock-guarded ConcurrentDictionary (NSLock).
- No callouts under the lock.
notifyListenerssnapshots (copy) the listener array inside the critical section and invokessuccessHandlerafter unlocking. Listeners can re-enter the plugin (remove themselves, add listeners, callnotifyListeners/hasListeners) without deadlocking, and a self-removing listener no longer causes the next listener to be skipped. - Retained events keep their semantics. When the first listener for an event is added, the listener is registered and the retained arguments are taken and removed in the same critical section, then replayed in order outside the lock via
notifyListeners:data:. They are delivered exactly once, to the first listener. Taking them atomically with the registration also closes a window where a concurrentnotifyListenerscould append to the list being replayed. - Why a lock and not a serial queue. Every critical section is a few dictionary / array operations with no callouts, so a lock is cheaper than
dispatch_syncand avoids the "already on the queue"dispatch_syncdeadlock withoutdispatch_queue_set_specificbookkeeping.os_unfair_lockwas chosen overNSLockbecause a zero-initialized ivar is already a valid unlockedos_unfair_lock, so it is correct for every initializer path (initfromCapacitorBridge.loadPlugin, the deprecatedinitWithBridge:, and Swift subclasses) without adding aninitoverride. - Public API is unchanged. Header untouched. The only visible differences:
getListeners:returns an immutable snapshot instead of the live internalNSMutableArray. Swift callers already got a copy through bridging, but that copy was made outside any synchronization.notifyListeners:...retainUntilConsumed:YESwithnildata is now ignored instead of throwingNSInvalidArgumentExceptionfromaddObject:nil.- The private, undeclared
sendRetainedArgumentsForEvent:helper is replaced by a lock-internaltakeRetainedArgumentsForEvent:.
Known limitation: eventListeners and retainedEventArguments are still public properties.
Third-party code that reads or mutates them directly bypasses the lock, same as before.
Guarding that would need an API change, so it is out of scope here.
Tests
New ios/Capacitor/CapacitorTests/PluginEventListenerTests.swift (added to the CapacitorTests target):
testConcurrentListenerMutationAndNotifyIsThreadSafe: 20,000DispatchQueue.concurrentPerformiterations mixingaddEventListener,removeEventListener,notifyListeners(with and withoutretainUntilConsumed),hasListeners,getListenersandremoveAllListeners.testRetainedEventsAreDeliveredOnceInOrderToFirstListenerandtestRetainedEventsAreDeliveredAgainAfterAllListenersAreRemoved: retained events are replayed once, in order, to the first listener only.testListenerRemovingItselfDuringNotifyDoesNotDeadlockandtestListenerReenteringPluginDuringRetainedDeliveryDoesNotDeadlock: re-entrant listeners complete (with a 5s deadlock guard) and no listener is skipped.testGetListenersReturnsSnapshot.
Before the fix (Thread Sanitizer, iPhone 17 / iOS 26.5, Xcode 26.6)
cd ios/Capacitor
TEST_RUNNER_TSAN_OPTIONS="halt_on_error=1" xcodebuild test -workspace Capacitor.xcworkspace -scheme Capacitor \
-destination 'platform=iOS Simulator,name=iPhone 17' -enableThreadSanitizer YES \
-only-testing:CapacitorTests/PluginEventListenerTests
WARNING: ThreadSanitizer: race on NSMutableDictionary (pid=86936)
Modifying access of NSMutableDictionary at 0x000108a36cc0 by thread T1:
#0 -[__NSDictionaryM setObject:forKey:] <null> (CoreFoundation:arm64+0x1a10cc)
#1 -[CAPPlugin addEventListener:listener:] CAPPlugin.m:46 (Capacitor:arm64+0x5b10)
...
Previous read-only access of NSMutableDictionary at 0x000108a36cc0 by thread T3:
#0 -[__NSDictionaryM objectForKey:] <null> (CoreFoundation:arm64+0x1a09ac)
#1 -[CAPPlugin addEventListener:listener:] CAPPlugin.m:43 (Capacitor:arm64+0x59c8)
...
SUMMARY: ThreadSanitizer: race on NSMutableDictionary (CoreFoundation:arm64+0x1a10cc) in -[__NSDictionaryM setObject:forKey:]+0x338
PluginEventListenerTests.swift:126: error: -[CapacitorTests.PluginEventListenerTests testListenerRemovingItselfDuringNotifyDoesNotDeadlock] : XCTAssertEqual failed: ("1") is not equal to ("2")
Failing tests:
PluginEventListenerTests.testConcurrentListenerMutationAndNotifyIsThreadSafe()
PluginEventListenerTests.testListenerRemovingItselfDuringNotifyDoesNotDeadlock()
** TEST FAILED **
Without Thread Sanitizer, the stress test crashes the test host on the unfixed code (EXC_BREAKPOINT in -[__NSArrayM insertObject:atIndex:] <- -[CAPPlugin addEventListener:listener:]), which is the same class of collection corruption as the production crash.
After the fix
# Thread Sanitizer, new tests x50 iterations
TEST_RUNNER_TSAN_OPTIONS="halt_on_error=1" xcodebuild test -workspace Capacitor.xcworkspace -scheme Capacitor \
-destination 'platform=iOS Simulator,name=iPhone 17' -enableThreadSanitizer YES \
-only-testing:CapacitorTests/PluginEventListenerTests -test-iterations 50 -run-tests-until-failure
# -> ** TEST SUCCEEDED **, 300/300 test runs passed, 0 ThreadSanitizer warnings
# Thread Sanitizer, full suite
TEST_RUNNER_TSAN_OPTIONS="halt_on_error=1" xcodebuild test -workspace Capacitor.xcworkspace -scheme Capacitor \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' -enableThreadSanitizer YES
# -> ** TEST SUCCEEDED **, CapacitorTests 56/56, CodableTests 81/81, 0 ThreadSanitizer warnings
# Full suite, no sanitizer (same as `npm run verify`, different simulator)
xcodebuild clean test -workspace Capacitor.xcworkspace -scheme Capacitor \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5'
# -> ** TEST SUCCEEDED **, CapacitorTests 56/56, CodableTests 81/81
npm run lint passes (SwiftLint 0.65.1: 122 existing warnings before and after, 0 serious).
Note: on the iOS 26.5 simulator runtime, HttpInterceptorNavigationTests.testBlocksSubframeNavigationToInterceptorPath crashes on main with or without this change (CFRetain in -[WKFrameInfo dealloc] for the WKFrameInfo test stub).
It passes on iOS 18.5 and in CI (iOS 26.0.1), so it is unrelated to this PR.
🤖 Generated with Claude Code