also looking at this
fix(ios): give the Swift-only calls that wait on a framework callback a deadline
#244
2 files
+192
-11
| @@ -261,6 +261,63 @@ describe('Craft iOS builder', () => { | ||
| 261 | 261 | .toBeLessThan(swift.indexOf('if CraftZigRuntime.offer(')) |
| 262 | 262 | }) |
| 263 | 263 | |
| 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 | ||
| 264 | 321 | it('settles calendar callbacks only after a real EventKit operation', async () => { |
| 265 | 322 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-calendar-')) |
| 266 | 323 | await init({ |
| @@ -3417,31 +3417,68 @@ struct CraftWebView: UIViewRepresentable { | ||
| 3417 | 3417 | } |
| 3418 | 3418 | |
| 3419 | 3419 | // MARK: - Push Notifications |
| 3420 | ||
| 3421 | /// The deadline covering the APNs round trip, once authorization is in. | |
| 3422 | private var pendingPushDeadline: UUID? | |
| 3423 | ||
| 3420 | 3424 | 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 | } | |
| 3421 | 3434 | pendingPushCallbackId = callbackId |
| 3422 | 3435 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { [weak self] granted, error in |
| 3423 | 3436 | if granted { |
| 3424 | 3437 | 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 | |
| 3425 | 3448 | UIApplication.shared.registerForRemoteNotifications() |
| 3426 | 3449 | } |
| 3427 | 3450 | } 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 | } | |
| 3430 | 3456 | } |
| 3431 | 3457 | } |
| 3432 | 3458 | } |
| 3433 | 3459 | |
| 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 | ||
| 3434 | 3469 | @objc private func receivePushToken(_ notification: Notification) { |
| 3435 | 3470 | 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) } | |
| 3438 | 3475 | sendToWeb("craftPushToken", data: ["token": token]) |
| 3439 | 3476 | } |
| 3440 | 3477 | |
| 3441 | 3478 | @objc private func receivePushRegistrationError(_ notification: Notification) { |
| 3442 | 3479 | 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") | |
| 3445 | 3482 | } |
| 3446 | 3483 | |
| 3447 | 3484 | @objc private func receiveNotificationResponse(_ notification: Notification) { |
| @@ -3537,6 +3574,13 @@ struct CraftWebView: UIViewRepresentable { | ||
| 3537 | 3574 | let status = CBManager.authorization |
| 3538 | 3575 | resolveCallback(callbackId, result: permissionStatus(status == .allowedAlways, denied: status == .denied, restricted: status == .restricted)) |
| 3539 | 3576 | 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 | ) | |
| 3540 | 3584 | UNUserNotificationCenter.current().getNotificationSettings { settings in |
| 3541 | 3585 | let status: String |
| 3542 | 3586 | switch settings.authorizationStatus { |
| @@ -3545,7 +3589,10 @@ struct CraftWebView: UIViewRepresentable { | ||
| 3545 | 3589 | case .notDetermined: status = "undetermined" |
| 3546 | 3590 | @unknown default: status = "undetermined" |
| 3547 | 3591 | } |
| 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 | } | |
| 3549 | 3596 | } |
| 3550 | 3597 | default: |
| 3551 | 3598 | resolveCallback(callbackId, result: "undetermined") |
| @@ -4244,6 +4291,13 @@ struct CraftWebView: UIViewRepresentable { | ||
| 4244 | 4291 | |
| 4245 | 4292 | // MARK: - In-App Purchase |
| 4246 | 4293 | 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 | ) | |
| 4247 | 4301 | Task { |
| 4248 | 4302 | do { |
| 4249 | 4303 | let products = try await Product.products(for: Set(productIds)) |
| @@ -4256,9 +4310,15 @@ struct CraftWebView: UIViewRepresentable { | ||
| 4256 | 4310 | "displayPrice": product.displayPrice |
| 4257 | 4311 | ] |
| 4258 | 4312 | } |
| 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 | } | |
| 4260 | 4317 | } 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 | } | |
| 4262 | 4322 | } |
| 4263 | 4323 | } |
| 4264 | 4324 | } |
| @@ -4978,9 +5038,17 @@ struct CraftWebView: UIViewRepresentable { | ||
| 4978 | 5038 | durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds, |
| 4979 | 5039 | progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1) |
| 4980 | 5040 | ) |
| 5041 | let token = armDeadline( | |
| 5042 | Coordinator.liveActivityDeadline, | |
| 5043 | callbackId: callbackId, | |
| 5044 | error: "Activity.update did not return within \(Int(Coordinator.liveActivityDeadline))s" | |
| 5045 | ) | |
| 4981 | 5046 | Task { |
| 4982 | 5047 | 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 | } | |
| 4984 | 5052 | } |
| 4985 | 5053 | } |
| 4986 | 5054 | |
| @@ -5021,9 +5089,17 @@ struct CraftWebView: UIViewRepresentable { | ||
| 5021 | 5089 | } else { |
| 5022 | 5090 | finalContent = nil |
| 5023 | 5091 | } |
| 5092 | let token = armDeadline( | |
| 5093 | Coordinator.liveActivityDeadline, | |
| 5094 | callbackId: callbackId, | |
| 5095 | error: "Activity.end did not return within \(Int(Coordinator.liveActivityDeadline))s" | |
| 5096 | ) | |
| 5024 | 5097 | Task { |
| 5025 | 5098 | 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 | } | |
| 5027 | 5103 | } |
| 5028 | 5104 | } |
| 5029 | 5105 | |
| @@ -5652,6 +5728,54 @@ struct CraftWebView: UIViewRepresentable { | ||
| 5652 | 5728 | |
| 5653 | 5729 | // Removals waiting on their completion, each under a token of its own: |
| 5654 | 5730 | // 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 | ||
| 5655 | 5779 | private var pendingSiriRemovals: [UUID: DispatchWorkItem] = [:] |
| 5656 | 5780 | // The only thing that settles craft.siri.remove, which arms no timeout |
| 5657 | 5781 | // of its own; the same as Zig's. |