Skip to content

Crash: EXC_BREAKPOINT in _os_object_retain — data race on sendTimerSource (SignalManager) #296

Description

@buzzyrobot

Crash: EXC_BREAKPOINT in _os_object_retain — data race on sendTimerSource (SignalManager)

SDK version: 2.14.1 (also affects every release since 2.11.0)
Platform: iOS 26.5.2, physical device, Release build (TestFlight)
Frequency: intermittent (race condition) — reproduces during normal use, not on every run

Summary

SignalManager.sendTimerSource (a DispatchSourceTimer?) is mutated from two different threads without synchronization, causing an over-release / torn access of the dispatch-source object. The result is a crash on a background dispatch queue:

Thread N (com.apple.root / SignalTimer queue), EXC_BREAKPOINT (SIGTRAP):
  _os_object_retain
  <SignalManager timer teardown/reschedule>
  _dispatch_call_block_and_release
  _dispatch_client_callout
  _dispatch_lane_serial_drain
  _dispatch_lane_invoke
  _dispatch_workloop_worker_thread
  _pthread_wqthread

Crash line (symbolicated against the matching dSYM): SignalManager.swift, sendTimerSource?.cancel() in scheduleNextTransmission().

Root cause

scheduleNextTransmission() is explicitly documented as "Must only be called from timerQueue", and the class is @unchecked Sendable — i.e. thread-safety is promised manually. consecutiveFailures / sendCompletions are correctly serialized on timerQueue. sendTimerSource is not.

The var is written on timerQueue:

// scheduleNextTransmission()  — runs on timerQueue
sendTimerSource?.cancel()
let source = DispatchSource.makeTimerSource(queue: timerQueue)
...
source.resume()
sendTimerSource = source

…but it is also cancelled and nil-ed directly on the main thread in the background notification handler, with no hop onto timerQueue:

@MainActor
@objc func didEnterBackground() {
    ...
    sendTimerSource?.cancel()   // main thread
    sendTimerSource = nil       // main thread
    ...
}

didEnterBackground() (main thread) and scheduleNextTransmission() (timerQueue, e.g. fired from the timer's own event handler, sendCachedSignalsRepeatedly(), handleSendFailure/handleSendSuccess) can run concurrently. Both read/release the same DispatchSourceTimer reference, so the os_object gets over-released → EXC_BREAKPOINT in _os_object_retain.

This matches the git history: the self-rescheduling DispatchSourceTimer was introduced in 2.11.0 (commit a518df7, "feat: improve signal cache concurrency"). Versions ≤ 2.10.0 (which use a plain Timer) are unaffected.

Suggested fix

Serialize all access to sendTimerSource on timerQueue, the same discipline already applied to the other mutable state. e.g. in didEnterBackground():

@objc func didEnterBackground() {
    ...
    timerQueue.async { [weak self] in
        self?.sendTimerSource?.cancel()
        self?.sendTimerSource = nil
    }
    ...
}

(and likewise ensure any other sendTimerSource access happens on timerQueue).

Workaround for affected apps

Pin the SDK to 2.10.0 (the last release before the DispatchSourceTimer was introduced) until this is fixed:

# Package.swift
.package(url: "https://github.com/TelemetryDeck/SwiftSDK", exact: "2.10.0")

Happy to test a patched build.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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