Skip to content

feat(runtime): deferred INTC pending-raise latch with per-tick drain#151

Open
smmathews wants to merge 4 commits into
ran-j:mainfrom
smmathews:feature/04-intc-pending-latch-drain
Open

feat(runtime): deferred INTC pending-raise latch with per-tick drain#151
smmathews wants to merge 4 commits into
ran-j:mainfrom
smmathews:feature/04-intc-pending-latch-drain

Conversation

@smmathews

@smmathews smmathews commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a deferred INTC pending-raise latch with per-tick drain and late-handler delivery.

submitDmaSend completes DMA transfers synchronously (processPendingTransfers()) but never raised the completion interrupt, so the standard sce-libdma kick-and-wait protocol (sceDmaSendCreateSemaAddIntcHandlerEnableIntcWaitSema) 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 on WaitSema forever (observed on DQ8's per-frame VIF1 display-list submit path).

What changed

  • Kernel/Syscalls/Interrupt.cpp / Interrupt.h
    • New level-triggered pending-cause latch: g_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.
    • dispatchIntcHandlersForCausedispatchAndCountIntcHandlersForCause, voidint (count of handlers dispatched) so the drain can distinguish delivery from a miss.
  • Kernel/Stubs/Helpers/Support.h
    • submitDmaSend now calls ps2_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:

  • The VIFcode i bit is decoded — into vif*_regs.stat bit 11 (INT) at ps2_vif1_interpreter.cpp:311-312 (VIF1) and :79-80 (VIF0). But that flag is sticky (cleared only by FBRST — RST memset at ps2_memory.cpp:1018, or STC at :1024 — or a CPU write) and currently inert (nothing consumes the INT bit; the only stat reads, ps2_vif1_interpreter.cpp:380/398, test bit 7 / DBF, and the guest MMIO read path does not expose vif1_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.
  • The per-tag DMAtag IRQ bit is decoded, at 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)

drainPendingIntc clears the single pending bit on delivery. If another raise of the same cause lands mid-drain (between the dispatch and the fetch_and clear), 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 the fetch_and site marks the tradeoff as deliberate (not a lost-wakeup bug).

Design notes / header-exposed symbols

All behavior-identical; testability-driven:

  • g_pending_intc_age is std::atomic<uint32_t>[32] rather than plain uint32_t[32], because raisePendingIntc (any thread) resets an age while the worker increments it in the drain.
  • drainPendingIntc is non-static and declared in Interrupt.h so the regression tests can drive drain ticks deterministically; std::countr_zero (C++20) is used instead of __builtin_ctz for MSVC portability.
  • interrupt_state::g_pending_intc_causes (extern) and interrupt_state::kPendingIntcMaxAgeTicks are exposed in Interrupt.h so tests can assert the latch state and the exact age-out boundary directly.
  • resetInterruptHandlerState() is declared in Interrupt.h and defined in Interrupt.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 in Interrupt.cpp because the handler tables are static per translation unit and g_pending_intc_age is not header-exposed — only that TU can reset the live instances. Tests call it in cleanupRuntime as belt-and-suspenders defensive isolation — it is not the load-bearing mechanism for order-independence. The suite's actual isolation is main.cpp's BeforeEach(reset_ps2_test_function_table), which wipes the recompiled-function table between tests; disabling resetInterruptHandlerState() 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:

  1. Delivery — register+enable a cause-5 handler, raise, one drain: handler runs exactly once with $a0 == 5, pending bit clears.
  2. Age-out boundary — raise with no handler: the bit survives exactly 120 undelivered drains, drops on the 121st, and a re-raise restarts the age. This test now starts from an empty handler table by construction (via the cleanupRuntime reset), so its premise holds independent of test order rather than relying on a leaked handler being non-resolvable in a fresh runtime.
  3. Registration race — raise, drain (no handler yet, bit survives), register+enable, drain: delivered exactly once, bit clears.
  4. ExclusionraisePendingIntc on vblank causes 2/3 and cause 32 never sets a bit.

Verification

  • Built ps2x_tests (Release, GCC/Ninja on Linux) from a clean tree.
  • Full existing suite + new tests: 300/300 passed, 0 failed.
  • Age-out test delivery-sensitivity confirmed by a temporary probe: registering a function-backed cause-5 handler in the age-out test makes it fail (the first drain delivers and clears the bit before 120 ticks), proving the test exercises real age-out by construction. Probe reverted; suite back to 300/300.
  • Verified the exact age-out off-by-one (survive 120, drop on 121), vblank exclusion, and no scope creep. Fiber/token handling is deliberately left out of scope; dispatchDmacHandlersForCause is untouched.

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
smmathews force-pushed the feature/04-intc-pending-latch-drain branch from f98e577 to 1ade90c Compare July 7, 2026 18:17
@smmathews
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant