feat(runtime): deferred INTC pending-raise latch with per-tick drain#151
Open
smmathews wants to merge 4 commits into
Open
feat(runtime): deferred INTC pending-raise latch with per-tick drain#151smmathews wants to merge 4 commits into
smmathews wants to merge 4 commits into
Conversation
smmathews
force-pushed
the
feature/04-intc-pending-latch-drain
branch
from
July 7, 2026 18:13
6adba13 to
f98e577
Compare
submitDmaSend completes DMA transfers synchronously but never raised the completion interrupt, so the standard sce-libdma kick-and-wait protocol (sceDmaSend -> CreateSema -> AddIntcHandler -> EnableIntc -> WaitSema) could never be released: a synchronous dispatch at the raise site would fire before the handler is registered and be lost, hanging the guest. Add a level-triggered pending-cause latch: - raisePendingIntc(cause): atomically sets a per-cause bit in an atomic pending bitmask (safe from any thread; vblank causes 2/3 and out-of-range causes are excluded -- vblank stays the worker's own periodic dispatch). - drainPendingIntc(rdram, runtime): called once per tick by the interrupt worker; dispatches each pending cause, clearing the bit only once at least one registered+enabled handler actually ran, or after it ages out. A per-cause age array tracks undelivered ticks: a pending cause survives 120 drains and drops on the 121st (~2 s @ 60 Hz), with the age reset on re-raise. This covers the raise-vs-registration race in both directions. - dispatchIntcHandlersForCause is renamed dispatchAndCountIntcHandlersForCause and changes return type void -> int, returning the number of handlers dispatched (0 when the cause is disabled or unregistered) so the drain can tell delivery from a miss. submitDmaSend raises INTC cause 5 after a VIF1 (0x10009000) kick; delivery is intentionally deferred to the next drain tick, which is hardware-plausible DMA-completion latency. The raise is an unconditional over-approximation rather than a per-kick edge: the VIFcode i-bit and DMAtag irq bit are decoded, but the resulting INT stat bit is sticky (cleared only by FBRST/CPU write) and has no consumer, so it is inert and cannot gate a clean edge. Over-raising is benign when unused (the cause ages out), whereas gating on the inert bit would drop legitimate interrupts. resetInterruptHandlerState() (called from cleanupRuntime) resets the handler tables, masks, and latch so runtime state does not leak between tests. Regression tests cover delivery on the next drain tick, the age-out boundary (survives exactly 120 undelivered drains, drops on the 121st, age resets on re-raise), pending persistence across the registration race, and vblank/out-of-range exclusion. raisePendingIntc, drainPendingIntc, and resetInterruptHandlerState are exposed in the runtime header for use by the interrupt worker and tests.
smmathews
force-pushed
the
feature/04-intc-pending-latch-drain
branch
from
July 7, 2026 18:17
f98e577 to
1ade90c
Compare
smmathews
marked this pull request as ready for review
July 7, 2026 18:17
…er libstdc++ elfio's elf_types.hpp relies on uint16_t/uint32_t/uint64_t being transitively available without including <cstdint> itself. This build environment's libstdc++ no longer pulls those in transitively, causing elf_parser.cpp to fail to compile with 'does not name a type' errors.
The "Semaphore poll/signal remains stable under host-thread contention" test asserted signalOkCount > 0, but SignalSema only returns success when count < max_count. With init_count == max_count == 1 the counter starts full, so the signaler thread only succeeds after the poller has drained the count. Whether that interleaving occurs depends entirely on host thread scheduling, so under load (as on the linux-clang CI runner) the signaler can run all 64 iterations while the counter is full, hitting KE_SEMA_OVF every time and leaving signalOkCount == 0 -> spurious failure. Replace the non-deterministic signalOk > 0 assertion with the conservation invariant finalCount == init_count - successful_polls + successful_signals, which holds for every interleaving and is a stronger stability check: it detects any lost or double-applied acquire/release under contention. The guaranteed pollOk > 0 assertion (the first poll always observes count == 1) and the count-range check are retained.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a deferred INTC pending-raise latch with per-tick drain and late-handler delivery.
submitDmaSendcompletes DMA transfers synchronously (processPendingTransfers()) but never raised the completion interrupt, so the standard sce-libdma kick-and-wait protocol (sceDmaSend→CreateSema→AddIntcHandler→EnableIntc→WaitSema) could never be released — the handler is registered after the kick returns, so a synchronous dispatch at the raise site would be lost and the guest would block onWaitSemaforever (observed on DQ8's per-frame VIF1 display-list submit path).What changed
Kernel/Syscalls/Interrupt.cpp/Interrupt.hg_pending_intc_causes(atomic bitmask) + per-cause age counters.raisePendingIntc(cause): atomically sets the pending bit; safe from any thread; excludes vblank causes 2/3 (they remain the worker's own periodic dispatches) and out-of-range causes; resets the cause's age on a fresh raise.drainPendingIntc(rdram, runtime): called once per tick by the interrupt worker (alongside the vblank dispatches). A pending bit is cleared only once ≥1 registered+enabled handler actually ran ([INTC:deliver]), or after aging out at 120 undelivered ticks ≈ 2 s @ 60 Hz ([INTC:drop]). This covers the raise-vs-registration race in both directions.dispatchIntcHandlersForCause→dispatchAndCountIntcHandlersForCause,void→int(count of handlers dispatched) so the drain can distinguish delivery from a miss.Kernel/Stubs/Helpers/Support.hsubmitDmaSendnow callsps2_syscalls::raisePendingIntc(5u)after a VIF1 (0x10009000) kick, via a small forward declaration (avoids pulling the Syscalls include graph into the Stubs helper). Delivery is intentionally deferred to the next drain tick (≤ one vblank of latency — hardware-plausible DMA-completion timing).VIF1 cause-5 raise is an intentional over-approximation
The
raisePendingIntc(5u)fires on every VIF1 kick, not only when an interrupt was actually requested. This is a deliberate over-approximation, documented at the call site.Honest fidelity gap (stated plainly): when a cause-5 handler is registered, this unconditional raise delivers on every VIF1 kick regardless of whether real hardware would have raised the interrupt.
Why not gate it? The runtime does decode both relevant interrupt sources, but neither decoded signal is usable as a clean per-kick gate at this call site:
ibit is decoded — intovif*_regs.statbit 11 (INT) atps2_vif1_interpreter.cpp:311-312(VIF1) and:79-80(VIF0). But that flag is sticky (cleared only by FBRST — RST memset atps2_memory.cpp:1018, or STC at:1024— or a CPU write) and currently inert (nothing consumes the INT bit; the onlystatreads,ps2_vif1_interpreter.cpp:380/398, test bit 7 / DBF, and the guest MMIO read path does not exposevif1_regs.stat). With no per-kick clear point and no consumer, it is not a clean edge signal — gating on it would require new snapshot-and-clear semantics, and getting that wrong reintroduces the exact interrupt-drop this raise exists to avoid.ps2_memory.cpp:1216— but in the register-path DMA chain walker; this sce-libdma stub kick path parses only the head tag (for logging).Over-raising is chosen because it is benign when unused: the latch is level-triggered, so an unconsumed cause 5 ages out in
kPendingIntcMaxAgeTicks(~2 s) drain ticks with no side effect, while DQ8's per-frame VIF1 display-list path consumes cause 5 every frame. A mis-gated raise, by contrast, would silently drop legitimate interrupts. If the runtime ever makes the INT stat bit a clean per-kick edge (clear-on-read, or a consumer with defined clear semantics), the gate belongs here.Level-triggered raise-mid-drain collapse (by design)
drainPendingIntcclears the single pending bit on delivery. If another raise of the same cause lands mid-drain (between the dispatch and thefetch_andclear), the two raises collapse into one delivery. This is accepted under the level-triggered design — a set bit means "at least one pending", not a count. A code comment at thefetch_andsite marks the tradeoff as deliberate (not a lost-wakeup bug).Design notes / header-exposed symbols
All behavior-identical; testability-driven:
g_pending_intc_ageisstd::atomic<uint32_t>[32]rather than plainuint32_t[32], becauseraisePendingIntc(any thread) resets an age while the worker increments it in the drain.drainPendingIntcis non-static and declared inInterrupt.hso the regression tests can drive drain ticks deterministically;std::countr_zero(C++20) is used instead of__builtin_ctzfor MSVC portability.interrupt_state::g_pending_intc_causes(extern) andinterrupt_state::kPendingIntcMaxAgeTicksare exposed inInterrupt.hso tests can assert the latch state and the exact age-out boundary directly.resetInterruptHandlerState()is declared inInterrupt.hand defined inInterrupt.cpp(test-support). It restores the INTC/DMAC handler tables, enable masks, id/order counters, and the pending latch to their process-start defaults. It must live inInterrupt.cppbecause the handler tables arestaticper translation unit andg_pending_intc_ageis not header-exposed — only that TU can reset the live instances. Tests call it incleanupRuntimeas belt-and-suspenders defensive isolation — it is not the load-bearing mechanism for order-independence. The suite's actual isolation ismain.cpp'sBeforeEach(reset_ps2_test_function_table), which wipes the recompiled-function table between tests; disablingresetInterruptHandlerState()still yields 300/300. It is kept as forward-defensive cleanup of the INTC-specific global state.Tests
Four new cases in
ps2xTest(ps2_runtime_interrupt_tests.cpp), driving the latch directly with the worker thread stopped so drain ticks are deterministic:$a0 == 5, pending bit clears.cleanupRuntimereset), so its premise holds independent of test order rather than relying on a leaked handler being non-resolvable in a fresh runtime.raisePendingIntcon vblank causes 2/3 and cause 32 never sets a bit.Verification
ps2x_tests(Release, GCC/Ninja on Linux) from a clean tree.dispatchDmacHandlersForCauseis untouched.