ReviewOS

also looking at this

craft-native/craft

fix(ios): settle a Siri shortcut removal whose completion never comes

#227
Merged glennmichael123 wants to merge fix/ios-siri-removal-deadline into main
7 files +503 -94

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/ios/fixtures/zig-slice/build-and-run.shmodified+58-5
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -152,7 +152,10 @@ gather_log() { cat "$STDOUT_LOG" "$STDERR_LOG" > "$LOG" 2>/dev/null || true; }
152152# So: a deadline, and an explicit failure that names what is missing. The two
153153# situations look nothing alike now.
154154SLICE_TIMEOUT="${CRAFT_SLICE_TIMEOUT:-600}"
155AWAITED_IDS="20 22 24 26 28 32 34 38 44 46 72 76 78 82"
155# `38|84|86` is one wait: the Siri removal settles as its completion, its
156# deadline, or something else, and the assertions below tell them apart. 54
157# is awaited because openSettings now waits for that removal.
158AWAITED_IDS="20 22 24 26 28 32 34 38|84|86 44 46 54 72 76 78 82"
156159DEADLINE=$(( $(date +%s) + SLICE_TIMEOUT ))
157160
158161missing_ids() {
@@ -162,7 +165,8 @@ missing_ids() {
162165 # ERE with an explicit boundary rather than \b, which is a GNU
163166 # extension this script cannot count on, and rather than a bare
164167 # "i=$id", which matches i=260 when it is looking for i=26.
165 grep -qE "i=$id(\$|[^0-9])" "$LOG" 2>/dev/null || missing="$missing i=$id"
168 # Grouped, so an alternation cannot match a bare "84" elsewhere.
169 grep -qE "i=($id)(\$|[^0-9])" "$LOG" 2>/dev/null || missing="$missing i=$id"
166170 done
167171 echo "$missing"
168172}
@@ -181,6 +185,18 @@ while :; do
181185 fi
182186 sleep 1
183187done
188
189# A removal its deadline answered can still be completed later, and when it is,
190# Zig logs how late. Give that line a little while before judging i=84 below,
191# so the report says which it was.
192LATE_PATTERN='a deletion completion arrived [0-9]+ ms after the call'
193if grep -qE "i=84(\$|[^0-9])" "$LOG" 2>/dev/null; then
194 for _ in $(seq 1 15); do
195 gather_log
196 grep -qE "$LATE_PATTERN" "$LOG" 2>/dev/null && break
197 sleep 1
198 done
199fi
184200xcrun simctl terminate "$UDID" "$BUNDLE_ID" 2>/dev/null || true
185201gather_log
186202
@@ -359,12 +375,49 @@ C36="$(count 36)"
359375[ "$C36" -ge 1 ] || { echo "FAIL: registerSiriShortcut did not echo the donated shortcut"; exit 1; }
360376echo "ok: NSUserActivity donated and echoed back (i=36 seen ${C36}x)"
361377
362C38="$(count 38)"
378C38="$(count 38)"; C84="$(count 84)"; C86="$(count 86)"
363379# removeSiriShortcut answers from a void(^)(void) completion that carries no
364380# arguments at all, so this reply arriving proves Foundation fired the block
365381# and the parked payload was delivered under the right request id.
366[ "$C38" -ge 1 ] || { echo "FAIL: the deletion completion never fired, or its reply was lost"; exit 1; }
367echo "ok: argument-free completion block delivered its parked reply (i=38 seen ${C38}x)"
382#
383# The completion comes from a system daemon (#211), and whether it comes is
384# the simulator's business. Locally it lands in tens of milliseconds. On CI's
385# macOS runners, three runs of this fixture saw none at all: not within 75 s in
386# the foreground, and not within 30 s after the app was backgrounded. Before
387# the deadline, the runs that passed did so because openSettings happened to
388# background the app while the call was still waiting.
389#
390# So this asserts what #211 is about, which the app controls: the call
391# settles. i=38 is the completion; i=84 is the 15 s deadline answering TIMEOUT
392# instead of the page waiting for ever, which passes with a warning that the
393# completion path went unexercised on this run. What that path needs from Zig
394# (a global block per call, and exactly one answer when it races the deadline)
395# is covered by bridge_mobile_siri.zig's host tests, and any run where the
396# simulator does call it, as it does locally, still goes through i=38.
397#
398# Settling twice is caught here only when both answers land before the app is
399# backgrounded, which is rare by construction: the Zig host tests in
400# bridge_mobile_siri.zig are what guard "exactly one answer". This is a cheap
401# extra look, not that guarantee.
402SIRI_KINDS=$(( (C38 > 0) + (C84 > 0) + (C86 > 0) ))
403if [ "$SIRI_KINDS" -ge 2 ]; then
404 echo "FAIL: removeSiriShortcut settled more than one way (i=38 ${C38}x, i=84 ${C84}x, i=86 ${C86}x)"
405 exit 1
406elif [ "$C38" -ge 1 ]; then
407 echo "ok: argument-free completion block delivered its parked reply (i=38 seen ${C38}x)"
408elif [ "$C84" -ge 1 ]; then
409 LATE="$(grep -oE "$LATE_PATTERN" "$PLAIN" | head -1 || true)"
410 echo "ok: removeSiriShortcut settled with TIMEOUT from its native deadline instead of hanging (i=84 seen ${C84}x)"
411 NOTE="the simulator never called the NSUserActivity deletion completion, so this run did not exercise that path"
412 [ -n "$LATE" ] && NOTE="the simulator called the NSUserActivity deletion completion only after the deadline (${LATE}), and it was ignored"
413 echo "note: $NOTE"
414 # A GitHub annotation, so an unexercised path shows on the run page rather
415 # than only in the log.
416 if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning title=removeSiriShortcut completion::$NOTE"; fi
417else
418 echo "FAIL: removeSiriShortcut settled with neither {removed: true} nor TIMEOUT (i=86 seen ${C86}x)"
419 exit 1
420fi
368421
369422C46="$(count 46)"
370423# pageCount is PDFKit's answer for the bytes the page sent, so it cannot be
packages/ios/fixtures/zig-slice/page.mmodified+37-8
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -64,10 +64,19 @@ const char craft_slice_page[] =
6464 " if (sentSpeechEnd || !e.detail || typeof e.detail !== 'object') { return; }\n"
6565 " sentSpeechEnd = true;\n"
6666 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:72});\n"
67 " // Genuinely last: this backgrounds the app, and every chain that needs\n"
68 " // a foreground process has now closed.\n"
69 " bridge.postMessage({t:'mobile', a:'openSettings', i:102});\n"
67 " speechEnded = true;\n"
68 " openSettingsWhenDone();\n"
7069 "});\n"
70 "// Genuinely last: opening Settings backgrounds the app, and a suspended\n"
71 "// app runs neither a framework completion nor a main-queue deadline. So\n"
72 "// this waits for the Siri removal to settle first, by its completion or\n"
73 "// by its 15 s deadline (#211). Before, the removal was often still waiting\n"
74 "// when the app was backgrounded here, and a suspended app never heard it.\n"
75 "function openSettingsWhenDone() {\n"
76 " if (postedSettings || !speechEnded || !siriSettled) { return; }\n"
77 " postedSettings = true;\n"
78 " bridge.postMessage({t:'mobile', a:'openSettings', i:102});\n"
79 "}\n"
7180 "var sentEvent = false;\n"
7281 "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false, sentSecure = false, sentDb = false, sentNotif = false;\n"
7382 "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n"
@@ -84,6 +93,8 @@ const char craft_slice_page[] =
8493 "var sentShot = false, sentDeepLink = false;\n"
8594 "var sentOcr = false, sentClassify = false, sentDetect = false;\n"
8695 "var sentAuth = false, sentSiriAdd = false, sentSiriDel = false;\n"
96 "var sentSiriTimeout = false, sentSiriOdd = false, siriSettled = false;\n"
97 "var speechEnded = false, postedSettings = false;\n"
8798 "var sentPdfOpen = false, sentPdfClose = false;\n"
8899 "var sentDownload = false, sentWatch = false, sentSettings = false;\n"
89100 "var sentBtStart = false, sentBtStop = false;\n"
@@ -91,6 +102,19 @@ const char craft_slice_page[] =
91102 "var sentLocationFresh = false, sentLocationCached = false, sentLocationTimeout = false;\n"
92103 "var sentSpeechStart = false, sentSpeechErr = false, sentSpeechEnd = false;\n"
93104 "window.__craftBridgeError = function (ctx) {\n"
105 " // A registration that failed never posts the removal, and a removal\n"
106 " // that was refused or timed out still settles it, so neither can hold\n"
107 " // openSettings back for ever.\n"
108 " if (ctx && ctx.id === 96) { siriSettled = true; openSettingsWhenDone(); }\n"
109 " if (ctx && ctx.id === 97) {\n"
110 " siriSettled = true;\n"
111 " if (ctx.code === 'TIMEOUT') {\n"
112 " if (!sentSiriTimeout) { sentSiriTimeout = true; bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:84}); }\n"
113 " } else if (!sentSiriOdd) {\n"
114 " sentSiriOdd = true; bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:86});\n"
115 " }\n"
116 " openSettingsWhenDone();\n"
117 " }\n"
94118 " // The message crossed Zig's escaping with a backslash, quote, and\n"
95119 " // newline intact; anything mangled and this equality fails, i=7 never\n"
96120 " // fires, and the harness reports the error route broken.\n"
@@ -318,11 +342,16 @@ const char craft_slice_page[] =
318342 " // removeSiriShortcut answers from inside Foundation's completion\n"
319343 " // handler, a void(^)(void) that carries nothing at all — so this reply\n"
320344 " // arriving proves the block fired and its parked payload was delivered\n"
321 " // under the request id captured back at dispatch.\n"
322 " if (!sentSiriDel && action === 'removeSiriShortcut' && payload\n"
323 " && payload.removed === true && payload.action === 'sliceAct') {\n"
324 " sentSiriDel = true;\n"
325 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:38});\n"
345 " // under the request id captured back at dispatch. Anything else it\n"
346 " // resolves with is odd (i=86); its deadline arrives as an error (i=84).\n"
347 " if (action === 'removeSiriShortcut' && id === 97) {\n"
348 " siriSettled = true;\n"
349 " if (payload && payload.removed === true && payload.action === 'sliceAct') {\n"
350 " if (!sentSiriDel) { sentSiriDel = true; bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:38}); }\n"
351 " } else if (!sentSiriOdd) {\n"
352 " sentSiriOdd = true; bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:86});\n"
353 " }\n"
354 " openSettingsWhenDone();\n"
326355 " }\n"
327356 " if (!sentOcr && action === 'recognizeText' && Array.isArray(payload)) {\n"
328357 " var joined = payload.map(function (r) { return r.text; }).join(' ').toUpperCase();\n"
packages/ios/src/index.test.tsmodified+23-0
Changes to packages/ios/src/index.test.ts
@@ -173,6 +173,29 @@ describe('Craft iOS builder', () => {
173173 expect(swift).not.toContain("addEventListener('craftOTAStatus'")
174174 })
175175
176 it('settles a Siri shortcut removal whose completion never comes', async () => {
177 // #211: the deletion completion comes from a system daemon, and on a
178 // fresh simulator it sometimes never does. The Swift arm only runs in
179 // apps without the Zig runtime, so no simulator suite reaches it.
180 const output = mkdtempSync(join(tmpdir(), 'craft-ios-siri-removal-'))
181 await init({ runtimeDir: null, name: 'WildLoop', bundleId: 'org.wildloop.app', output })
182
183 const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8')
184 const start = swift.indexOf('private func removeSiriShortcut(')
185 const removal = swift.slice(start, swift.indexOf('// MARK: - Watch Connectivity', start))
186
187 expect(start).toBeGreaterThan(-1)
188 expect(removal).toContain('DispatchQueue.main.asyncAfter(deadline: .now() + Coordinator.siriRemovalDeadline, execute: deadline)')
189 expect(removal).toContain('code: "TIMEOUT"')
190 // Exactly one answer: both halves take the same entry, and only the one
191 // that finds it replies.
192 expect(removal.split('self.pendingSiriRemovals.removeValue(forKey: token)')).toHaveLength(3)
193 expect(removal).toContain('pending.cancel()')
194 // The deadline never claims the shortcut is gone.
195 const deadline = removal.slice(removal.indexOf('let deadline'), removal.indexOf('pendingSiriRemovals[token] = deadline'))
196 expect(deadline).not.toContain('"removed": true')
197 })
198
176199 it('returns completed video recordings as the documented base64 string', async () => {
177200 const output = mkdtempSync(join(tmpdir(), 'craft-ios-video-recording-'))
178201 await init({
packages/ios/templates/CraftApp.swiftmodified+34-1
Changes to packages/ios/templates/CraftApp.swift
@@ -5608,9 +5608,42 @@ struct CraftWebView: UIViewRepresentable {
56085608 resolveCallback(callbackId, result: ["registered": true, "action": action, "phrase": phrase])
56095609 }
56105610
5611 // Removals waiting on their completion, each under a token of its own:
5612 // callbackId can be nil, and restarts when the page reloads.
5613 private var pendingSiriRemovals: [UUID: DispatchWorkItem] = [:]
5614 // The only thing that settles craft.siri.remove, which arms no timeout
5615 // of its own; the same as Zig's.
5616 private static let siriRemovalDeadline: TimeInterval = 15
5617
56115618 private func removeSiriShortcut(action: String, callbackId: String?) {
5619 // The completion comes from a system daemon, and sometimes it never
5620 // comes (#211). The deadline settles the call instead, and never
5621 // with removed: true, because the deletion may still have happened.
5622 // Both run on the main queue and remove the same entry, so only one
5623 // of them answers.
5624 let token = UUID()
5625 let deadline = DispatchWorkItem { [weak self] in
5626 guard let self, self.pendingSiriRemovals.removeValue(forKey: token) != nil else { return }
5627 self.rejectCallback(
5628 callbackId,
5629 error: "NSUserActivity.deleteSavedUserActivities did not call its completion handler within \(Int(Coordinator.siriRemovalDeadline))s",
5630 code: "TIMEOUT"
5631 )
5632 }
5633 pendingSiriRemovals[token] = deadline
5634 DispatchQueue.main.asyncAfter(deadline: .now() + Coordinator.siriRemovalDeadline, execute: deadline)
5635
56125636 NSUserActivity.deleteSavedUserActivities(withPersistentIdentifiers: [action]) {
5613 self.resolveCallback(callbackId, result: ["removed": true, "action": action])
5637 // The queue this runs on is not documented, and a zig: hand-off
5638 // reply goes to evaluateJavaScript without a hop of its own.
5639 DispatchQueue.main.async { [weak self] in
5640 guard let self, let pending = self.pendingSiriRemovals.removeValue(forKey: token) else {
5641 print("removeSiriShortcut: the completion for \(action) arrived after its deadline; ignored")
5642 return
5643 }
5644 pending.cancel()
5645 self.resolveCallback(callbackId, result: ["removed": true, "action": action])
5646 }
56145647 }
56155648 }
56165649
packages/zig/src/bridge_mobile_location.zigmodified+2-35
Changes to packages/zig/src/bridge_mobile_location.zig
@@ -1788,42 +1788,16 @@ fn takePendingFixIf(ticket: ios_async.Ticket) ?PendingFix {
17881788 return call;
17891789}
17901790
1791const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void;
1792extern "c" fn dispatch_time(when: u64, delta: i64) u64;
1793extern "c" fn dispatch_after_f(
1794 when: u64,
1795 queue: *anyopaque,
1796 context: ?*anyopaque,
1797 work: dispatch_function_t,
1798) void;
1799extern var _dispatch_main_q: anyopaque;
1800
1801fn timeoutContext(ticket: ios_async.Ticket) ?*anyopaque {
1802 const encoded = (@as(usize, ticket.generation) << 5) | @as(usize, ticket.index);
1803 return @ptrFromInt(encoded + 1);
1804}
1805
1806fn ticketFromTimeoutContext(context: ?*anyopaque) ios_async.Ticket {
1807 const encoded = @intFromPtr(context orelse unreachable) - 1;
1808 return .{
1809 .index = @intCast(encoded & 31),
1810 .generation = @intCast(encoded >> 5),
1811 };
1812}
1813
18141791fn positionTimedOut(context: ?*anyopaque) callconv(.c) void {
18151792 if (!is_darwin) return;
1816 const ticket = ticketFromTimeoutContext(context);
1793 const ticket = ios_async.ticketFromDeadline(context);
18171794 const call = takePendingFixIf(ticket) orelse return;
18181795 restoreBestAccuracy();
18191796 ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.Timeout);
18201797}
18211798
18221799fn schedulePositionTimeout(ticket: ios_async.Ticket, timeout_ms: u32) void {
1823 if (!is_darwin) return;
1824 const delay_ns: i64 = @as(i64, timeout_ms) * 1_000_000;
1825 const deadline = dispatch_time(0, delay_ns);
1826 dispatch_after_f(deadline, &_dispatch_main_q, timeoutContext(ticket), positionTimedOut);
1800 ios_async.scheduleDeadline(ticket, timeout_ms, positionTimedOut);
18271801}
18281802
18291803/// Record the running watch. Overwriting is the whole behaviour: a second
@@ -2980,13 +2954,6 @@ test "cache age is explicit, inclusive and future-safe" {
29802954 try testing.expect(cacheAgeIsAcceptable(10_000, 11_000, 1));
29812955}
29822956
2983test "position timeout contexts retain the complete async ticket" {
2984 const original: ios_async.Ticket = .{ .index = 15, .generation = 0xfedcba98 };
2985 const decoded = ticketFromTimeoutContext(timeoutContext(original));
2986 try testing.expectEqual(original.index, decoded.index);
2987 try testing.expectEqual(original.generation, decoded.generation);
2988}
2989
29902957test "watch payloads remain ignored" {
29912958 if (!is_darwin) return error.SkipZigTest;
29922959 // Real dispatches, so this needs the process the gate stops.
packages/zig/src/bridge_mobile_siri.zigmodified+301-45
Changes to packages/zig/src/bridge_mobile_siri.zig
@@ -56,6 +56,7 @@ const capabilities = ;
5656const bridge_error = ;
5757const objc_runtime = ;
5858const ios_async = ;
59const compat = ;
5960const compat_mutex = ;
6061
6162const objc = objc_runtime.objc;
@@ -121,12 +122,24 @@ pub const SiriBridge = struct {
121122 bridge_error.sendResultToJS(self.allocator, A.register_siri_shortcut, out.items);
122123 }
123124
124 /// Delete a donated activity, and answer when the deletion is done.
125 /// Delete a donated activity, and answer when the deletion is done, or
126 /// with `TIMEOUT` if it has not said so within `removal_deadline_ms`.
125127 ///
126128 /// `deleteSavedUserActivitiesWithPersistentIdentifiers:completionHandler:`
127129 /// takes a `void (^)(void)`, and Swift replies from inside it. Replying
128130 /// before it fired would tell the page a shortcut is gone while Siri can
129131 /// still match it — the window is small and the claim is still false.
132 ///
133 /// The completion comes from a system daemon, and on a freshly booted
134 /// simulator it sometimes never comes (#211). Nothing else would ever
135 /// settle the call, so the deadline does, and never with `removed: true`:
136 /// the deletion may or may not have happened, and a second removal is
137 /// harmless.
138 ///
139 /// Every step that can fail comes before the reply slot is leased, except
140 /// claiming a completion block, whose refusal the two `errdefer`s undo.
141 /// Once a block holds the parked reply, nothing may fail until Foundation
142 /// has it.
130143 fn removeShortcut(self: *Self, data: []const u8) !void {
131144 if (!is_darwin) return BridgeError.PlatformNotSupported;
132145
@@ -136,6 +149,9 @@ pub const SiriBridge = struct { moved, unchanged
136149 const NSUserActivity = objc.objc_getClass("NSUserActivity") orelse
137150 return BridgeError.PlatformNotSupported;
138151 const identifiers = try singletonArray(self.allocator, fields.action);
152 const sel = objc.sel_registerName(
153 "deleteSavedUserActivitiesWithPersistentIdentifiers:completionHandler:",
154 ) orelse return BridgeError.NativeCallFailed;
139155
140156 // The reply names the action, so it is shaped before the ticket exists
141157 // and handed to the slot: the completion carries no arguments at all
@@ -159,14 +175,20 @@ pub const SiriBridge = struct {
159175 };
160176 errdefer ios_async.abandon(ticket);
161177
162 publishPendingCall(ticket, reply);
178 const block = claimBlock(ticket, reply) orelse {
179 std.log.warn(
180 "removeSiriShortcut: all {d} completion blocks are still owed a completion " ++
181 "Foundation never delivered; refusing rather than reusing one",
182 .{block_count},
183 );
184 return BridgeError.NativeCallFailed;
185 };
186
187 ios_async.scheduleDeadline(ticket, removal_deadline_ms, removalTimedOut);
163188
164 const sel = objc.sel_registerName(
165 "deleteSavedUserActivitiesWithPersistentIdentifiers:completionHandler:",
166 ) orelse return BridgeError.NativeCallFailed;
167189 const DeleteFn = *const fn (objc.Class, objc.SEL, Id, *anyopaque) callconv(.c) void;
168190 const deleteFn: DeleteFn = @ptrCast(&objc.objc_msgSend);
169 deleteFn(NSUserActivity, sel, identifiers, doneBlock(ticket));
191 deleteFn(NSUserActivity, sel, identifiers, @ptrCast(&done_blocks[block]));
170192 }
171193};
172194
@@ -353,36 +375,107 @@ fn release(object: Id) void {
353375// The deletion completion
354376// ---------------------------------------------------------------------------
355377
356/// The reply a slot's completion will send.
378/// How long a removal waits for its completion before answering `TIMEOUT`.
379///
380/// For `craft.siri.remove`, which arms no timeout of its own, this is the only
381/// thing that settles the call; for `_invoke` and `craft-bridge.js`, it is
382/// well under their 30 s, so the page hears the native answer rather than its
383/// own. It is the same budget the slice fixture
384/// gives a daemon-served location fix on the same simulator. A completion
385/// that arrives in time logs how long it took, so the number can be checked
386/// against CI.
387const removal_deadline_ms: u32 = 15_000;
388
389/// A removal waiting on its completion.
357390///
358391/// `void (^)(void)` carries nothing — not even a success flag — so everything
359392/// the reply needs is shaped before the call and parked here. The ticket is
360/// stored rather than rebuilt from the slot index, because a `Ticket` is an
361/// index *and* a generation and inventing the generation would defeat the
362/// check that stops a late completion answering whoever holds the slot now.
393/// stored rather than rebuilt, because a `Ticket` is an index *and* a
394/// generation, and the deadline matches on both.
363395const PendingCall = struct {
364396 ticket: ios_async.Ticket,
365 /// Owned, `c_allocator`. Freed when the completion fires.
397 /// Owned, `c_allocator`. Freed by whichever of the completion and the
398 /// deadline takes the call.
366399 reply: []u8,
400 started_ms: i64,
401};
402
403/// One per completion block, not per reply slot.
404///
405/// A block knows only its own index. If blocks were chosen by reply slot, a
406/// deadline would release slot k, the next removal would lease slot k and get
407/// the same block, and the first removal's late completion would then answer
408/// the second with `removed: true` before its deletion had finished. So a
409/// block whose call the deadline answered stays `owed` until Foundation calls
410/// it, and is never handed to another call in the meantime.
411const BlockState = union(enum) {
412 free,
413 waiting: PendingCall,
414 /// Answered by the deadline. Foundation still holds this block and may
415 /// call it; when it does, that call is ignored and the block is freed.
416 /// Carries when the call started, so a late completion can say how late.
417 owed: i64,
367418};
368419
369var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null);
420/// Twice the reply slots, so every slot can be waiting while as many blocks
421/// again are still owed a completion that has not come.
422const block_count = 2 * ios_async.max_in_flight;
423
424var blocks: [block_count]BlockState = @splat(.free);
370425var pending_mutex: compat_mutex.Mutex = .{};
371426
372fn publishPendingCall(ticket: ios_async.Ticket, reply: []u8) void {
427/// Park `reply` on the first free block. Null when every block is waiting or
428/// owed, which the caller refuses rather than reusing an owed block.
429fn claimBlock(ticket: ios_async.Ticket, reply: []u8) ?u5 {
373430 pending_mutex.lock();
374431 defer pending_mutex.unlock();
375 pending_calls[ticket.index] = .{ .ticket = ticket, .reply = reply };
432 for (&blocks, 0..) |*state, i| {
433 if (state.* != .free) continue;
434 state.* = .{ .waiting = .{ .ticket = ticket, .reply = reply, .started_ms = compat.milliTimestamp() } };
435 return @intCast(i);
436 }
437 return null;
376438}
377439
378/// Read and clear. Clearing is what makes a second fire of the same completion
379/// a no-op rather than a second reply and a double free.
380fn takePendingCall(index: u5) ?PendingCall {
440/// The deadline's half: take the call `ticket` names if it is still waiting,
441/// and leave its block owed. Matching the whole ticket is what stops a timer
442/// from expiring a newer call that leased the same reply slot.
443fn expireIfWaiting(ticket: ios_async.Ticket) ?PendingCall {
381444 pending_mutex.lock();
382445 defer pending_mutex.unlock();
383 const call = pending_calls[index];
384 pending_calls[index] = null;
385 return call;
446 for (&blocks) |*state| {
447 const call = switch (state.*) {
448 .waiting => |call| call,
449 else => continue,
450 };
451 if (call.ticket.index != ticket.index or call.ticket.generation != ticket.generation) continue;
452 state.* = .{ .owed = call.started_ms };
453 return call;
454 }
455 return null;
456}
457
458const Settled = union(enum) {
459 /// The completion arrived first: answer with this call.
460 reply: PendingCall,
461 /// The deadline already answered. Nothing to send. When the call started.
462 late: i64,
463 /// No call was ever parked on this block.
464 stray,
465};
466
467/// The completion's half. Each block leaves `waiting` or `owed` exactly once,
468/// under the lock, so the completion and the deadline cannot both answer.
469fn settleBlock(block: u5) Settled {
470 pending_mutex.lock();
471 defer pending_mutex.unlock();
472 const state = blocks[block];
473 blocks[block] = .free;
474 return switch (state) {
475 .waiting => |call| .{ .reply = call },
476 .owed => |started_ms| .{ .late = started_ms },
477 .free => .stray,
478 };
386479}
387480
388481const BlockDescriptor = extern struct {
@@ -408,17 +501,17 @@ const done_block_descriptor = BlockDescriptor{ .size = @sizeOf(DoneBlock) };
408501
409502extern var _NSConcreteGlobalBlock: anyopaque;
410503
411fn makeDoneInvoke(comptime index: u5) *const anyopaque {
504fn makeDoneInvoke(comptime block: u5) *const anyopaque {
412505 const S = struct {
413506 fn invoke(_: *const DoneBlock) callconv(.c) void {
414 deletionFired(index);
507 deletionFired(block);
415508 }
416509 };
417510 return @ptrCast(&S.invoke);
418511}
419512
420fn makeDoneBlocks() [ios_async.max_in_flight]DoneBlock {
421 var out: [ios_async.max_in_flight]DoneBlock = undefined;
513fn makeDoneBlocks() [block_count]DoneBlock {
514 var out: [block_count]DoneBlock = undefined;
422515 for (&out, 0..) |*b, i| {
423516 b.* = .{
424517 .isa = &_NSConcreteGlobalBlock,
@@ -430,28 +523,52 @@ fn makeDoneBlocks() [ios_async.max_in_flight]DoneBlock {
430523 return out;
431524}
432525
433var done_blocks: [ios_async.max_in_flight]DoneBlock =
526var done_blocks: [block_count]DoneBlock =
434527 if (is_darwin) makeDoneBlocks() else undefined;
435528
436fn doneBlock(ticket: ios_async.Ticket) *anyopaque {
437 return @ptrCast(&done_blocks[ticket.index]);
438}
439
440529/// Runs on whatever queue Foundation chose. `ios_async.deliverJson` copies the
441530/// payload and hops to the main queue, where `evaluateJavaScript:` is legal.
442fn deletionFired(index: u5) void {
531fn deletionFired(block: u5) void {
443532 if (!is_darwin) return;
444533
445 const call = takePendingCall(index) orelse {
446 std.log.warn(
447 "removeSiriShortcut completion fired for slot {d} with no call recorded; ignored",
448 .{index},
449 );
450 return;
451 };
534 switch (settleBlock(block)) {
535 .reply => |call| {
536 defer std.heap.c_allocator.free(call.reply);
537 std.log.info(
538 "removeSiriShortcut: the deletion completion arrived after {d} ms",
539 .{compat.milliTimestamp() - call.started_ms},
540 );
541 ios_async.deliverJson(call.ticket, call.reply);
542 },
543 .late => |started_ms| std.log.warn(
544 "removeSiriShortcut: a deletion completion arrived {d} ms after the call, after " ++
545 "its deadline had answered; ignored",
546 .{compat.milliTimestamp() - started_ms},
547 ),
548 .stray => std.log.warn(
549 "removeSiriShortcut completion fired for block {d} with no call recorded; ignored",
550 .{block},
551 ),
552 }
553}
554
555/// On the main queue, `removal_deadline_ms` after the call. Does nothing when
556/// the completion got there first, which is the usual case: the timer cannot be
557/// cancelled.
558fn removalTimedOut(context: ?*anyopaque) callconv(.c) void {
559 if (!is_darwin) return;
560
561 const call = expireIfWaiting(ios_async.ticketFromDeadline(context)) orelse return;
452562 defer std.heap.c_allocator.free(call.reply);
453563
454 ios_async.deliverJson(call.ticket, call.reply);
564 // The page's message is fixed by the error code, so the framework is named
565 // here. No `i=` in this line: the slice fixture greps for markers by it.
566 std.log.warn(
567 "removeSiriShortcut: +[NSUserActivity deleteSavedUserActivitiesWithPersistentIdentifiers:" ++
568 "completionHandler:] did not call its completion within {d} ms; answering TIMEOUT",
569 .{removal_deadline_ms},
570 );
571 ios_async.deliverErrorCode(call.ticket, BridgeError.Timeout);
455572}
456573
457574const testing = std.testing;
@@ -526,6 +643,7 @@ test "every declared action dispatches to something" {
526643
527644test "a refused removal leases no reply slot" {
528645 if (!is_darwin) return error.SkipZigTest;
646 resetBlocks();
529647
530648 var bridge = SiriBridge.init(testing.allocator);
531649 defer bridge.deinit();
@@ -534,10 +652,10 @@ test "a refused removal leases no reply slot" {
534652
535653 pending_mutex.lock();
536654 defer pending_mutex.unlock();
537 for (pending_calls) |entry| try testing.expect(entry == null);
655 for (blocks) |state| try testing.expect(state == .free);
538656}
539657
540test "each slot's completion block is global and has its own invoke" {
658test "each completion block is global and has its own invoke" {
541659 if (!is_darwin) return error.SkipZigTest;
542660
543661 for (&done_blocks) |*b| {
@@ -546,18 +664,156 @@ test "each slot's completion block is global and has its own invoke" {
546664 try testing.expectEqual(@sizeOf(DoneBlock), @as(usize, @intCast(b.descriptor.size)));
547665 }
548666 try testing.expect(done_blocks[0].invoke != done_blocks[1].invoke);
667 try testing.expect(done_blocks[0].invoke != done_blocks[block_count - 1].invoke);
549668}
550669
551test "a completion for a slot with no recorded call is ignored" {
670test "a completion for a block with no recorded call is ignored" {
552671 // `void (^)(void)` carries nothing, so a stray fire has no way to identify
553 // itself. Clearing the entry on take is what makes the second fire a
672 // itself. Freeing the block on settle is what makes the second fire a
554673 // no-op rather than a double free of the parked reply.
555674 if (!is_darwin) return error.SkipZigTest;
675 resetBlocks();
676
677 deletionFired(0);
678 deletionFired(0);
679}
556680
681// The deadline (#211). None of these reach `ios_async.deliver*`: they drive
682// the block table the deadline and the completion race over, which is where
683// "exactly one answer, to the right call" is decided. A host test has no main
684// queue for the timer or the hop.
685
686/// Free every block, and any reply a previous test left parked.
687fn resetBlocks() void {
557688 pending_mutex.lock();
558 for (&pending_calls) |*entry| entry.* = null;
689 defer pending_mutex.unlock();
690 for (&blocks) |*state| {
691 switch (state.*) {
692 .waiting => |call| std.heap.c_allocator.free(call.reply),
693 else => {},
694 }
695 state.* = .free;
696 }
697}
698
699fn parkedReply(text: []const u8) ![]u8 {
700 return std.heap.c_allocator.dupe(u8, text);
701}
702
703test "a deadline answers a waiting removal once, and leaves its block owed" {
704 resetBlocks();
705 defer resetBlocks();
706
707 const ticket: ios_async.Ticket = .{ .index = 3, .generation = 7 };
708 const block = claimBlock(ticket, try parkedReply("{\"removed\":true}")) orelse return error.NoBlock;
709
710 const call = expireIfWaiting(ticket) orelse return error.DeadlineFoundNothing;
711 defer std.heap.c_allocator.free(call.reply);
712 try testing.expectEqualStrings("{\"removed\":true}", call.reply);
713 try testing.expect(blocks[block] == .owed);
714
715 // A second expiry of the same call finds nothing to answer.
716 try testing.expect(expireIfWaiting(ticket) == null);
717}
718
719test "a completion after its deadline is ignored, and frees its block" {
720 resetBlocks();
721 defer resetBlocks();
722
723 const ticket: ios_async.Ticket = .{ .index = 3, .generation = 7 };
724 const block = claimBlock(ticket, try parkedReply("r")) orelse return error.NoBlock;
725 const call = expireIfWaiting(ticket) orelse return error.DeadlineFoundNothing;
726 std.heap.c_allocator.free(call.reply);
727
728 try testing.expect(settleBlock(block) == .late);
729 try testing.expect(blocks[block] == .free);
730 try testing.expect(settleBlock(block) == .stray);
731}
732
733test "a deadline that fires after the completion does nothing" {
734 // The usual case: the timer cannot be cancelled, so it fires for every
735 // removal, including the ones that were answered long ago.
736 resetBlocks();
737 defer resetBlocks();
738
739 const ticket: ios_async.Ticket = .{ .index = 3, .generation = 7 };
740 const block = claimBlock(ticket, try parkedReply("r")) orelse return error.NoBlock;
741 switch (settleBlock(block)) {
742 .reply => |call| std.heap.c_allocator.free(call.reply),
743 else => return error.CompletionFoundNothing,
744 }
745
746 try testing.expect(expireIfWaiting(ticket) == null);
747 try testing.expect(blocks[block] == .free);
748}
749
750test "a stale deadline cannot expire a newer removal on the same reply slot" {
751 resetBlocks();
752 defer resetBlocks();
753
754 const first: ios_async.Ticket = .{ .index = 3, .generation = 7 };
755 const block = claimBlock(first, try parkedReply("first")) orelse return error.NoBlock;
756 switch (settleBlock(block)) {
757 .reply => |call| std.heap.c_allocator.free(call.reply),
758 else => return error.CompletionFoundNothing,
759 }
760
761 const second: ios_async.Ticket = .{ .index = 3, .generation = 9 };
762 const again = claimBlock(second, try parkedReply("second")) orelse return error.NoBlock;
763
764 try testing.expect(expireIfWaiting(first) == null);
765 switch (blocks[again]) {
766 .waiting => |call| try testing.expectEqual(@as(u32, 9), call.ticket.generation),
767 else => return error.NewerCallWasExpired,
768 }
769}
770
771test "a reply slot reused after a deadline never gets the block still owed" {
772 // The mix-up a deadline keyed only by reply slot would ship: the first
773 // removal's late completion answering the second with `removed: true`
774 // before the second deletion had finished.
775 resetBlocks();
776 defer resetBlocks();
777
778 const first: ios_async.Ticket = .{ .index = 0, .generation = 1 };
779 const first_block = claimBlock(first, try parkedReply("first")) orelse return error.NoBlock;
780 const expired = expireIfWaiting(first) orelse return error.DeadlineFoundNothing;
781 std.heap.c_allocator.free(expired.reply);
782
783 // The same slot index, as `ios_async.acquire` hands out the lowest free one.
784 const second: ios_async.Ticket = .{ .index = 0, .generation = 3 };
785 const second_block = claimBlock(second, try parkedReply("second")) orelse return error.NoBlock;
786 try testing.expect(second_block != first_block);
787
788 // The late completion for the first removal answers nobody.
789 try testing.expect(settleBlock(first_block) == .late);
790 switch (settleBlock(second_block)) {
791 .reply => |call| {
792 defer std.heap.c_allocator.free(call.reply);
793 try testing.expectEqual(@as(u32, 3), call.ticket.generation);
794 try testing.expectEqualStrings("second", call.reply);
795 },
796 else => return error.SecondCallLost,
797 }
798}
799
800test "with every block still owed, a removal is refused rather than reusing one" {
801 resetBlocks();
802 defer resetBlocks();
803
804 pending_mutex.lock();
805 for (&blocks) |*state| state.* = .{ .owed = 0 };
559806 pending_mutex.unlock();
560807
561 deletionFired(0);
562 deletionFired(0);
808 const reply = try parkedReply("r");
809 defer std.heap.c_allocator.free(reply);
810 try testing.expect(claimBlock(.{ .index = 0, .generation = 1 }, reply) == null);
811 for (blocks) |state| try testing.expect(state == .owed);
812}
813
814test "the deadline answers before the page's own request timeout" {
815 // `craft-bridge.js` and `_invoke` give up after 30 s with a message that
816 // names no framework and no code. (`craft.siri.remove` never gives up, so
817 // for it this deadline is the only answer there is.)
818 try testing.expect(removal_deadline_ms < 30_000);
563819}
packages/zig/src/ios_async.zigmodified+48-0
Changes to packages/zig/src/ios_async.zig
@@ -328,6 +328,47 @@ pub fn deliverErrorCode(ticket: Ticket, err: bridge_error.BridgeError) void {
328328 dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain);
329329}
330330
331// ---------------------------------------------------------------------------
332// Deadlines: a reply for a framework that may never call back.
333// ---------------------------------------------------------------------------
334
335extern "c" fn dispatch_time(when: u64, delta: i64) u64;
336extern "c" fn dispatch_after_f(when: u64, queue: *anyopaque, context: ?*anyopaque, work: dispatch_function_t) void;
337
338/// Run `work` on the main queue after `timeout_ms`, with `ticket` as its
339/// context. Read it back with `ticketFromDeadline`.
340///
341/// The timer cannot be cancelled, so it always fires, and `work` must do
342/// nothing for a call that has already been answered. The module decides that
343/// under its own lock, by comparing the whole ticket, index and generation:
344/// `deliver*` checks the generation only when it queues the hop, so it cannot
345/// stop two answers on its own.
346///
347/// The clock is uptime, which pauses only while the device sleeps. A suspended
348/// app cannot run `work`, but a deadline that passed meanwhile fires as soon as
349/// the app resumes, and can race a completion that was also held back.
350pub fn scheduleDeadline(ticket: Ticket, timeout_ms: u32, work: dispatch_function_t) void {
351 if (!is_darwin) return;
352 const delay_ns: i64 = @as(i64, timeout_ms) * 1_000_000;
353 dispatch_after_f(dispatch_time(0, delay_ns), &_dispatch_main_q, deadlineContext(ticket), work);
354}
355
356/// The ticket a deadline's `work` was scheduled for.
357pub fn ticketFromDeadline(context: ?*anyopaque) Ticket {
358 const encoded = @intFromPtr(context orelse unreachable) - 1;
359 return .{
360 .index = @intCast(encoded & 31),
361 .generation = @intCast(encoded >> 5),
362 };
363}
364
365/// The whole ticket in the context pointer, plus one so it is never null.
366/// Relies on 64-bit pointers: a `u32` generation shifted past the index bits.
367fn deadlineContext(ticket: Ticket) ?*anyopaque {
368 const encoded = (@as(usize, ticket.generation) << 5) | @as(usize, ticket.index);
369 return @ptrFromInt(encoded + 1);
370}
371
331372/// Reply to a captured call with an error rather than a result.
332373///
333374/// A module's own completion can fail to shape its answer — a nil object where
@@ -486,3 +527,10 @@ test "the generic deliverError still means a native failure" {
486527 slots[ticket.index].in_use = false;
487528 slots[ticket.index].generation +%= 1;
488529}
530
531test "a deadline's context carries the complete ticket" {
532 const original: Ticket = .{ .index = 31, .generation = 0xfedcba98 };
533 const decoded = ticketFromDeadline(deadlineContext(original));
534 try testing.expectEqual(original.index, decoded.index);
535 try testing.expectEqual(original.generation, decoded.generation);
536}