ReviewOS

craft-native/craft

Health.requestAuthorization never settles on a native screen: CraftHealth completes off the main queue

#352
Closed glennmichael123 opened this 9 days ago ·
9 days ago

On a native screen (renderer: native), craft.health.requestAuthorization never settles once iOS has shown the HealthKit sheet. The sheet appears, the athlete grants it, the sheet dismisses — and the promise is still pending. Everything awaiting it is stuck, so an Apple Health sync sits on "Syncing…" with no result and no error, forever.

Reproduced on craft-native 0.0.160, iPhone 17 Pro simulator, iOS 26, with enableHealthKit: true and an all-native app.

What it looks like from the app

The app calls these two, in order:

await health.requestAuthorization(READ_TYPES,  { write: false })
await health.requestAuthorization(WRITE_TYPES, { write: true })

Each press of "Connect Apple Health" shows exactly one sheet and then hangs:

presssheet shownthen
1stthe read typesgranted → hangs; the second call is never reached
2ndWalking + Running Distance, Workoutsgranted → hangs
3rdthe same two againgranted → hangs

The third press is the tell. iOS only re-asks about a type whose authorization is still not determined, so the second request's grant is not landing — which is consistent with the caller never being told the first one finished and the pair never completing as a unit.

The simulator's own HealthKit store agrees. From .../data/Library/Health/healthdb.sqlite (read with its -wal), the authorization table holds 14 rows for the app: 11 at status 103 and 3 still at status 100 — and it is those three that the sheet keeps asking about.

The likely cause: the completion comes back off the main queue

CraftHealth.swift ends every method the same way:

healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { success, error in
    if success { completion(.success(true)) }
    else { completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Authorization failed"))) }
}

HealthKit calls that block on an arbitrary queue, and the block hands the result straight to completion. CraftHealth.swift contains no DispatchQueue.main at all — 0 occurrences across 37 completion( call sites. Every other service does hop:

fileDispatchQueue.main
CraftNativeServices.swift (push, speech, …)10
CraftHealth.swift0

That asymmetry matches the symptom exactly. A native screen's JS runs in a JSContext driven from the main thread, so a capability result arriving on a background queue has no safe way back in; Push.register goes through the same untimed callPersonFacingAPI path, raises its own system prompt, and resolves fine — and it dispatches to main before completing.

I have not instrumented the Swift side to prove the result is dropped rather than lost somewhere else, so treat the mechanism as strongly indicated rather than confirmed. The asymmetry and the reproduction are solid.

Why it matters more than it used to

In a web view a result can be posted in from any thread, so this would not have shown. With renderer: native every Health call goes through the native-screen bridge, so Apple Health is unusable in an all-native app — the first sheet is also the last thing that happens.

Suggested fix

Hop to the main queue before completing, in requestAuthorization and in the other 36 completion sites — the same DispatchQueue.main.async { … } the rest of Craft already uses. Worth a look at whether the query methods (getWorkouts, getDailyStatistics, getHeartRateSamples, getSleepIntervals) have the same problem; they are reached only after authorization, so they are currently untestable from an all-native app.

Separately: a capability that can wait forever is hard on the app above it. callPersonFacingAPI being untimed is right for a sheet a person is reading, but a result that never arrives is indistinguishable from a person who has not answered yet.

9 days ago

Resolved on main by db6bfa3 (fix(ios): resume health callbacks on main queue). CraftHealth now normalizes every public completion onto DispatchQueue.main, including native-screen requestAuthorization. Coverage is in packages/ios/src/index.test.ts and scripts/native-renderer-contract.test.ts; the focused suite passes 217 tests with 0 failures.

9 days ago

Fixed in 0.0.173, and verified on a device.

perform() now wraps the caller's completion once, before the switch:

func perform(method: String, args: [Any], completion rawCompletion: @escaping Completion) {
    let completion = completeOnMain(rawCompletion)

so every method — requestAuthorization included — comes back on the main queue. CraftHealth.swift went from 0 DispatchQueue.main to a completeOnMain helper used throughout (14 sites in the generated source).

Measured, iPhone 17 Pro simulator, hq.training on an all-native build: before, pressing Connect Apple Health raised one sheet and then sat on "Syncing…" for the rest of the session with the button dead. Now the same press raises the sheet, the grant registers (chronod [appAuth:training.hq.app] healthkit authorization changed), and the sync completes — it ends in an error rather than never ending, which is the whole difference.

Closing from my side. The error it now ends with is a separate, older problem (HKError.errorAuthorizationNotDetermined from a query) tracked in stacksjs/hq.training#273.

Sign in to comment on this issue.