Skip to content

iOS crash after dev reload: EXC_BAD_ACCESS in CFNotification callback (use-after-free in NativeEventObserver.registerListener) #72

@kayson-argyle

Description

@kayson-argyle

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

  1. Open app in iOS dev client, let Metro download the bundle.
  2. Press “r” in the Metro terminal to reload React Native.
  3. In the app, call ReactNativeDeviceActivity.startMonitoring (e.g., via a button).
  4. 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.registerListener on mySelf.sendEvent(...)
  • This occurs only after a RN dev reload (not a cold app launch).

Root Cause Hypothesis

  • CFNotificationCenterAddObserver is called with observer pointing to a BaseModule instance via Unmanaged.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 stale observer pointer.
  • 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 before startMonitoring. Still crashes.
  • Forcing a full process restart via expo-updates on 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)

  1. Properly unregister observers:
    • Store the observer pointer and call:
      CFNotificationCenterRemoveObserver(notificationCenter, observer, nil, nil)
      in deinit of NativeEventObserver and/or in a module lifecycle hook (e.g., on stop observing / teardown).
  2. Avoid passing BaseModule directly as the observer:
    • Use a NativeEventObserver wrapper instance as the observer and keep a weak reference to BaseModule:
      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)
          }
        }
      }
      And in the callback:
      let selfWrapper = Unmanaged<NativeEventObserver>.fromOpaque(observer).takeUnretainedValue()
      guard let module = selfWrapper.module else { return } // module may be gone after reload
      module.sendEvent(...)
  3. Consider using Unmanaged.passRetained with a balanced .release() when removing the observer to ensure the callback has a valid target while registered.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions