Closes #225.
The hop knew the slot, not the lease
deliverJson, deliverErrorCode and deliverError each compared the ticket's generation — and then queued a hop carrying index + 1. deliverOnMain unpacked that index and checked only in_use.
So two deliver* calls for one ticket could both pass their own check before the first hop ran:
- hop A answers, releases the slot, bumps the generation;
- a new call leases the same slot —
acquirehands out the lowest free one, so it is likely to be the same; - hop B arrives, finds a slot in use, and answers that call with A's outcome.
completionFired did the same: it checked in_use, wrote granted, and queued an index.
Nothing in the tree drives this deliberately today. #211's Siri deadline avoids it by deciding who answers under its own lock before it calls deliver* at all. But the pool is the thing that is supposed to make a stale fire harmless, and it was not doing that job.
What it does now
The hop carries the whole ticket, in the encoding scheduleDeadline already used for its own context — ((generation << 5) | index) + 1 — and deliverOnMain compares generation as well as in_use. One encoder now, ticketContext / ticketFromContext, used by both the async hop and the deadline; ticketFromDeadline keeps its name and delegates.
completionFired captures the generation under the lock and passes it on, so an outcome recorded against one lease cannot be delivered to the next.
A block belongs to a slot, not a lease. There is one pre-built block per slot, so the block itself cannot tell its own second fire from its first. The arming can: boolBlock marks the lease, the first fire consumes the mark, and a framework that calls its completion twice now queues one answer instead of two. A fire on a slot whose lease never armed a block — a module shaping its own reply through deliverJson — is likewise not its answer to give.
The window that stays open, honestly
If a framework fires a stale completion after its slot was released and the next lease also armed a pool block, that fire is still accepted. Per-slot blocks cannot distinguish it, and the only fix is per-lease blocks — which is exactly the owed-block table #211 built for Siri, at a cost worth paying there and not obviously here. It is narrower than what this PR closes, and I would rather state it than imply the hole is gone.
A leak found on the way
abandon flipped in_use and bumped the generation, and left json alone. A module that filled a ticket and then abandoned it — a completion that shaped its reply and found the call already settled — leaked that payload, and left failed / fail_code set for whoever leased the slot next. Both release paths now share resetReply.
One correction, not an invention
The comment on max_in_flight said a page exceeding it "gets an explicit Busy error". There is no Busy code; every caller reports NativeCallFailed when acquire returns null. The issue offered either fix; I corrected the comment, because adding the code means touching a poolFull helper in twelve modules and changing what pages receive. Filed separately rather than smuggled in here.
Tests that fail without the fix
| mutation | fails |
|---|---|
the hop checks only in_use again | "a hop for a lease that has ended cannot answer the call that replaced it" |
| a second fire accepted again | "a completion that fires twice answers with the first outcome, once" and "a fire for a lease that armed no block is not its answer to give" |
A third mutation is worth reporting because it passed: I had also put resetReply in acquire, and removing it changed nothing — both release paths already clear, so it was unreachable. Rather than keep an untested line, I removed it from acquire and made deliverOnMain use resetReply too, so the one function is exercised by both paths. The abandon-clears-its-payload behaviour is pinned by its own test.
These tests run on Darwin only, which is where ios_module_tests is gated — they call deliverOnMain and completionFired directly, with no run loop, so the enqueued hops never execute and the assertions read slot state instead.
Verification
zig build test and zig build test:ios, bun run typecheck, bun run verify:packages, Pickier (0 errors), and the ios/android package tests (150).