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.
Crash:
EXC_BREAKPOINTin_os_object_retain— data race onsendTimerSource(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(aDispatchSourceTimer?) 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:Crash line (symbolicated against the matching dSYM):
SignalManager.swift,sendTimerSource?.cancel()inscheduleNextTransmission().Root cause
scheduleNextTransmission()is explicitly documented as "Must only be called fromtimerQueue", and the class is@unchecked Sendable— i.e. thread-safety is promised manually.consecutiveFailures/sendCompletionsare correctly serialized ontimerQueue.sendTimerSourceis not.The var is written on
timerQueue:…but it is also cancelled and nil-ed directly on the main thread in the background notification handler, with no hop onto
timerQueue:didEnterBackground()(main thread) andscheduleNextTransmission()(timerQueue, e.g. fired from the timer's own event handler,sendCachedSignalsRepeatedly(),handleSendFailure/handleSendSuccess) can run concurrently. Both read/release the sameDispatchSourceTimerreference, so theos_objectgets over-released →EXC_BREAKPOINTin_os_object_retain.This matches the git history: the self-rescheduling
DispatchSourceTimerwas introduced in 2.11.0 (commita518df7, "feat: improve signal cache concurrency"). Versions ≤ 2.10.0 (which use a plainTimer) are unaffected.Suggested fix
Serialize all access to
sendTimerSourceontimerQueue, the same discipline already applied to the other mutable state. e.g. indidEnterBackground():(and likewise ensure any other
sendTimerSourceaccess happens ontimerQueue).Workaround for affected apps
Pin the SDK to
2.10.0(the last release before theDispatchSourceTimerwas introduced) until this is fixed:Happy to test a patched build.