Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/production-incident-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -9533,6 +9533,29 @@ new incremental tables are populated.
because local Withings secrets were unavailable; the fix is based on Sentry
stack evidence, Withings docs, and targeted unit tests for the failing path.

### 2026-06-02 Mobile native bridge crash during foreground sync

- Symptoms: Sentry issue `DOFEK-MOBILE-P` recorded one production fatal
`EXC_BAD_ACCESS: ExceptionsManager > name >` in `dofek-mobile` release
`com.dofek.app@1.0.0+1780011142` on iOS 26.5.
- User impact: One mobile user experienced a fatal app crash while the app was
in the foreground.
- Evidence: Sentry breadcrumbs showed a foreground transition followed by
overlapping WHOOP BLE buffer drain and background HealthKit sync activity.
The native stack failed inside React Native TurboModule Objective-C exception
conversion while Hermes was creating a JS error string.
- Root cause: Native module events could cross the Expo/React Native bridge
from background callback queues. The WHOOP disconnect event could also include
an optional nil `error` payload value, which is unsafe to bridge as
`[String: Any]`.
- Fix / mitigation: WHOOP BLE connection/orientation events and HealthKit
observer events now emit through a main-thread event helper. The helper also
compacts optional nil payload values before calling `sendEvent`.
- Remaining risk: The crash has only one Sentry occurrence, so the exact device
sequence was not locally reproduced. The fix targets the native bridge
hazards active in the Sentry breadcrumb window and is covered by Swift package
tests.

### 2026-06-02 HealthKit workout push Timescale metric_stream update failure

- Symptoms: Sentry issue `DOFEK-SERVER-30` reported one production
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import XCTest
@testable import HealthKitLib

final class MainThreadEventEmitterTests: XCTestCase {
func testEmitsInlineWhenAlreadyOnMainThread() {
var emittedPayload: [String: Any]?

MainThreadEventEmitter.emit(["typeIdentifier": "HKQuantityTypeIdentifierStepCount"]) { payload in
XCTAssertTrue(Thread.isMainThread)
emittedPayload = payload
}

XCTAssertEqual(emittedPayload?["typeIdentifier"] as? String, "HKQuantityTypeIdentifierStepCount")
}

func testDispatchesBackgroundEmissionsToMainThread() {
let expectation = expectation(description: "event emitted on main thread")

DispatchQueue.global(qos: .userInitiated).async {
MainThreadEventEmitter.emit(["typeIdentifier": "HKQuantityTypeIdentifierHeartRate"]) { payload in
XCTAssertTrue(Thread.isMainThread)
XCTAssertEqual(payload["typeIdentifier"] as? String, "HKQuantityTypeIdentifierHeartRate")
expectation.fulfill()
}
}

waitForExpectations(timeout: 1)
}

func testRunsCompletionAfterBackgroundEmission() {
let expectation = expectation(description: "completion runs after event emission")
var emitted = false

DispatchQueue.global(qos: .userInitiated).async {
MainThreadEventEmitter.emit(
["typeIdentifier": "HKQuantityTypeIdentifierActiveEnergyBurned"],
completion: {
XCTAssertTrue(Thread.isMainThread)
XCTAssertTrue(emitted)
expectation.fulfill()
},
send: { payload in
XCTAssertTrue(Thread.isMainThread)
XCTAssertEqual(payload["typeIdentifier"] as? String, "HKQuantityTypeIdentifierActiveEnergyBurned")
emitted = true
}
)
}

waitForExpectations(timeout: 1)
}
}
15 changes: 11 additions & 4 deletions packages/mobile/modules/health-kit/ios/HealthKitModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -548,11 +548,18 @@ public class HealthKitModule: Module {

let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { [weak self] _, completionHandler, error in
if error == nil {
self?.sendEvent("onHealthKitSampleUpdate", [
"typeIdentifier": sampleType.identifier,
])
MainThreadEventEmitter.emit(
[
"typeIdentifier": sampleType.identifier,
],
completion: completionHandler,
send: { payload in
self?.sendEvent("onHealthKitSampleUpdate", payload)
}
)
} else {
completionHandler()
}
completionHandler()
}
self.observerQueries.append(query)
self.healthStore.execute(query)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

enum MainThreadEventEmitter {
static func emit(
_ payload: [String: Any?],
completion: @escaping () -> Void = {},
send: @escaping ([String: Any]) -> Void
) {
let bridgePayload = payload.compactMapValues { $0 }

if Thread.isMainThread {
send(bridgePayload)
completion()
return
}

DispatchQueue.main.async {
send(bridgePayload)
completion()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import XCTest
@testable import WhoopBleLib

final class MainThreadEventEmitterTests: XCTestCase {
func testEmitsInlineWhenAlreadyOnMainThread() {
var emittedPayload: [String: Any]?

MainThreadEventEmitter.emit(["state": "connected"]) { payload in
XCTAssertTrue(Thread.isMainThread)
emittedPayload = payload
}

XCTAssertEqual(emittedPayload?["state"] as? String, "connected")
}

func testDispatchesBackgroundEmissionsToMainThread() {
let expectation = expectation(description: "event emitted on main thread")

DispatchQueue.global(qos: .userInitiated).async {
MainThreadEventEmitter.emit(["state": "disconnected"]) { payload in
XCTAssertTrue(Thread.isMainThread)
XCTAssertEqual(payload["state"] as? String, "disconnected")
expectation.fulfill()
}
}

waitForExpectations(timeout: 1)
}

func testOmitsNilPayloadValuesBeforeEmission() {
var emittedPayload: [String: Any]?

MainThreadEventEmitter.emit(["state": "disconnected", "error": nil]) { payload in
emittedPayload = payload
}

XCTAssertEqual(emittedPayload?["state"] as? String, "disconnected")
XCTAssertNil(emittedPayload?["error"])
}
}
16 changes: 16 additions & 0 deletions packages/mobile/modules/whoop-ble/ios/MainThreadEventEmitter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

enum MainThreadEventEmitter {
static func emit(_ payload: [String: Any?], send: @escaping ([String: Any]) -> Void) {
let bridgePayload = payload.compactMapValues { $0 }

if Thread.isMainThread {
send(bridgePayload)
return
}

DispatchQueue.main.async {
send(bridgePayload)
}
}
}
20 changes: 13 additions & 7 deletions packages/mobile/modules/whoop-ble/ios/WhoopBleModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,12 @@ extension WhoopBleModule: WhoopBleConnectionManagerDelegate {
cmdCharacteristic: CBCharacteristic,
wasStreaming: Bool
) {
sendEvent("onConnectionStateChanged", [
MainThreadEventEmitter.emit([
"state": "connected",
"peripheralId": peripheral.identifier.uuidString,
])
]) { [weak self] payload in
self?.sendEvent("onConnectionStateChanged", payload)
}

sendActivationCommands(includeImu: false)
watchdog.start()
Expand All @@ -317,11 +319,13 @@ extension WhoopBleModule: WhoopBleConnectionManagerDelegate {
frameParser.reset()
cmdFrameParser.reset()

sendEvent("onConnectionStateChanged", [
MainThreadEventEmitter.emit([
"state": "disconnected",
"peripheralId": peripheralId,
"error": error?.localizedDescription as Any,
])
"error": error?.localizedDescription,
]) { [weak self] payload in
self?.sendEvent("onConnectionStateChanged", payload)
}
}

func connectionManager(
Expand Down Expand Up @@ -364,15 +368,17 @@ extension WhoopBleModule: WhoopBleConnectionManagerDelegate {
totalSamplesExtracted += UInt64(newImuSamples.count)

orientationProcessor.processSamples(newImuSamples) { [weak self] quaternion, euler in
self?.sendEvent("onOrientation", [
MainThreadEventEmitter.emit([
"w": quaternion.w,
"x": quaternion.x,
"y": quaternion.y,
"z": quaternion.z,
"roll": euler.roll,
"pitch": euler.pitch,
"yaw": euler.yaw,
])
]) { payload in
self?.sendEvent("onOrientation", payload)
}
}

sampleBuffer.appendImuSamples(newImuSamples)
Expand Down
Loading