ReviewOS

also looking at this

craft-native/craft

fix(ios): give getHealthData and saveHealthWorkout deadlines, on the shared block table

#250
Merged glennmichael123 wants to merge fix/ios-health-deadlines into main
1 file +343 -122
packages/zig/src/bridge_mobile_health.zigmodified+343-122
Changes to packages/zig/src/bridge_mobile_health.zig
@@ -48,6 +48,8 @@ const capabilities = ;
4848const bridge_error = ;
4949const objc_runtime = ;
5050const ios_async = ;
51const ios_pending = ;
52const compat = ;
5153const compat_mutex = ;
5254
5355const objc = objc_runtime.objc;
@@ -166,6 +168,11 @@ pub const HealthBridge = struct {
166168 const share = try shareTypeSet(self.allocator, requested);
167169 const read = try readTypeSet(self.allocator, requested);
168170
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
169176 const ticket = ios_async.acquire(A.request_health_authorization) orelse {
170177 std.log.warn(
171178 "requestHealthAuthorization: no free reply slot; {d} native calls are already awaiting one",
@@ -174,13 +181,20 @@ pub const HealthBridge = struct {
174181 return BridgeError.NativeCallFailed;
175182 };
176183 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 );
180190 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.
181195 const RequestFn = *const fn (Id, objc.SEL, Id, Id, *anyopaque) callconv(.c) void;
182196 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]));
184198 }
185199
186200 /// Save a workout, then optionally attach a GPS route to it.
@@ -217,6 +231,10 @@ pub const HealthBridge = struct {
217231 return BridgeError.AllocationFailed;
218232 errdefer std.heap.c_allocator.free(activity_id);
219233
234 // Resolved before the lease (#223).
235 const sel = objc.sel_registerName("saveObject:withCompletion:") orelse
236 return BridgeError.NativeCallFailed;
237
220238 const ticket = ios_async.acquire(A.save_health_workout) orelse {
221239 std.log.warn(
222240 "saveHealthWorkout: no free reply slot; {d} native calls are already awaiting one",
@@ -226,18 +244,27 @@ pub const HealthBridge = struct {
226244 };
227245 errdefer ios_async.abandon(ticket);
228246
229 publishWorkoutCall(ticket, .{
247 const block = calls.claim(ticket, .{ .workout = .{
230248 .workout = workout,
231249 .activity_id = activity_id,
232250 .locations = locations,
233251 .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 );
237258 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
238265 const SaveFn = *const fn (Id, objc.SEL, Id, *anyopaque) callconv(.c) void;
239266 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]));
241268 }
242269
243270 /// Sum one quantity type over a window and answer `{value, unit}`.
@@ -258,6 +285,18 @@ pub const HealthBridge = struct {
258285 const unit = try unitFromString(request.type.unit);
259286 const predicate = try samplePredicate(request.start, request.end);
260287
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
261300 const ticket = ios_async.acquire(A.get_health_data) orelse {
262301 std.log.warn(
263302 "getHealthData: no free reply slot; {d} native calls are already awaiting one",
@@ -266,14 +305,21 @@ pub const HealthBridge = struct {
266305 return BridgeError.NativeCallFailed;
267306 };
268307 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);
270322
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;
277323 const allocated = objc.msgSendId(HKStatisticsQuery, sel_alloc) orelse
278324 return BridgeError.NativeCallFailed;
279325
@@ -285,11 +331,9 @@ pub const HealthBridge = struct {
285331 quantity_type,
286332 predicate,
287333 statistics_option_cumulative_sum,
288 @ptrCast(&stats_blocks[ticket.index]),
334 @ptrCast(&stats_blocks[block]),
289335 ) orelse return BridgeError.NativeCallFailed;
290336
291 const sel_execute = objc.sel_registerName("executeQuery:") orelse
292 return BridgeError.NativeCallFailed;
293337 objc.msgSendVoid1(store, sel_execute, query);
294338 }
295339};
@@ -867,7 +911,6 @@ fn releaseObject(object: Id) void {
867911
868912/// What a slot's block needs that the block itself cannot carry.
869913const PendingCall = struct {
870 ticket: ios_async.Ticket,
871914 /// The `HKUnit` the statistics reply reports in, or null for the calls
872915 /// that report none.
873916 unit: Id = null,
@@ -902,41 +945,26 @@ const WorkoutChain = struct {
902945 }
903946};
904947
905var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null);
906var pending_mutex: compat_mutex.Mutex = .{};
907
908fn 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
914fn 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).
921949///
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.
926fn 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.
933fn 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.
954var 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.
959const 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.
967const workout_stage_deadline_ms: u32 = 8_000;
940968
941969const BlockDescriptor = extern struct {
942970 reserved: c_ulong = 0,
@@ -961,26 +989,26 @@ const health_block_descriptor = BlockDescriptor{ .size = @sizeOf(HealthBlock) };
961989
962990extern var _NSConcreteGlobalBlock: anyopaque;
963991
964fn makeAuthInvoke(comptime index: u5) *const anyopaque {
992fn makeAuthInvoke(comptime block: u5) *const anyopaque {
965993 const S = struct {
966994 fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void {
967 authorizationAnswered(index, success, err);
995 authorizationAnswered(block, success, err);
968996 }
969997 };
970998 return @ptrCast(&S.invoke);
971999}
9721000
973fn makeStatsInvoke(comptime index: u5) *const anyopaque {
1001fn makeStatsInvoke(comptime block: u5) *const anyopaque {
9741002 const S = struct {
9751003 fn invoke(_: *const HealthBlock, _: Id, result: Id, err: Id) callconv(.c) void {
976 statisticsAnswered(index, result, err);
1004 statisticsAnswered(block, result, err);
9771005 }
9781006 };
9791007 return @ptrCast(&S.invoke);
9801008}
9811009
982fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [ios_async.max_in_flight]HealthBlock {
983 var out: [ios_async.max_in_flight]HealthBlock = undefined;
1010fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [ios_pending.block_count]HealthBlock {
1011 var out: [ios_pending.block_count]HealthBlock = undefined;
9841012 for (&out, 0..) |*b, i| {
9851013 b.* = .{
9861014 .isa = &_NSConcreteGlobalBlock,
@@ -992,54 +1020,68 @@ fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [ios_async.max_
9921020 return out;
9931021}
9941022
995var auth_blocks: [ios_async.max_in_flight]HealthBlock =
1023var auth_blocks: [ios_pending.block_count]HealthBlock =
9961024 if (is_darwin) makeBlocks(makeAuthInvoke) else undefined;
997var stats_blocks: [ios_async.max_in_flight]HealthBlock =
1025var stats_blocks: [ios_pending.block_count]HealthBlock =
9981026 if (is_darwin) makeBlocks(makeStatsInvoke) else undefined;
9991027
1000fn makeSaveInvoke(comptime index: u5) *const anyopaque {
1028fn makeSaveInvoke(comptime block: u5) *const anyopaque {
10011029 const S = struct {
10021030 fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void {
1003 workoutSaved(index, success, err);
1031 workoutSaved(block, success, err);
10041032 }
10051033 };
10061034 return @ptrCast(&S.invoke);
10071035}
10081036
1009fn makeInsertInvoke(comptime index: u5) *const anyopaque {
1037fn makeInsertInvoke(comptime block: u5) *const anyopaque {
10101038 const S = struct {
10111039 fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void {
1012 routeInserted(index, success, err);
1040 routeInserted(block, success, err);
10131041 }
10141042 };
10151043 return @ptrCast(&S.invoke);
10161044}
10171045
1018fn makeFinishInvoke(comptime index: u5) *const anyopaque {
1046fn makeFinishInvoke(comptime block: u5) *const anyopaque {
10191047 const S = struct {
10201048 fn invoke(_: *const HealthBlock, route: Id, err: Id) callconv(.c) void {
1021 routeFinished(index, route, err);
1049 routeFinished(block, route, err);
10221050 }
10231051 };
10241052 return @ptrCast(&S.invoke);
10251053}
10261054
1027var save_blocks: [ios_async.max_in_flight]HealthBlock =
1055var save_blocks: [ios_pending.block_count]HealthBlock =
10281056 if (is_darwin) makeBlocks(makeSaveInvoke) else undefined;
1029var insert_blocks: [ios_async.max_in_flight]HealthBlock =
1057var insert_blocks: [ios_pending.block_count]HealthBlock =
10301058 if (is_darwin) makeBlocks(makeInsertInvoke) else undefined;
1031var finish_blocks: [ios_async.max_in_flight]HealthBlock =
1059var finish_blocks: [ios_pending.block_count]HealthBlock =
10321060 if (is_darwin) makeBlocks(makeFinishInvoke) else undefined;
10331061
10341062/// Stage one: the workout is in the store.
1035fn workoutSaved(index: u5, success: bool, err: Id) void {
1063fn workoutSaved(block: u5, success: bool, err: Id) void {
10361064 if (!is_darwin) return;
10371065
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 },
10411082 };
1042 const chain = call.workout orelse {
1083 const ticket = entry.ticket;
1084 const chain = entry.call.workout orelse {
10431085 std.log.warn("saveHealthWorkout: stage one fired for a slot holding no workout chain", .{});
10441086 return;
10451087 };
@@ -1047,42 +1089,69 @@ fn workoutSaved(index: u5, success: bool, err: Id) void {
10471089 if (!success) {
10481090 logNSError(A.save_health_workout, err);
10491091 chain.deinit();
1050 ios_async.deliverErrorCode(call.ticket, BridgeError.PermissionDenied);
1092 ios_async.deliverErrorCode(ticket, BridgeError.PermissionDenied);
10511093 return;
10521094 }
10531095
10541096 // Swift's early exit. The workout is saved either way; a route is
10551097 // optional, so having none is not a failure.
10561098 if (chain.locations == null) {
1057 replyWithWorkout(call.ticket, chain, null);
1099 replyWithWorkout(ticket, chain, null);
10581100 chain.deinit();
10591101 return;
10601102 }
10611103
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
10621110 var next = chain;
10631111 next.builder = routeBuilder(chain.store) catch |build_err| {
10641112 std.log.warn("saveHealthWorkout: could not build a route builder: {}", .{build_err});
10651113 // The workout is already saved, so answering with its id is truer
10661114 // than reporting a failure — Swift rejects here, and would tell a page
10671115 // nothing was written when something was.
1068 replyWithWorkout(call.ticket, chain, null);
1116 replyWithWorkout(ticket, chain, null);
10691117 chain.deinit();
10701118 return;
10711119 };
1072 republishWorkoutCall(call, next);
10731120
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
10751127 const InsertFn = *const fn (Id, objc.SEL, Id, *anyopaque) callconv(.c) void;
10761128 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]));
10781130}
10791131
10801132/// Stage two: the fixes are in the builder.
1081fn routeInserted(index: u5, success: bool, err: Id) void {
1133fn routeInserted(block: u5, success: bool, err: Id) void {
10821134 if (!is_darwin) return;
10831135
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;
10861155
10871156 if (!success) {
10881157 // Rejects, as the spec does. Resolving here reported a workout with
@@ -1098,26 +1167,80 @@ fn routeInserted(index: u5, success: bool, err: Id) void {
10981167 "saveHealthWorkout: route insert failed; the workout itself was saved as {s}",
10991168 .{uuidStringOf(chain.workout) orelse "<no uuid>"},
11001169 );
1101 ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.NativeCallFailed);
1170 ios_async.deliverErrorCode(ticket, bridge_error.BridgeError.NativeCallFailed);
11021171 chain.deinit();
11031172 return;
11041173 }
11051174
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");
11081179 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
11101185 const FinishFn = *const fn (Id, objc.SEL, Id, Id, *anyopaque) callconv(.c) void;
11111186 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.
1195fn 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.
1208fn 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);
11131219}
11141220
11151221/// Stage three: the route is attached.
1116fn routeFinished(index: u5, route: Id, err: Id) void {
1222fn routeFinished(block: u5, route: Id, err: Id) void {
11171223 if (!is_darwin) return;
11181224
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;
11211244 defer chain.deinit();
11221245
11231246 if (err != null) {
@@ -1129,10 +1252,10 @@ fn routeFinished(index: u5, route: Id, err: Id) void {
11291252 "saveHealthWorkout: route finish failed; the workout itself was saved as {s}",
11301253 .{uuidStringOf(chain.workout) orelse "<no uuid>"},
11311254 );
1132 ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.NativeCallFailed);
1255 ios_async.deliverErrorCode(ticket, bridge_error.BridgeError.NativeCallFailed);
11331256 return;
11341257 }
1135 replyWithWorkout(call.ticket, chain, route);
1258 replyWithWorkout(ticket, chain, route);
11361259}
11371260
11381261/// `{"id":…}` or `{"id":…,"routeId":…}`.
@@ -1204,48 +1327,78 @@ fn routeMetadata(activity_id: []const u8) !Id {
12041327 return objc.msgSendId2(NSDictionary, sel, value, key) orelse error.NativeCallFailed;
12051328}
12061329
1207fn authorizationAnswered(index: u5, success: bool, err: Id) void {
1330fn authorizationAnswered(block: u5, success: bool, err: Id) void {
12081331 if (!is_darwin) return;
12091332
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 },
12161345 };
12171346
12181347 if (success) {
1219 ios_async.deliverJson(call.ticket, authorized_reply);
1348 ios_async.deliverJson(entry.ticket, authorized_reply);
12201349 return;
12211350 }
12221351 logNSError(A.request_health_authorization, err);
1223 ios_async.deliverErrorCode(call.ticket, BridgeError.PermissionDenied);
1352 ios_async.deliverErrorCode(entry.ticket, BridgeError.PermissionDenied);
12241353}
12251354
1226fn statisticsAnswered(index: u5, result: Id, err: Id) void {
1355fn statisticsAnswered(block: u5, result: Id, err: Id) void {
12271356 if (!is_darwin) return;
12281357
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 },
12321372 };
12331373
12341374 if (err != null) {
12351375 logNSError(A.get_health_data, err);
1236 ios_async.deliverErrorCode(call.ticket, BridgeError.NativeCallFailed);
1376 ios_async.deliverErrorCode(entry.ticket, BridgeError.NativeCallFailed);
12371377 return;
12381378 }
12391379
12401380 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| {
12421382 std.log.warn("getHealthData: could not shape the reply: {}", .{shape_err});
1243 ios_async.deliverError(call.ticket);
1383 ios_async.deliverError(entry.ticket);
12441384 return;
12451385 };
12461386 defer allocator.free(json);
12471387
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.
1392fn 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);
12491402}
12501403
12511404/// `{"value":…,"unit":…}`.
@@ -1405,20 +1558,88 @@ test "the three workout stages have distinct invokes per slot" {
14051558 try testing.expect(save_blocks[0].invoke != save_blocks[1].invoke);
14061559}
14071560
1408test "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.
1561test "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.
14111564 if (!is_darwin) return error.SkipZigTest;
14121565
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));
14161569
14171570 workoutSaved(0, true, null);
14181571 routeInserted(0, true, null);
14191572 routeFinished(0, null, null);
14201573}
14211574
1575test "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
1593test "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
1615test "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
1633test "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
14221643test "the four data types are the ones the Swift switch recognises" {
14231644 // The names are the page's vocabulary and the units are the reply's, so
14241645 // 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
15511772 try testing.expect(stats_blocks[0].invoke != stats_blocks[1].invoke);
15521773}
15531774
1554test "a completion for a slot with no recorded call is ignored" {
1775test "a completion for a block with no recorded call is ignored" {
15551776 if (!is_darwin) return error.SkipZigTest;
15561777
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));
15601781
15611782 authorizationAnswered(0, true, null);
15621783 statisticsAnswered(0, null, null);