Skip to content

fix(runtime): unwind all callers when guest code yields to the EE scheduler - #270

Open
drakolordx7 wants to merge 1 commit into
ran-j:mainfrom
drakolordx7:fix/guest-unwinding-yield
Open

drakolordx7 wants to merge 1 commit into
ran-j:mainfrom
drakolordx7:fix/guest-unwinding-yield

Conversation

@drakolordx7

Copy link
Copy Markdown

Generated code yields to the EE scheduler by returning up the host stack. dispatchGuestBranch then inferred a normal return from ctx->pc == entry, which a yield can also produce (a recursive call, or a loop head on the callee's first instruction). The caller carried on while the callee's guest frames were still live, leaving stale return addresses: jumps into heap addresses and random crashes, found in Killzone's Lua interpreter (deep recursion).

EeScheduler now counts unwinds (noteGuestUnwind / guestUnwindCount): bumped on a due checkpoint, on a branch the dispatcher cannot follow in place and on a non-local return. dispatchGuestBranch compares the count across the callee call and propagates the unwind instead of guessing from ctx->pc. Nothing has to be reset, so a stale flag cannot leak.

Two new tests reproduce both cases and fail on main; ps2x_tests 438/438. In the Killzone port a global-flag version with the same semantics went from crashing within seconds (with a forced-yield stress knob) to no crash in 120 s; this counter version has been checked with the unit tests and the existing suite. #137 (fiber scheduler) would make this unnecessary if it lands.

Made by drakolord and assisted with Claude Code.

…eduler

Guest code gives control back to the EE scheduler by returning up the
host stack: generated functions `return` when eeCheckpointDue() is true
(backward branches), and dispatchGuestBranch() returns false when a
checkpoint is due before a call. The scheduler then resumes at ctx->pc.

dispatchGuestBranch() decides whether a callee returned normally by
looking at ctx->pc afterwards, and treats "pc still equals the callee's
entry" as an implicit return (it sets pc to the fallthrough and reports
success). A yield can leave exactly that pc: a recursive call (the nested
dispatch yields with pc = the shared entry), or a loop head that is the
function's first instruction. The caller then continued as if the callee
had finished, while the abandoned callee's frames were still live and
left stale return addresses behind, which showed up as jumps to heap
addresses and random crashes in Lua-style recursive code.

EeScheduler now counts unwinds (noteGuestUnwind / guestUnwindCount).
The count is bumped when eeCheckpointDue() or dispatchGuestBranch() yields,
when a non-call branch is handed back to the dispatcher, and when a callee
returns somewhere other than its fallthrough. dispatchGuestBranch()
compares the count across the callee call and propagates the unwind
(returns false, pc untouched) instead of guessing from ctx->pc. Nothing
has to be reset, so a stale flag cannot leak into later calls, and the
state lives in the scheduler rather than a global.

Two new dispatchGuestBranch tests reproduce both cases (recursive yield,
yield at the callee entry); they fail on upstream main without the change.
The bug was first found in a game with a debug knob that forces a yield at
every Nth checkpoint: every run crashed within seconds before a fix with
the same semantics (a global "unwinding" flag there) and none did in 120 s
after it. This version keeps the semantics but was only checked with the
unit tests above and the existing suite, not in that game.

Made by drakolord and assisted with Claude Code.
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