Refs #223 — the first of its six actions, on the table #245 extracted.
The action
getPendingNotifications is answered only by getPendingNotificationRequestsWithCompletionHandler:. No person is involved, and nothing settled the call if that completion never fired.
It could not simply take a deadline, which is what #223 spends most of its length on: the module kept a side table keyed by reply-slot index, so a deadline that freed slot k let the next call lease slot k and receive the same block, and the first call's late completion would then answer the second with the first one's notifications.
It now parks on ios_pending.Table(Sels), and the blocks are indexed per block rather than per reply slot — 32 of them rather than 16, so every slot can be waiting while as many blocks again are still owed a completion that has not come.
10 s. Comfortably under the 30 s the page's own _invoke allows, so the caller hears this answer rather than its own timeout. A completion arriving after it is logged with how late it was and ignored; a block with no call recorded is a stray and says so.
A ratchet for the remaining five
a module parks calls on ios_pending.Table and never calls scheduleDeadline.
A parked call whose completion never comes settles nothing at all.Parking makes a late completion harmless. It does not make one that never comes answer. Both halves or neither — and as the rest of #223 lands, this is what stops the second half being forgotten.
I added it because a mutation exposed the gap in my own work: removing the scheduleDeadline call passed every test. Nothing asserted the deadline was armed at all, and a host test cannot observe dispatch_after_f without a main queue. The scan is what closes that, and it now fails the mutation.
| mutation | fails |
|---|---|
| the deadline is never armed | the new ratchet (passed everything before it existed) |
| every call gets the same block | the module's own block tests |
What is deliberately not here
scheduleNotification stays on the old side table. It is two stages, and #223 is explicit that its deadline has to start after the authorization prompt rather than at dispatch — a prompt has a person in front of it and must not be timed out. That is a different shape and gets its own change, along with getHealthData, saveHealthWorkout, openSettings and openURL.
openSettings will need the most care: the app is backgrounded while Settings is open, a main-queue deadline does not run while suspended, and a naive one would report TIMEOUT for an open that succeeded.
A local toolchain note, unrelated to this change
bun run test currently fails on this machine, and not because of anything here: the local Zig moved from 0.17.0-dev.1441+d5181a9c9 to 0.17.0-dev.2163+89ff10d56 during the session, and the external zig-js library no longer compiles against it (expected optional type, missing struct field: pointer_stability in its compiler.zig, value.zig and vm.zig).
Confirmed pre-existing: zig build test-js fails the same way on a clean main with these changes stashed, and zero of the errors are in craft's own sources. CI pins its own toolchain, so it is unaffected — but anyone running the suite locally will hit it until zig-js catches up or the toolchain is pinned back.
Verification
zig build test:ios (which covers this module), Pickier (0 errors), and the mutations above. zig build test is blocked locally by the zig-js issue described above, not by this change.