ReviewOS

also looking at this

craft-native/craft

fix(ios): give the Swift-only calls that wait on a framework callback a deadline

#244
Merged glennmichael123 wants to merge fix/ios-swift-deadlines into main
2 files +192 -11
packages/ios/src/index.test.tsmodified+57-0
Changes to packages/ios/src/index.test.ts
@@ -261,6 +261,63 @@ describe('Craft iOS builder', () => {
261261 .toBeLessThan(swift.indexOf('if CraftZigRuntime.offer('))
262262 })
263263
264 it('gives every Swift-only call that waits on a framework callback a deadline', async () => {
265 // #224: each of these is answered only by Swift, on both runtimes, and
266 // only by a framework callback no person is waiting on. Nothing settled
267 // the page's promise if it never came — the shape #211 hit twice on CI.
268 const output = mkdtempSync(join(tmpdir(), 'craft-ios-swift-deadlines-'))
269 await init({ name: 'WildLoop', bundleId: 'org.wildloop.app', output })
270
271 const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8')
272
273 // One mechanism, and both halves of it: arming alone would never answer,
274 // claiming alone would let both paths answer.
275 expect(swift).toContain('private func armDeadline(')
276 expect(swift).toContain('private func claimDeadline(_ token: UUID) -> Bool')
277 expect(swift).toContain('guard let work = pendingDeadlines.removeValue(forKey: token) else { return false }')
278 // It reports a timeout, never the work having happened.
279 expect(swift).toContain('self.rejectCallback(callbackId, error: error, code: "TIMEOUT")')
280
281 // Five call sites, each with its own budget, all well under the 30s the
282 // page's own _invoke allows so the caller hears the native answer.
283 for (const [constant, uses] of [
284 ['notificationSettingsDeadline', 1],
285 ['pushRegistrationDeadline', 1],
286 ['storeKitDeadline', 1],
287 // Live Activities share one budget across update and end.
288 ['liveActivityDeadline', 2],
289 ] as const) {
290 expect(swift).toContain(`private static let ${constant}: TimeInterval =`)
291 expect(swift.match(new RegExp(`Coordinator\\.${constant},`, 'g'))?.length).toBe(uses)
292 }
293 // Every arm has a claim. Five sites arm; the claims are those five plus
294 // the push helper's own and the two places it is reached through.
295 expect(swift.match(/armDeadline\(/g)?.length).toBe(6)
296 expect(swift.match(/claimDeadline\(/g)?.length).toBe(8)
297
298 // The push registration's second call used to overwrite the first's
299 // callbackId, so the first promise never settled at all.
300 const register = swift.slice(
301 swift.indexOf('private func registerPushNotifications('),
302 swift.indexOf('@objc private func receivePushToken('),
303 )
304 expect(register).toContain('if let displaced = pendingPushCallbackId {')
305 expect(register).toContain('Replaced by another push registration')
306 // Armed after the grant, not at dispatch: the prompt has a person in front
307 // of it and must not be timed out.
308 expect(register.indexOf('UIApplication.shared.registerForRemoteNotifications()'))
309 .toBeGreaterThan(register.indexOf('Coordinator.pushRegistrationDeadline'))
310
311 // A token that arrives late still reaches a page listening for the event,
312 // even though the caller that timed out is gone.
313 const receive = swift.slice(
314 swift.indexOf('@objc private func receivePushToken('),
315 swift.indexOf('@objc private func receiveNotificationResponse('),
316 )
317 expect(receive).toContain('if let claimed = claimPushRegistration() { resolveCallback(claimed, result: token) }')
318 expect(receive).toContain('sendToWeb("craftPushToken", data: ["token": token])')
319 })
320
264321 it('settles calendar callbacks only after a real EventKit operation', async () => {
265322 const output = mkdtempSync(join(tmpdir(), 'craft-ios-calendar-'))
266323 await init({
packages/ios/templates/CraftApp.swiftmodified+135-11
Changes to packages/ios/templates/CraftApp.swift
@@ -3417,31 +3417,68 @@ struct CraftWebView: UIViewRepresentable {
34173417 }
34183418
34193419 // MARK: - Push Notifications
3420
3421 /// The deadline covering the APNs round trip, once authorization is in.
3422 private var pendingPushDeadline: UUID?
3423
34203424 private func registerPushNotifications(callbackId: String?) {
3425 // A second call used to overwrite the first's callbackId, and the
3426 // first promise then never settled at all. Displacing a call is an
3427 // answer the caller can act on, the way a replaced
3428 // getCurrentPosition request is rejected rather than dropped.
3429 if let displaced = pendingPushCallbackId {
3430 if let token = pendingPushDeadline { _ = claimDeadline(token) }
3431 pendingPushDeadline = nil
3432 rejectCallback(displaced, error: "Replaced by another push registration", code: "CANCELLED")
3433 }
34213434 pendingPushCallbackId = callbackId
34223435 UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { [weak self] granted, error in
34233436 if granted {
34243437 DispatchQueue.main.async {
3438 guard let self else { return }
3439 // Armed here rather than at dispatch: until the person
3440 // answers the prompt there is someone to wait for, and
3441 // only the APNs round trip afterwards is unattended.
3442 self.pendingPushDeadline = self.armDeadline(
3443 Coordinator.pushRegistrationDeadline,
3444 callbackId: callbackId,
3445 error: "APNs did not call back within \(Int(Coordinator.pushRegistrationDeadline))s"
3446 )
3447 self.pendingPushCallbackId = callbackId
34253448 UIApplication.shared.registerForRemoteNotifications()
34263449 }
34273450 } else {
3428 self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3429 self?.pendingPushCallbackId = nil
3451 DispatchQueue.main.async { [weak self] in
3452 guard let self, self.pendingPushCallbackId == callbackId else { return }
3453 self.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3454 self.pendingPushCallbackId = nil
3455 }
34303456 }
34313457 }
34323458 }
34333459
3460 /// Whether this push callback is still the one waiting, claiming it if so.
3461 private func claimPushRegistration() -> String?? {
3462 guard let callbackId = pendingPushCallbackId else { return nil }
3463 if let token = pendingPushDeadline, !claimDeadline(token) { return nil }
3464 pendingPushDeadline = nil
3465 pendingPushCallbackId = nil
3466 return .some(callbackId)
3467 }
3468
34343469 @objc private func receivePushToken(_ notification: Notification) {
34353470 guard let token = notification.object as? String else { return }
3436 resolveCallback(pendingPushCallbackId, result: token)
3437 pendingPushCallbackId = nil
3471 // The event goes out either way: a token that arrives after its
3472 // deadline is still this app's token, and a page listening for
3473 // craftPushToken is not the caller that timed out.
3474 if let claimed = claimPushRegistration() { resolveCallback(claimed, result: token) }
34383475 sendToWeb("craftPushToken", data: ["token": token])
34393476 }
34403477
34413478 @objc private func receivePushRegistrationError(_ notification: Notification) {
34423479 let message = notification.object as? String ?? "Push registration failed"
3443 rejectCallback(pendingPushCallbackId, error: message, code: "PUSH_REGISTRATION_ERROR")
3444 pendingPushCallbackId = nil
3480 guard let claimed = claimPushRegistration() else { return }
3481 rejectCallback(claimed, error: message, code: "PUSH_REGISTRATION_ERROR")
34453482 }
34463483
34473484 @objc private func receiveNotificationResponse(_ notification: Notification) {
@@ -3537,6 +3574,13 @@ struct CraftWebView: UIViewRepresentable {
35373574 let status = CBManager.authorization
35383575 resolveCallback(callbackId, result: permissionStatus(status == .allowedAlways, denied: status == .denied, restricted: status == .restricted))
35393576 case "notifications":
3577 // Zig answers UnknownAction for this permission, so Swift
3578 // serves it on both runtimes and this is the only answer.
3579 let token = armDeadline(
3580 Coordinator.notificationSettingsDeadline,
3581 callbackId: callbackId,
3582 error: "UNUserNotificationCenter.getNotificationSettings did not answer within \(Int(Coordinator.notificationSettingsDeadline))s"
3583 )
35403584 UNUserNotificationCenter.current().getNotificationSettings { settings in
35413585 let status: String
35423586 switch settings.authorizationStatus {
@@ -3545,7 +3589,10 @@ struct CraftWebView: UIViewRepresentable {
35453589 case .notDetermined: status = "undetermined"
35463590 @unknown default: status = "undetermined"
35473591 }
3548 self.resolveCallback(callbackId, result: status)
3592 DispatchQueue.main.async { [weak self] in
3593 guard let self, self.claimDeadline(token) else { return }
3594 self.resolveCallback(callbackId, result: status)
3595 }
35493596 }
35503597 default:
35513598 resolveCallback(callbackId, result: "undetermined")
@@ -4244,6 +4291,13 @@ struct CraftWebView: UIViewRepresentable {
42444291
42454292 // MARK: - In-App Purchase
42464293 private func getProducts(_ productIds: [String], callbackId: String?) {
4294 // Nobody is in front of a StoreKit fetch, and it reaches the
4295 // network: a request that never returns leaves the page waiting.
4296 let token = armDeadline(
4297 Coordinator.storeKitDeadline,
4298 callbackId: callbackId,
4299 error: "StoreKit did not return products within \(Int(Coordinator.storeKitDeadline))s"
4300 )
42474301 Task {
42484302 do {
42494303 let products = try await Product.products(for: Set(productIds))
@@ -4256,9 +4310,15 @@ struct CraftWebView: UIViewRepresentable {
42564310 "displayPrice": product.displayPrice
42574311 ]
42584312 }
4259 resolveCallback(callbackId, result: productData)
4313 await MainActor.run { [weak self] in
4314 guard let self, self.claimDeadline(token) else { return }
4315 self.resolveCallback(callbackId, result: productData)
4316 }
42604317 } catch {
4261 rejectCallback(callbackId, error: error.localizedDescription)
4318 await MainActor.run { [weak self] in
4319 guard let self, self.claimDeadline(token) else { return }
4320 self.rejectCallback(callbackId, error: error.localizedDescription)
4321 }
42624322 }
42634323 }
42644324 }
@@ -4978,9 +5038,17 @@ struct CraftWebView: UIViewRepresentable {
49785038 durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds,
49795039 progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1)
49805040 )
5041 let token = armDeadline(
5042 Coordinator.liveActivityDeadline,
5043 callbackId: callbackId,
5044 error: "Activity.update did not return within \(Int(Coordinator.liveActivityDeadline))s"
5045 )
49815046 Task {
49825047 await activity.update(ActivityContent(state: state, staleDate: nil))
4983 resolveCallback(callbackId, result: ["updated": true])
5048 await MainActor.run { [weak self] in
5049 guard let self, self.claimDeadline(token) else { return }
5050 self.resolveCallback(callbackId, result: ["updated": true])
5051 }
49845052 }
49855053 }
49865054
@@ -5021,9 +5089,17 @@ struct CraftWebView: UIViewRepresentable {
50215089 } else {
50225090 finalContent = nil
50235091 }
5092 let token = armDeadline(
5093 Coordinator.liveActivityDeadline,
5094 callbackId: callbackId,
5095 error: "Activity.end did not return within \(Int(Coordinator.liveActivityDeadline))s"
5096 )
50245097 Task {
50255098 await activity.end(finalContent, dismissalPolicy: .default)
5026 resolveCallback(callbackId, result: ["ended": true])
5099 await MainActor.run { [weak self] in
5100 guard let self, self.claimDeadline(token) else { return }
5101 self.resolveCallback(callbackId, result: ["ended": true])
5102 }
50275103 }
50285104 }
50295105
@@ -5652,6 +5728,54 @@ struct CraftWebView: UIViewRepresentable {
56525728
56535729 // Removals waiting on their completion, each under a token of its own:
56545730 // callbackId can be nil, and restarts when the page reloads.
5731 /// One entry per call whose only answer is a framework callback.
5732 ///
5733 /// Each of these waits on something no person is in front of — a
5734 /// notification-settings read, an APNs registration, a StoreKit fetch,
5735 /// a Live Activity update — and nothing else settles the page's
5736 /// promise if the callback never comes (#224). #211 found the same
5737 /// shape in a Siri deletion, twice, on CI.
5738 ///
5739 /// Whoever claims the token first answers: the callback or the
5740 /// deadline. Both run on the main queue, so the race is decided there
5741 /// rather than by a lock, and the loser finds nothing and does
5742 /// nothing. The deadline never claims the work happened — it may well
5743 /// have — only that nothing reported back in time.
5744 private var pendingDeadlines: [UUID: DispatchWorkItem] = [:]
5745
5746 /// How long each of them waits. Comfortably under the 30s the page's
5747 /// own `_invoke` allows, so the caller hears the native answer rather
5748 /// than its own timeout, and long enough that a slow device is not
5749 /// cut off mid-answer.
5750 private static let notificationSettingsDeadline: TimeInterval = 10
5751 private static let pushRegistrationDeadline: TimeInterval = 20
5752 private static let storeKitDeadline: TimeInterval = 20
5753 private static let liveActivityDeadline: TimeInterval = 10
5754
5755 /// Arm a deadline for a call, and return the token that claims it.
5756 private func armDeadline(_ seconds: TimeInterval, callbackId: String?, error: String) -> UUID {
5757 let token = UUID()
5758 let work = DispatchWorkItem { [weak self] in
5759 guard let self, self.pendingDeadlines.removeValue(forKey: token) != nil else { return }
5760 self.rejectCallback(callbackId, error: error, code: "TIMEOUT")
5761 }
5762 pendingDeadlines[token] = work
5763 DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
5764 return token
5765 }
5766
5767 /// Claim the right to answer. False means the deadline already did.
5768 ///
5769 /// Call it on the main queue: the callbacks below hop there first,
5770 /// because the queue a framework completion arrives on is its own
5771 /// business and a `zig:` hand-off reply reaches `evaluateJavaScript`
5772 /// with no hop of its own.
5773 private func claimDeadline(_ token: UUID) -> Bool {
5774 guard let work = pendingDeadlines.removeValue(forKey: token) else { return false }
5775 work.cancel()
5776 return true
5777 }
5778
56555779 private var pendingSiriRemovals: [UUID: DispatchWorkItem] = [:]
56565780 // The only thing that settles craft.siri.remove, which arms no timeout
56575781 // of its own; the same as Zig's.