D652 - The pending-signal slot
Status: measured Date: 2026-09-09
Built where D651 said, not where D650 said
D650 sketched a slot checked on the way into the thunk dispatch. D651
ruled that out by measurement - the raise is call 310,985 of 310,986, so
the target never dispatches again - and found the route that works:
main is asleep in sceKernelSyncOnAddressWait,
a wait this project owns.
So the slot is checked in the wait, not in the dispatch.
The three pieces
A slot per thread, read without a lock.
SignalSlot { pending: AtomicU64, parked: AtomicBool }, held
by Arc and cached in a thread-local by
become_thread. The Arc is the point: the
pending flag is read from inside a condition-variable predicate, under
the wait queue's lock, and reading it from the thread table there would
take the table lock while holding the queue lock -
sceKernelRaiseException takes them the other way round, so
it would be a lock-order inversion and eventually a deadlock. A cached
Arc is read with no lock at all and the inversion cannot
arise.
A loop in the wait, because a signal is not a wake.
The predicate becomes
tokens == 0 && !signal_pending(). A thread woken
for a signal has not had its word written and is still waiting
for what it came for, so it runs the handler and goes back to sleep.
Returning Woken there would hand a guest a satisfied wait
whose condition is still false. The lock is dropped before the handler
runs, because the handler is guest code and may call anything in the
module - including a wait on the same queue.
The signal is taken before the token, even when a wake arrives with it: hardware runs the handler whatever else was happening, and breaking out on the token would leave the signal pending until some later wait, which for a thread that never waits again is never.
Hooks rather than a call upward. sync
knows nothing about guest memory or guest threads -
wait_on_address already takes its word-read as a closure so
the module can be tested against a word it owns. Running a guest handler
is further outside that boundary again, so it is inverted down as a
SignalDelivery { pending, deliver } pair, the same shape as
the thread-start hook. Absent one, a wait behaves exactly as it did.
The refusal is what makes the acceptance honest
raise_pending answers false for a
thread that is not parked, and raise_exception then returns
the loud placeholder rather than 0. This is the
load-bearing part: 0 tells the guest a handler will run,
and a collector that believes it waits forever for an acknowledgement it
was promised. A signal accepted and silently dropped is worse than one
refused, and the difference is invisible from inside the guest.
The negative test is written first for that reason - accepting when delivery is impossible is the failure that would never show up as a failure.
What it did
PPSA25872, whose whole story this has been:
before: ran to the time limit (silent for 19.7s of 20)
after: the title's own modules+0x170021b
The handler ran. rdi = 0x1e, so the
signal number arrived; rsp is inside the reentrant stack
call_guest reserves, so it ran where it should;
r14 still holds the handler's own address. It executed
inc dword [rip+...], cmp edi, 0x1e, fell
through the jne - and then
mov rax, [rsi+0xf8], where rsi is the
exception context and orbistoun passes null.
read of 0xf8, at a named instruction, for a stated
reason.
That fault is the design working, not failing. The context layout is unpublished; a plausible block would have carried the guest past this instruction on invented fields and buried the real question. Null makes the guest stop exactly where the missing knowledge is, and the next request asks for the sixteen bytes that matter.
Two runs of the build agree on all of it (D181).
The limit, unchanged from D651
Delivery reaches a thread parked in a wait that consults its slot,
and no other. A mutator spinning in pure guest code is still
unreachable, and this title works only because Unity's happens to be on
a futex. sceKernelSyncOnAddressWait is the one wait wired
up; the others are a line each when a guest needs them, and none should
be wired speculatively.