ReviewOS

also looking at this

craft-native/craft

refactor(ios): lift #211's owed-block design out of Siri, so other actions can use it

#245
Merged glennmichael123 wants to merge fix/ios-pending-call-table into main
2 files +248 -195
packages/zig/src/ios_pending.zigadded+216-0
Changes to packages/zig/src/ios_pending.zig
@@ -0,0 +1,216 @@
1//! A call parked on a completion block, answerable from either side exactly once.
2//!
3//! `ios_async` hands out reply slots, and a slot is released the moment
4//! something answers it. That is right for the reply, and wrong for the block:
5//! a deadline releases slot k, the next call leases slot k, and — if blocks
6//! were chosen by reply slot — the first call's late completion would fire the
7//! same block and answer the second call with the first one's result.
8//!
9//! #211 found that in `removeSiriShortcut`, where the only answer came from a
10//! system daemon that sometimes never called back. The fix there was to make
11//! blocks their own resource: a block whose call the deadline answered stays
12//! `owed` until the framework calls it, and is never handed to another call in
13//! the meantime. This file is that design, lifted out so the other actions with
14//! the same shape can take a deadline safely (#223).
15//!
16//! What a caller still owns: the block array itself (its shape is the
17//! framework's, `void (^)(void)` for Siri and `void (^)(NSArray *)` elsewhere),
18//! and what it parks. This owns only who may answer, and when.
19
20const std = ;
21const ios_async = ;
22const compat = ;
23const compat_mutex = ;
24
25/// Twice the reply slots, so every slot can be waiting while as many blocks
26/// again are still owed a completion that has not come.
27pub const block_count = 2 * ios_async.max_in_flight;
28
29/// `Call` is whatever the module needs to answer: a shaped reply, a selector
30/// pair, a chain of HealthKit stages. This file neither reads nor frees it —
31/// whichever side takes the entry owns it from then on.
32pub fn Table(comptime Call: type) type {
33 return struct {
34 const Self = @This();
35
36 /// A parked call. The ticket is stored rather than rebuilt, because a
37 /// `Ticket` is an index *and* a generation and the deadline matches on
38 /// both — matching on the index alone is exactly the bug this prevents.
39 pub const Entry = struct {
40 ticket: ios_async.Ticket,
41 call: Call,
42 started_ms: i64,
43 };
44
45 const State = union(enum) {
46 free,
47 waiting: Entry,
48 /// Answered by the deadline. The framework still holds this block
49 /// and may call it; when it does, that call is ignored and the
50 /// block is freed. Carries when the call started, so a late
51 /// completion can say how late it was.
52 owed: i64,
53 };
54
55 pub const Settled = union(enum) {
56 /// The completion arrived first: answer with this call.
57 reply: Entry,
58 /// The deadline already answered. Nothing to send, and when it began.
59 late: i64,
60 /// No call was ever parked on this block.
61 stray,
62 };
63
64 blocks: [block_count]State = @splat(.free),
65 mutex: compat_mutex.Mutex = .{},
66
67 /// Park `call` on the first free block.
68 ///
69 /// Null when every block is waiting or owed. The caller refuses the
70 /// action rather than reusing an owed block — a refusal the page can
71 /// act on, against an answer that would go to the wrong call.
72 pub fn claim(self: *Self, ticket: ios_async.Ticket, call: Call) ?u5 {
73 self.mutex.lock();
74 defer self.mutex.unlock();
75 for (&self.blocks, 0..) |*state, i| {
76 if (state.* != .free) continue;
77 state.* = .{ .waiting = .{
78 .ticket = ticket,
79 .call = call,
80 .started_ms = compat.milliTimestamp(),
81 } };
82 return @intCast(i);
83 }
84 return null;
85 }
86
87 /// The deadline's half: take the call `ticket` names if it is still
88 /// waiting, and leave its block owed.
89 ///
90 /// Matching the whole ticket is what stops a timer armed for one call
91 /// from expiring a newer call that leased the same reply slot.
92 pub fn expireIfWaiting(self: *Self, ticket: ios_async.Ticket) ?Entry {
93 self.mutex.lock();
94 defer self.mutex.unlock();
95 for (&self.blocks) |*state| {
96 const entry = switch (state.*) {
97 .waiting => |waiting| waiting,
98 else => continue,
99 };
100 if (entry.ticket.index != ticket.index or
101 entry.ticket.generation != ticket.generation) continue;
102 state.* = .{ .owed = entry.started_ms };
103 return entry;
104 }
105 return null;
106 }
107
108 /// The completion's half.
109 ///
110 /// Each block leaves `waiting` or `owed` exactly once, under the lock,
111 /// so the completion and the deadline cannot both answer.
112 pub fn settle(self: *Self, block: u5) Settled {
113 self.mutex.lock();
114 defer self.mutex.unlock();
115 const state = self.blocks[block];
116 self.blocks[block] = .free;
117 return switch (state) {
118 .waiting => |entry| .{ .reply = entry },
119 .owed => |started_ms| .{ .late = started_ms },
120 .free => .stray,
121 };
122 }
123
124 /// Test-only: drop every parked call without answering any of them.
125 pub fn resetForTest(self: *Self) void {
126 self.mutex.lock();
127 defer self.mutex.unlock();
128 self.blocks = @splat(.free);
129 }
130 };
131}
132
133const testing = std.testing;
134
135/// A payload with nothing in it: these tests are about who may answer.
136const Marker = struct { id: u32 };
137var table: Table(Marker) = .{};
138
139fn fresh() *Table(Marker) {
140 table.resetForTest();
141 return &table;
142}
143
144test "a deadline answers a waiting call once and leaves its block owed" {
145 const t = fresh();
146 const ticket: ios_async.Ticket = .{ .index = 3, .generation = 9 };
147 const block = t.claim(ticket, .{ .id = 1 }) orelse return error.NoFreeBlock;
148
149 const taken = t.expireIfWaiting(ticket) orelse return error.NothingToExpire;
150 try testing.expectEqual(@as(u32, 1), taken.call.id);
151 // Gone from the deadline's point of view, and not free for another call.
152 try testing.expect(t.expireIfWaiting(ticket) == null);
153 try testing.expect(t.claim(.{ .index = 4, .generation = 1 }, .{ .id = 2 }) != block);
154}
155
156test "a completion after its deadline is ignored, and frees the block" {
157 const t = fresh();
158 const ticket: ios_async.Ticket = .{ .index = 3, .generation = 9 };
159 const block = t.claim(ticket, .{ .id = 1 }) orelse return error.NoFreeBlock;
160 _ = t.expireIfWaiting(ticket);
161
162 switch (t.settle(block)) {
163 .late => {},
164 else => return error.LateCompletionAnswered,
165 }
166 // And now the block is reusable, which an owed one was not.
167 try testing.expectEqual(block, t.claim(.{ .index = 5, .generation = 2 }, .{ .id = 3 }).?);
168}
169
170test "a deadline that fires after the completion does nothing" {
171 const t = fresh();
172 const ticket: ios_async.Ticket = .{ .index = 3, .generation = 9 };
173 const block = t.claim(ticket, .{ .id = 1 }) orelse return error.NoFreeBlock;
174
175 switch (t.settle(block)) {
176 .reply => |entry| try testing.expectEqual(@as(u32, 1), entry.call.id),
177 else => return error.CompletionDidNotAnswer,
178 }
179 try testing.expect(t.expireIfWaiting(ticket) == null);
180}
181
182test "a stale deadline cannot expire a newer call on the same reply slot" {
183 // The whole reason blocks are not chosen by reply slot: slot 3 is answered
184 // and released, the next call leases slot 3, and the first call's timer
185 // still fires.
186 const t = fresh();
187 const stale: ios_async.Ticket = .{ .index = 3, .generation = 9 };
188 const current: ios_async.Ticket = .{ .index = 3, .generation = 10 };
189 _ = t.claim(current, .{ .id = 7 }) orelse return error.NoFreeBlock;
190
191 try testing.expect(t.expireIfWaiting(stale) == null);
192 const taken = t.expireIfWaiting(current) orelse return error.NothingToExpire;
193 try testing.expectEqual(@as(u32, 7), taken.call.id);
194}
195
196test "a settle on a block nobody parked on is a stray, not an answer" {
197 const t = fresh();
198 switch (t.settle(0)) {
199 .stray => {},
200 else => return error.StrayAnswered,
201 }
202}
203
204test "with every block owed, a call is refused rather than given one" {
205 const t = fresh();
206 for (0..block_count) |i| {
207 const ticket: ios_async.Ticket = .{ .index = @intCast(i % ios_async.max_in_flight), .generation = @intCast(i + 1) };
208 _ = t.claim(ticket, .{ .id = @intCast(i) }) orelse return error.NoFreeBlock;
209 _ = t.expireIfWaiting(ticket);
210 }
211 try testing.expect(t.claim(.{ .index = 0, .generation = 999 }, .{ .id = 0 }) == null);
212}
213
214test "there are more blocks than reply slots, so a full pool can still be owed" {
215 try testing.expect(block_count > ios_async.max_in_flight);
216}