also looking at this
iOS Phase 4: the secure tier — async replies, Keychain, permissions; ratchet 86 → 74
#86
11 files
+4297
-4
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.
| @@ -0,0 +1,295 @@ | ||
| 1 | //! The machinery an asynchronous reply needs, built once. | |
| 2 | //! | |
| 3 | //! Everything served so far answers from inside the dispatch: the request id | |
| 4 | //! is on `request_context`'s stack, the reply reads it, done. A completion | |
| 5 | //! handler breaks both halves of that at once — it fires after the dispatch | |
| 6 | //! frame is gone (so the id is no longer anywhere), and it fires on whatever | |
| 7 | //! queue the framework felt like (so `evaluateJavaScript`, which is | |
| 8 | //! main-thread-only, cannot be called from it directly). | |
| 9 | //! | |
| 10 | //! Getting this wrong is not hypothetical; it is the repo's inheritance. | |
| 11 | //! `request_context.current()` returning null in a late reply silently drops | |
| 12 | //! correlation to action-name matching, which hands caller B's answer to | |
| 13 | //! caller A whenever two calls of one action are in flight. And the previous | |
| 14 | //! block implementation was built on the stack and handed to an async API, | |
| 15 | //! with its own doc comment asserting a lifetime ("valid for duration of | |
| 16 | //! call") that async precisely does not honour. | |
| 17 | //! | |
| 18 | //! Three pieces, each chosen for being checkable: | |
| 19 | //! | |
| 20 | //! - a slot pool that captures {request id, action} at dispatch time, with | |
| 21 | //! generation counters so a stale or double-firing completion cannot | |
| 22 | //! resolve a slot that has been reused; | |
| 23 | //! - completion blocks that are module-level and flagged BLOCK_IS_GLOBAL, | |
| 24 | //! because `Block_copy` on a global block returns the same pointer — | |
| 25 | //! no heap copy, no descriptor lifetime, no copy/dispose helpers. Each | |
| 26 | //! pool slot has its own pre-built block with the slot index baked in, | |
| 27 | //! which is how a non-capturing block knows who it is; | |
| 28 | //! - delivery hopped to the main queue with `dispatch_async_f` — the | |
| 29 | //! function-pointer variant, so the hop itself needs no block at all. | |
| 30 | ||
| 31 | const std = ; | |
| 32 | const builtin = ; | |
| 33 | const objc_runtime = ; | |
| 34 | const request_context = ; | |
| 35 | const bridge_error = ; | |
| 36 | const compat_mutex = ; | |
| 37 | ||
| 38 | const objc = objc_runtime.objc; | |
| 39 | const is_darwin = builtin.target.os.tag.isDarwin(); | |
| 40 | ||
| 41 | /// How many async calls may be in flight at once. A page that exceeds this | |
| 42 | /// gets an explicit Busy error, which is an answer — the alternative was a | |
| 43 | /// silently dropped reply. | |
| 44 | pub const max_in_flight = 16; | |
| 45 | ||
| 46 | const Slot = struct { | |
| 47 | /// Incremented on every acquire and release. A completion carrying a | |
| 48 | /// stale generation is a late or duplicate fire for a call that has | |
| 49 | /// already been answered; it must do nothing rather than resolve | |
| 50 | /// whoever owns the slot now. | |
| 51 | generation: u32 = 0, | |
| 52 | in_use: bool = false, | |
| 53 | /// The id the reply names. -1 for a call the page sent without one. | |
| 54 | request_id: i64 = -1, | |
| 55 | /// The action, copied into fixed storage: the dispatch frame's slices are | |
| 56 | /// dead by the time the completion fires. | |
| 57 | action_buf: [64]u8 = undefined, | |
| 58 | action_len: usize = 0, | |
| 59 | /// What the completion resolved. Written on the firing thread, read on | |
| 60 | /// the main thread after the dispatch_async_f hop. | |
| 61 | granted: bool = false, | |
| 62 | }; | |
| 63 | ||
| 64 | var slots: [max_in_flight]Slot = @splat(.{}); | |
| 65 | var slots_mutex: compat_mutex.Mutex = .{}; | |
| 66 | ||
| 67 | /// A slot ticket: which slot, and which lease of it. | |
| 68 | pub const Ticket = struct { | |
| 69 | index: u5, | |
| 70 | generation: u32, | |
| 71 | }; | |
| 72 | ||
| 73 | /// Capture the current request's identity for a reply that will arrive after | |
| 74 | /// the dispatch is gone. Called from inside the dispatch, which is the only | |
| 75 | /// place `request_context.current()` still means this call. | |
| 76 | /// | |
| 77 | /// Null means the pool is full. The caller must answer Busy — returning | |
| 78 | /// silence here would reproduce the exact bug this file exists to prevent. | |
| 79 | pub fn acquire(action: []const u8) ?Ticket { | |
| 80 | if (action.len > 64) return null; | |
| 81 | ||
| 82 | slots_mutex.lock(); | |
| 83 | defer slots_mutex.unlock(); | |
| 84 | ||
| 85 | for (&slots, 0..) |*slot, i| { | |
| 86 | if (slot.in_use) continue; | |
| 87 | slot.in_use = true; | |
| 88 | slot.generation +%= 1; | |
| 89 | slot.request_id = if (request_context.current()) |id| @intCast(id) else -1; | |
| 90 | @memcpy(slot.action_buf[0..action.len], action); | |
| 91 | slot.action_len = action.len; | |
| 92 | return .{ .index = @intCast(i), .generation = slot.generation }; | |
| 93 | } | |
| 94 | return null; | |
| 95 | } | |
| 96 | ||
| 97 | /// Release without replying — for the error path between acquire and the | |
| 98 | /// framework call, where the caller is about to answer synchronously itself. | |
| 99 | pub fn abandon(ticket: Ticket) void { | |
| 100 | slots_mutex.lock(); | |
| 101 | defer slots_mutex.unlock(); | |
| 102 | const slot = &slots[ticket.index]; | |
| 103 | if (slot.generation != ticket.generation or !slot.in_use) return; | |
| 104 | slot.in_use = false; | |
| 105 | slot.generation +%= 1; | |
| 106 | } | |
| 107 | ||
| 108 | // --------------------------------------------------------------------------- | |
| 109 | // The block pool: one pre-built BOOL-completion block per slot. | |
| 110 | // --------------------------------------------------------------------------- | |
| 111 | ||
| 112 | const BlockDescriptor = extern struct { | |
| 113 | reserved: c_ulong = 0, | |
| 114 | size: c_ulong, | |
| 115 | }; | |
| 116 | ||
| 117 | /// void (^)(BOOL) — the shape of `requestAccessForMediaType:completionHandler:` | |
| 118 | /// and, with the error parameter ignored by the invoke, close enough to | |
| 119 | /// `requestAuthorizationWithOptions:` that each gets its own invoke below. | |
| 120 | const BoolBlock = extern struct { | |
| 121 | isa: ?*anyopaque, | |
| 122 | flags: c_int, | |
| 123 | reserved: c_int = 0, | |
| 124 | invoke: *const anyopaque, | |
| 125 | descriptor: *const BlockDescriptor, | |
| 126 | }; | |
| 127 | ||
| 128 | /// 1 << 28. A global block is never copied: `Block_copy` returns the same | |
| 129 | /// pointer. That is the entire trick — no heap block, no copy/dispose pair, | |
| 130 | /// no descriptor lifetime, and the pool's blocks are safely handed to any | |
| 131 | /// API that escapes them. | |
| 132 | const BLOCK_IS_GLOBAL: c_int = 1 << 28; | |
| 133 | ||
| 134 | const bool_block_descriptor = BlockDescriptor{ .size = @sizeOf(BoolBlock) }; | |
| 135 | ||
| 136 | extern var _NSConcreteGlobalBlock: anyopaque; | |
| 137 | ||
| 138 | /// The per-slot invokes. Comptime-generated so each block knows its slot | |
| 139 | /// without capturing anything: the index is baked into the function, and the | |
| 140 | /// function is baked into the block. | |
| 141 | fn makeBoolInvoke(comptime index: u5) *const anyopaque { | |
| 142 | const S = struct { | |
| 143 | fn invoke(_: *const BoolBlock, granted: bool) callconv(.c) void { | |
| 144 | completionFired(index, granted); | |
| 145 | } | |
| 146 | }; | |
| 147 | return @ptrCast(&S.invoke); | |
| 148 | } | |
| 149 | ||
| 150 | /// void (^)(BOOL, NSError *) — `requestAuthorizationWithOptions:` and friends. | |
| 151 | /// The error object is deliberately unused: the granted flag is the answer | |
| 152 | /// the page's contract carries, and Swift discarded the error here too. | |
| 153 | fn makeBoolErrorInvoke(comptime index: u5) *const anyopaque { | |
| 154 | const S = struct { | |
| 155 | fn invoke(_: *const BoolBlock, granted: bool, _: objc.id) callconv(.c) void { | |
| 156 | completionFired(index, granted); | |
| 157 | } | |
| 158 | }; | |
| 159 | return @ptrCast(&S.invoke); | |
| 160 | } | |
| 161 | ||
| 162 | fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [max_in_flight]BoolBlock { | |
| 163 | var out: [max_in_flight]BoolBlock = undefined; | |
| 164 | for (&out, 0..) |*b, i| { | |
| 165 | b.* = .{ | |
| 166 | .isa = &_NSConcreteGlobalBlock, | |
| 167 | .flags = BLOCK_IS_GLOBAL, | |
| 168 | .invoke = maker(@intCast(i)), | |
| 169 | .descriptor = &bool_block_descriptor, | |
| 170 | }; | |
| 171 | } | |
| 172 | return out; | |
| 173 | } | |
| 174 | ||
| 175 | var bool_blocks: [max_in_flight]BoolBlock = if (is_darwin) makeBlocks(makeBoolInvoke) else undefined; | |
| 176 | var bool_error_blocks: [max_in_flight]BoolBlock = if (is_darwin) makeBlocks(makeBoolErrorInvoke) else undefined; | |
| 177 | ||
| 178 | /// The block to pass as a `void (^)(BOOL)` completion handler for the call | |
| 179 | /// this ticket was acquired for. | |
| 180 | pub fn boolBlock(ticket: Ticket) *anyopaque { | |
| 181 | return @ptrCast(&bool_blocks[ticket.index]); | |
| 182 | } | |
| 183 | ||
| 184 | /// The block to pass as a `void (^)(BOOL, NSError *)` completion handler. | |
| 185 | pub fn boolErrorBlock(ticket: Ticket) *anyopaque { | |
| 186 | return @ptrCast(&bool_error_blocks[ticket.index]); | |
| 187 | } | |
| 188 | ||
| 189 | // --------------------------------------------------------------------------- | |
| 190 | // Firing: any thread → main queue → reply. | |
| 191 | // --------------------------------------------------------------------------- | |
| 192 | ||
| 193 | const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void; | |
| 194 | extern "c" fn dispatch_async_f(queue: *anyopaque, context: ?*anyopaque, work: dispatch_function_t) void; | |
| 195 | /// `dispatch_get_main_queue()` is a macro over this global. | |
| 196 | extern var _dispatch_main_q: anyopaque; | |
| 197 | ||
| 198 | fn completionFired(index: u5, granted: bool) void { | |
| 199 | // Record the outcome under the lock, but deliver from the main queue: | |
| 200 | // the reply ends in `evaluateJavaScript`, which is main-thread-only, and | |
| 201 | // this may be running on whatever queue the framework chose. | |
| 202 | { | |
| 203 | slots_mutex.lock(); | |
| 204 | defer slots_mutex.unlock(); | |
| 205 | const slot = &slots[index]; | |
| 206 | if (!slot.in_use) return; // stale fire for an abandoned slot | |
| 207 | slot.granted = granted; | |
| 208 | } | |
| 209 | ||
| 210 | // The context pointer carries only the slot index; everything else stays | |
| 211 | // in the slot, guarded by its generation. | |
| 212 | dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, index) + 1), deliverOnMain); | |
| 213 | } | |
| 214 | ||
| 215 | fn deliverOnMain(context: ?*anyopaque) callconv(.c) void { | |
| 216 | const index: usize = @intFromPtr(context orelse return) - 1; | |
| 217 | ||
| 218 | var action_buf: [64]u8 = undefined; | |
| 219 | var action_len: usize = 0; | |
| 220 | var request_id: i64 = -1; | |
| 221 | var granted = false; | |
| 222 | { | |
| 223 | slots_mutex.lock(); | |
| 224 | defer slots_mutex.unlock(); | |
| 225 | const slot = &slots[index]; | |
| 226 | if (!slot.in_use) return; | |
| 227 | action_len = slot.action_len; | |
| 228 | @memcpy(action_buf[0..action_len], slot.action_buf[0..action_len]); | |
| 229 | request_id = slot.request_id; | |
| 230 | granted = slot.granted; | |
| 231 | slot.in_use = false; | |
| 232 | slot.generation +%= 1; | |
| 233 | } | |
| 234 | ||
| 235 | const action = action_buf[0..action_len]; | |
| 236 | ||
| 237 | // Restore the identity captured at dispatch, so the reply names the call | |
| 238 | // that is actually waiting — the whole point of this file. | |
| 239 | request_context.push(if (request_id < 0) null else @intCast(request_id)); | |
| 240 | defer request_context.pop(); | |
| 241 | ||
| 242 | bridge_error.sendResultToJS( | |
| 243 | std.heap.c_allocator, | |
| 244 | action, | |
| 245 | if (granted) "\"granted\"" else "\"denied\"", | |
| 246 | ); | |
| 247 | } | |
| 248 | ||
| 249 | const testing = std.testing; | |
| 250 | ||
| 251 | test "acquire captures the current request id, release makes the slot reusable" { | |
| 252 | request_context.push(42); | |
| 253 | defer request_context.pop(); | |
| 254 | ||
| 255 | const ticket = acquire("requestPermission") orelse return error.PoolUnexpectedlyFull; | |
| 256 | try testing.expectEqual(@as(i64, 42), slots[ticket.index].request_id); | |
| 257 | try testing.expectEqualStrings("requestPermission", slots[ticket.index].action_buf[0..slots[ticket.index].action_len]); | |
| 258 | ||
| 259 | abandon(ticket); | |
| 260 | try testing.expect(!slots[ticket.index].in_use); | |
| 261 | } | |
| 262 | ||
| 263 | test "a stale ticket cannot abandon a slot that was re-leased" { | |
| 264 | const first = acquire("a") orelse return error.PoolUnexpectedlyFull; | |
| 265 | abandon(first); | |
| 266 | ||
| 267 | // Re-lease the same slot; the old ticket's generation is now stale. | |
| 268 | const second = acquire("b") orelse return error.PoolUnexpectedlyFull; | |
| 269 | defer abandon(second); | |
| 270 | ||
| 271 | abandon(first); // must be a no-op | |
| 272 | try testing.expect(slots[second.index].in_use); | |
| 273 | } | |
| 274 | ||
| 275 | test "the pool exhausts loudly rather than silently" { | |
| 276 | var tickets: [max_in_flight]Ticket = undefined; | |
| 277 | var n: usize = 0; | |
| 278 | defer for (tickets[0..n]) |t| abandon(t); | |
| 279 | ||
| 280 | while (n < max_in_flight) : (n += 1) { | |
| 281 | tickets[n] = acquire("x") orelse break; | |
| 282 | } | |
| 283 | // Whatever other tests leased, the pool must end by refusing with null — | |
| 284 | // never by handing out a slot it does not have. | |
| 285 | try testing.expect(acquire("one-too-many") == null); | |
| 286 | } | |
| 287 | ||
| 288 | test "an action longer than the slot's storage is refused at acquire" { | |
| 289 | // Truncating it instead would make the eventual reply name a different | |
| 290 | // action than the page called — correlation by wrong name, reported as | |
| 291 | // success. | |
| 292 | // (`**` no longer lexes as one operator in this Zig; @splat says the same.) | |
| 293 | const long: [65]u8 = @splat('a'); | |
| 294 | try testing.expect(acquire(&long) == null); | |
| 295 | } | |
| @@ -3,6 +3,7 @@ const builtin = ; | ||
| 3 | 3 | const objc_runtime = ; |
| 4 | 4 | const request_context = ; |
| 5 | 5 | const bridge_error = ; |
| 6 | pub const ios_async = ; | |
| 6 | 7 | const bridge_mobile = ; |
| 7 | 8 | const bridge_mobile_clipboard = ; |
| 8 | 9 | const bridge_mobile_haptics = ; |
| @@ -355,6 +356,10 @@ fn buildShimError( | ||
| 355 | 356 | try out.append(allocator, '}'); |
| 356 | 357 | } |
| 357 | 358 | |
| 359 | test { | |
| 360 | _ = ios_async; | |
| 361 | } | |
| 362 | ||
| 358 | 363 | const testing = std.testing; |
| 359 | 364 | |
| 360 | 365 | test "an envelope without a type is rejected" { |