[Kernel] Hand off mutex ownership directly to the head waiter on unlock (#439)

pthread_mutex_unlock cleared ownership (OwnerThreadId = 0) and only woke
the head waiter, relying on that woken thread to re-acquire the lock
itself. If the wake raced or was lost, the mutex was left "free but with
a queued waiter" — a state the fast-acquire path in PthreadMutexLockCore
explicitly refuses (OwnerThreadId == 0 && Waiters.Count == 0), so every
later locker, including the game's main thread, queued behind a head that
never advanced and the whole process wedged.

Grant the mutex to the head waiter directly inside unlock (the same
TryGrantMutexWaiterLocked hand-off the thread-exit cleanup already uses),
then wake it. The mutex is therefore never observable as free-with-waiter.

Verified against The Invincible (PPSA06426): forward progress jumps from
~3.5M to ~40M dispatched imports and the repeated unlock INVALID_ARGUMENT
errors disappear. No regression on Dead Cells (PPSA15552), which still
reaches AGC rendering with zero mutex errors.
This commit is contained in:
Youss
2026-07-19 19:33:21 +02:00
committed by GitHub
parent bc51cc2c4d
commit 73e8821d5b
@@ -777,9 +777,21 @@ public static class KernelPthreadCompatExports
if (state.RecursionCount == 0)
{
state.OwnerThreadId = 0;
nextWakeKey = state.Waiters.First?.Value.Cooperative == true
? state.Waiters.First.Value.WakeKey
: null;
// Hand the mutex directly to the head waiter instead of only
// waking it and relying on it to re-acquire. A woken waiter that
// fails to self-grant (its wake races or is lost) would leave the
// mutex "free with a queued waiter"; the fast-acquire path refuses
// such a mutex (OwnerThreadId == 0 && Waiters.Count == 0), so every
// later locker — including the game's main thread — then queues
// behind a head that never advances and the process wedges.
if (state.Waiters.First is { } headNode &&
TryGrantMutexWaiterLocked(state, headNode.Value) &&
headNode.Value.Cooperative)
{
nextWakeKey = headNode.Value.WakeKey;
}
Monitor.PulseAll(state);
}
}