also looking at this
fix(ios): settle a Siri shortcut removal whose completion never comes
#227
7 files
+503
-94
| @@ -152,7 +152,10 @@ gather_log() { cat "$STDOUT_LOG" "$STDERR_LOG" > "$LOG" 2>/dev/null || true; } | ||
| 152 | 152 | # So: a deadline, and an explicit failure that names what is missing. The two |
| 153 | 153 | # situations look nothing alike now. |
| 154 | 154 | SLICE_TIMEOUT="${CRAFT_SLICE_TIMEOUT:-600}" |
| 155 | AWAITED_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. | |
| 158 | AWAITED_IDS="20 22 24 26 28 32 34 38|84|86 44 46 54 72 76 78 82" | |
| 156 | 159 | DEADLINE=$(( $(date +%s) + SLICE_TIMEOUT )) |
| 157 | 160 | |
| 158 | 161 | missing_ids() { |
| @@ -162,7 +165,8 @@ missing_ids() { | ||
| 162 | 165 | # ERE with an explicit boundary rather than \b, which is a GNU |
| 163 | 166 | # extension this script cannot count on, and rather than a bare |
| 164 | 167 | # "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" | |
| 166 | 170 | done |
| 167 | 171 | echo "$missing" |
| 168 | 172 | } |
| @@ -181,6 +185,18 @@ while :; do | ||
| 181 | 185 | fi |
| 182 | 186 | sleep 1 |
| 183 | 187 | done |
| 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. | |
| 192 | LATE_PATTERN='a deletion completion arrived [0-9]+ ms after the call' | |
| 193 | if 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 | |
| 199 | fi | |
| 184 | 200 | xcrun simctl terminate "$UDID" "$BUNDLE_ID" 2>/dev/null || true |
| 185 | 201 | gather_log |
| 186 | 202 | |
| @@ -359,12 +375,49 @@ C36="$(count 36)" | ||
| 359 | 375 | [ "$C36" -ge 1 ] || { echo "FAIL: registerSiriShortcut did not echo the donated shortcut"; exit 1; } |
| 360 | 376 | echo "ok: NSUserActivity donated and echoed back (i=36 seen ${C36}x)" |
| 361 | 377 | |
| 362 | C38="$(count 38)" | |
| 378 | C38="$(count 38)"; C84="$(count 84)"; C86="$(count 86)" | |
| 363 | 379 | # removeSiriShortcut answers from a void(^)(void) completion that carries no |
| 364 | 380 | # arguments at all, so this reply arriving proves Foundation fired the block |
| 365 | 381 | # 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; } | |
| 367 | echo "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. | |
| 402 | SIRI_KINDS=$(( (C38 > 0) + (C84 > 0) + (C86 > 0) )) | |
| 403 | if [ "$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 | |
| 406 | elif [ "$C38" -ge 1 ]; then | |
| 407 | echo "ok: argument-free completion block delivered its parked reply (i=38 seen ${C38}x)" | |
| 408 | elif [ "$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 | |
| 417 | else | |
| 418 | echo "FAIL: removeSiriShortcut settled with neither {removed: true} nor TIMEOUT (i=86 seen ${C86}x)" | |
| 419 | exit 1 | |
| 420 | fi | |
| 368 | 421 | |
| 369 | 422 | C46="$(count 46)" |
| 370 | 423 | # pageCount is PDFKit's answer for the bytes the page sent, so it cannot be |
| @@ -64,10 +64,19 @@ const char craft_slice_page[] = | ||
| 64 | 64 | " if (sentSpeechEnd || !e.detail || typeof e.detail !== 'object') { return; }\n" |
| 65 | 65 | " sentSpeechEnd = true;\n" |
| 66 | 66 | " 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" | |
| 70 | 69 | "});\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" | |
| 71 | 80 | "var sentEvent = false;\n" |
| 72 | 81 | "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false, sentSecure = false, sentDb = false, sentNotif = false;\n" |
| 73 | 82 | "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n" |
| @@ -84,6 +93,8 @@ const char craft_slice_page[] = | ||
| 84 | 93 | "var sentShot = false, sentDeepLink = false;\n" |
| 85 | 94 | "var sentOcr = false, sentClassify = false, sentDetect = false;\n" |
| 86 | 95 | "var sentAuth = false, sentSiriAdd = false, sentSiriDel = false;\n" |
| 96 | "var sentSiriTimeout = false, sentSiriOdd = false, siriSettled = false;\n" | |
| 97 | "var speechEnded = false, postedSettings = false;\n" | |
| 87 | 98 | "var sentPdfOpen = false, sentPdfClose = false;\n" |
| 88 | 99 | "var sentDownload = false, sentWatch = false, sentSettings = false;\n" |
| 89 | 100 | "var sentBtStart = false, sentBtStop = false;\n" |
| @@ -91,6 +102,19 @@ const char craft_slice_page[] = | ||
| 91 | 102 | "var sentLocationFresh = false, sentLocationCached = false, sentLocationTimeout = false;\n" |
| 92 | 103 | "var sentSpeechStart = false, sentSpeechErr = false, sentSpeechEnd = false;\n" |
| 93 | 104 | "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" | |
| 94 | 118 | " // The message crossed Zig's escaping with a backslash, quote, and\n" |
| 95 | 119 | " // newline intact; anything mangled and this equality fails, i=7 never\n" |
| 96 | 120 | " // fires, and the harness reports the error route broken.\n" |
| @@ -318,11 +342,16 @@ const char craft_slice_page[] = | ||
| 318 | 342 | " // removeSiriShortcut answers from inside Foundation's completion\n" |
| 319 | 343 | " // handler, a void(^)(void) that carries nothing at all — so this reply\n" |
| 320 | 344 | " // 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" | |
| 326 | 355 | " }\n" |
| 327 | 356 | " if (!sentOcr && action === 'recognizeText' && Array.isArray(payload)) {\n" |
| 328 | 357 | " var joined = payload.map(function (r) { return r.text; }).join(' ').toUpperCase();\n" |
| @@ -173,6 +173,29 @@ describe('Craft iOS builder', () => { | ||
| 173 | 173 | expect(swift).not.toContain("addEventListener('craftOTAStatus'") |
| 174 | 174 | }) |
| 175 | 175 | |
| 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 | ||
| 176 | 199 | it('returns completed video recordings as the documented base64 string', async () => { |
| 177 | 200 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-video-recording-')) |
| 178 | 201 | await init({ |
| @@ -5608,9 +5608,42 @@ struct CraftWebView: UIViewRepresentable { | ||
| 5608 | 5608 | resolveCallback(callbackId, result: ["registered": true, "action": action, "phrase": phrase]) |
| 5609 | 5609 | } |
| 5610 | 5610 | |
| 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 | ||
| 5611 | 5618 | 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 | ||
| 5612 | 5636 | 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 | } | |
| 5614 | 5647 | } |
| 5615 | 5648 | } |
| 5616 | 5649 | |
| @@ -1788,42 +1788,16 @@ fn takePendingFixIf(ticket: ios_async.Ticket) ?PendingFix { | ||
| 1788 | 1788 | return call; |
| 1789 | 1789 | } |
| 1790 | 1790 | |
| 1791 | const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void; | |
| 1792 | extern "c" fn dispatch_time(when: u64, delta: i64) u64; | |
| 1793 | extern "c" fn dispatch_after_f( | |
| 1794 | when: u64, | |
| 1795 | queue: *anyopaque, | |
| 1796 | context: ?*anyopaque, | |
| 1797 | work: dispatch_function_t, | |
| 1798 | ) void; | |
| 1799 | extern var _dispatch_main_q: anyopaque; | |
| 1800 | ||
| 1801 | fn 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 | ||
| 1806 | fn 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 | ||
| 1814 | 1791 | fn positionTimedOut(context: ?*anyopaque) callconv(.c) void { |
| 1815 | 1792 | if (!is_darwin) return; |
| 1816 | const ticket = ticketFromTimeoutContext(context); | |
| 1793 | const ticket = ios_async.ticketFromDeadline(context); | |
| 1817 | 1794 | const call = takePendingFixIf(ticket) orelse return; |
| 1818 | 1795 | restoreBestAccuracy(); |
| 1819 | 1796 | ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.Timeout); |
| 1820 | 1797 | } |
| 1821 | 1798 | |
| 1822 | 1799 | fn 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); | |
| 1827 | 1801 | } |
| 1828 | 1802 | |
| 1829 | 1803 | /// Record the running watch. Overwriting is the whole behaviour: a second |
| @@ -2980,13 +2954,6 @@ test "cache age is explicit, inclusive and future-safe" { | ||
| 2980 | 2954 | try testing.expect(cacheAgeIsAcceptable(10_000, 11_000, 1)); |
| 2981 | 2955 | } |
| 2982 | 2956 | |
| 2983 | test "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 | ||
| 2990 | 2957 | test "watch payloads remain ignored" { |
| 2991 | 2958 | if (!is_darwin) return error.SkipZigTest; |
| 2992 | 2959 | // Real dispatches, so this needs the process the gate stops. |
| @@ -56,6 +56,7 @@ const capabilities = ; | ||
| 56 | 56 | const bridge_error = ; |
| 57 | 57 | const objc_runtime = ; |
| 58 | 58 | const ios_async = ; |
| 59 | const compat = ; | |
| 59 | 60 | const compat_mutex = ; |
| 60 | 61 | |
| 61 | 62 | const objc = objc_runtime.objc; |
| @@ -121,12 +122,24 @@ pub const SiriBridge = struct { | ||
| 121 | 122 | bridge_error.sendResultToJS(self.allocator, A.register_siri_shortcut, out.items); |
| 122 | 123 | } |
| 123 | 124 | |
| 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`. | |
| 125 | 127 | /// |
| 126 | 128 | /// `deleteSavedUserActivitiesWithPersistentIdentifiers:completionHandler:` |
| 127 | 129 | /// takes a `void (^)(void)`, and Swift replies from inside it. Replying |
| 128 | 130 | /// before it fired would tell the page a shortcut is gone while Siri can |
| 129 | 131 | /// 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. | |
| 130 | 143 | fn removeShortcut(self: *Self, data: []const u8) !void { |
| 131 | 144 | if (!is_darwin) return BridgeError.PlatformNotSupported; |
| 132 | 145 | |
moved, unchanged · 9 hidden lines
| @@ -136,6 +149,9 @@ pub const SiriBridge = struct { moved, unchanged | ||
| 136 | 149 | const NSUserActivity = objc.objc_getClass("NSUserActivity") orelse |
| 137 | 150 | return BridgeError.PlatformNotSupported; |
| 138 | 151 | const identifiers = try singletonArray(self.allocator, fields.action); |
| 152 | const sel = objc.sel_registerName( | |
| 153 | "deleteSavedUserActivitiesWithPersistentIdentifiers:completionHandler:", | |
| 154 | ) orelse return BridgeError.NativeCallFailed; | |
| 139 | 155 | |
| 140 | 156 | // The reply names the action, so it is shaped before the ticket exists |
| 141 | 157 | // and handed to the slot: the completion carries no arguments at all |
| @@ -159,14 +175,20 @@ pub const SiriBridge = struct { | ||
| 159 | 175 | }; |
| 160 | 176 | errdefer ios_async.abandon(ticket); |
| 161 | 177 | |
| 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); | |
| 163 | 188 | |
| 164 | const sel = objc.sel_registerName( | |
| 165 | "deleteSavedUserActivitiesWithPersistentIdentifiers:completionHandler:", | |
| 166 | ) orelse return BridgeError.NativeCallFailed; | |
| 167 | 189 | const DeleteFn = *const fn (objc.Class, objc.SEL, Id, *anyopaque) callconv(.c) void; |
| 168 | 190 | const deleteFn: DeleteFn = @ptrCast(&objc.objc_msgSend); |
| 169 | deleteFn(NSUserActivity, sel, identifiers, doneBlock(ticket)); | |
| 191 | deleteFn(NSUserActivity, sel, identifiers, @ptrCast(&done_blocks[block])); | |
| 170 | 192 | } |
| 171 | 193 | }; |
| 172 | 194 | |
| @@ -353,36 +375,107 @@ fn release(object: Id) void { | ||
| 353 | 375 | // The deletion completion |
| 354 | 376 | // --------------------------------------------------------------------------- |
| 355 | 377 | |
| 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. | |
| 387 | const removal_deadline_ms: u32 = 15_000; | |
| 388 | ||
| 389 | /// A removal waiting on its completion. | |
| 357 | 390 | /// |
| 358 | 391 | /// `void (^)(void)` carries nothing — not even a success flag — so everything |
| 359 | 392 | /// 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. | |
| 363 | 395 | const PendingCall = struct { |
| 364 | 396 | 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. | |
| 366 | 399 | 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. | |
| 411 | const 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, | |
| 367 | 418 | }; |
| 368 | 419 | |
| 369 | var 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. | |
| 422 | const block_count = 2 * ios_async.max_in_flight; | |
| 423 | ||
| 424 | var blocks: [block_count]BlockState = @splat(.free); | |
| 370 | 425 | var pending_mutex: compat_mutex.Mutex = .{}; |
| 371 | 426 | |
| 372 | fn 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. | |
| 429 | fn claimBlock(ticket: ios_async.Ticket, reply: []u8) ?u5 { | |
| 373 | 430 | pending_mutex.lock(); |
| 374 | 431 | 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; | |
| 376 | 438 | } |
| 377 | 439 | |
| 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. | |
| 380 | fn 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. | |
| 443 | fn expireIfWaiting(ticket: ios_async.Ticket) ?PendingCall { | |
| 381 | 444 | pending_mutex.lock(); |
| 382 | 445 | 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 | ||
| 458 | const 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. | |
| 469 | fn 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 | }; | |
| 386 | 479 | } |
| 387 | 480 | |
| 388 | 481 | const BlockDescriptor = extern struct { |
| @@ -408,17 +501,17 @@ const done_block_descriptor = BlockDescriptor{ .size = @sizeOf(DoneBlock) }; | ||
| 408 | 501 | |
| 409 | 502 | extern var _NSConcreteGlobalBlock: anyopaque; |
| 410 | 503 | |
| 411 | fn makeDoneInvoke(comptime index: u5) *const anyopaque { | |
| 504 | fn makeDoneInvoke(comptime block: u5) *const anyopaque { | |
| 412 | 505 | const S = struct { |
| 413 | 506 | fn invoke(_: *const DoneBlock) callconv(.c) void { |
| 414 | deletionFired(index); | |
| 507 | deletionFired(block); | |
| 415 | 508 | } |
| 416 | 509 | }; |
| 417 | 510 | return @ptrCast(&S.invoke); |
| 418 | 511 | } |
| 419 | 512 | |
| 420 | fn makeDoneBlocks() [ios_async.max_in_flight]DoneBlock { | |
| 421 | var out: [ios_async.max_in_flight]DoneBlock = undefined; | |
| 513 | fn makeDoneBlocks() [block_count]DoneBlock { | |
| 514 | var out: [block_count]DoneBlock = undefined; | |
| 422 | 515 | for (&out, 0..) |*b, i| { |
| 423 | 516 | b.* = .{ |
| 424 | 517 | .isa = &_NSConcreteGlobalBlock, |
| @@ -430,28 +523,52 @@ fn makeDoneBlocks() [ios_async.max_in_flight]DoneBlock { | ||
| 430 | 523 | return out; |
| 431 | 524 | } |
| 432 | 525 | |
| 433 | var done_blocks: [ios_async.max_in_flight]DoneBlock = | |
| 526 | var done_blocks: [block_count]DoneBlock = | |
| 434 | 527 | if (is_darwin) makeDoneBlocks() else undefined; |
| 435 | 528 | |
| 436 | fn doneBlock(ticket: ios_async.Ticket) *anyopaque { | |
| 437 | return @ptrCast(&done_blocks[ticket.index]); | |
| 438 | } | |
| 439 | ||
| 440 | 529 | /// Runs on whatever queue Foundation chose. `ios_async.deliverJson` copies the |
| 441 | 530 | /// payload and hops to the main queue, where `evaluateJavaScript:` is legal. |
| 442 | fn deletionFired(index: u5) void { | |
| 531 | fn deletionFired(block: u5) void { | |
| 443 | 532 | if (!is_darwin) return; |
| 444 | 533 | |
| 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. | |
| 558 | fn removalTimedOut(context: ?*anyopaque) callconv(.c) void { | |
| 559 | if (!is_darwin) return; | |
| 560 | ||
| 561 | const call = expireIfWaiting(ios_async.ticketFromDeadline(context)) orelse return; | |
| 452 | 562 | defer std.heap.c_allocator.free(call.reply); |
| 453 | 563 | |
| 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); | |
| 455 | 572 | } |
| 456 | 573 | |
| 457 | 574 | const testing = std.testing; |
| @@ -526,6 +643,7 @@ test "every declared action dispatches to something" { | ||
| 526 | 643 | |
| 527 | 644 | test "a refused removal leases no reply slot" { |
| 528 | 645 | if (!is_darwin) return error.SkipZigTest; |
| 646 | resetBlocks(); | |
| 529 | 647 | |
| 530 | 648 | var bridge = SiriBridge.init(testing.allocator); |
| 531 | 649 | defer bridge.deinit(); |
| @@ -534,10 +652,10 @@ test "a refused removal leases no reply slot" { | ||
| 534 | 652 | |
| 535 | 653 | pending_mutex.lock(); |
| 536 | 654 | defer pending_mutex.unlock(); |
| 537 | for (pending_calls) |entry| try testing.expect(entry == null); | |
| 655 | for (blocks) |state| try testing.expect(state == .free); | |
| 538 | 656 | } |
| 539 | 657 | |
| 540 | test "each slot's completion block is global and has its own invoke" { | |
| 658 | test "each completion block is global and has its own invoke" { | |
| 541 | 659 | if (!is_darwin) return error.SkipZigTest; |
| 542 | 660 | |
| 543 | 661 | for (&done_blocks) |*b| { |
| @@ -546,18 +664,156 @@ test "each slot's completion block is global and has its own invoke" { | ||
| 546 | 664 | try testing.expectEqual(@sizeOf(DoneBlock), @as(usize, @intCast(b.descriptor.size))); |
| 547 | 665 | } |
| 548 | 666 | 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); | |
| 549 | 668 | } |
| 550 | 669 | |
| 551 | test "a completion for a slot with no recorded call is ignored" { | |
| 670 | test "a completion for a block with no recorded call is ignored" { | |
| 552 | 671 | // `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 | |
| 554 | 673 | // no-op rather than a double free of the parked reply. |
| 555 | 674 | if (!is_darwin) return error.SkipZigTest; |
| 675 | resetBlocks(); | |
| 676 | ||
| 677 | deletionFired(0); | |
| 678 | deletionFired(0); | |
| 679 | } | |
| 556 | 680 | |
| 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. | |
| 687 | fn resetBlocks() void { | |
| 557 | 688 | 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 | ||
| 699 | fn parkedReply(text: []const u8) ![]u8 { | |
| 700 | return std.heap.c_allocator.dupe(u8, text); | |
| 701 | } | |
| 702 | ||
| 703 | test "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 | ||
| 719 | test "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 | ||
| 733 | test "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 | ||
| 750 | test "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 | ||
| 771 | test "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 | ||
| 800 | test "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 }; | |
| 559 | 806 | pending_mutex.unlock(); |
| 560 | 807 | |
| 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 | ||
| 814 | test "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); | |
| 563 | 819 | } |
| @@ -328,6 +328,47 @@ pub fn deliverErrorCode(ticket: Ticket, err: bridge_error.BridgeError) void { | ||
| 328 | 328 | dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain); |
| 329 | 329 | } |
| 330 | 330 | |
| 331 | // --------------------------------------------------------------------------- | |
| 332 | // Deadlines: a reply for a framework that may never call back. | |
| 333 | // --------------------------------------------------------------------------- | |
| 334 | ||
| 335 | extern "c" fn dispatch_time(when: u64, delta: i64) u64; | |
| 336 | extern "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. | |
| 350 | pub 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. | |
| 357 | pub 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. | |
| 367 | fn deadlineContext(ticket: Ticket) ?*anyopaque { | |
| 368 | const encoded = (@as(usize, ticket.generation) << 5) | @as(usize, ticket.index); | |
| 369 | return @ptrFromInt(encoded + 1); | |
| 370 | } | |
| 371 | ||
| 331 | 372 | /// Reply to a captured call with an error rather than a result. |
| 332 | 373 | /// |
| 333 | 374 | /// 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" { | ||
| 486 | 527 | slots[ticket.index].in_use = false; |
| 487 | 528 | slots[ticket.index].generation +%= 1; |
| 488 | 529 | } |
| 530 | ||
| 531 | test "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 | } | |