-
Notifications
You must be signed in to change notification settings - Fork 26
Description
Environment
- react-native: 0.79.5
- react-native-device-activity: ^0.5.0
- expo: ^53.0.13
- expo-dev-client: ~5.2.4
- expo-router: ~5.1.1
- expo-updates: ~0.28.15
- Platform: iOS (physical device and simulator)
- Dev flow: running Metro, press “r” to reload after app has launched
Description
When the app is opened, then React Native is reloaded from terminal (“r”), calling ReactNativeDeviceActivity.startMonitoring crashes the app with EXC_BAD_ACCESS. Crash points to the CFNotification callback in NativeEventObserver.registerListener where the observer pointer is turned back into a Swift object and used to send an event.
Relevant callback code inside the module:
func registerListener(name: String) {
let notificationName = name as CFString
CFNotificationCenterAddObserver(
notificationCenter,
observer,
{ (
_: CFNotificationCenter?,
observer: UnsafeMutableRawPointer?,
name: CFNotificationName?,
_: UnsafeRawPointer?,
_: CFDictionary?
) in
if let observer = observer, let name = name {
let mySelf = Unmanaged<BaseModule>.fromOpaque(observer).takeUnretainedValue()
mySelf.sendEvent(
"onDeviceActivityMonitorEvent" as String,
[
"callbackName": name.rawValue
])
}
},
notificationName,
nil,
CFNotificationSuspensionBehavior.deliverImmediately
)
}The crash happens on mySelf.sendEvent(...) due to EXC_BAD_ACCESS, which implies observer was a stale pointer and fromOpaque(...).takeUnretainedValue() dereferenced a deallocated object.
Steps to Reproduce
- Open app in iOS dev client, let Metro download the bundle.
- Press “r” in the Metro terminal to reload React Native.
- In the app, call
ReactNativeDeviceActivity.startMonitoring(e.g., via a button). - App crashes with EXC_BAD_ACCESS in the CFNotification callback.
Expected Behavior
No crash; monitor starts, actions execute, optional JS events emitted.
Actual Behavior
App crashes in the CFNotification callback with EXC_BAD_ACCESS (use-after-free).
Crash/Logs
- Exception: EXC_BAD_ACCESS (code=1, address=0x0)
- Location: CFNotification callback inside
NativeEventObserver.registerListeneronmySelf.sendEvent(...) - This occurs only after a RN dev reload (not a cold app launch).
Root Cause Hypothesis
CFNotificationCenterAddObserveris called withobserverpointing to aBaseModuleinstance viaUnmanaged.passUnretained(module).toOpaque().- After a React Native dev reload, the original module instance is deallocated, but the Darwin observer remains registered. When a device activity event occurs (e.g.,
intervalDidStart), the stale callback is invoked with the staleobserverpointer. fromOpaque(...).takeUnretainedValue()then dereferences freed memory, causing EXC_BAD_ACCESS.- Additionally, the extension posts fixed notification names (
intervalDidStart,intervalDidEnd, etc.), and the module registers listeners to those fixed names. If the old observers aren’t removed on teardown, they will continue to receive notifications.
What I Tried (workarounds)
- Dev-only unique callback names for
configureActions(e.g.,intervalDidStart__dev__...) to avoid triggering stale observers listening to fixed names. Still crashes. - Calling
reloadDeviceActivityCenter()at startup and beforestartMonitoring. Still crashes. - Forcing a full process restart via
expo-updateson fast refresh and then resuming. Still crashes.
These suggest the stale CFNotification observer persists across dev reload and is not removed.
Proposed Fixes (module-level)
- Properly unregister observers:
- Store the observer pointer and call:
in
CFNotificationCenterRemoveObserver(notificationCenter, observer, nil, nil)
deinitofNativeEventObserverand/or in a module lifecycle hook (e.g., on stop observing / teardown).
- Store the observer pointer and call:
- Avoid passing
BaseModuledirectly as the observer:- Use a
NativeEventObserverwrapper instance as theobserverand keep aweakreference toBaseModule:And in the callback:class NativeEventObserver { let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() weak var module: BaseModule? var observer: UnsafeMutableRawPointer? init(module: BaseModule) { self.module = module self.observer = Unmanaged.passUnretained(self).toOpaque() // Add observers... } deinit { if let observer = observer { CFNotificationCenterRemoveObserver(notificationCenter, observer, nil, nil) } } }
let selfWrapper = Unmanaged<NativeEventObserver>.fromOpaque(observer).takeUnretainedValue() guard let module = selfWrapper.module else { return } // module may be gone after reload module.sendEvent(...)
- Use a
- Consider using
Unmanaged.passRetainedwith a balanced.release()when removing the observer to ensure the callback has a valid target while registered. - Optionally, maintain a token registry (global map) and validate the token in the callback before dereferencing to ensure the instance hasn’t been torn down.
Why a fix is needed in the module
Workarounds on the JS side can’t guarantee that stale CFNotification observers are removed or that their observer pointer maps to a live instance. The underlying crash is caused by native memory management around the Darwin notification observer lifecycle. Properly unregistering observers and guarding against dereferencing a deallocated module is necessary to be robust to RN dev reloads and app lifecycle events.
Additional Context
- The device activity extension posts Darwin notifications with fixed names (
intervalDidStart,intervalDidEnd, etc.). The module registers listeners for those names during init, but does not appear to remove them later. - The crash only reproduces after a JS dev reload and subsequent call to
startMonitoring.
Please let me know if you’d like a minimal repo; I can extract a pared-down example from my app if needed.