Skip to content

Commit d55a540

Browse files
authored
Merge pull request #327 from debitan/fix/main-thread-continuation-resume
fix: dispatch HealthKit callback continuations to main thread
2 parents be819a4 + 2cdec47 commit d55a540

8 files changed

Lines changed: 186 additions & 147 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@kingstinct/react-native-healthkit": patch
3+
---
4+
5+
Fix SIGSEGV crash caused by HealthKit callbacks resuming Swift continuations on background threads. All HealthKit completion handlers now dispatch `continuation.resume(...)` to the main thread via `DispatchQueue.main.async`, preventing JSI/Hermes thread-safety violations when used with Nitro Modules.

packages/react-native-healthkit/ios/CoreModule.swift

Lines changed: 65 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,20 @@ async throws -> [HKQuantityType: HKUnit] {
5959
return try await withCheckedThrowingContinuation { continuation in
6060
store.preferredUnits(for: Set(quantityTypes)) {
6161
(typePerUnits: [HKQuantityType: HKUnit], error: Error?) in
62-
if let error = error {
63-
return continuation.resume(throwing: error)
64-
}
62+
DispatchQueue.main.async {
63+
if let error = error {
64+
return continuation.resume(throwing: error)
65+
}
6566

66-
// Thread-safe write: barrier ensures exclusive access
67-
quantityTypeCacheQueue.sync(flags: .barrier) {
68-
typePerUnits.forEach { (type: HKQuantityType, unit: HKUnit) in
69-
quantityTypeUnitCache.updateValue(unit, forKey: type)
67+
// Thread-safe write: barrier ensures exclusive access
68+
quantityTypeCacheQueue.sync(flags: .barrier) {
69+
typePerUnits.forEach { (type: HKQuantityType, unit: HKUnit) in
70+
quantityTypeUnitCache.updateValue(unit, forKey: type)
71+
}
7072
}
71-
}
7273

73-
return continuation.resume(returning: typePerUnits)
74+
return continuation.resume(returning: typePerUnits)
75+
}
7476
}
7577
}
7678
}
@@ -157,17 +159,18 @@ class CoreModule: HybridCoreModuleSpec {
157159
}
158160
return store.getRequestStatusForAuthorization(toShare: toShare, read: toRead) {
159161
status, error in
160-
if let error = error {
161-
continuation.resume(throwing: error)
162-
} else {
163-
if let authStatus = AuthorizationRequestStatus(rawValue: Int32(status.rawValue)) {
164-
continuation.resume(returning: authStatus)
162+
DispatchQueue.main.async {
163+
if let error = error {
164+
continuation.resume(throwing: error)
165165
} else {
166-
continuation.resume(
167-
throwing: runtimeErrorWithPrefix(
168-
"Unrecognized authStatus returned: \(status.rawValue)"))
166+
if let authStatus = AuthorizationRequestStatus(rawValue: Int32(status.rawValue)) {
167+
continuation.resume(returning: authStatus)
168+
} else {
169+
continuation.resume(
170+
throwing: runtimeErrorWithPrefix(
171+
"Unrecognized authStatus returned: \(status.rawValue)"))
172+
}
169173
}
170-
171174
}
172175
}
173176
}
@@ -181,10 +184,12 @@ class CoreModule: HybridCoreModuleSpec {
181184

182185
return try await withCheckedThrowingContinuation { continuation in
183186
store.requestAuthorization(toShare: share, read: toRead) { status, error in
184-
if let error = error {
185-
continuation.resume(throwing: error)
186-
} else {
187-
continuation.resume(returning: status)
187+
DispatchQueue.main.async {
188+
if let error = error {
189+
continuation.resume(throwing: error)
190+
} else {
191+
continuation.resume(returning: status)
192+
}
188193
}
189194
}
190195
}
@@ -203,25 +208,27 @@ class CoreModule: HybridCoreModuleSpec {
203208
sampleType: sampleType,
204209
samplePredicate: predicate
205210
) { (_: HKSourceQuery, sources: Set<HKSource>?, error: Error?) in
206-
if let error = error {
207-
continuation.resume(throwing: error)
208-
return
209-
}
211+
DispatchQueue.main.async {
212+
if let error = error {
213+
continuation.resume(throwing: error)
214+
return
215+
}
210216

211-
guard let sources = sources else {
212-
return continuation.resume(
213-
throwing: runtimeErrorWithPrefix(
214-
"Empty response for sample type \(identifier.stringValue)"))
215-
}
217+
guard let sources = sources else {
218+
return continuation.resume(
219+
throwing: runtimeErrorWithPrefix(
220+
"Empty response for sample type \(identifier.stringValue)"))
221+
}
216222

217-
let serializedSources = sources.map { source -> SourceProxy in
223+
let serializedSources = sources.map { source -> SourceProxy in
218224

219-
return SourceProxy(
220-
source: source
221-
)
222-
}
225+
return SourceProxy(
226+
source: source
227+
)
228+
}
223229

224-
continuation.resume(returning: serializedSources)
230+
continuation.resume(returning: serializedSources)
231+
}
225232
}
226233

227234
store.execute(query)
@@ -240,10 +247,12 @@ class CoreModule: HybridCoreModuleSpec {
240247
for: type,
241248
frequency: frequency
242249
) { (success, error) in
243-
if let err = error {
244-
return continuation.resume(throwing: err)
250+
DispatchQueue.main.async {
251+
if let err = error {
252+
return continuation.resume(throwing: err)
253+
}
254+
return continuation.resume(returning: success)
245255
}
246-
return continuation.resume(returning: success)
247256
}
248257
}
249258
} else {
@@ -262,10 +271,12 @@ class CoreModule: HybridCoreModuleSpec {
262271
store.disableBackgroundDelivery(
263272
for: type
264273
) { (success, error) in
265-
if let err = error {
266-
return continuation.resume(throwing: err)
274+
DispatchQueue.main.async {
275+
if let err = error {
276+
return continuation.resume(throwing: err)
277+
}
278+
return continuation.resume(returning: success)
267279
}
268-
return continuation.resume(returning: success)
269280
}
270281
}
271282
}
@@ -275,10 +286,12 @@ class CoreModule: HybridCoreModuleSpec {
275286
return Promise.async {
276287
try await withCheckedThrowingContinuation { continuation in
277288
store.disableAllBackgroundDelivery(completion: { (success, error) in
278-
guard let err = error else {
279-
return continuation.resume(returning: success)
289+
DispatchQueue.main.async {
290+
guard let err = error else {
291+
return continuation.resume(returning: success)
292+
}
293+
return continuation.resume(throwing: err)
280294
}
281-
return continuation.resume(throwing: err)
282295
})
283296
}
284297
}
@@ -345,10 +358,12 @@ class CoreModule: HybridCoreModuleSpec {
345358
let of = try sampleTypeFrom(sampleTypeIdentifierWriteable: objectTypeIdentifier)
346359
return try await withCheckedThrowingContinuation { continuation in
347360
store.deleteObjects(of: of, predicate: predicate) { (_, count, error) in
348-
if let error = error {
349-
continuation.resume(throwing: error)
350-
} else {
351-
continuation.resume(returning: Double(count))
361+
DispatchQueue.main.async {
362+
if let error = error {
363+
continuation.resume(throwing: error)
364+
} else {
365+
continuation.resume(returning: Double(count))
366+
}
352367
}
353368
}
354369
}

packages/react-native-healthkit/ios/ElectrocardiogramModule.swift

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -84,26 +84,28 @@ func getECGVoltages(sample: HKElectrocardiogram) async throws -> [Electrocardiog
8484

8585
// Stream measurements
8686
let q = HKElectrocardiogramQuery(sample) { _, result in
87-
switch result {
88-
case .error(let error):
89-
continuation.resume(throwing: error)
90-
91-
case .measurement(let m):
92-
// Lead I-like from Apple Watch
93-
if let v = m.quantity(for: .appleWatchSimilarToLeadI)?.doubleValue(for: .volt()) {
94-
let item = ElectrocardiogramVoltage(
95-
timeSinceSampleStart: m.timeSinceSampleStart,
96-
voltage: v,
97-
lead: ElectrocardiogramLead(fromString: "appleWatchSimilarToLeadI")!
98-
)
99-
all.append(item)
100-
}
87+
DispatchQueue.main.async {
88+
switch result {
89+
case .error(let error):
90+
continuation.resume(throwing: error)
91+
92+
case .measurement(let m):
93+
// Lead I-like from Apple Watch
94+
if let v = m.quantity(for: .appleWatchSimilarToLeadI)?.doubleValue(for: .volt()) {
95+
let item = ElectrocardiogramVoltage(
96+
timeSinceSampleStart: m.timeSinceSampleStart,
97+
voltage: v,
98+
lead: ElectrocardiogramLead(fromString: "appleWatchSimilarToLeadI")!
99+
)
100+
all.append(item)
101+
}
101102

102-
case .done:
103-
continuation.resume(returning: all)
104-
@unknown default:
105-
continuation.resume(
106-
throwing: runtimeErrorWithPrefix("HKElectrocardiogramQuery received unknown result type"))
103+
case .done:
104+
continuation.resume(returning: all)
105+
@unknown default:
106+
continuation.resume(
107+
throwing: runtimeErrorWithPrefix("HKElectrocardiogramQuery received unknown result type"))
108+
}
107109
}
108110
}
109111
store.execute(q)

packages/react-native-healthkit/ios/HeartbeatSeriesModule.swift

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,22 @@ func getHeartbeatSeriesHeartbeats(sample: HKHeartbeatSeriesSample) async throws
6060
done: Bool, error: Error?
6161
) in
6262

63-
if let error = error {
64-
continuation.resume(throwing: error)
65-
return
66-
}
67-
68-
let heartbeat = Heartbeat(
69-
timeSinceSeriesStart: timeSinceSeriesStart,
70-
precededByGap: precededByGap
71-
)
72-
73-
allBeats.append(heartbeat)
74-
75-
if done {
76-
continuation.resume(returning: allBeats)
63+
DispatchQueue.main.async {
64+
if let error = error {
65+
continuation.resume(throwing: error)
66+
return
67+
}
68+
69+
let heartbeat = Heartbeat(
70+
timeSinceSeriesStart: timeSinceSeriesStart,
71+
precededByGap: precededByGap
72+
)
73+
74+
allBeats.append(heartbeat)
75+
76+
if done {
77+
continuation.resume(returning: allBeats)
78+
}
7779
}
7880
}
7981

packages/react-native-healthkit/ios/Helpers.swift

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -54,25 +54,27 @@ func sampleAnchoredQueryAsync(
5454
newAnchor:
5555
HKQueryAnchor?, error: Error?
5656
) in
57-
if let error = error {
58-
return continuation.resume(throwing: error)
59-
}
57+
DispatchQueue.main.async {
58+
if let error = error {
59+
return continuation.resume(throwing: error)
60+
}
61+
62+
if let samples = samples, let deletedSamples = deletedSamples,
63+
let newAnchor = serializeAnchor(anchor: newAnchor) {
64+
return continuation.resume(
65+
returning: AnchoredQueryResponse(
66+
samples: samples,
67+
deletedSamples: deletedSamples.map({ deletedSample in
68+
return serializeDeletedSample(sample: deletedSample)
69+
}),
70+
newAnchor: newAnchor
71+
)
72+
)
73+
}
6074

61-
if let samples = samples, let deletedSamples = deletedSamples,
62-
let newAnchor = serializeAnchor(anchor: newAnchor) {
6375
return continuation.resume(
64-
returning: AnchoredQueryResponse(
65-
samples: samples,
66-
deletedSamples: deletedSamples.map({ deletedSample in
67-
return serializeDeletedSample(sample: deletedSample)
68-
}),
69-
newAnchor: newAnchor
70-
)
71-
)
76+
throwing: runtimeErrorWithPrefix("Unexpected empty response"))
7277
}
73-
74-
return continuation.resume(
75-
throwing: runtimeErrorWithPrefix("Unexpected empty response"))
7678
}
7779

7880
store.execute(query)
@@ -108,16 +110,18 @@ func sampleQueryAsync(
108110
limit: limit,
109111
sortDescriptors: sortDescriptors,
110112
) { (_: HKSampleQuery, samples: [HKSample]?, error: Error?) in
111-
if let error = error {
112-
return continuation.resume(throwing: error)
113-
}
113+
DispatchQueue.main.async {
114+
if let error = error {
115+
return continuation.resume(throwing: error)
116+
}
114117

115-
if let samples = samples {
116-
return continuation.resume(returning: samples)
117-
}
118+
if let samples = samples {
119+
return continuation.resume(returning: samples)
120+
}
118121

119-
return continuation.resume(
120-
throwing: runtimeErrorWithPrefix("Unexpected empty response"))
122+
return continuation.resume(
123+
throwing: runtimeErrorWithPrefix("Unexpected empty response"))
124+
}
121125
}
122126

123127
store.execute(q)
@@ -127,10 +131,12 @@ func sampleQueryAsync(
127131
func saveAsync(sample: HKObject) async throws -> Bool {
128132
return try await withCheckedThrowingContinuation { continuation in
129133
store.save(sample) { (success: Bool, error: Error?) in
130-
if let error = error {
131-
continuation.resume(throwing: error)
132-
} else {
133-
continuation.resume(returning: success)
134+
DispatchQueue.main.async {
135+
if let error = error {
136+
continuation.resume(throwing: error)
137+
} else {
138+
continuation.resume(returning: success)
139+
}
134140
}
135141
}
136142
}

packages/react-native-healthkit/ios/QuantityTypeModule.swift

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -163,17 +163,19 @@ func queryStatisticsCollectionForQuantityInternal(
163163
)
164164

165165
query.initialResultsHandler = { (_, results: HKStatisticsCollection?, error: Error?) in
166-
if let error = error {
167-
return handleHKNoDataOrThrow(error: error, continuation: continuation, noDataFallback: {
168-
return nil
169-
})
170-
}
166+
DispatchQueue.main.async {
167+
if let error = error {
168+
return handleHKNoDataOrThrow(error: error, continuation: continuation, noDataFallback: {
169+
return nil
170+
})
171+
}
171172

172-
guard let statistics = results else {
173-
return continuation.resume(throwing: runtimeErrorWithPrefix("queryStatisticsCollectionForQuantityInternal: unexpected empty results"))
174-
}
173+
guard let statistics = results else {
174+
return continuation.resume(throwing: runtimeErrorWithPrefix("queryStatisticsCollectionForQuantityInternal: unexpected empty results"))
175+
}
175176

176-
return continuation.resume(returning: statistics)
177+
return continuation.resume(returning: statistics)
178+
}
177179
}
178180

179181
store.execute(query)

0 commit comments

Comments
 (0)