also looking at this
fix(ios): give getHealthData and saveHealthWorkout deadlines, on the shared block table
#250
1 file
+343
-122
| @@ -48,6 +48,8 @@ const capabilities = ; | ||
| 48 | 48 | const bridge_error = ; |
| 49 | 49 | const objc_runtime = ; |
| 50 | 50 | const ios_async = ; |
| 51 | const ios_pending = ; | |
| 52 | const compat = ; | |
| 51 | 53 | const compat_mutex = ; |
| 52 | 54 | |
| 53 | 55 | const objc = objc_runtime.objc; |
| @@ -166,6 +168,11 @@ pub const HealthBridge = struct { | ||
| 166 | 168 | const share = try shareTypeSet(self.allocator, requested); |
| 167 | 169 | const read = try readTypeSet(self.allocator, requested); |
| 168 | 170 | |
| 171 | // Resolved before the lease (#223): after it, a failure here would | |
| 172 | // have to release the slot and the block by hand. | |
| 173 | const sel = objc.sel_registerName("requestAuthorizationToShareTypes:readTypes:completion:") orelse | |
| 174 | return BridgeError.NativeCallFailed; | |
| 175 | ||
| 169 | 176 | const ticket = ios_async.acquire(A.request_health_authorization) orelse { |
| 170 | 177 | std.log.warn( |
| 171 | 178 | "requestHealthAuthorization: no free reply slot; {d} native calls are already awaiting one", |
| @@ -174,13 +181,20 @@ pub const HealthBridge = struct { | ||
| 174 | 181 | return BridgeError.NativeCallFailed; |
| 175 | 182 | }; |
| 176 | 183 | errdefer ios_async.abandon(ticket); |
| 177 | publishCall(ticket, null); | |
| 178 | ||
| 179 | const sel = objc.sel_registerName("requestAuthorizationToShareTypes:readTypes:completion:") orelse | |
| 184 | const block = calls.claim(ticket, .{}) orelse { | |
| 185 | std.log.warn( | |
| 186 | "{s}: all {d} completion blocks are still owed a completion HealthKit never " ++ | |
| 187 | "delivered; refusing rather than reusing one", | |
| 188 | .{ "requestHealthAuthorization", ios_pending.block_count }, | |
| 189 | ); | |
| 180 | 190 | return BridgeError.NativeCallFailed; |
| 191 | }; | |
| 192 | ||
| 193 | // no-deadline: a person answers the HealthKit authorization sheet, and | |
| 194 | // timing that out would answer a question they are still reading. | |
| 181 | 195 | const RequestFn = *const fn (Id, objc.SEL, Id, Id, *anyopaque) callconv(.c) void; |
| 182 | 196 | const request: RequestFn = @ptrCast(&objc.objc_msgSend); |
| 183 | request(store, sel, share, read, @ptrCast(&auth_blocks[ticket.index])); | |
| 197 | request(store, sel, share, read, @ptrCast(&auth_blocks[block])); | |
| 184 | 198 | } |
| 185 | 199 | |
| 186 | 200 | /// Save a workout, then optionally attach a GPS route to it. |
| @@ -217,6 +231,10 @@ pub const HealthBridge = struct { | ||
| 217 | 231 | return BridgeError.AllocationFailed; |
| 218 | 232 | errdefer std.heap.c_allocator.free(activity_id); |
| 219 | 233 | |
| 234 | // Resolved before the lease (#223). | |
| 235 | const sel = objc.sel_registerName("saveObject:withCompletion:") orelse | |
| 236 | return BridgeError.NativeCallFailed; | |
| 237 | ||
| 220 | 238 | const ticket = ios_async.acquire(A.save_health_workout) orelse { |
| 221 | 239 | std.log.warn( |
| 222 | 240 | "saveHealthWorkout: no free reply slot; {d} native calls are already awaiting one", |
| @@ -226,18 +244,27 @@ pub const HealthBridge = struct { | ||
| 226 | 244 | }; |
| 227 | 245 | errdefer ios_async.abandon(ticket); |
| 228 | 246 | |
| 229 | publishWorkoutCall(ticket, .{ | |
| 247 | const block = calls.claim(ticket, .{ .workout = .{ | |
| 230 | 248 | .workout = workout, |
| 231 | 249 | .activity_id = activity_id, |
| 232 | 250 | .locations = locations, |
| 233 | 251 | .store = store, |
| 234 | }); | |
| 235 | ||
| 236 | const sel = objc.sel_registerName("saveObject:withCompletion:") orelse | |
| 252 | } }) orelse { | |
| 253 | std.log.warn( | |
| 254 | "{s}: all {d} completion blocks are still owed a completion HealthKit never " ++ | |
| 255 | "delivered; refusing rather than reusing one", | |
| 256 | .{ "saveHealthWorkout", ios_pending.block_count }, | |
| 257 | ); | |
| 237 | 258 | return BridgeError.NativeCallFailed; |
| 259 | }; | |
| 260 | ||
| 261 | // The first of three stages, each re-arming its own clock: see | |
| 262 | // `workout_stage_deadline_ms` for why one armed here would not do. | |
| 263 | ios_async.scheduleDeadline(ticket, workout_stage_deadline_ms, workoutTimedOut); | |
| 264 | ||
| 238 | 265 | const SaveFn = *const fn (Id, objc.SEL, Id, *anyopaque) callconv(.c) void; |
| 239 | 266 | const save: SaveFn = @ptrCast(&objc.objc_msgSend); |
| 240 | save(store, sel, workout, @ptrCast(&save_blocks[ticket.index])); | |
| 267 | save(store, sel, workout, @ptrCast(&save_blocks[block])); | |
| 241 | 268 | } |
| 242 | 269 | |
| 243 | 270 | /// Sum one quantity type over a window and answer `{value, unit}`. |
| @@ -258,6 +285,18 @@ pub const HealthBridge = struct { | ||
| 258 | 285 | const unit = try unitFromString(request.type.unit); |
| 259 | 286 | const predicate = try samplePredicate(request.start, request.end); |
| 260 | 287 | |
| 288 | // Every class and selector is resolved before the lease (#223). They | |
| 289 | // used to be looked up after `publishCall`, so a failure there left an | |
| 290 | // entry parked for a call that was already being refused. | |
| 291 | const HKStatisticsQuery = objc.objc_getClass("HKStatisticsQuery") orelse | |
| 292 | return BridgeError.PlatformNotSupported; | |
| 293 | const sel_alloc = objc.sel_registerName("alloc") orelse return BridgeError.NativeCallFailed; | |
| 294 | const sel_init = objc.sel_registerName( | |
| 295 | "initWithQuantityType:quantitySamplePredicate:options:completionHandler:", | |
| 296 | ) orelse return BridgeError.NativeCallFailed; | |
| 297 | const sel_execute = objc.sel_registerName("executeQuery:") orelse | |
| 298 | return BridgeError.NativeCallFailed; | |
| 299 | ||
| 261 | 300 | const ticket = ios_async.acquire(A.get_health_data) orelse { |
| 262 | 301 | std.log.warn( |
| 263 | 302 | "getHealthData: no free reply slot; {d} native calls are already awaiting one", |
| @@ -266,14 +305,21 @@ pub const HealthBridge = struct { | ||
| 266 | 305 | return BridgeError.NativeCallFailed; |
| 267 | 306 | }; |
| 268 | 307 | errdefer ios_async.abandon(ticket); |
| 269 | publishCall(ticket, unit); | |
| 308 | const block = calls.claim(ticket, .{ .unit = unit }) orelse { | |
| 309 | std.log.warn( | |
| 310 | "{s}: all {d} completion blocks are still owed a completion HealthKit never " ++ | |
| 311 | "delivered; refusing rather than reusing one", | |
| 312 | .{ "getHealthData", ios_pending.block_count }, | |
| 313 | ); | |
| 314 | return BridgeError.NativeCallFailed; | |
| 315 | }; | |
| 316 | // `alloc` and the init are all that is left between the claim and the | |
| 317 | // framework. Neither fails in practice, but if one did, the block was | |
| 318 | // never handed to anything that would call it back. | |
| 319 | errdefer _ = calls.settle(block); | |
| 320 | ||
| 321 | ios_async.scheduleDeadline(ticket, stats_deadline_ms, statsTimedOut); | |
| 270 | 322 | |
| 271 | const HKStatisticsQuery = objc.objc_getClass("HKStatisticsQuery") orelse | |
| 272 | return BridgeError.PlatformNotSupported; | |
| 273 | const sel_alloc = objc.sel_registerName("alloc") orelse return BridgeError.NativeCallFailed; | |
| 274 | const sel_init = objc.sel_registerName( | |
| 275 | "initWithQuantityType:quantitySamplePredicate:options:completionHandler:", | |
| 276 | ) orelse return BridgeError.NativeCallFailed; | |
| 277 | 323 | const allocated = objc.msgSendId(HKStatisticsQuery, sel_alloc) orelse |
| 278 | 324 | return BridgeError.NativeCallFailed; |
| 279 | 325 | |
| @@ -285,11 +331,9 @@ pub const HealthBridge = struct { | ||
| 285 | 331 | quantity_type, |
| 286 | 332 | predicate, |
| 287 | 333 | statistics_option_cumulative_sum, |
| 288 | @ptrCast(&stats_blocks[ticket.index]), | |
| 334 | @ptrCast(&stats_blocks[block]), | |
| 289 | 335 | ) orelse return BridgeError.NativeCallFailed; |
| 290 | 336 | |
| 291 | const sel_execute = objc.sel_registerName("executeQuery:") orelse | |
| 292 | return BridgeError.NativeCallFailed; | |
| 293 | 337 | objc.msgSendVoid1(store, sel_execute, query); |
| 294 | 338 | } |
| 295 | 339 | }; |
| @@ -867,7 +911,6 @@ fn releaseObject(object: Id) void { | ||
| 867 | 911 | |
| 868 | 912 | /// What a slot's block needs that the block itself cannot carry. |
| 869 | 913 | const PendingCall = struct { |
| 870 | ticket: ios_async.Ticket, | |
| 871 | 914 | /// The `HKUnit` the statistics reply reports in, or null for the calls |
| 872 | 915 | /// that report none. |
| 873 | 916 | unit: Id = null, |
| @@ -902,41 +945,26 @@ const WorkoutChain = struct { | ||
| 902 | 945 | } |
| 903 | 946 | }; |
| 904 | 947 | |
| 905 | var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null); | |
| 906 | var pending_mutex: compat_mutex.Mutex = .{}; | |
| 907 | ||
| 908 | fn publishCall(ticket: ios_async.Ticket, unit: Id) void { | |
| 909 | pending_mutex.lock(); | |
| 910 | defer pending_mutex.unlock(); | |
| 911 | pending_calls[ticket.index] = .{ .ticket = ticket, .unit = unit }; | |
| 912 | } | |
| 913 | ||
| 914 | fn publishWorkoutCall(ticket: ios_async.Ticket, chain: WorkoutChain) void { | |
| 915 | pending_mutex.lock(); | |
| 916 | defer pending_mutex.unlock(); | |
| 917 | pending_calls[ticket.index] = .{ .ticket = ticket, .workout = chain }; | |
| 918 | } | |
| 919 | ||
| 920 | /// Put a chain back for its next stage, keeping the same slot and ticket. | |
| 948 | /// One entry per completion block, not per reply slot (#223). | |
| 921 | 949 | /// |
| 922 | /// The entry is taken at the top of every stage so a duplicate fire finds | |
| 923 | /// nothing; a stage that intends to continue has to put it back explicitly, | |
| 924 | /// which is what makes "this stage is done with the chain" and "the chain | |
| 925 | /// continues" different statements rather than the same silence. | |
| 926 | fn republishWorkoutCall(call: PendingCall, chain: WorkoutChain) void { | |
| 927 | pending_mutex.lock(); | |
| 928 | defer pending_mutex.unlock(); | |
| 929 | pending_calls[call.ticket.index] = .{ .ticket = call.ticket, .workout = chain }; | |
| 930 | } | |
| 931 | ||
| 932 | /// Read and clear, so a second fire is a no-op rather than a second reply. | |
| 933 | fn takeCall(index: u5) ?PendingCall { | |
| 934 | pending_mutex.lock(); | |
| 935 | defer pending_mutex.unlock(); | |
| 936 | const call = pending_calls[index]; | |
| 937 | pending_calls[index] = null; | |
| 938 | return call; | |
| 939 | } | |
| 950 | /// Keyed by reply slot, a deadline would free slot k, the next call would | |
| 951 | /// lease slot k and get the same block, and the first call's late completion | |
| 952 | /// would answer the second with the first one's statistics or workout. All | |
| 953 | /// three of this file's actions park here; two of them get deadlines. | |
| 954 | var calls: ios_pending.Table(PendingCall) = .{}; | |
| 955 | ||
| 956 | /// `HKStatisticsQuery` reads the local store, but a year of samples is real | |
| 957 | /// work, so this is more generous than a notification read. Well under the | |
| 958 | /// 30s the page's own `_invoke` allows either way. | |
| 959 | const stats_deadline_ms: u32 = 15_000; | |
| 960 | ||
| 961 | /// Per stage of the workout chain, not per call. Each stage re-arms its own, | |
| 962 | /// because a single deadline armed at dispatch can land in the moment between | |
| 963 | /// one stage settling its block and the next claiming one — find no block | |
| 964 | /// holding the ticket, do nothing, and leave the next stage with no clock at | |
| 965 | /// all. Three stages at this budget stay under `_invoke`'s 30s even if every | |
| 966 | /// earlier deadline lands in such a moment. | |
| 967 | const workout_stage_deadline_ms: u32 = 8_000; | |
| 940 | 968 | |
| 941 | 969 | const BlockDescriptor = extern struct { |
| 942 | 970 | reserved: c_ulong = 0, |
| @@ -961,26 +989,26 @@ const health_block_descriptor = BlockDescriptor{ .size = @sizeOf(HealthBlock) }; | ||
| 961 | 989 | |
| 962 | 990 | extern var _NSConcreteGlobalBlock: anyopaque; |
| 963 | 991 | |
| 964 | fn makeAuthInvoke(comptime index: u5) *const anyopaque { | |
| 992 | fn makeAuthInvoke(comptime block: u5) *const anyopaque { | |
| 965 | 993 | const S = struct { |
| 966 | 994 | fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void { |
| 967 | authorizationAnswered(index, success, err); | |
| 995 | authorizationAnswered(block, success, err); | |
| 968 | 996 | } |
| 969 | 997 | }; |
| 970 | 998 | return @ptrCast(&S.invoke); |
| 971 | 999 | } |
| 972 | 1000 | |
| 973 | fn makeStatsInvoke(comptime index: u5) *const anyopaque { | |
| 1001 | fn makeStatsInvoke(comptime block: u5) *const anyopaque { | |
| 974 | 1002 | const S = struct { |
| 975 | 1003 | fn invoke(_: *const HealthBlock, _: Id, result: Id, err: Id) callconv(.c) void { |
| 976 | statisticsAnswered(index, result, err); | |
| 1004 | statisticsAnswered(block, result, err); | |
| 977 | 1005 | } |
| 978 | 1006 | }; |
| 979 | 1007 | return @ptrCast(&S.invoke); |
| 980 | 1008 | } |
| 981 | 1009 | |
| 982 | fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [ios_async.max_in_flight]HealthBlock { | |
| 983 | var out: [ios_async.max_in_flight]HealthBlock = undefined; | |
| 1010 | fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [ios_pending.block_count]HealthBlock { | |
| 1011 | var out: [ios_pending.block_count]HealthBlock = undefined; | |
| 984 | 1012 | for (&out, 0..) |*b, i| { |
| 985 | 1013 | b.* = .{ |
| 986 | 1014 | .isa = &_NSConcreteGlobalBlock, |
| @@ -992,54 +1020,68 @@ fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [ios_async.max_ | ||
| 992 | 1020 | return out; |
| 993 | 1021 | } |
| 994 | 1022 | |
| 995 | var auth_blocks: [ios_async.max_in_flight]HealthBlock = | |
| 1023 | var auth_blocks: [ios_pending.block_count]HealthBlock = | |
| 996 | 1024 | if (is_darwin) makeBlocks(makeAuthInvoke) else undefined; |
| 997 | var stats_blocks: [ios_async.max_in_flight]HealthBlock = | |
| 1025 | var stats_blocks: [ios_pending.block_count]HealthBlock = | |
| 998 | 1026 | if (is_darwin) makeBlocks(makeStatsInvoke) else undefined; |
| 999 | 1027 | |
| 1000 | fn makeSaveInvoke(comptime index: u5) *const anyopaque { | |
| 1028 | fn makeSaveInvoke(comptime block: u5) *const anyopaque { | |
| 1001 | 1029 | const S = struct { |
| 1002 | 1030 | fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void { |
| 1003 | workoutSaved(index, success, err); | |
| 1031 | workoutSaved(block, success, err); | |
| 1004 | 1032 | } |
| 1005 | 1033 | }; |
| 1006 | 1034 | return @ptrCast(&S.invoke); |
| 1007 | 1035 | } |
| 1008 | 1036 | |
| 1009 | fn makeInsertInvoke(comptime index: u5) *const anyopaque { | |
| 1037 | fn makeInsertInvoke(comptime block: u5) *const anyopaque { | |
| 1010 | 1038 | const S = struct { |
| 1011 | 1039 | fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void { |
| 1012 | routeInserted(index, success, err); | |
| 1040 | routeInserted(block, success, err); | |
| 1013 | 1041 | } |
| 1014 | 1042 | }; |
| 1015 | 1043 | return @ptrCast(&S.invoke); |
| 1016 | 1044 | } |
| 1017 | 1045 | |
| 1018 | fn makeFinishInvoke(comptime index: u5) *const anyopaque { | |
| 1046 | fn makeFinishInvoke(comptime block: u5) *const anyopaque { | |
| 1019 | 1047 | const S = struct { |
| 1020 | 1048 | fn invoke(_: *const HealthBlock, route: Id, err: Id) callconv(.c) void { |
| 1021 | routeFinished(index, route, err); | |
| 1049 | routeFinished(block, route, err); | |
| 1022 | 1050 | } |
| 1023 | 1051 | }; |
| 1024 | 1052 | return @ptrCast(&S.invoke); |
| 1025 | 1053 | } |
| 1026 | 1054 | |
| 1027 | var save_blocks: [ios_async.max_in_flight]HealthBlock = | |
| 1055 | var save_blocks: [ios_pending.block_count]HealthBlock = | |
| 1028 | 1056 | if (is_darwin) makeBlocks(makeSaveInvoke) else undefined; |
| 1029 | var insert_blocks: [ios_async.max_in_flight]HealthBlock = | |
| 1057 | var insert_blocks: [ios_pending.block_count]HealthBlock = | |
| 1030 | 1058 | if (is_darwin) makeBlocks(makeInsertInvoke) else undefined; |
| 1031 | var finish_blocks: [ios_async.max_in_flight]HealthBlock = | |
| 1059 | var finish_blocks: [ios_pending.block_count]HealthBlock = | |
| 1032 | 1060 | if (is_darwin) makeBlocks(makeFinishInvoke) else undefined; |
| 1033 | 1061 | |
| 1034 | 1062 | /// Stage one: the workout is in the store. |
| 1035 | fn workoutSaved(index: u5, success: bool, err: Id) void { | |
| 1063 | fn workoutSaved(block: u5, success: bool, err: Id) void { | |
| 1036 | 1064 | if (!is_darwin) return; |
| 1037 | 1065 | |
| 1038 | const call = takeCall(index) orelse { | |
| 1039 | std.log.warn("saveHealthWorkout: stage one fired for slot {d} with no call; ignored", .{index}); | |
| 1040 | return; | |
| 1066 | const entry = switch (calls.settle(block)) { | |
| 1067 | .reply => |parked| parked, | |
| 1068 | // The deadline answered this call and freed its chain. The framework | |
| 1069 | // still held this block, and has now called it; nothing here to touch. | |
| 1070 | .late => |started_ms| { | |
| 1071 | std.log.warn( | |
| 1072 | "saveHealthWorkout: stage one completion arrived {d} ms after the call, after its " ++ | |
| 1073 | "deadline had answered; ignored", | |
| 1074 | .{compat.milliTimestamp() - started_ms}, | |
| 1075 | ); | |
| 1076 | return; | |
| 1077 | }, | |
| 1078 | .stray => { | |
| 1079 | std.log.warn("saveHealthWorkout: stage one fired for block {d} with no call; ignored", .{block}); | |
| 1080 | return; | |
| 1081 | }, | |
| 1041 | 1082 | }; |
| 1042 | const chain = call.workout orelse { | |
| 1083 | const ticket = entry.ticket; | |
| 1084 | const chain = entry.call.workout orelse { | |
| 1043 | 1085 | std.log.warn("saveHealthWorkout: stage one fired for a slot holding no workout chain", .{}); |
| 1044 | 1086 | return; |
| 1045 | 1087 | }; |
| @@ -1047,42 +1089,69 @@ fn workoutSaved(index: u5, success: bool, err: Id) void { | ||
| 1047 | 1089 | if (!success) { |
| 1048 | 1090 | logNSError(A.save_health_workout, err); |
| 1049 | 1091 | chain.deinit(); |
| 1050 | ios_async.deliverErrorCode(call.ticket, BridgeError.PermissionDenied); | |
| 1092 | ios_async.deliverErrorCode(ticket, BridgeError.PermissionDenied); | |
| 1051 | 1093 | return; |
| 1052 | 1094 | } |
| 1053 | 1095 | |
| 1054 | 1096 | // Swift's early exit. The workout is saved either way; a route is |
| 1055 | 1097 | // optional, so having none is not a failure. |
| 1056 | 1098 | if (chain.locations == null) { |
| 1057 | replyWithWorkout(call.ticket, chain, null); | |
| 1099 | replyWithWorkout(ticket, chain, null); | |
| 1058 | 1100 | chain.deinit(); |
| 1059 | 1101 | return; |
| 1060 | 1102 | } |
| 1061 | 1103 | |
| 1104 | // Resolved before the next claim, never after (#223). This was | |
| 1105 | // `orelse return;` after the call had been republished, which left it | |
| 1106 | // parked with no framework call made — a promise nothing could settle. | |
| 1107 | const sel = objc.sel_registerName("insertRouteData:completion:") orelse | |
| 1108 | return routeAbandoned(ticket, chain, "insertRouteData:completion: is not in this runtime"); | |
| 1109 | ||
| 1062 | 1110 | var next = chain; |
| 1063 | 1111 | next.builder = routeBuilder(chain.store) catch |build_err| { |
| 1064 | 1112 | std.log.warn("saveHealthWorkout: could not build a route builder: {}", .{build_err}); |
| 1065 | 1113 | // The workout is already saved, so answering with its id is truer |
| 1066 | 1114 | // than reporting a failure — Swift rejects here, and would tell a page |
| 1067 | 1115 | // nothing was written when something was. |
| 1068 | replyWithWorkout(call.ticket, chain, null); | |
| 1116 | replyWithWorkout(ticket, chain, null); | |
| 1069 | 1117 | chain.deinit(); |
| 1070 | 1118 | return; |
| 1071 | 1119 | }; |
| 1072 | republishWorkoutCall(call, next); | |
| 1073 | 1120 | |
| 1074 | const sel = objc.sel_registerName("insertRouteData:completion:") orelse return; | |
| 1121 | // A fresh block for stage two: this one was settled above and may already | |
| 1122 | // belong to another call. | |
| 1123 | const next_block = calls.claim(ticket, .{ .workout = next }) orelse | |
| 1124 | return routeAbandoned(ticket, next, "every completion block is owed, so the route could not start"); | |
| 1125 | ios_async.scheduleDeadline(ticket, workout_stage_deadline_ms, workoutTimedOut); | |
| 1126 | ||
| 1075 | 1127 | const InsertFn = *const fn (Id, objc.SEL, Id, *anyopaque) callconv(.c) void; |
| 1076 | 1128 | const insert: InsertFn = @ptrCast(&objc.objc_msgSend); |
| 1077 | insert(next.builder, sel, next.locations, @ptrCast(&insert_blocks[index])); | |
| 1129 | insert(next.builder, sel, next.locations, @ptrCast(&insert_blocks[next_block])); | |
| 1078 | 1130 | } |
| 1079 | 1131 | |
| 1080 | 1132 | /// Stage two: the fixes are in the builder. |
| 1081 | fn routeInserted(index: u5, success: bool, err: Id) void { | |
| 1133 | fn routeInserted(block: u5, success: bool, err: Id) void { | |
| 1082 | 1134 | if (!is_darwin) return; |
| 1083 | 1135 | |
| 1084 | const call = takeCall(index) orelse return; | |
| 1085 | const chain = call.workout orelse return; | |
| 1136 | const entry = switch (calls.settle(block)) { | |
| 1137 | .reply => |parked| parked, | |
| 1138 | // The deadline answered this call and freed its chain. The framework | |
| 1139 | // still held this block, and has now called it; nothing here to touch. | |
| 1140 | .late => |started_ms| { | |
| 1141 | std.log.warn( | |
| 1142 | "saveHealthWorkout: stage two completion arrived {d} ms after the call, after its " ++ | |
| 1143 | "deadline had answered; ignored", | |
| 1144 | .{compat.milliTimestamp() - started_ms}, | |
| 1145 | ); | |
| 1146 | return; | |
| 1147 | }, | |
| 1148 | .stray => { | |
| 1149 | std.log.warn("saveHealthWorkout: stage two fired for block {d} with no call; ignored", .{block}); | |
| 1150 | return; | |
| 1151 | }, | |
| 1152 | }; | |
| 1153 | const ticket = entry.ticket; | |
| 1154 | const chain = entry.call.workout orelse return; | |
| 1086 | 1155 | |
| 1087 | 1156 | if (!success) { |
| 1088 | 1157 | // Rejects, as the spec does. Resolving here reported a workout with |
| @@ -1098,26 +1167,80 @@ fn routeInserted(index: u5, success: bool, err: Id) void { | ||
| 1098 | 1167 | "saveHealthWorkout: route insert failed; the workout itself was saved as {s}", |
| 1099 | 1168 | .{uuidStringOf(chain.workout) orelse "<no uuid>"}, |
| 1100 | 1169 | ); |
| 1101 | ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.NativeCallFailed); | |
| 1170 | ios_async.deliverErrorCode(ticket, bridge_error.BridgeError.NativeCallFailed); | |
| 1102 | 1171 | chain.deinit(); |
| 1103 | 1172 | return; |
| 1104 | 1173 | } |
| 1105 | 1174 | |
| 1106 | republishWorkoutCall(call, chain); | |
| 1107 | ||
| 1175 | // Resolved before the next claim (#223); it was `orelse return;` after the | |
| 1176 | // republish, parking the call for good. | |
| 1177 | const sel = objc.sel_registerName("finishRouteWithWorkout:metadata:completion:") orelse | |
| 1178 | return routeAbandoned(ticket, chain, "finishRouteWithWorkout:metadata:completion: is not in this runtime"); | |
| 1108 | 1179 | const metadata = routeMetadata(chain.activity_id) catch null; |
| 1109 | const sel = objc.sel_registerName("finishRouteWithWorkout:metadata:completion:") orelse return; | |
| 1180 | ||
| 1181 | const next_block = calls.claim(ticket, .{ .workout = chain }) orelse | |
| 1182 | return routeAbandoned(ticket, chain, "every completion block is owed, so the route could not finish"); | |
| 1183 | ios_async.scheduleDeadline(ticket, workout_stage_deadline_ms, workoutTimedOut); | |
| 1184 | ||
| 1110 | 1185 | const FinishFn = *const fn (Id, objc.SEL, Id, Id, *anyopaque) callconv(.c) void; |
| 1111 | 1186 | const finish: FinishFn = @ptrCast(&objc.objc_msgSend); |
| 1112 | finish(chain.builder, sel, chain.workout, metadata, @ptrCast(&finish_blocks[index])); | |
| 1187 | finish(chain.builder, sel, chain.workout, metadata, @ptrCast(&finish_blocks[next_block])); | |
| 1188 | } | |
| 1189 | ||
| 1190 | /// The workout is in the store but its route will not be. Answered as a | |
| 1191 | /// failure, for the reason `routeInserted` gives: resolving would look exactly | |
| 1192 | /// like a finish that succeeded with no route, and a page would file the | |
| 1193 | /// workout as complete with its locations silently missing. The id is kept in | |
| 1194 | /// the log, since the workout really was written. | |
| 1195 | fn routeAbandoned(ticket: ios_async.Ticket, chain: WorkoutChain, why: []const u8) void { | |
| 1196 | std.log.warn( | |
| 1197 | "saveHealthWorkout: {s}; the workout itself was saved as {s}", | |
| 1198 | .{ why, uuidStringOf(chain.workout) orelse "<no uuid>" }, | |
| 1199 | ); | |
| 1200 | ios_async.deliverErrorCode(ticket, bridge_error.BridgeError.NativeCallFailed); | |
| 1201 | chain.deinit(); | |
| 1202 | } | |
| 1203 | ||
| 1204 | /// On the main queue, `workout_stage_deadline_ms` after a stage started. | |
| 1205 | /// Takes whichever stage is waiting on this ticket, which is the point: every | |
| 1206 | /// stage re-arms one, so an earlier deadline landing between two stages finds | |
| 1207 | /// nothing and a later one covers the stage that follows. | |
| 1208 | fn workoutTimedOut(context: ?*anyopaque) callconv(.c) void { | |
| 1209 | if (!is_darwin) return; | |
| 1210 | ||
| 1211 | const entry = calls.expireIfWaiting(ios_async.ticketFromDeadline(context)) orelse return; | |
| 1212 | if (entry.call.workout) |chain| chain.deinit(); | |
| 1213 | std.log.warn( | |
| 1214 | "saveHealthWorkout: a HealthKit stage did not call its completion within {d} ms; " ++ | |
| 1215 | "answering TIMEOUT", | |
| 1216 | .{workout_stage_deadline_ms}, | |
| 1217 | ); | |
| 1218 | ios_async.deliverErrorCode(entry.ticket, BridgeError.Timeout); | |
| 1113 | 1219 | } |
| 1114 | 1220 | |
| 1115 | 1221 | /// Stage three: the route is attached. |
| 1116 | fn routeFinished(index: u5, route: Id, err: Id) void { | |
| 1222 | fn routeFinished(block: u5, route: Id, err: Id) void { | |
| 1117 | 1223 | if (!is_darwin) return; |
| 1118 | 1224 | |
| 1119 | const call = takeCall(index) orelse return; | |
| 1120 | const chain = call.workout orelse return; | |
| 1225 | const entry = switch (calls.settle(block)) { | |
| 1226 | .reply => |parked| parked, | |
| 1227 | // The deadline answered this call and freed its chain. The framework | |
| 1228 | // still held this block, and has now called it; nothing here to touch. | |
| 1229 | .late => |started_ms| { | |
| 1230 | std.log.warn( | |
| 1231 | "saveHealthWorkout: stage three completion arrived {d} ms after the call, after its " ++ | |
| 1232 | "deadline had answered; ignored", | |
| 1233 | .{compat.milliTimestamp() - started_ms}, | |
| 1234 | ); | |
| 1235 | return; | |
| 1236 | }, | |
| 1237 | .stray => { | |
| 1238 | std.log.warn("saveHealthWorkout: stage three fired for block {d} with no call; ignored", .{block}); | |
| 1239 | return; | |
| 1240 | }, | |
| 1241 | }; | |
| 1242 | const ticket = entry.ticket; | |
| 1243 | const chain = entry.call.workout orelse return; | |
| 1121 | 1244 | defer chain.deinit(); |
| 1122 | 1245 | |
| 1123 | 1246 | if (err != null) { |
| @@ -1129,10 +1252,10 @@ fn routeFinished(index: u5, route: Id, err: Id) void { | ||
| 1129 | 1252 | "saveHealthWorkout: route finish failed; the workout itself was saved as {s}", |
| 1130 | 1253 | .{uuidStringOf(chain.workout) orelse "<no uuid>"}, |
| 1131 | 1254 | ); |
| 1132 | ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.NativeCallFailed); | |
| 1255 | ios_async.deliverErrorCode(ticket, bridge_error.BridgeError.NativeCallFailed); | |
| 1133 | 1256 | return; |
| 1134 | 1257 | } |
| 1135 | replyWithWorkout(call.ticket, chain, route); | |
| 1258 | replyWithWorkout(ticket, chain, route); | |
| 1136 | 1259 | } |
| 1137 | 1260 | |
| 1138 | 1261 | /// `{"id":…}` or `{"id":…,"routeId":…}`. |
| @@ -1204,48 +1327,78 @@ fn routeMetadata(activity_id: []const u8) !Id { | ||
| 1204 | 1327 | return objc.msgSendId2(NSDictionary, sel, value, key) orelse error.NativeCallFailed; |
| 1205 | 1328 | } |
| 1206 | 1329 | |
| 1207 | fn authorizationAnswered(index: u5, success: bool, err: Id) void { | |
| 1330 | fn authorizationAnswered(block: u5, success: bool, err: Id) void { | |
| 1208 | 1331 | if (!is_darwin) return; |
| 1209 | 1332 | |
| 1210 | const call = takeCall(index) orelse { | |
| 1211 | std.log.warn( | |
| 1212 | "requestHealthAuthorization answered for slot {d} with no call recorded; ignored", | |
| 1213 | .{index}, | |
| 1214 | ); | |
| 1215 | return; | |
| 1333 | const entry = switch (calls.settle(block)) { | |
| 1334 | .reply => |parked| parked, | |
| 1335 | // No deadline is armed for this one — a person answers the sheet — so | |
| 1336 | // `late` can only mean the reply slot was answered some other way. | |
| 1337 | .late => return, | |
| 1338 | .stray => { | |
| 1339 | std.log.warn( | |
| 1340 | "requestHealthAuthorization answered for block {d} with no call recorded; ignored", | |
| 1341 | .{block}, | |
| 1342 | ); | |
| 1343 | return; | |
| 1344 | }, | |
| 1216 | 1345 | }; |
| 1217 | 1346 | |
| 1218 | 1347 | if (success) { |
| 1219 | ios_async.deliverJson(call.ticket, authorized_reply); | |
| 1348 | ios_async.deliverJson(entry.ticket, authorized_reply); | |
| 1220 | 1349 | return; |
| 1221 | 1350 | } |
| 1222 | 1351 | logNSError(A.request_health_authorization, err); |
| 1223 | ios_async.deliverErrorCode(call.ticket, BridgeError.PermissionDenied); | |
| 1352 | ios_async.deliverErrorCode(entry.ticket, BridgeError.PermissionDenied); | |
| 1224 | 1353 | } |
| 1225 | 1354 | |
| 1226 | fn statisticsAnswered(index: u5, result: Id, err: Id) void { | |
| 1355 | fn statisticsAnswered(block: u5, result: Id, err: Id) void { | |
| 1227 | 1356 | if (!is_darwin) return; |
| 1228 | 1357 | |
| 1229 | const call = takeCall(index) orelse { | |
| 1230 | std.log.warn("getHealthData answered for slot {d} with no call recorded; ignored", .{index}); | |
| 1231 | return; | |
| 1358 | const entry = switch (calls.settle(block)) { | |
| 1359 | .reply => |parked| parked, | |
| 1360 | .late => |started_ms| { | |
| 1361 | std.log.warn( | |
| 1362 | "getHealthData: a statistics result arrived {d} ms after the call, after its " ++ | |
| 1363 | "deadline had answered; ignored", | |
| 1364 | .{compat.milliTimestamp() - started_ms}, | |
| 1365 | ); | |
| 1366 | return; | |
| 1367 | }, | |
| 1368 | .stray => { | |
| 1369 | std.log.warn("getHealthData answered for block {d} with no call recorded; ignored", .{block}); | |
| 1370 | return; | |
| 1371 | }, | |
| 1232 | 1372 | }; |
| 1233 | 1373 | |
| 1234 | 1374 | if (err != null) { |
| 1235 | 1375 | logNSError(A.get_health_data, err); |
| 1236 | ios_async.deliverErrorCode(call.ticket, BridgeError.NativeCallFailed); | |
| 1376 | ios_async.deliverErrorCode(entry.ticket, BridgeError.NativeCallFailed); | |
| 1237 | 1377 | return; |
| 1238 | 1378 | } |
| 1239 | 1379 | |
| 1240 | 1380 | const allocator = std.heap.c_allocator; |
| 1241 | const json = shapeStatistics(allocator, result, call.unit) catch |shape_err| { | |
| 1381 | const json = shapeStatistics(allocator, result, entry.call.unit) catch |shape_err| { | |
| 1242 | 1382 | std.log.warn("getHealthData: could not shape the reply: {}", .{shape_err}); |
| 1243 | ios_async.deliverError(call.ticket); | |
| 1383 | ios_async.deliverError(entry.ticket); | |
| 1244 | 1384 | return; |
| 1245 | 1385 | }; |
| 1246 | 1386 | defer allocator.free(json); |
| 1247 | 1387 | |
| 1248 | ios_async.deliverJson(call.ticket, json); | |
| 1388 | ios_async.deliverJson(entry.ticket, json); | |
| 1389 | } | |
| 1390 | ||
| 1391 | /// On the main queue, `stats_deadline_ms` after the query was executed. | |
| 1392 | fn statsTimedOut(context: ?*anyopaque) callconv(.c) void { | |
| 1393 | if (!is_darwin) return; | |
| 1394 | ||
| 1395 | const entry = calls.expireIfWaiting(ios_async.ticketFromDeadline(context)) orelse return; | |
| 1396 | std.log.warn( | |
| 1397 | "getHealthData: HKStatisticsQuery did not call its completion within {d} ms; " ++ | |
| 1398 | "answering TIMEOUT", | |
| 1399 | .{stats_deadline_ms}, | |
| 1400 | ); | |
| 1401 | ios_async.deliverErrorCode(entry.ticket, BridgeError.Timeout); | |
| 1249 | 1402 | } |
| 1250 | 1403 | |
| 1251 | 1404 | /// `{"value":…,"unit":…}`. |
| @@ -1405,20 +1558,88 @@ test "the three workout stages have distinct invokes per slot" { | ||
| 1405 | 1558 | try testing.expect(save_blocks[0].invoke != save_blocks[1].invoke); |
| 1406 | 1559 | } |
| 1407 | 1560 | |
| 1408 | test "a workout stage firing for a slot with no chain is ignored" { | |
| 1409 | // Each stage takes the entry and a continuing stage puts it back, so a | |
| 1410 | // duplicate fire finds nothing rather than releasing the workout twice. | |
| 1561 | test "a workout stage firing for a block with no chain is ignored" { | |
| 1562 | // Each stage settles its block and a continuing stage claims a fresh one, | |
| 1563 | // so a duplicate fire finds nothing rather than releasing the workout twice. | |
| 1411 | 1564 | if (!is_darwin) return error.SkipZigTest; |
| 1412 | 1565 | |
| 1413 | pending_mutex.lock(); | |
| 1414 | for (&pending_calls) |*entry| entry.* = null; | |
| 1415 | pending_mutex.unlock(); | |
| 1566 | // Drained first, so a stray fire is what this reads rather than whatever | |
| 1567 | // another test left parked. | |
| 1568 | for (0..ios_pending.block_count) |i| _ = calls.settle(@intCast(i)); | |
| 1416 | 1569 | |
| 1417 | 1570 | workoutSaved(0, true, null); |
| 1418 | 1571 | routeInserted(0, true, null); |
| 1419 | 1572 | routeFinished(0, null, null); |
| 1420 | 1573 | } |
| 1421 | 1574 | |
| 1575 | test "a statistics deadline answers once, and the result it beat is ignored" { | |
| 1576 | // #223: without the table a deadline would free the reply slot, the next | |
| 1577 | // call would lease it and get the same block, and this late result would | |
| 1578 | // answer that call with these statistics. | |
| 1579 | for (0..ios_pending.block_count) |i| _ = calls.settle(@intCast(i)); | |
| 1580 | ||
| 1581 | const ticket: ios_async.Ticket = .{ .index = 7, .generation = 30 }; | |
| 1582 | const block = calls.claim(ticket, .{}) orelse return error.NoFreeBlock; | |
| 1583 | const expired = calls.expireIfWaiting(ticket) orelse return error.DeadlineFoundNothing; | |
| 1584 | try testing.expectEqual(@as(u32, 30), expired.ticket.generation); | |
| 1585 | ||
| 1586 | const next: ios_async.Ticket = .{ .index = 7, .generation = 31 }; | |
| 1587 | const elsewhere = calls.claim(next, .{}) orelse return error.NoFreeBlock; | |
| 1588 | try testing.expect(elsewhere != block); | |
| 1589 | try testing.expect(calls.settle(block) == .late); | |
| 1590 | _ = calls.settle(elsewhere); | |
| 1591 | } | |
| 1592 | ||
| 1593 | test "a deadline that lands between two workout stages cannot strand the next one" { | |
| 1594 | // The reason each stage re-arms its own clock. Stage one settles its block, | |
| 1595 | // and until stage two claims one no block holds the ticket: a deadline that | |
| 1596 | // fires in that moment finds nothing, and timers fire once. Were that the | |
| 1597 | // only deadline, stage two would run with none. So stage two arms its own, | |
| 1598 | // and this is the sequence that makes it necessary. | |
| 1599 | for (0..ios_pending.block_count) |i| _ = calls.settle(@intCast(i)); | |
| 1600 | ||
| 1601 | const ticket: ios_async.Ticket = .{ .index = 2, .generation = 40 }; | |
| 1602 | const stage_one = calls.claim(ticket, .{}) orelse return error.NoFreeBlock; | |
| 1603 | try testing.expect(calls.settle(stage_one) == .reply); | |
| 1604 | ||
| 1605 | // The moment between the stages: an earlier deadline finds nothing. | |
| 1606 | try testing.expect(calls.expireIfWaiting(ticket) == null); | |
| 1607 | ||
| 1608 | // Stage two claims, and its own deadline has something to take. | |
| 1609 | const stage_two = calls.claim(ticket, .{}) orelse return error.NoFreeBlock; | |
| 1610 | const taken = calls.expireIfWaiting(ticket) orelse return error.StageTwoHadNoDeadline; | |
| 1611 | try testing.expectEqual(@as(u32, 40), taken.ticket.generation); | |
| 1612 | try testing.expect(calls.settle(stage_two) == .late); | |
| 1613 | } | |
| 1614 | ||
| 1615 | test "every workout stage that claims a block arms the workout's own deadline" { | |
| 1616 | // The conformance ratchet already fails a stage that claims with no | |
| 1617 | // deadline at all. What it cannot see is a stage armed with the *wrong* | |
| 1618 | // one: it asks only for some `scheduleDeadline`. Re-arming stage two with | |
| 1619 | // `statsTimedOut` passes it, and would answer TIMEOUT without ever freeing | |
| 1620 | // the workout chain — a leak on every timed-out workout. So the handler is | |
| 1621 | // part of the needle, and there must be one per stage that claims. | |
| 1622 | // | |
| 1623 | // Joined at comptime so the needle cannot match this test's own text, | |
| 1624 | // which is how #248's ordering test once passed with its call deleted. | |
| 1625 | const source = @embedFile("bridge_mobile_health.zig"); | |
| 1626 | const needle = "scheduleDeadline(ticket, workout_stage_deadline_ms, " ++ "workoutTimedOut)"; | |
| 1627 | var arms: usize = 0; | |
| 1628 | var search: usize = 0; | |
| 1629 | while (std.mem.indexOfPos(u8, source, search, needle)) |at| : (search = at + needle.len) arms += 1; | |
| 1630 | try testing.expectEqual(@as(usize, 3), arms); | |
| 1631 | } | |
| 1632 | ||
| 1633 | test "the authorization sheet is not on a clock" { | |
| 1634 | // A person answers it. The dispatch declares that with `no-deadline:`, | |
| 1635 | // which the conformance ratchet requires of a claim with no deadline. | |
| 1636 | const source = @embedFile("bridge_mobile_health.zig"); | |
| 1637 | const needle = "// no-deadline: " ++ "a person answers the HealthKit"; | |
| 1638 | try testing.expect(std.mem.indexOf(u8, source, needle) != null); | |
| 1639 | try testing.expect(stats_deadline_ms < 30_000); | |
| 1640 | try testing.expect(3 * workout_stage_deadline_ms < 30_000); | |
| 1641 | } | |
| 1642 | ||
| 1422 | 1643 | test "the four data types are the ones the Swift switch recognises" { |
| 1423 | 1644 | // The names are the page's vocabulary and the units are the reply's, so |
| 1424 | 1645 | // both halves are wire contract. A fifth entry here would answer an action |
| @@ -1551,12 +1772,12 @@ test "both block families are global, distinct per slot, and distinct from each | ||
| 1551 | 1772 | try testing.expect(stats_blocks[0].invoke != stats_blocks[1].invoke); |
| 1552 | 1773 | } |
| 1553 | 1774 | |
| 1554 | test "a completion for a slot with no recorded call is ignored" { | |
| 1775 | test "a completion for a block with no recorded call is ignored" { | |
| 1555 | 1776 | if (!is_darwin) return error.SkipZigTest; |
| 1556 | 1777 | |
| 1557 | pending_mutex.lock(); | |
| 1558 | for (&pending_calls) |*entry| entry.* = null; | |
| 1559 | pending_mutex.unlock(); | |
| 1778 | // Drained first, so a stray fire is what this reads rather than whatever | |
| 1779 | // another test left parked. | |
| 1780 | for (0..ios_pending.block_count) |i| _ = calls.settle(@intCast(i)); | |
| 1560 | 1781 | |
| 1561 | 1782 | authorizationAnswered(0, true, null); |
| 1562 | 1783 | statisticsAnswered(0, null, null); |