ReviewOS

also looking at this

craft-native/craft

fix(ios): report async pool exhaustion as busy

#267
Merged glennmichael123 wants to merge codex/ios-busy-error-code into main
19 files +53 -65
packages/zig/src/bridge_error.zigmodified+5-0
Changes to packages/zig/src/bridge_error.zig
@@ -18,6 +18,8 @@ pub const BridgeError = error{
1818 InvalidJSON,
1919 /// Invalid parameter value
2020 InvalidParameter,
21 /// Native work is temporarily at capacity or an exclusive resource is in use
22 Busy,
2123 /// Platform not supported for this operation
2224 PlatformNotSupported,
2325 /// Native API call failed
@@ -152,6 +154,7 @@ pub fn errorCodeString(err: BridgeError) []const u8 {
152154 BridgeError.MissingData => "MISSING_DATA",
153155 BridgeError.InvalidJSON => "INVALID_JSON",
154156 BridgeError.InvalidParameter => "INVALID_PARAMETER",
157 BridgeError.Busy => "BUSY",
155158 BridgeError.PlatformNotSupported => "PLATFORM_NOT_SUPPORTED",
156159 BridgeError.NativeCallFailed => "NATIVE_CALL_FAILED",
157160 BridgeError.AllocationFailed => "ALLOCATION_FAILED",
@@ -174,6 +177,7 @@ pub fn errorMessage(err: BridgeError) []const u8 {
174177 BridgeError.MissingData => "Required data is missing",
175178 BridgeError.InvalidJSON => "Failed to parse JSON data",
176179 BridgeError.InvalidParameter => "Invalid parameter value",
180 BridgeError.Busy => "Native operation is temporarily busy",
177181 BridgeError.PlatformNotSupported => "This operation is not supported on the current platform",
178182 BridgeError.NativeCallFailed => "Native API call failed",
179183 BridgeError.AllocationFailed => "Memory allocation failed",
@@ -351,6 +355,7 @@ test "errorCodeString returns correct codes" {
351355 try testing.expectEqualStrings("WINDOW_HANDLE_NOT_SET", errorCodeString(BridgeError.WindowHandleNotSet));
352356 try testing.expectEqualStrings("UNKNOWN_ACTION", errorCodeString(BridgeError.UnknownAction));
353357 try testing.expectEqualStrings("MISSING_DATA", errorCodeString(BridgeError.MissingData));
358 try testing.expectEqualStrings("BUSY", errorCodeString(BridgeError.Busy));
354359}
355360
356361test "ErrorContext.toJSON produces valid JSON" {
packages/zig/src/bridge_mobile_audiorec.zigmodified+1-1
Changes to packages/zig/src/bridge_mobile_audiorec.zig
@@ -148,7 +148,7 @@ pub const AudioRecordingBridge = struct {
148148 "startAudioRecording: no free reply slot; {d} native calls are already awaiting one",
149149 .{ios_async.max_in_flight},
150150 );
151 return BridgeError.NativeCallFailed;
151 return BridgeError.Busy;
152152 };
153153 errdefer ios_async.abandon(ticket);
154154 pending = ticket;
packages/zig/src/bridge_mobile_auth.zigmodified+1-1
Changes to packages/zig/src/bridge_mobile_auth.zig
@@ -142,7 +142,7 @@ pub const AuthBridge = struct {
142142 "authenticate: no free reply slot; {d} native calls are already awaiting one",
143143 .{ios_async.max_in_flight},
144144 );
145 return BridgeError.NativeCallFailed;
145 return BridgeError.Busy;
146146 };
147147 errdefer ios_async.abandon(ticket);
148148
packages/zig/src/bridge_mobile_auth_apple.zigmodified+1-1
Changes to packages/zig/src/bridge_mobile_auth_apple.zig
@@ -149,7 +149,7 @@ pub const AppleAuthBridge = struct {
149149
150150fn poolFull() BridgeError {
151151 std.log.warn("signInWithApple refused: all {d} async slots in flight", .{ios_async.max_in_flight});
152 return BridgeError.InvalidParameter;
152 return BridgeError.Busy;
153153}
154154
155155/// `@[ASAuthorizationScopeFullName, ASAuthorizationScopeEmail]`, or null when
packages/zig/src/bridge_mobile_bluetooth.zigmodified+1-1
Changes to packages/zig/src/bridge_mobile_bluetooth.zig
@@ -196,7 +196,7 @@ pub const BluetoothBridge = struct {
196196 "startBluetoothScan: no free reply slot; {d} native calls are already awaiting one",
197197 .{ios_async.max_in_flight},
198198 );
199 return BridgeError.NativeCallFailed;
199 return BridgeError.Busy;
200200 };
201201 errdefer ios_async.abandon(ticket);
202202 pending = ticket;
packages/zig/src/bridge_mobile_calendar.zigmodified+2-5
Changes to packages/zig/src/bridge_mobile_calendar.zig
@@ -419,16 +419,13 @@ fn requestAccess(store: Id, sels: Sels, ticket: ios_async.Ticket) void {
419419 request(store, sels.request_access, ek_entity_type_event, accessBlock(ticket));
420420}
421421
422/// The answer for a full block pool, copied from `bridge_mobile_location`:
423/// `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration notes'
424/// designated stand-in, and the point is that the caller gets an explicit
425/// rejection instead of a promise that never settles.
422/// Reject a call when every asynchronous reply slot is already in use.
426423fn poolFull(action: []const u8) bridge_error.BridgeError {
427424 std.log.warn(
428425 "{s} refused: all {d} async slots are in flight",
429426 .{ action, ios_async.max_in_flight },
430427 );
431 return bridge_error.BridgeError.InvalidParameter;
428 return bridge_error.BridgeError.Busy;
432429}
433430
434431// =============================================================================
packages/zig/src/bridge_mobile_contactpicker.zigmodified+11-9
Changes to packages/zig/src/bridge_mobile_contactpicker.zig
@@ -374,16 +374,14 @@ pub const ContactPickerBridge = struct {
374374 }
375375};
376376
377/// The answer for a full block pool, copied from `bridge_mobile_location`:
378/// `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration notes'
379/// designated stand-in, and the point is that the caller gets an explicit
380/// rejection instead of a promise that never settles.
377/// The answer for a full block pool. The call is valid, but all reply capacity
378/// is temporarily leased, so the page can retry rather than blaming its input.
381379fn poolFull() bridge_error.BridgeError {
382380 std.log.warn(
383381 "pickContact refused: all {d} async slots are in flight",
384382 .{ios_async.max_in_flight},
385383 );
386 return bridge_error.BridgeError.InvalidParameter;
384 return bridge_error.BridgeError.Busy;
387385}
388386
389387/// The answer for a second `pickContact` while one is on screen.
@@ -395,16 +393,15 @@ fn poolFull() bridge_error.BridgeError {
395393/// promise alive. Swift does neither: it overwrites `pendingCallbackId` and the
396394/// first caller waits forever on an untimed promise.
397395///
398/// Same `INVALID_PARAMETER` stand-in as `poolFull`, for the same reason —
399/// `BridgeError` has no word for "busy" — with a log line that says which of
400/// the two happened.
396/// Same retryable `Busy` answer as `poolFull`, with a log line that says which
397/// kind of capacity is occupied.
401398fn alreadyPresented() bridge_error.BridgeError {
402399 std.log.warn(
403400 "pickContact refused: a contact picker is already presented and still waiting for an " ++
404401 "answer; the first call is left alone rather than being stranded",
405402 .{},
406403 );
407 return bridge_error.BridgeError.InvalidParameter;
404 return bridge_error.BridgeError.Busy;
408405}
409406
410407// =============================================================================
@@ -1617,6 +1614,7 @@ test "a second request while one is presented is refused, and the first is left
16171614 // promise never settles; the picker on screen is about to answer, so the
16181615 // newcomer is the one that has to be told no.
16191616 try testing.expect(!publishPending(.{ .ticket = fakeTicket(4, 9), .sels = fakeSels() }));
1617 try testing.expectEqual(bridge_error.BridgeError.Busy, alreadyPresented());
16201618
16211619 // And the slot still holds the *first* ticket, not the newcomer's — a
16221620 // refusal that quietly replaced would look identical from the return value.
@@ -1626,6 +1624,10 @@ test "a second request while one is presented is refused, and the first is left
16261624 try testing.expectEqual(first.generation, pending.?.ticket.generation);
16271625}
16281626
1627test "a full async pool reports that the call is retryable" {
1628 try testing.expectEqual(bridge_error.BridgeError.Busy, poolFull());
1629}
1630
16291631test "the slot is handed over once, so a double-firing delegate cannot reply twice" {
16301632 resetPendingForTesting();
16311633 defer resetPendingForTesting();
packages/zig/src/bridge_mobile_contacts.zigmodified+2-5
Changes to packages/zig/src/bridge_mobile_contacts.zig
@@ -343,16 +343,13 @@ pub const ContactsBridge = struct {
343343 }
344344};
345345
346/// The answer for a full block pool, copied from `bridge_mobile_location` and
347/// the picker: `BridgeError` has no "Busy", `INVALID_PARAMETER` is the
348/// migration notes' designated stand-in, and the point is that the caller gets
349/// an explicit rejection instead of a promise that never settles.
346/// Reject a call when every asynchronous reply slot is already in use.
350347fn poolFull(action: []const u8) bridge_error.BridgeError {
351348 std.log.warn(
352349 "{s} refused: all {d} async slots are in flight",
353350 .{ action, ios_async.max_in_flight },
354351 );
355 return bridge_error.BridgeError.InvalidParameter;
352 return bridge_error.BridgeError.Busy;
356353}
357354
358355// =============================================================================
packages/zig/src/bridge_mobile_display.zigmodified+1-1
Changes to packages/zig/src/bridge_mobile_display.zig
@@ -268,7 +268,7 @@ fn requireBundleIdentifier() !void {
268268
269269fn poolFull(action: []const u8) bridge_error.BridgeError {
270270 std.log.warn("{s} refused: all {d} async slots in flight", .{ action, ios_async.max_in_flight });
271 return bridge_error.BridgeError.InvalidParameter;
271 return bridge_error.BridgeError.Busy;
272272}
273273
274274/// What a slot's block will answer. The ticket is stored, not rebuilt from the
packages/zig/src/bridge_mobile_filepicker.zigmodified+4-7
Changes to packages/zig/src/bridge_mobile_filepicker.zig
@@ -428,7 +428,7 @@ pub const FilePickerBridge = struct {
428428 "pending reply; refusing the second call rather than stranding the first",
429429 .{},
430430 );
431 return BridgeError.InvalidParameter;
431 return BridgeError.Busy;
432432 }
433433
434434 const PresentFn = *const fn (Id, Id, Id, bool, Id) callconv(.c) void;
@@ -543,7 +543,7 @@ pub const FilePickerBridge = struct {
543543 "downloadFile: no free reply slot; {d} native calls are already awaiting one",
544544 .{ios_async.max_in_flight},
545545 );
546 return BridgeError.NativeCallFailed;
546 return BridgeError.Busy;
547547 };
548548 errdefer ios_async.abandon(ticket);
549549
@@ -562,13 +562,10 @@ pub const FilePickerBridge = struct {
562562 }
563563};
564564
565/// The answer for a full block pool, in the shape `bridge_mobile_location.zig`
566/// uses: `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration
567/// notes' designated stand-in, and the point is that the seventeenth concurrent
568/// call gets an explicit rejection instead of a promise that never settles.
565/// Reject a call when every asynchronous reply slot is already in use.
569566fn poolFull() BridgeError {
570567 std.log.warn("pickFile refused: all {d} async slots in flight", .{ios_async.max_in_flight});
571 return BridgeError.InvalidParameter;
568 return BridgeError.Busy;
572569}
573570
574571fn selector(name: [*:0]const u8) !Id {
packages/zig/src/bridge_mobile_health.zigmodified+3-3
Changes to packages/zig/src/bridge_mobile_health.zig
@@ -178,7 +178,7 @@ pub const HealthBridge = struct {
178178 "requestHealthAuthorization: no free reply slot; {d} native calls are already awaiting one",
179179 .{ios_async.max_in_flight},
180180 );
181 return BridgeError.NativeCallFailed;
181 return BridgeError.Busy;
182182 };
183183 errdefer ios_async.abandon(ticket);
184184 const block = calls.claim(ticket, .{}) orelse {
@@ -240,7 +240,7 @@ pub const HealthBridge = struct {
240240 "saveHealthWorkout: no free reply slot; {d} native calls are already awaiting one",
241241 .{ios_async.max_in_flight},
242242 );
243 return BridgeError.NativeCallFailed;
243 return BridgeError.Busy;
244244 };
245245 errdefer ios_async.abandon(ticket);
246246
@@ -302,7 +302,7 @@ pub const HealthBridge = struct {
302302 "getHealthData: no free reply slot; {d} native calls are already awaiting one",
303303 .{ios_async.max_in_flight},
304304 );
305 return BridgeError.NativeCallFailed;
305 return BridgeError.Busy;
306306 };
307307 errdefer ios_async.abandon(ticket);
308308 const block = calls.claim(ticket, .{ .unit = unit }) orelse {
packages/zig/src/bridge_mobile_imagepicker.zigmodified+10-11
Changes to packages/zig/src/bridge_mobile_imagepicker.zig
@@ -375,31 +375,30 @@ pub const ImagePickerBridge = struct {
375375 }
376376};
377377
378/// The answer for a full slot pool, copied from `bridge_mobile_location`:
379/// `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration notes'
380/// designated stand-in, and the point is that the caller gets an explicit
381/// rejection instead of a promise that never settles.
378/// The answer for a full slot pool. The call is valid, but all reply capacity
379/// is temporarily leased, so the page can distinguish retryable pressure from
380/// a bad argument or a broken native call.
382381fn poolFull(action: []const u8) bridge_error.BridgeError {
383382 std.log.warn(
384383 "{s} refused: all {d} async slots in flight",
385384 .{ action, ios_async.max_in_flight },
386385 );
387 return bridge_error.BridgeError.InvalidParameter;
386 return bridge_error.BridgeError.Busy;
388387}
389388
390389/// The answer for a second request while a picker is already on screen.
391390///
392391/// The *second* caller is refused and the first is left alone, because the
393/// first picker is still up and will answer. Same `INVALID_PARAMETER`
394/// stand-in, for the same reason: `BridgeError` cannot say "busy", and silence
395/// is not an option.
392/// first picker is still up and will answer. This is the same retryable state
393/// as a full reply pool from the page's perspective: the call is valid, but
394/// this moment is not.
396395fn busyRefusal(action: []const u8, holder: Source) bridge_error.BridgeError {
397396 std.log.warn(
398397 "{s} refused: {s} already has a picker presented; refusing the second call rather " ++
399398 "than replacing the first, whose promise would then never settle",
400399 .{ action, holder.actionName() },
401400 );
402 return bridge_error.BridgeError.InvalidParameter;
401 return bridge_error.BridgeError.Busy;
403402}
404403
405404// =============================================================================
@@ -1398,7 +1397,7 @@ test "a second request while a picker is presented is refused, and the first is
13981397
13991398 // And it is a rejection the caller can see, never silence.
14001399 try testing.expectEqual(
1401 bridge_error.BridgeError.InvalidParameter,
1400 bridge_error.BridgeError.Busy,
14021401 busyRefusal(A.pick_image, .camera),
14031402 );
14041403
@@ -1434,7 +1433,7 @@ test "a delegate callback with no call recorded touches no slot" {
14341433
14351434test "the pool refusal is an error the caller sees, not a dropped call" {
14361435 try testing.expectEqual(
1437 bridge_error.BridgeError.InvalidParameter,
1436 bridge_error.BridgeError.Busy,
14381437 poolFull(A.open_camera),
14391438 );
14401439}
packages/zig/src/bridge_mobile_location.zigmodified+2-5
Changes to packages/zig/src/bridge_mobile_location.zig
@@ -1140,16 +1140,13 @@ fn nowMillis() f64 {
11401140 return @floatFromInt(compat.milliTimestamp());
11411141}
11421142
1143/// The answer for a full block pool, copied from `bridge_mobile_permissions`:
1144/// `BridgeError` has no "Busy", INVALID_PARAMETER is the migration notes'
1145/// designated stand-in, and the point is that the seventeenth concurrent call
1146/// gets an explicit rejection instead of a promise that never settles.
1143/// Reject a call when every asynchronous reply slot is already in use.
11471144fn poolFull() bridge_error.BridgeError {
11481145 std.log.warn(
11491146 "getCurrentPosition refused: all {d} async slots in flight",
11501147 .{ios_async.max_in_flight},
11511148 );
1152 return bridge_error.BridgeError.InvalidParameter;
1149 return bridge_error.BridgeError.Busy;
11531150}
11541151
11551152// =============================================================================
packages/zig/src/bridge_mobile_nfc.zigmodified+1-1
Changes to packages/zig/src/bridge_mobile_nfc.zig
@@ -156,7 +156,7 @@ const alert_message = "Hold your iPhone near the NFC tag";
156156
157157fn poolFull() BridgeError {
158158 std.log.warn("scanNFC refused: all {d} async slots in flight", .{ios_async.max_in_flight});
159 return BridgeError.InvalidParameter;
159 return BridgeError.Busy;
160160}
161161
162162// =============================================================================
packages/zig/src/bridge_mobile_notifications.zigmodified+2-5
Changes to packages/zig/src/bridge_mobile_notifications.zig
@@ -294,16 +294,13 @@ pub const NotificationsBridge = struct {
294294 }
295295};
296296
297/// The answer for a full block pool, copied from `bridge_mobile_permissions`:
298/// `BridgeError` has no "Busy", INVALID_PARAMETER is the migration notes'
299/// designated stand-in, and the point is that the seventeenth concurrent call
300/// gets an explicit rejection instead of a promise that never settles.
297/// Reject a call when every asynchronous reply slot is already in use.
301298fn poolFull() bridge_error.BridgeError {
302299 std.log.warn(
303300 "getPendingNotifications refused: all {d} async slots in flight",
304301 .{ios_async.max_in_flight},
305302 );
306 return bridge_error.BridgeError.InvalidParameter;
303 return bridge_error.BridgeError.Busy;
307304}
308305
309306// =============================================================================
packages/zig/src/bridge_mobile_permissions.zigmodified+3-6
Changes to packages/zig/src/bridge_mobile_permissions.zig
@@ -542,7 +542,7 @@ pub const PermissionsBridge = struct {
542542 "openSettings: no free reply slot; {d} native calls are already awaiting one",
543543 .{ios_async.max_in_flight},
544544 );
545 return bridge_error.BridgeError.NativeCallFailed;
545 return bridge_error.BridgeError.Busy;
546546 };
547547 errdefer ios_async.abandon(ticket);
548548 publishSettingsCall(ticket);
@@ -564,13 +564,10 @@ pub const PermissionsBridge = struct {
564564 }
565565};
566566
567/// The answer for a full block pool. `BridgeError` has no "Busy";
568/// INVALID_PARAMETER is the designated stand-in from the migration notes,
569/// and the point is that the sixteen-plus-first concurrent prompt gets an
570/// explicit rejection instead of a promise that never settles.
567/// Reject a call when every asynchronous reply slot is already in use.
571568fn poolFull() bridge_error.BridgeError {
572569 std.log.warn("permission request refused: all {d} async slots in flight", .{ios_async.max_in_flight});
573 return bridge_error.BridgeError.InvalidParameter;
570 return bridge_error.BridgeError.Busy;
574571}
575572
576573// =============================================================================
packages/zig/src/bridge_mobile_siri.zigmodified+1-1
Changes to packages/zig/src/bridge_mobile_siri.zig
@@ -172,7 +172,7 @@ pub const SiriBridge = struct {
172172 "awaiting one",
173173 .{ios_async.max_in_flight},
174174 );
175 return BridgeError.NativeCallFailed;
175 return BridgeError.Busy;
176176 };
177177 errdefer ios_async.abandon(ticket);
178178
packages/zig/src/bridge_mobile_vision.zigmodified+1-1
Changes to packages/zig/src/bridge_mobile_vision.zig
@@ -191,7 +191,7 @@ pub const VisionBridge = struct {
191191 "{s}: no free reply slot; {d} native calls are already awaiting one",
192192 .{ kind.action(), ios_async.max_in_flight },
193193 );
194 return BridgeError.NativeCallFailed;
194 return BridgeError.Busy;
195195 };
196196
197197 work.* = .{ .handler = handler, .request = request, .kind = kind, .ticket = ticket };
packages/zig/src/bridge_mobile_watch.zigmodified+1-1
Changes to packages/zig/src/bridge_mobile_watch.zig
@@ -318,7 +318,7 @@ pub const WatchBridge = struct {
318318 "sendToWatch: no free reply slot; {d} native calls are already awaiting one",
319319 .{ios_async.max_in_flight},
320320 );
321 return bridge_error.BridgeError.NativeCallFailed;
321 return bridge_error.BridgeError.Busy;
322322 };
323323 errdefer ios_async.abandon(ticket);
324324 publishWatchCall(ticket);