Files
Julio Cacko 2c1f52f867 Fix(Runtime): returned EBUSY when the guest destroys a held mutex
MinGW winpthreads lets pthread_mutex_destroy succeed on a mutex that is held
or waited on. scePthreadMutexDestroy then freed the handle while its owner or
a waiter still used it, so the next unlock or wake-up touched freed memory
inside winpthreads. Each GuestMutex now counts holders and waiters (lock,
trylock and timedlock add one and undo it on failure, unlock removes one).
Destroy returns EBUSY while the count is non-zero and keeps the handle, as
FreeBSD does, and unlock with no holder returns EPERM. No God of War III crash
is tied to this; it guards against a use-after-free that has no other
protection on Windows. runtime-test covers held, recursive, timed and
cond-wait cases (--mutex-destroy-busy).

Lineage:
- GetLostIn/CTR-NitroFueled-PC@5e6c525e05 (GetLostIn, 2026-10-19): destroyed mutexes and conds never freed and marked dead (EINVAL)
- JulioCacko/The-Order-1886_pc@23f726f120 (Julio Cacko, 2026-10-18): EBUSY while held, FreeBSD semantics
- Final form: JulioCacko/The-Order-1886_pc@23f726f120. Not taken: GetLostIn/CTR-NitroFueled-PC@5e6c525e05, which leaks every handle and returns a code FreeBSD does not

Adapted for SpartanPort: the users counter sits after the native mutex in the
existing GuestMutex layout (the guest libc's 0x20 area is unchanged). The
mutex change and busy_mutex_lifetimes() are taken from the source. Its
Windows test-gate changes (CMake runtime-test target, tests/paths.py) are not,
because SpartanPort builds runtime-test only in build.sh --test.

Ported-by: Mahdi Akbari Zarkesh <[email protected]>
2026-10-26 10:59:37 +03:30

76 lines
3.2 KiB
Python

import os
import sys
from paths import ROOT
from pathlib import Path
import subprocess
import unittest
EXE = ROOT / ('out/runtime-test.exe' if sys.platform == 'win32' else 'out/runtime-test')
@unittest.skipUnless(EXE.exists(), 'run bash build.sh --test first')
class RuntimeTests(unittest.TestCase):
def run_case(self, *args, env=None):
run_env = {**os.environ, **env} if env else None
return subprocess.run([str(EXE.resolve()), *args], capture_output=True, text=True, timeout=10, env=run_env)
def test_runtime_contracts(self):
r=self.run_case()
self.assertEqual(r.returncode,0,r.stdout+r.stderr)
self.assertIn('PASS: callback lifecycle',r.stdout)
def test_recursive_guard_fails_explicitly(self):
r=self.run_case('--guard-recursion')
self.assertEqual(r.returncode,21)
self.assertIn('recursive/concurrent static initialization',r.stderr)
def test_busy_mutex_destruction_preserves_handle_and_recursive_depth(self):
r=self.run_case('--mutex-destroy-busy')
self.assertEqual(r.returncode,0,r.stdout+r.stderr)
self.assertIn('busy mutex destruction preserves',r.stdout)
def test_stack_corruption_is_not_ignored(self):
r=self.run_case('--stack-failure')
self.assertEqual(r.returncode,22)
self.assertIn('stack protector detected corruption',r.stderr)
def test_tls_bounds_are_checked(self):
r=self.run_case('--bad-tls')
self.assertEqual(r.returncode,21)
self.assertIn('unsupported TLS module/offset',r.stderr)
def test_rwlock_lifecycle_and_ownership(self):
r=self.run_case('--rwlock-lifecycle')
self.assertEqual(r.returncode,0,r.stdout+r.stderr)
def test_rwlock_multiple_readers_exclude_writer(self):
r=self.run_case('--rwlock-concurrency')
self.assertEqual(r.returncode,0,r.stdout+r.stderr)
def test_rwlock_timeouts_use_orbis_errors(self):
r=self.run_case('--rwlock-timeouts')
self.assertEqual(r.returncode,0,r.stdout+r.stderr)
def test_direct_memory_pool_size(self):
r = self.run_case('--direct-memory-size', env={'SPARTAN_DMEM_MB': '6144'})
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
self.assertIn('direct memory 6144 MiB', r.stdout)
def test_refused_pool_falls_back_to_retail_size(self):
r = self.run_case('--direct-memory-size',
env={'SPARTAN_DMEM_MB': '6144', 'SPARTAN_TEST_REFUSE_DMEM': '1'})
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
self.assertIn("falling back to the PS4's 5056 MiB", r.stdout)
self.assertIn('direct memory 5056 MiB', r.stdout)
self.assertNotIn('of guest memory', r.stdout + r.stderr) # no launcher memory hint
def test_guest_memory_failure_exits_with_error(self):
r = self.run_case('--guest-memory-fail', env={'SPARTAN_FAIL_GUEST_MEMORY': '1450'})
self.assertEqual(r.returncode, 21)
self.assertIn('cannot create', r.stderr)
self.assertIn('error 1450', r.stderr)
if sys.platform == 'win32':
self.assertIn('is the page file large enough?', r.stderr)
else:
self.assertIn('is /dev/shm or RAM large enough?', r.stderr)