ReviewOS

also looking at this

craft-native/craft

fix(ios): answer the lease a hop was queued for, not whichever call holds the slot

#241
Merged glennmichael123 wants to merge fix/ios-async-lease-check into main
1 file +163 -22
packages/zig/src/ios_async.zigmodified+163-22
Changes to packages/zig/src/ios_async.zig
@@ -38,9 +38,13 @@ const compat_mutex = ;
3838const objc = objc_runtime.objc;
3939const is_darwin = builtin.target.os.tag.isDarwin();
4040
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.
41/// How many async calls may be in flight at once.
42///
43/// A page that exceeds it gets an answer rather than silence, which was the
44/// point: `acquire` returns null and every caller reports `NativeCallFailed`.
45/// Not the `Busy` this comment used to promise — no such code exists, and
46/// adding one touches every caller's `poolFull`, so it is filed rather than
47/// smuggled in here (#225).
4448pub const max_in_flight = 16;
4549
4650const Slot = struct {
@@ -72,8 +76,30 @@ const Slot = struct {
7276 /// `"denied"` for a caller expecting an array. That is a wrong answer
7377 /// reported as success, and the page's catch never runs.
7478 failed: bool = false,
79 /// Whether this lease has handed a pool block to a framework and is still
80 /// waiting for it. Cleared by the first fire, so a framework that calls
81 /// its completion twice queues one hop rather than two.
82 completion_armed: bool = false,
7583};
7684
85/// Clear everything a lease may have written, so the next one starts clean.
86///
87/// Both release paths call it — the hop that delivered, and `abandon` — so a
88/// slot is never handed out carrying the last lease's answer. `abandon` did
89/// not free a stashed payload before, which leaked it whenever a module
90/// abandoned a ticket it had already filled (#225).
91///
92/// Call with the mutex held, and move `json` out first if you are delivering
93/// it: this frees whatever is still there.
94fn resetReply(slot: *Slot) void {
95 if (slot.json) |owned| std.heap.c_allocator.free(owned);
96 slot.json = null;
97 slot.failed = false;
98 slot.fail_code = bridge_error.BridgeError.NativeCallFailed;
99 slot.granted = false;
100 slot.completion_armed = false;
101}
102
77103var slots: [max_in_flight]Slot = @splat(.{});
78104var slots_mutex: compat_mutex.Mutex = .{};
79105
@@ -114,6 +140,7 @@ pub fn abandon(ticket: Ticket) void {
114140 defer slots_mutex.unlock();
115141 const slot = &slots[ticket.index];
116142 if (slot.generation != ticket.generation or !slot.in_use) return;
143 resetReply(slot);
117144 slot.in_use = false;
118145 slot.generation +%= 1;
119146}
@@ -188,14 +215,30 @@ fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [max_in_flight]
188215var bool_blocks: [max_in_flight]BoolBlock = if (is_darwin) makeBlocks(makeBoolInvoke) else undefined;
189216var bool_error_blocks: [max_in_flight]BoolBlock = if (is_darwin) makeBlocks(makeBoolErrorInvoke) else undefined;
190217
218/// Mark this lease as waiting for its pool block to fire.
219///
220/// There is one block per *slot*, not per lease, so the block itself cannot
221/// say which call it belongs to. This is what lets the first fire be told
222/// from the second: the fire consumes the arming, and a framework that calls
223/// its completion twice gets one hop.
224fn armCompletion(ticket: Ticket) void {
225 slots_mutex.lock();
226 defer slots_mutex.unlock();
227 const slot = &slots[ticket.index];
228 if (!slot.in_use or slot.generation != ticket.generation) return;
229 slot.completion_armed = true;
230}
231
191232/// The block to pass as a `void (^)(BOOL)` completion handler for the call
192233/// this ticket was acquired for.
193234pub fn boolBlock(ticket: Ticket) *anyopaque {
235 armCompletion(ticket);
194236 return @ptrCast(&bool_blocks[ticket.index]);
195237}
196238
197239/// The block to pass as a `void (^)(BOOL, NSError *)` completion handler.
198240pub fn boolErrorBlock(ticket: Ticket) *anyopaque {
241 armCompletion(ticket);
199242 return @ptrCast(&bool_error_blocks[ticket.index]);
200243}
201244
@@ -212,21 +255,27 @@ fn completionFired(index: u5, granted: bool) void {
212255 // Record the outcome under the lock, but deliver from the main queue:
213256 // the reply ends in `evaluateJavaScript`, which is main-thread-only, and
214257 // this may be running on whatever queue the framework chose.
258 var ticket: Ticket = undefined;
215259 {
216260 slots_mutex.lock();
217261 defer slots_mutex.unlock();
218262 const slot = &slots[index];
219263 if (!slot.in_use) return; // stale fire for an abandoned slot
264 // A second fire for the same lease, or a fire for a lease that never
265 // armed a pool block. Either way the answer is not this one's to give.
266 if (!slot.completion_armed) return;
267 slot.completion_armed = false;
220268 slot.granted = granted;
269 ticket = .{ .index = index, .generation = slot.generation };
221270 }
222271
223 // The context pointer carries only the slot index; everything else stays
224 // in the slot, guarded by its generation.
225 dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, index) + 1), deliverOnMain);
272 // The lease the outcome belongs to, not just the slot: by the time this
273 // hop runs, the slot may have been answered and re-leased (#225).
274 dispatch_async_f(&_dispatch_main_q, ticketContext(ticket), deliverOnMain);
226275}
227276
228277fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
229 const index: usize = @intFromPtr(context orelse return) - 1;
278 const ticket = ticketFromContext(context) orelse return;
230279
231280 var action_buf: [64]u8 = undefined;
232281 var action_len: usize = 0;
@@ -238,8 +287,12 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
238287 {
239288 slots_mutex.lock();
240289 defer slots_mutex.unlock();
241 const slot = &slots[index];
242 if (!slot.in_use) return;
290 const slot = &slots[ticket.index];
291 // The slot being in use is not enough: it may be in use by a *later*
292 // call. Two deliver* calls for one ticket could both pass their own
293 // check before the first hop ran, and the second would then answer
294 // whoever leased the slot next, with the first one's outcome (#225).
295 if (!slot.in_use or slot.generation != ticket.generation) return;
243296 action_len = slot.action_len;
244297 @memcpy(action_buf[0..action_len], slot.action_buf[0..action_len]);
245298 request_id = slot.request_id;
@@ -247,9 +300,9 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
247300 json = slot.json;
248301 failed = slot.failed;
249302 fail_code = slot.fail_code;
303 // Moved out, so the reset below has nothing left to free.
250304 slot.json = null;
251 slot.failed = false;
252 slot.fail_code = bridge_error.BridgeError.NativeCallFailed;
305 resetReply(slot);
253306 slot.in_use = false;
254307 slot.generation +%= 1;
255308 }
@@ -305,7 +358,7 @@ pub fn deliverJson(ticket: Ticket, json: []const u8) void {
305358 slots_mutex.unlock();
306359
307360 if (!valid) return;
308 dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain);
361 dispatch_async_f(&_dispatch_main_q, ticketContext(ticket), deliverOnMain);
309362}
310363
311364/// Reply to a captured call with a *specific* error.
@@ -325,7 +378,7 @@ pub fn deliverErrorCode(ticket: Ticket, err: bridge_error.BridgeError) void {
325378 slots_mutex.unlock();
326379
327380 if (!valid) return;
328 dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain);
381 dispatch_async_f(&_dispatch_main_q, ticketContext(ticket), deliverOnMain);
329382}
330383
331384// ---------------------------------------------------------------------------
@@ -350,25 +403,33 @@ extern "c" fn dispatch_after_f(when: u64, queue: *anyopaque, context: ?*anyopaqu
350403pub fn scheduleDeadline(ticket: Ticket, timeout_ms: u32, work: dispatch_function_t) void {
351404 if (!is_darwin) return;
352405 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);
406 dispatch_after_f(dispatch_time(0, delay_ns), &_dispatch_main_q, ticketContext(ticket), work);
354407}
355408
356409/// The ticket a deadline's `work` was scheduled for.
357410pub 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 };
411 return ticketFromContext(context) orelse unreachable;
363412}
364413
365414/// The whole ticket in the context pointer, plus one so it is never null.
366415/// Relies on 64-bit pointers: a `u32` generation shifted past the index bits.
367fn deadlineContext(ticket: Ticket) ?*anyopaque {
416///
417/// Used by the main-queue hop as well as the deadline. Both need to say which
418/// *lease* they are for, and a pointer-sized context is the only thing either
419/// `dispatch_async_f` or `dispatch_after_f` will carry.
420fn ticketContext(ticket: Ticket) ?*anyopaque {
368421 const encoded = (@as(usize, ticket.generation) << 5) | @as(usize, ticket.index);
369422 return @ptrFromInt(encoded + 1);
370423}
371424
425fn ticketFromContext(context: ?*anyopaque) ?Ticket {
426 const encoded = @intFromPtr(context orelse return null) - 1;
427 return .{
428 .index = @intCast(encoded & 31),
429 .generation = @intCast(encoded >> 5),
430 };
431}
432
372433/// Reply to a captured call with an error rather than a result.
373434///
374435/// A module's own completion can fail to shape its answer — a nil object where
@@ -382,7 +443,7 @@ pub fn deliverError(ticket: Ticket) void {
382443 slots_mutex.unlock();
383444
384445 if (!valid) return;
385 dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain);
446 dispatch_async_f(&_dispatch_main_q, ticketContext(ticket), deliverOnMain);
386447}
387448
388449const testing = std.testing;
@@ -528,9 +589,89 @@ test "the generic deliverError still means a native failure" {
528589 slots[ticket.index].generation +%= 1;
529590}
530591
592test "a hop for a lease that has ended cannot answer the call that replaced it" {
593 // The race #225 is about. Two deliver* calls for one ticket can both pass
594 // their own generation check before the first hop runs: the first hop
595 // answers and releases the slot, a new call leases it, and the second hop
596 // then hands that call the first one's outcome.
597 request_context.push(7);
598 const first = acquire("pickImage") orelse return error.PoolUnexpectedlyFull;
599 request_context.pop();
600
601 deliverJson(first, "\"first\"");
602 deliverOnMain(ticketContext(first));
603 try testing.expect(!slots[first.index].in_use);
604
605 const second = acquire("getContacts") orelse return error.PoolUnexpectedlyFull;
606 defer abandon(second);
607 // Not incidental: `acquire` hands out the lowest free slot, so the call
608 // that follows a released one takes its place. That is the whole setup.
609 try testing.expectEqual(first.index, second.index);
610
611 // The stale hop arrives. It must find the lease gone and do nothing.
612 deliverOnMain(ticketContext(first));
613 try testing.expect(slots[second.index].in_use);
614 try testing.expectEqualStrings(
615 "getContacts",
616 slots[second.index].action_buf[0..slots[second.index].action_len],
617 );
618}
619
620test "abandoning a ticket that already has an answer takes the answer with it" {
621 // A module can fill a ticket and then abandon it — a completion that
622 // shaped its reply and then found the call already settled. `abandon`
623 // only flipped the flags, so the payload leaked and the flags stayed for
624 // whoever leased the slot next.
625 const first = acquire("getPendingNotifications") orelse return error.PoolUnexpectedlyFull;
626 deliverJson(first, "[{\"id\":\"orphan\"}]");
627 deliverErrorCode(first, bridge_error.BridgeError.Cancelled);
628 try testing.expect(slots[first.index].json != null);
629
630 abandon(first);
631 try testing.expect(slots[first.index].json == null);
632 try testing.expect(!slots[first.index].failed);
633 try testing.expectEqual(bridge_error.BridgeError.NativeCallFailed, slots[first.index].fail_code);
634
635 // And the next lease of that slot sees none of it.
636 const second = acquire("getContacts") orelse return error.PoolUnexpectedlyFull;
637 defer abandon(second);
638 try testing.expectEqual(first.index, second.index);
639 try testing.expect(slots[second.index].json == null);
640 try testing.expect(!slots[second.index].granted);
641}
642
643test "a completion that fires twice answers with the first outcome, once" {
644 // One block per slot, not per lease, so the block cannot tell its own
645 // second fire from its first. The arming can: it is consumed by the fire.
646 const ticket = acquire("requestPermission") orelse return error.PoolUnexpectedlyFull;
647 defer abandon(ticket);
648
649 _ = boolBlock(ticket);
650 try testing.expect(slots[ticket.index].completion_armed);
651
652 completionFired(ticket.index, true);
653 try testing.expect(!slots[ticket.index].completion_armed);
654 try testing.expect(slots[ticket.index].granted);
655
656 // The framework calls back again, with the opposite answer.
657 completionFired(ticket.index, false);
658 try testing.expect(slots[ticket.index].granted);
659}
660
661test "a fire for a lease that armed no block is not its answer to give" {
662 // A module that shapes its own reply through deliverJson never arms a
663 // pool block. A stray fire on its slot must not resolve it.
664 const ticket = acquire("getContacts") orelse return error.PoolUnexpectedlyFull;
665 defer abandon(ticket);
666
667 try testing.expect(!slots[ticket.index].completion_armed);
668 completionFired(ticket.index, true);
669 try testing.expect(!slots[ticket.index].granted);
670}
671
531672test "a deadline's context carries the complete ticket" {
532673 const original: Ticket = .{ .index = 31, .generation = 0xfedcba98 };
533 const decoded = ticketFromDeadline(deadlineContext(original));
674 const decoded = ticketFromDeadline(ticketContext(original));
534675 try testing.expectEqual(original.index, decoded.index);
535676 try testing.expectEqual(original.generation, decoded.generation);
536677}