also looking at this
fix(ios): report async pool exhaustion as busy
#267
19 files
+53
-65
| @@ -18,6 +18,8 @@ pub const BridgeError = error{ | ||
| 18 | 18 | InvalidJSON, |
| 19 | 19 | /// Invalid parameter value |
| 20 | 20 | InvalidParameter, |
| 21 | /// Native work is temporarily at capacity or an exclusive resource is in use | |
| 22 | Busy, | |
| 21 | 23 | /// Platform not supported for this operation |
| 22 | 24 | PlatformNotSupported, |
| 23 | 25 | /// Native API call failed |
| @@ -152,6 +154,7 @@ pub fn errorCodeString(err: BridgeError) []const u8 { | ||
| 152 | 154 | BridgeError.MissingData => "MISSING_DATA", |
| 153 | 155 | BridgeError.InvalidJSON => "INVALID_JSON", |
| 154 | 156 | BridgeError.InvalidParameter => "INVALID_PARAMETER", |
| 157 | BridgeError.Busy => "BUSY", | |
| 155 | 158 | BridgeError.PlatformNotSupported => "PLATFORM_NOT_SUPPORTED", |
| 156 | 159 | BridgeError.NativeCallFailed => "NATIVE_CALL_FAILED", |
| 157 | 160 | BridgeError.AllocationFailed => "ALLOCATION_FAILED", |
| @@ -174,6 +177,7 @@ pub fn errorMessage(err: BridgeError) []const u8 { | ||
| 174 | 177 | BridgeError.MissingData => "Required data is missing", |
| 175 | 178 | BridgeError.InvalidJSON => "Failed to parse JSON data", |
| 176 | 179 | BridgeError.InvalidParameter => "Invalid parameter value", |
| 180 | BridgeError.Busy => "Native operation is temporarily busy", | |
| 177 | 181 | BridgeError.PlatformNotSupported => "This operation is not supported on the current platform", |
| 178 | 182 | BridgeError.NativeCallFailed => "Native API call failed", |
| 179 | 183 | BridgeError.AllocationFailed => "Memory allocation failed", |
| @@ -351,6 +355,7 @@ test "errorCodeString returns correct codes" { | ||
| 351 | 355 | try testing.expectEqualStrings("WINDOW_HANDLE_NOT_SET", errorCodeString(BridgeError.WindowHandleNotSet)); |
| 352 | 356 | try testing.expectEqualStrings("UNKNOWN_ACTION", errorCodeString(BridgeError.UnknownAction)); |
| 353 | 357 | try testing.expectEqualStrings("MISSING_DATA", errorCodeString(BridgeError.MissingData)); |
| 358 | try testing.expectEqualStrings("BUSY", errorCodeString(BridgeError.Busy)); | |
| 354 | 359 | } |
| 355 | 360 | |
| 356 | 361 | test "ErrorContext.toJSON produces valid JSON" { |
| @@ -148,7 +148,7 @@ pub const AudioRecordingBridge = struct { | ||
| 148 | 148 | "startAudioRecording: no free reply slot; {d} native calls are already awaiting one", |
| 149 | 149 | .{ios_async.max_in_flight}, |
| 150 | 150 | ); |
| 151 | return BridgeError.NativeCallFailed; | |
| 151 | return BridgeError.Busy; | |
| 152 | 152 | }; |
| 153 | 153 | errdefer ios_async.abandon(ticket); |
| 154 | 154 | pending = ticket; |
| @@ -142,7 +142,7 @@ pub const AuthBridge = struct { | ||
| 142 | 142 | "authenticate: no free reply slot; {d} native calls are already awaiting one", |
| 143 | 143 | .{ios_async.max_in_flight}, |
| 144 | 144 | ); |
| 145 | return BridgeError.NativeCallFailed; | |
| 145 | return BridgeError.Busy; | |
| 146 | 146 | }; |
| 147 | 147 | errdefer ios_async.abandon(ticket); |
| 148 | 148 | |
| @@ -149,7 +149,7 @@ pub const AppleAuthBridge = struct { | ||
| 149 | 149 | |
| 150 | 150 | fn poolFull() BridgeError { |
| 151 | 151 | std.log.warn("signInWithApple refused: all {d} async slots in flight", .{ios_async.max_in_flight}); |
| 152 | return BridgeError.InvalidParameter; | |
| 152 | return BridgeError.Busy; | |
| 153 | 153 | } |
| 154 | 154 | |
| 155 | 155 | /// `@[ASAuthorizationScopeFullName, ASAuthorizationScopeEmail]`, or null when |
| @@ -196,7 +196,7 @@ pub const BluetoothBridge = struct { | ||
| 196 | 196 | "startBluetoothScan: no free reply slot; {d} native calls are already awaiting one", |
| 197 | 197 | .{ios_async.max_in_flight}, |
| 198 | 198 | ); |
| 199 | return BridgeError.NativeCallFailed; | |
| 199 | return BridgeError.Busy; | |
| 200 | 200 | }; |
| 201 | 201 | errdefer ios_async.abandon(ticket); |
| 202 | 202 | pending = ticket; |
| @@ -419,16 +419,13 @@ fn requestAccess(store: Id, sels: Sels, ticket: ios_async.Ticket) void { | ||
| 419 | 419 | request(store, sels.request_access, ek_entity_type_event, accessBlock(ticket)); |
| 420 | 420 | } |
| 421 | 421 | |
| 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. | |
| 426 | 423 | fn poolFull(action: []const u8) bridge_error.BridgeError { |
| 427 | 424 | std.log.warn( |
| 428 | 425 | "{s} refused: all {d} async slots are in flight", |
| 429 | 426 | .{ action, ios_async.max_in_flight }, |
| 430 | 427 | ); |
| 431 | return bridge_error.BridgeError.InvalidParameter; | |
| 428 | return bridge_error.BridgeError.Busy; | |
| 432 | 429 | } |
| 433 | 430 | |
| 434 | 431 | // ============================================================================= |
| @@ -374,16 +374,14 @@ pub const ContactPickerBridge = struct { | ||
| 374 | 374 | } |
| 375 | 375 | }; |
| 376 | 376 | |
| 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. | |
| 381 | 379 | fn poolFull() bridge_error.BridgeError { |
| 382 | 380 | std.log.warn( |
| 383 | 381 | "pickContact refused: all {d} async slots are in flight", |
| 384 | 382 | .{ios_async.max_in_flight}, |
| 385 | 383 | ); |
| 386 | return bridge_error.BridgeError.InvalidParameter; | |
| 384 | return bridge_error.BridgeError.Busy; | |
| 387 | 385 | } |
| 388 | 386 | |
| 389 | 387 | /// The answer for a second `pickContact` while one is on screen. |
| @@ -395,16 +393,15 @@ fn poolFull() bridge_error.BridgeError { | ||
| 395 | 393 | /// promise alive. Swift does neither: it overwrites `pendingCallbackId` and the |
| 396 | 394 | /// first caller waits forever on an untimed promise. |
| 397 | 395 | /// |
| 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. | |
| 401 | 398 | fn alreadyPresented() bridge_error.BridgeError { |
| 402 | 399 | std.log.warn( |
| 403 | 400 | "pickContact refused: a contact picker is already presented and still waiting for an " ++ |
| 404 | 401 | "answer; the first call is left alone rather than being stranded", |
| 405 | 402 | .{}, |
| 406 | 403 | ); |
| 407 | return bridge_error.BridgeError.InvalidParameter; | |
| 404 | return bridge_error.BridgeError.Busy; | |
| 408 | 405 | } |
| 409 | 406 | |
| 410 | 407 | // ============================================================================= |
| @@ -1617,6 +1614,7 @@ test "a second request while one is presented is refused, and the first is left | ||
| 1617 | 1614 | // promise never settles; the picker on screen is about to answer, so the |
| 1618 | 1615 | // newcomer is the one that has to be told no. |
| 1619 | 1616 | try testing.expect(!publishPending(.{ .ticket = fakeTicket(4, 9), .sels = fakeSels() })); |
| 1617 | try testing.expectEqual(bridge_error.BridgeError.Busy, alreadyPresented()); | |
| 1620 | 1618 | |
| 1621 | 1619 | // And the slot still holds the *first* ticket, not the newcomer's — a |
| 1622 | 1620 | // 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 | ||
| 1626 | 1624 | try testing.expectEqual(first.generation, pending.?.ticket.generation); |
| 1627 | 1625 | } |
| 1628 | 1626 | |
| 1627 | test "a full async pool reports that the call is retryable" { | |
| 1628 | try testing.expectEqual(bridge_error.BridgeError.Busy, poolFull()); | |
| 1629 | } | |
| 1630 | ||
| 1629 | 1631 | test "the slot is handed over once, so a double-firing delegate cannot reply twice" { |
| 1630 | 1632 | resetPendingForTesting(); |
| 1631 | 1633 | defer resetPendingForTesting(); |
| @@ -343,16 +343,13 @@ pub const ContactsBridge = struct { | ||
| 343 | 343 | } |
| 344 | 344 | }; |
| 345 | 345 | |
| 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. | |
| 350 | 347 | fn poolFull(action: []const u8) bridge_error.BridgeError { |
| 351 | 348 | std.log.warn( |
| 352 | 349 | "{s} refused: all {d} async slots are in flight", |
| 353 | 350 | .{ action, ios_async.max_in_flight }, |
| 354 | 351 | ); |
| 355 | return bridge_error.BridgeError.InvalidParameter; | |
| 352 | return bridge_error.BridgeError.Busy; | |
| 356 | 353 | } |
| 357 | 354 | |
| 358 | 355 | // ============================================================================= |
| @@ -268,7 +268,7 @@ fn requireBundleIdentifier() !void { | ||
| 268 | 268 | |
| 269 | 269 | fn poolFull(action: []const u8) bridge_error.BridgeError { |
| 270 | 270 | 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; | |
| 272 | 272 | } |
| 273 | 273 | |
| 274 | 274 | /// What a slot's block will answer. The ticket is stored, not rebuilt from the |
| @@ -428,7 +428,7 @@ pub const FilePickerBridge = struct { | ||
| 428 | 428 | "pending reply; refusing the second call rather than stranding the first", |
| 429 | 429 | .{}, |
| 430 | 430 | ); |
| 431 | return BridgeError.InvalidParameter; | |
| 431 | return BridgeError.Busy; | |
| 432 | 432 | } |
| 433 | 433 | |
| 434 | 434 | const PresentFn = *const fn (Id, Id, Id, bool, Id) callconv(.c) void; |
| @@ -543,7 +543,7 @@ pub const FilePickerBridge = struct { | ||
| 543 | 543 | "downloadFile: no free reply slot; {d} native calls are already awaiting one", |
| 544 | 544 | .{ios_async.max_in_flight}, |
| 545 | 545 | ); |
| 546 | return BridgeError.NativeCallFailed; | |
| 546 | return BridgeError.Busy; | |
| 547 | 547 | }; |
| 548 | 548 | errdefer ios_async.abandon(ticket); |
| 549 | 549 | |
| @@ -562,13 +562,10 @@ pub const FilePickerBridge = struct { | ||
| 562 | 562 | } |
| 563 | 563 | }; |
| 564 | 564 | |
| 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. | |
| 569 | 566 | fn poolFull() BridgeError { |
| 570 | 567 | std.log.warn("pickFile refused: all {d} async slots in flight", .{ios_async.max_in_flight}); |
| 571 | return BridgeError.InvalidParameter; | |
| 568 | return BridgeError.Busy; | |
| 572 | 569 | } |
| 573 | 570 | |
| 574 | 571 | fn selector(name: [*:0]const u8) !Id { |
| @@ -178,7 +178,7 @@ pub const HealthBridge = struct { | ||
| 178 | 178 | "requestHealthAuthorization: no free reply slot; {d} native calls are already awaiting one", |
| 179 | 179 | .{ios_async.max_in_flight}, |
| 180 | 180 | ); |
| 181 | return BridgeError.NativeCallFailed; | |
| 181 | return BridgeError.Busy; | |
| 182 | 182 | }; |
| 183 | 183 | errdefer ios_async.abandon(ticket); |
| 184 | 184 | const block = calls.claim(ticket, .{}) orelse { |
| @@ -240,7 +240,7 @@ pub const HealthBridge = struct { | ||
| 240 | 240 | "saveHealthWorkout: no free reply slot; {d} native calls are already awaiting one", |
| 241 | 241 | .{ios_async.max_in_flight}, |
| 242 | 242 | ); |
| 243 | return BridgeError.NativeCallFailed; | |
| 243 | return BridgeError.Busy; | |
| 244 | 244 | }; |
| 245 | 245 | errdefer ios_async.abandon(ticket); |
| 246 | 246 | |
| @@ -302,7 +302,7 @@ pub const HealthBridge = struct { | ||
| 302 | 302 | "getHealthData: no free reply slot; {d} native calls are already awaiting one", |
| 303 | 303 | .{ios_async.max_in_flight}, |
| 304 | 304 | ); |
| 305 | return BridgeError.NativeCallFailed; | |
| 305 | return BridgeError.Busy; | |
| 306 | 306 | }; |
| 307 | 307 | errdefer ios_async.abandon(ticket); |
| 308 | 308 | const block = calls.claim(ticket, .{ .unit = unit }) orelse { |
| @@ -375,31 +375,30 @@ pub const ImagePickerBridge = struct { | ||
| 375 | 375 | } |
| 376 | 376 | }; |
| 377 | 377 | |
| 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. | |
| 382 | 381 | fn poolFull(action: []const u8) bridge_error.BridgeError { |
| 383 | 382 | std.log.warn( |
| 384 | 383 | "{s} refused: all {d} async slots in flight", |
| 385 | 384 | .{ action, ios_async.max_in_flight }, |
| 386 | 385 | ); |
| 387 | return bridge_error.BridgeError.InvalidParameter; | |
| 386 | return bridge_error.BridgeError.Busy; | |
| 388 | 387 | } |
| 389 | 388 | |
| 390 | 389 | /// The answer for a second request while a picker is already on screen. |
| 391 | 390 | /// |
| 392 | 391 | /// 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. | |
| 396 | 395 | fn busyRefusal(action: []const u8, holder: Source) bridge_error.BridgeError { |
| 397 | 396 | std.log.warn( |
| 398 | 397 | "{s} refused: {s} already has a picker presented; refusing the second call rather " ++ |
| 399 | 398 | "than replacing the first, whose promise would then never settle", |
| 400 | 399 | .{ action, holder.actionName() }, |
| 401 | 400 | ); |
| 402 | return bridge_error.BridgeError.InvalidParameter; | |
| 401 | return bridge_error.BridgeError.Busy; | |
| 403 | 402 | } |
| 404 | 403 | |
| 405 | 404 | // ============================================================================= |
| @@ -1398,7 +1397,7 @@ test "a second request while a picker is presented is refused, and the first is | ||
| 1398 | 1397 | |
| 1399 | 1398 | // And it is a rejection the caller can see, never silence. |
| 1400 | 1399 | try testing.expectEqual( |
| 1401 | bridge_error.BridgeError.InvalidParameter, | |
| 1400 | bridge_error.BridgeError.Busy, | |
| 1402 | 1401 | busyRefusal(A.pick_image, .camera), |
| 1403 | 1402 | ); |
| 1404 | 1403 | |
| @@ -1434,7 +1433,7 @@ test "a delegate callback with no call recorded touches no slot" { | ||
| 1434 | 1433 | |
| 1435 | 1434 | test "the pool refusal is an error the caller sees, not a dropped call" { |
| 1436 | 1435 | try testing.expectEqual( |
| 1437 | bridge_error.BridgeError.InvalidParameter, | |
| 1436 | bridge_error.BridgeError.Busy, | |
| 1438 | 1437 | poolFull(A.open_camera), |
| 1439 | 1438 | ); |
| 1440 | 1439 | } |
| @@ -1140,16 +1140,13 @@ fn nowMillis() f64 { | ||
| 1140 | 1140 | return @floatFromInt(compat.milliTimestamp()); |
| 1141 | 1141 | } |
| 1142 | 1142 | |
| 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. | |
| 1147 | 1144 | fn poolFull() bridge_error.BridgeError { |
| 1148 | 1145 | std.log.warn( |
| 1149 | 1146 | "getCurrentPosition refused: all {d} async slots in flight", |
| 1150 | 1147 | .{ios_async.max_in_flight}, |
| 1151 | 1148 | ); |
| 1152 | return bridge_error.BridgeError.InvalidParameter; | |
| 1149 | return bridge_error.BridgeError.Busy; | |
| 1153 | 1150 | } |
| 1154 | 1151 | |
| 1155 | 1152 | // ============================================================================= |
| @@ -156,7 +156,7 @@ const alert_message = "Hold your iPhone near the NFC tag"; | ||
| 156 | 156 | |
| 157 | 157 | fn poolFull() BridgeError { |
| 158 | 158 | std.log.warn("scanNFC refused: all {d} async slots in flight", .{ios_async.max_in_flight}); |
| 159 | return BridgeError.InvalidParameter; | |
| 159 | return BridgeError.Busy; | |
| 160 | 160 | } |
| 161 | 161 | |
| 162 | 162 | // ============================================================================= |
| @@ -294,16 +294,13 @@ pub const NotificationsBridge = struct { | ||
| 294 | 294 | } |
| 295 | 295 | }; |
| 296 | 296 | |
| 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. | |
| 301 | 298 | fn poolFull() bridge_error.BridgeError { |
| 302 | 299 | std.log.warn( |
| 303 | 300 | "getPendingNotifications refused: all {d} async slots in flight", |
| 304 | 301 | .{ios_async.max_in_flight}, |
| 305 | 302 | ); |
| 306 | return bridge_error.BridgeError.InvalidParameter; | |
| 303 | return bridge_error.BridgeError.Busy; | |
| 307 | 304 | } |
| 308 | 305 | |
| 309 | 306 | // ============================================================================= |
| @@ -542,7 +542,7 @@ pub const PermissionsBridge = struct { | ||
| 542 | 542 | "openSettings: no free reply slot; {d} native calls are already awaiting one", |
| 543 | 543 | .{ios_async.max_in_flight}, |
| 544 | 544 | ); |
| 545 | return bridge_error.BridgeError.NativeCallFailed; | |
| 545 | return bridge_error.BridgeError.Busy; | |
| 546 | 546 | }; |
| 547 | 547 | errdefer ios_async.abandon(ticket); |
| 548 | 548 | publishSettingsCall(ticket); |
| @@ -564,13 +564,10 @@ pub const PermissionsBridge = struct { | ||
| 564 | 564 | } |
| 565 | 565 | }; |
| 566 | 566 | |
| 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. | |
| 571 | 568 | fn poolFull() bridge_error.BridgeError { |
| 572 | 569 | 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; | |
| 574 | 571 | } |
| 575 | 572 | |
| 576 | 573 | // ============================================================================= |
| @@ -191,7 +191,7 @@ pub const VisionBridge = struct { | ||
| 191 | 191 | "{s}: no free reply slot; {d} native calls are already awaiting one", |
| 192 | 192 | .{ kind.action(), ios_async.max_in_flight }, |
| 193 | 193 | ); |
| 194 | return BridgeError.NativeCallFailed; | |
| 194 | return BridgeError.Busy; | |
| 195 | 195 | }; |
| 196 | 196 | |
| 197 | 197 | work.* = .{ .handler = handler, .request = request, .kind = kind, .ticket = ticket }; |
| @@ -318,7 +318,7 @@ pub const WatchBridge = struct { | ||
| 318 | 318 | "sendToWatch: no free reply slot; {d} native calls are already awaiting one", |
| 319 | 319 | .{ios_async.max_in_flight}, |
| 320 | 320 | ); |
| 321 | return bridge_error.BridgeError.NativeCallFailed; | |
| 321 | return bridge_error.BridgeError.Busy; | |
| 322 | 322 | }; |
| 323 | 323 | errdefer ios_async.abandon(ticket); |
| 324 | 324 | publishWatchCall(ticket); |