Refs #223 — the second of its six actions, and the rest of the notifications module.
The action
scheduleNotification is two stages: ask for authorization, and on a grant build the request and add it. Its only answer comes from addNotificationRequest:withCompletionHandler:, and nothing settled the call if that never fired.
Both stages now park on ios_pending.Table, so this module's last side table keyed by reply-slot index is gone — and with it the pending_mutex that #247 left behind serving only the schedule half.
The clock starts on the grant, not at dispatch
This is the detail #223 calls out, and it is the whole reason this action is not the same change as getPendingNotifications:
- Stage one is
requestAuthorizationWithOptions:. There is a person reading a prompt. Timing that out would answer a question they have not finished with. - Stage two is the write. Nobody is in front of it.
So the deadline is armed in authorizationFired, over the add alone. A test pins the ordering — the arming must appear after the dispatch in the source — so it cannot quietly drift back up to dispatch, which would look like a tidier place to put it.
Stage two claims a fresh block, not stage one's. Stage one's was settled when its completion arrived and may already belong to another call; reusing it is the exact mix-up the table exists to prevent.
When the deadline wins, it frees the schedule, and the completion that eventually arrives finds the block owed and stops without touching it.
A test that passed when it should not have
The ordering test's first version did this:
const armed_at = std.mem.indexOf(u8, source, "scheduleDeadline(ticket, schedule_deadline_ms") orelse
return error.DeadlineNotArmed;That literal also appears in the test's own source. @embedFile reads the whole file, test included, so indexOf matched the test's copy of the string and the assertion passed with the real call deleted. The mutation that should have failed came back green, which is how I found it.
The needle is now joined at comptime — "scheduleDeadline(ticket, " ++ "schedule_deadline_ms" — so the whole form exists in the compiled string but never in the source text of the test. The occurrence count is asserted to be exactly one, so a second match means the needle is finding something other than the call.
Worth naming because it is a general hazard for source-scanning tests in the same file as the source they scan, and there are several in this tree.
Tests that fail without the fix
| mutation | fails |
|---|---|
| the add gets no deadline | the ordering test (after the self-match was fixed — it passed before) |
| the clock starts on the prompt instead of the write | the ordering test |
Also added: a deadline answering the add once and leaving its block owed, with the late completion ignored and the next schedule given a different block.
Note the conformance ratchet from #247 does not catch the first mutation: it requires a module that parks on ios_pending to call scheduleDeadline, and getPendingNotifications still does. It is per-module, not per-action. That hole is real and worth closing when the health module lands, which is the next file where two actions share one table.
Verification
zig build test:ios and zig build test:android, Pickier (0 errors), the ios/android package tests (154), and the mutations above. bun run test remains blocked locally by the zig-js toolchain issue described on #247, which is unrelated to this change.