From ffe4691999acc55f907310d339d22367ebfef0c6 Mon Sep 17 00:00:00 2001 From: Felipebmaitan Date: Sun, 25 Oct 2026 01:59:07 -0300 Subject: [PATCH 1/3] fix(ult): serialize ulthread publication and join ownership --- core/libs/prx/libSceUlt/CMakeLists.txt | 17 ++- core/libs/prx/libSceUlt/Export.cpp | 112 +++++++------- core/libs/prx/libSceUlt/SceUltTypes.hpp | 1 + core/libs/tests/GuestUltThread.cpp | 85 +++++++++++ core/libs/tests/UltThreadLifecycle.cpp | 192 ++++++++++++++++++++++++ docs/dev/debt/libSceUlt.md | 3 +- 6 files changed, 355 insertions(+), 55 deletions(-) create mode 100644 core/libs/tests/UltThreadLifecycle.cpp diff --git a/core/libs/prx/libSceUlt/CMakeLists.txt b/core/libs/prx/libSceUlt/CMakeLists.txt index ea7e2f4a31..e1fd7666dd 100644 --- a/core/libs/prx/libSceUlt/CMakeLists.txt +++ b/core/libs/prx/libSceUlt/CMakeLists.txt @@ -3,6 +3,19 @@ target_include_directories(libSceUlt PRIVATE ${LIBS_INCLUDE_DIR}) configure_windows_unwind(libSceUlt) if(BUILD_TESTING) + add_test_executable(guest_ult_thread_lifecycle_tests ${LIBS_INCLUDE_DIR}/tests/UltThreadLifecycle.cpp Export.cpp src/ReaderWriterLock.cpp) + target_include_directories(guest_ult_thread_lifecycle_tests PRIVATE ${LIBS_INCLUDE_DIR}) + target_link_libraries(guest_ult_thread_lifecycle_tests PRIVATE libkernel libc) + configure_windows_unwind(guest_ult_thread_lifecycle_tests) + foreach(testCase IN ITEMS create-error create-exception published-exception join-owner tryjoin-owner join-error join-exception) + add_test(NAME guest_ult_thread_lifecycle_${testCase} COMMAND guest_ult_thread_lifecycle_tests ${testCase}) + set_tests_properties(guest_ult_thread_lifecycle_${testCase} PROPERTIES TIMEOUT 10) + if(WIN32) + set_tests_properties(guest_ult_thread_lifecycle_${testCase} PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() + endforeach() + add_test_executable(guest_ult_condition_tests ${LIBS_INCLUDE_DIR}/tests/GuestUltCondition.cpp) target_include_directories(guest_ult_condition_tests PRIVATE ${LIBS_INCLUDE_DIR}) target_link_libraries(guest_ult_condition_tests PRIVATE libSceUlt libc) @@ -31,9 +44,9 @@ if(BUILD_TESTING) add_test_executable(guest_ult_thread_tests ${LIBS_INCLUDE_DIR}/tests/GuestUltThread.cpp) target_include_directories(guest_ult_thread_tests PRIVATE ${LIBS_INCLUDE_DIR}) - target_link_libraries(guest_ult_thread_tests PRIVATE libSceUlt libc) + target_link_libraries(guest_ult_thread_tests PRIVATE libSceUlt libkernel libc) configure_windows_unwind(guest_ult_thread_tests) - foreach(testCase IN ITEMS outstanding validation tryjoin destroy underscoredcreate) + foreach(testCase IN ITEMS outstanding validation tryjoin destroy underscoredcreate cancel-blocked cancel-pending self-join) add_test(NAME guest_ult_thread_${testCase} COMMAND guest_ult_thread_tests ${testCase}) set_tests_properties(guest_ult_thread_${testCase} PROPERTIES TIMEOUT 20) if(WIN32) diff --git a/core/libs/prx/libSceUlt/Export.cpp b/core/libs/prx/libSceUlt/Export.cpp index 88ac5556cc..6e9a414b6b 100644 --- a/core/libs/prx/libSceUlt/Export.cpp +++ b/core/libs/prx/libSceUlt/Export.cpp @@ -1,4 +1,5 @@ #include "prx/libkernel/Pthread/include/ThreadLifecycle.hpp" +#include "prx/libkernel/Pthread/include/Cancel.hpp" #include "prx/libkernel/include/Api.hpp" #include "prx/libSceUlt/include/Api.hpp" #include @@ -127,6 +128,51 @@ void* APS5_VABI ulthreadRunner(void* arg) { return reinterpret_cast(static_cast(result)); } +void APS5_VABI releaseUlthreadJoin(void* argument) { + auto& state = *static_cast*>(argument); + { + std::lock_guard lock(gMutex); + state->_joining = false; + } + state.reset(); +} + +int joinUlthread(void* ulthread, std::int32_t* status, bool blocking) { + if (ulthread == nullptr) return ULT_ERROR_NULL; + std::shared_ptr state; + { + std::lock_guard lock(gMutex); + const auto it = gUlthreads.find(ulthread); + if (it == gUlthreads.end()) return ULT_ERROR_STATE; + state = it->second; + if (!blocking && !state->_exited.load(std::memory_order_acquire)) return ULT_ERROR_AGAIN; + if (state->_joining || state.get() == gCurrentUlthread) return ULT_ERROR_STATE; + state->_joining = true; + } + CleanupHandler cleanup{}; + void* const mark = ThreadCancel::CleanupMark(); + __pthread_cleanup_push_imp_nid_postfix(releaseUlthreadJoin, &state, &cleanup); + void* result = nullptr; + int joined; + try { + joined = scePthreadJoin(state->_thread, &result); + } catch (...) { + ThreadCancel::CleanupRestore(mark); + releaseUlthreadJoin(&state); + throw; + } + ThreadCancel::CleanupRestore(mark); + if (joined != 0) { + releaseUlthreadJoin(&state); + return ULT_ERROR_STATE; + } + if (status != nullptr) *status = static_cast(reinterpret_cast(result)); + std::lock_guard lock(gMutex); + const auto it = gUlthreads.find(ulthread); + if (it != gUlthreads.end() && it->second == state) gUlthreads.erase(it); + return ULT_OK; +} + } bool UltWaitingPoolExists(void* pool) { @@ -804,67 +850,29 @@ int APS5_VABI sceUltUlthreadCreate(void* ulthread, const char* name, UltUlthread state->_arg = arg; state->_object = ulthread; state->_runtime = runtime; - { - std::lock_guard lock(gMutex); - if (gRuntimes.find(runtime) == gRuntimes.end()) { - return ULT_ERROR_STATE; - } - if (gUlthreads.find(ulthread) != gUlthreads.end()) { - return ULT_ERROR_STATE; - } - std::memset(ulthread, 0, 512); - gUlthreads[ulthread] = state; - } const std::string threadName = name != nullptr ? std::string(name).substr(0, 31) : std::string(); - const int result = scePthreadCreate(&state->_thread, nullptr, ulthreadRunner, state.get(), threadName.c_str()); - if (result != 0) { - std::lock_guard lock(gMutex); - gUlthreads.erase(ulthread); - return ULT_ERROR_AGAIN; + std::lock_guard lock(gMutex); + if (!gRuntimes.contains(runtime) || gUlthreads.contains(ulthread)) return ULT_ERROR_STATE; + gUlthreads.emplace(ulthread, state); + std::memset(ulthread, 0, 512); + try { + if (scePthreadCreate(&state->_thread, nullptr, ulthreadRunner, state.get(), threadName.c_str()) != 0) { + gUlthreads.erase(ulthread); + return ULT_ERROR_AGAIN; + } + } catch (...) { + if (state->_thread == nullptr) gUlthreads.erase(ulthread); + throw; } return ULT_OK; } int APS5_VABI sceUltUlthreadJoin(void* ulthread, std::int32_t* status) { - if (ulthread == nullptr) { - return ULT_ERROR_NULL; - } - std::shared_ptr state; - { - std::lock_guard lock(gMutex); - auto it = gUlthreads.find(ulthread); - if (it == gUlthreads.end()) { - return ULT_ERROR_STATE; - } - state = it->second; - } - void* result = nullptr; - if (scePthreadJoin(state->_thread, &result) != 0) { - return ULT_ERROR_STATE; - } - if (status != nullptr) { - *status = static_cast(reinterpret_cast(result)); - } - std::lock_guard lock(gMutex); - gUlthreads.erase(ulthread); - return ULT_OK; + return joinUlthread(ulthread, status, true); } int APS5_VABI sceUltUlthreadTryJoin(void* ulthread, std::int32_t* status) { - if (ulthread == nullptr) { - return ULT_ERROR_NULL; - } - { - std::lock_guard lock(gMutex); - auto it = gUlthreads.find(ulthread); - if (it == gUlthreads.end()) { - return ULT_ERROR_STATE; - } - if (!it->second->_exited.load(std::memory_order_acquire)) { - return ULT_ERROR_AGAIN; - } - } - return sceUltUlthreadJoin(ulthread, status); + return joinUlthread(ulthread, status, false); } int APS5_VABI sceUltUlthreadGetSelf(void** ulthread) { diff --git a/core/libs/prx/libSceUlt/SceUltTypes.hpp b/core/libs/prx/libSceUlt/SceUltTypes.hpp index 4c77af1649..0534b0f8cb 100644 --- a/core/libs/prx/libSceUlt/SceUltTypes.hpp +++ b/core/libs/prx/libSceUlt/SceUltTypes.hpp @@ -342,6 +342,7 @@ struct UltUlthreadState { Pthread _thread = nullptr; void* _runtime = nullptr; std::atomic _exited{false}; + bool _joining = false; }; #endif diff --git a/core/libs/tests/GuestUltThread.cpp b/core/libs/tests/GuestUltThread.cpp index 50fef67d4c..e3dda88b5e 100644 --- a/core/libs/tests/GuestUltThread.cpp +++ b/core/libs/tests/GuestUltThread.cpp @@ -1,4 +1,6 @@ #include "prx/libSceUlt/include/Api.hpp" +#include "prx/libkernel/include/Api.hpp" +#include "prx/libkernel/Pthread/include/Pthread.hpp" #include "SceTypes.hpp" #include #include @@ -8,6 +10,7 @@ #include #include #include +#include struct UltConditionVariableInfo; struct UltWaitingThreadInfo; @@ -46,6 +49,85 @@ static std::int32_t APS5_VABI Immediate(std::uint64_t arg) { struct alignas(8) Runtime { std::uint8_t bytes[4096]{}; }; struct alignas(8) Thread { std::uint8_t bytes[512]{}; }; +static std::binary_semaphore gTargetStarted{0}; +static std::binary_semaphore gTargetRelease{0}; +static std::binary_semaphore gJoinStart{0}; + +static std::int32_t APS5_VABI GatedTarget(std::uint64_t argument) { + gTargetStarted.release(); + CHECK(gTargetRelease.try_acquire_for(std::chrono::seconds(4))); + return static_cast(argument); +} + +static void* APS5_VABI JoinTarget(void* argument) { + CHECK(gJoinStart.try_acquire_for(std::chrono::seconds(4))); + std::int32_t status = -1; + CHECK(sceUltUlthreadJoin(argument, &status) == Ok && status == 91); + return nullptr; +} + +static void AwaitJoinWait(Pthread caller) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + for (;;) { + { + std::lock_guard lock(caller->cancelLock); + if (caller->cancelWait != nullptr) return; + } + CHECK(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } +} + +static void CanceledJoin(bool pending) { + Runtime runtime; + Thread thread; + CHECK(sceUltUlthreadRuntimeCreate(&runtime, "runtime", 1, 1, nullptr, nullptr, 0) == Ok); + CHECK(sceUltUlthreadCreate(&thread, "target", GatedTarget, 91, nullptr, 0, &runtime, nullptr, 0) == Ok); + CHECK(gTargetStarted.try_acquire_for(std::chrono::seconds(2))); + Pthread caller = nullptr; + CHECK(scePthreadCreate(&caller, nullptr, JoinTarget, &thread, "joiner") == 0); + if (!pending) { + gJoinStart.release(); + AwaitJoinWait(caller); + } + CHECK(scePthreadCancel(caller) == 0); + if (pending) gJoinStart.release(); + void* canceled = nullptr; + CHECK(scePthreadJoin(caller, &canceled) == 0); + CHECK(canceled == reinterpret_cast(std::uintptr_t{1})); + Pthread replacement = nullptr; + CHECK(scePthreadCreate(&replacement, nullptr, JoinTarget, &thread, "replacement") == 0); + gJoinStart.release(); + AwaitJoinWait(replacement); + CHECK(sceUltUlthreadRuntimeDestroy(&runtime) == State); + CHECK(sceUltFinalize() == Busy); + gTargetRelease.release(); + CHECK(scePthreadJoin(replacement, nullptr) == 0); + CHECK(sceUltUlthreadJoin(&thread, nullptr) == State); + CHECK(sceUltUlthreadRuntimeDestroy(&runtime) == Ok); + CHECK(sceUltFinalize() == Ok); +} + +static std::int32_t APS5_VABI SelfJoin(std::uint64_t) { + void* self = nullptr; + CHECK(sceUltUlthreadGetSelf(&self) == Ok); + std::int32_t status = -1; + CHECK(sceUltUlthreadJoin(self, &status) == State && status == -1); + CHECK(sceUltUlthreadTryJoin(self, &status) == Again && status == -1); + return 91; +} + +static void SelfJoin() { + Runtime runtime; + Thread thread; + CHECK(sceUltUlthreadRuntimeCreate(&runtime, "runtime", 1, 1, nullptr, nullptr, 0) == Ok); + CHECK(sceUltUlthreadCreate(&thread, "self", SelfJoin, 0, nullptr, 0, &runtime, nullptr, 0) == Ok); + std::int32_t status = -1; + CHECK(sceUltUlthreadJoin(&thread, &status) == Ok && status == 91); + CHECK(sceUltUlthreadRuntimeDestroy(&runtime) == Ok); + CHECK(sceUltFinalize() == Ok); +} + static void Outstanding() { Runtime runtime; Thread thread; @@ -276,5 +358,8 @@ int main(int argc, char** argv) { else if (std::strcmp(argv[1], "tryjoin") == 0) TryJoin(); else if (std::strcmp(argv[1], "destroy") == 0) Destroy(); else if (std::strcmp(argv[1], "underscoredcreate") == 0) UnderscoredCreate(); + else if (std::strcmp(argv[1], "cancel-blocked") == 0) CanceledJoin(false); + else if (std::strcmp(argv[1], "cancel-pending") == 0) CanceledJoin(true); + else if (std::strcmp(argv[1], "self-join") == 0) SelfJoin(); else CHECK(false); } diff --git a/core/libs/tests/UltThreadLifecycle.cpp b/core/libs/tests/UltThreadLifecycle.cpp new file mode 100644 index 0000000000..b54ec8325d --- /dev/null +++ b/core/libs/tests/UltThreadLifecycle.cpp @@ -0,0 +1,192 @@ +#include "prx/libSceUlt/include/Api.hpp" +#include "prx/libkernel/include/Api.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void Require(bool value, int line) { + if (value) return; + std::fprintf(stderr, "ULT lifecycle check failed at line %d\n", line); + std::abort(); +} + +#define CHECK(value) Require((value), __LINE__) + +struct alignas(8) Runtime { std::byte bytes[4096]{}; }; +struct alignas(8) Thread { std::byte bytes[512]{}; }; + +struct HostThread { + std::thread thread; + void* result = nullptr; +}; + +std::atomic entryFinished{false}; +std::atomic joinCalls{0}; +std::atomic hostThreads{0}; +std::binary_semaphore joining{0}; +std::binary_semaphore releaseJoin{0}; +bool gateJoin = false; +int createFailure = 0; +int joinFailure = 0; + +void Await(std::binary_semaphore& gate) { + CHECK(gate.try_acquire_for(std::chrono::seconds(2))); +} + +std::int32_t APS5_VABI Entry(std::uint64_t argument) { + void* self = nullptr; + CHECK(sceUltUlthreadGetSelf(&self) == ULT_OK && self != nullptr); + CHECK(sceUltUlthreadYield() == ULT_OK); + return static_cast(argument); +} + +void Create(Runtime& runtime, Thread& thread) { + CHECK(sceUltUlthreadRuntimeCreate(&runtime, "runtime", 1, 1, nullptr, nullptr, 0) == ULT_OK); + CHECK(sceUltUlthreadCreate(&thread, "thread", Entry, 73, nullptr, 0, &runtime, nullptr, 0) == ULT_OK); + entryFinished.wait(false); +} + +void Finish(Runtime& runtime, Thread& thread) { + std::int32_t status = -1; + CHECK(sceUltUlthreadJoin(&thread, &status) == ULT_OK && status == 73); + CHECK(hostThreads.load() == 0); + CHECK(sceUltUlthreadJoin(&thread, &status) == ULT_ERROR_STATE && status == 73); + CHECK(sceUltUlthreadRuntimeDestroy(&runtime) == ULT_OK); + CHECK(sceUltFinalize() == ULT_OK); +} + +void CreateFailure(int failure) { + Runtime runtime; + Thread thread; + CHECK(sceUltUlthreadRuntimeCreate(&runtime, "runtime", 1, 1, nullptr, nullptr, 0) == ULT_OK); + createFailure = failure; + bool threw = false; + int result = ULT_OK; + try { + result = sceUltUlthreadCreate(&thread, "thread", Entry, 73, nullptr, 0, &runtime, nullptr, 0); + } catch (const std::runtime_error&) { + threw = true; + } + CHECK(threw == (failure != 1)); + if (failure == 1) CHECK(result == ULT_ERROR_AGAIN); + if (failure == 3) { + CHECK(sceUltFinalize() == ULT_ERROR_BUSY); + CHECK(sceUltUlthreadRuntimeDestroy(&runtime) == ULT_ERROR_STATE); + Finish(runtime, thread); + return; + } + CHECK(hostThreads.load() == 0 && !entryFinished.load()); + CHECK(sceUltUlthreadTryJoin(&thread, nullptr) == ULT_ERROR_STATE); + CHECK(sceUltUlthreadCreate(&thread, "retry", Entry, 73, nullptr, 0, &runtime, nullptr, 0) == ULT_OK); + Finish(runtime, thread); +} + +void Ownership(bool nonblocking) { + Runtime runtime; + Thread thread; + Create(runtime, thread); + gateJoin = true; + std::int32_t firstStatus = -1; + int firstResult = ULT_ERROR_STATE; + std::thread first([&] { + firstResult = nonblocking ? sceUltUlthreadTryJoin(&thread, &firstStatus) : sceUltUlthreadJoin(&thread, &firstStatus); + }); + Await(joining); + std::int32_t secondStatus = -2; + CHECK(sceUltUlthreadJoin(&thread, &secondStatus) == ULT_ERROR_STATE && secondStatus == -2); + CHECK(sceUltUlthreadTryJoin(&thread, &secondStatus) == ULT_ERROR_STATE && secondStatus == -2); + CHECK(joinCalls.load() == 1); + CHECK(sceUltUlthreadCreate(&thread, "replacement", Entry, 74, nullptr, 0, &runtime, nullptr, 0) == ULT_ERROR_STATE); + CHECK(sceUltUlthreadRuntimeDestroy(&runtime) == ULT_ERROR_STATE); + CHECK(sceUltFinalize() == ULT_ERROR_BUSY); + releaseJoin.release(); + first.join(); + CHECK(firstResult == ULT_OK && firstStatus == 73 && hostThreads.load() == 0); + CHECK(sceUltUlthreadTryJoin(&thread, &secondStatus) == ULT_ERROR_STATE && secondStatus == -2); + gateJoin = false; + entryFinished.store(false); + CHECK(sceUltUlthreadCreate(&thread, "replacement", Entry, 73, nullptr, 0, &runtime, nullptr, 0) == ULT_OK); + Finish(runtime, thread); +} + +void JoinFailure(int failure) { + Runtime runtime; + Thread thread; + Create(runtime, thread); + joinFailure = failure; + std::int32_t status = -1; + bool threw = false; + int result = ULT_OK; + try { + result = sceUltUlthreadJoin(&thread, &status); + } catch (const std::runtime_error&) { + threw = true; + } + CHECK(threw == (failure == 2)); + if (!threw) CHECK(result == ULT_ERROR_STATE); + CHECK(status == -1 && hostThreads.load() == 1); + Finish(runtime, thread); +} + +} + +extern "C" { + +int APS5_VABI scePthreadCreate(Pthread* value, const PthreadAttr*, PthreadEntry entry, void* argument, const char*) { + const int failure = createFailure; + createFailure = 0; + if (failure == 1) return 11; + if (failure == 2) throw std::runtime_error("Injected prepublication creation failure"); + auto state = std::make_unique(); + auto* raw = state.get(); + state->thread = std::thread([=] { + raw->result = entry(argument); + entryFinished.store(true); + entryFinished.notify_all(); + }); + ++hostThreads; + *value = reinterpret_cast(state.release()); + if (failure == 3) throw std::runtime_error("Injected postpublication creation failure"); + return 0; +} + +int APS5_VABI scePthreadJoin(Pthread value, void** result) { + const int call = joinCalls.fetch_add(1); + if (gateJoin) { + if (call != 0) return 22; + joining.release(); + Await(releaseJoin); + } + const int failure = joinFailure; + joinFailure = 0; + if (failure == 1) return 22; + if (failure == 2) throw std::runtime_error("Injected precommit join failure"); + CHECK(value != nullptr); + std::unique_ptr state(reinterpret_cast(value)); + state->thread.join(); + if (result != nullptr) *result = state->result; + --hostThreads; + return 0; +} + +} + +int main(int argc, char** argv) { + CHECK(argc == 2); + if (std::strcmp(argv[1], "create-error") == 0) CreateFailure(1); + else if (std::strcmp(argv[1], "create-exception") == 0) CreateFailure(2); + else if (std::strcmp(argv[1], "published-exception") == 0) CreateFailure(3); + else if (std::strcmp(argv[1], "join-owner") == 0) Ownership(false); + else if (std::strcmp(argv[1], "tryjoin-owner") == 0) Ownership(true); + else if (std::strcmp(argv[1], "join-error") == 0) JoinFailure(1); + else if (std::strcmp(argv[1], "join-exception") == 0) JoinFailure(2); + else CHECK(false); +} diff --git a/docs/dev/debt/libSceUlt.md b/docs/dev/debt/libSceUlt.md index d539ce9772..cba64e972b 100644 --- a/docs/dev/debt/libSceUlt.md +++ b/docs/dev/debt/libSceUlt.md @@ -4,7 +4,8 @@ - [Bound condition variables](../../../core/libs/prx/libSceUlt/Export.cpp) use the creation-time mutex, inferred from PPSA28061 callers of `_sceUltConditionVariableCreate`; the wait caller reloads only the condition argument. Error precedence, busy/state errors, FIFO signal selection and unsupported recursive waits have not been measured on PS5 hardware. Non-null options and waits at recursion depth above one throw. A live condition prevents destroying its bound mutex. Finalization follows the existing host mutex lifecycle: it invalidates conditions and wakes queued or reacquiring waiters with a state error. Scheduling priority and build-version validation are not implemented. -- [libSceUlt ulthreads](../../../core/libs/prx/libSceUlt/Export.cpp) - Runtime option defaults still omit the native header, stack size, worker affinity and priority fields; affinity depends on unresolved process configuration queries. Concurrent join ownership is not modeled: the native runtime distinguishes an ordinary running thread from one with another joiner. Host threads do not use the guest context or worker scheduler. +- [libSceUlt ulthreads](../../../core/libs/prx/libSceUlt/Export.cpp) - Runtime option defaults still omit the native header, stack size, worker affinity and priority fields; affinity depends on unresolved process configuration queries. Host threads do not use the guest context or worker scheduler. Native concurrent-join error values and precedence remain unverified; the host retains running TryJoin `AGAIN` and competing committed-join `STATE` behavior. Guest TLS, cancellation state and cleanup handlers remain host-thread-affine; context migration has no established contract. +- [Ulthread completion](../../../core/libs/prx/libSceUlt/Export.cpp) is published before pthread destructors and native teardown finish, so TryJoin can still block in kernel join. Cancellation cleanup releases a pending join claim, but exceptions after kernel join commits cannot restore native joinability. Creation exceptions before handle publication remove the registration; after publication they retain it because a runner may already use its state. These are host lifecycle guarantees, not console measurements. - [libSceUlt queues](../../../core/libs/prx/libSceUlt/Export.cpp) - Queue-data pool destruction and shared slot limits still require comparison with the native node allocator. - [`sceUltConditionVariableCreate` and `sceUltConditionVariableOptParamInitialize`](../../../core/libs/prx/libSceUlt/Export.cpp) (libSceUlt) are absent from the console export list, where only the underscored variants `_sceUltConditionVariableCreate` and `_sceUltConditionVariableOptParamInitialize` appear; their non-underscored signatures are assumed. Condition variable option parameter initialization only zeroes 128 bytes, and its layout without the common header remains unverified on hardware. - [Ult factory and creation exports](../../../core/libs/prx/libSceUlt/Export.cpp) without a leading underscore are absent from the supplied module; their signatures remain unresolved. Diagnostic signatures and synchronization argument lists are recovered. From 2388d8a11a689941e4fb4ac6c23a906a6dd8148c Mon Sep 17 00:00:00 2001 From: Felipebmaitan Date: Sun, 25 Oct 2026 02:17:46 -0300 Subject: [PATCH 2/3] fix(ult): roll back cleanup setup and exercise registry races --- core/libs/prx/libSceUlt/CMakeLists.txt | 11 +- core/libs/prx/libSceUlt/Export.cpp | 11 +- core/libs/tests/UltThreadLifecycle.cpp | 155 +++++++++++++++++++++++++ docs/dev/debt/libSceUlt.md | 2 +- 4 files changed, 172 insertions(+), 7 deletions(-) diff --git a/core/libs/prx/libSceUlt/CMakeLists.txt b/core/libs/prx/libSceUlt/CMakeLists.txt index e1fd7666dd..ba901396bd 100644 --- a/core/libs/prx/libSceUlt/CMakeLists.txt +++ b/core/libs/prx/libSceUlt/CMakeLists.txt @@ -3,11 +3,18 @@ target_include_directories(libSceUlt PRIVATE ${LIBS_INCLUDE_DIR}) configure_windows_unwind(libSceUlt) if(BUILD_TESTING) - add_test_executable(guest_ult_thread_lifecycle_tests ${LIBS_INCLUDE_DIR}/tests/UltThreadLifecycle.cpp Export.cpp src/ReaderWriterLock.cpp) + add_test_executable(guest_ult_thread_lifecycle_tests ${LIBS_INCLUDE_DIR}/tests/UltThreadLifecycle.cpp src/ReaderWriterLock.cpp) target_include_directories(guest_ult_thread_lifecycle_tests PRIVATE ${LIBS_INCLUDE_DIR}) target_link_libraries(guest_ult_thread_lifecycle_tests PRIVATE libkernel libc) configure_windows_unwind(guest_ult_thread_lifecycle_tests) - foreach(testCase IN ITEMS create-error create-exception published-exception join-owner tryjoin-owner join-error join-exception) + set(ultLifecycleCases create-error create-exception published-exception join-owner tryjoin-owner join-error join-exception) + if(NOT APPLE AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_definitions(guest_ult_thread_lifecycle_tests PRIVATE ULT_LIFECYCLE_INTERPOSE=1) + target_link_options(guest_ult_thread_lifecycle_tests PRIVATE + LINKER:--wrap=pthread_mutex_lock LINKER:--wrap=pthread_mutex_unlock LINKER:--wrap=_ZN12ThreadCancel11CleanupMarkEv) + list(APPEND ultLifecycleCases publication generation cleanup-setup) + endif() + foreach(testCase IN LISTS ultLifecycleCases) add_test(NAME guest_ult_thread_lifecycle_${testCase} COMMAND guest_ult_thread_lifecycle_tests ${testCase}) set_tests_properties(guest_ult_thread_lifecycle_${testCase} PROPERTIES TIMEOUT 10) if(WIN32) diff --git a/core/libs/prx/libSceUlt/Export.cpp b/core/libs/prx/libSceUlt/Export.cpp index 6e9a414b6b..65edfb4e65 100644 --- a/core/libs/prx/libSceUlt/Export.cpp +++ b/core/libs/prx/libSceUlt/Export.cpp @@ -150,14 +150,17 @@ int joinUlthread(void* ulthread, std::int32_t* status, bool blocking) { state->_joining = true; } CleanupHandler cleanup{}; - void* const mark = ThreadCancel::CleanupMark(); - __pthread_cleanup_push_imp_nid_postfix(releaseUlthreadJoin, &state, &cleanup); + void* mark = nullptr; + bool cleanupRegistered = false; void* result = nullptr; int joined; try { + mark = ThreadCancel::CleanupMark(); + __pthread_cleanup_push_imp_nid_postfix(releaseUlthreadJoin, &state, &cleanup); + cleanupRegistered = true; joined = scePthreadJoin(state->_thread, &result); } catch (...) { - ThreadCancel::CleanupRestore(mark); + if (cleanupRegistered) ThreadCancel::CleanupRestore(mark); releaseUlthreadJoin(&state); throw; } @@ -850,9 +853,9 @@ int APS5_VABI sceUltUlthreadCreate(void* ulthread, const char* name, UltUlthread state->_arg = arg; state->_object = ulthread; state->_runtime = runtime; - const std::string threadName = name != nullptr ? std::string(name).substr(0, 31) : std::string(); std::lock_guard lock(gMutex); if (!gRuntimes.contains(runtime) || gUlthreads.contains(ulthread)) return ULT_ERROR_STATE; + const std::string threadName = name != nullptr ? std::string(name).substr(0, 31) : std::string(); gUlthreads.emplace(ulthread, state); std::memset(ulthread, 0, 512); try { diff --git a/core/libs/tests/UltThreadLifecycle.cpp b/core/libs/tests/UltThreadLifecycle.cpp index b54ec8325d..ebc654adcb 100644 --- a/core/libs/tests/UltThreadLifecycle.cpp +++ b/core/libs/tests/UltThreadLifecycle.cpp @@ -9,6 +9,7 @@ #include #include #include +#include "../prx/libSceUlt/Export.cpp" namespace { @@ -26,6 +27,7 @@ struct alignas(8) Thread { std::byte bytes[512]{}; }; struct HostThread { std::thread thread; void* result = nullptr; + std::uint64_t generation = 0; }; std::atomic entryFinished{false}; @@ -37,6 +39,22 @@ bool gateJoin = false; int createFailure = 0; int joinFailure = 0; +#ifdef ULT_LIFECYCLE_INTERPOSE +thread_local int actor = 0; +std::atomic pauseSelection{false}; +std::atomic failCleanupSetup{false}; +std::atomic originalGeneration{0}; +std::binary_semaphore selected{0}; +std::binary_semaphore releaseSelection{0}; +std::binary_semaphore creating{0}; +std::binary_semaphore releaseCreate{0}; +std::binary_semaphore registryAttempt{0}; +std::binary_semaphore releaseRegistryAttempt{0}; +std::binary_semaphore replacementStarted{0}; +std::binary_semaphore releaseReplacement{0}; +bool gateCreate = false; +#endif + void Await(std::binary_semaphore& gate) { CHECK(gate.try_acquire_for(std::chrono::seconds(2))); } @@ -136,16 +154,140 @@ void JoinFailure(int failure) { Finish(runtime, thread); } +#ifdef ULT_LIFECYCLE_INTERPOSE +void Publication() { + Runtime runtime; + Thread thread; + CHECK(sceUltUlthreadRuntimeCreate(&runtime, "runtime", 1, 1, nullptr, nullptr, 0) == ULT_OK); + gateCreate = true; + std::thread creator([&] { + CHECK(sceUltUlthreadCreate(&thread, "thread", Entry, 73, nullptr, 0, &runtime, nullptr, 0) == ULT_OK); + }); + Await(creating); + std::int32_t status = -1; + std::thread joiner([&] { + actor = 2; + CHECK(sceUltUlthreadJoin(&thread, &status) == ULT_OK); + }); + Await(registryAttempt); + const bool registryAvailable = gMutex.try_lock(); + if (registryAvailable) gMutex.unlock(); + CHECK(!registryAvailable && joinCalls.load() == 0); + releaseRegistryAttempt.release(); + releaseCreate.release(); + creator.join(); + joiner.join(); + CHECK(status == 73 && hostThreads.load() == 0); + CHECK(sceUltUlthreadRuntimeDestroy(&runtime) == ULT_OK); + CHECK(sceUltFinalize() == ULT_OK); +} + +std::int32_t APS5_VABI Replacement(std::uint64_t argument) { + replacementStarted.release(); + Await(releaseReplacement); + return static_cast(argument); +} + +void Generation() { + Runtime runtime; + Thread thread; + Create(runtime, thread); + pauseSelection.store(true); + std::int32_t originalStatus = -1; + int originalResult = ULT_ERROR_STATE; + std::thread original([&] { + actor = 1; + originalResult = sceUltUlthreadTryJoin(&thread, &originalStatus); + }); + Await(selected); + std::int32_t contenderStatus = -2; + const int contender = sceUltUlthreadJoin(&thread, &contenderStatus); + if (contender == ULT_OK) { + CHECK(contenderStatus == 73); + CHECK(sceUltUlthreadCreate(&thread, "replacement", Replacement, 92, nullptr, 0, &runtime, nullptr, 0) == ULT_OK); + Await(replacementStarted); + } else { + CHECK(contender == ULT_ERROR_STATE && contenderStatus == -2); + CHECK(sceUltUlthreadCreate(&thread, "replacement", Replacement, 92, nullptr, 0, &runtime, nullptr, 0) == ULT_ERROR_STATE); + } + releaseSelection.release(); + original.join(); + CHECK(originalGeneration.load() == 73 && originalResult == ULT_OK && originalStatus == 73); + CHECK(contender == ULT_ERROR_STATE && hostThreads.load() == 0); + CHECK(sceUltUlthreadCreate(&thread, "replacement", Replacement, 92, nullptr, 0, &runtime, nullptr, 0) == ULT_OK); + Await(replacementStarted); + CHECK(sceUltUlthreadTryJoin(&thread, &contenderStatus) == ULT_ERROR_AGAIN && contenderStatus == -2); + releaseReplacement.release(); + CHECK(sceUltUlthreadJoin(&thread, &contenderStatus) == ULT_OK && contenderStatus == 92); + CHECK(sceUltUlthreadRuntimeDestroy(&runtime) == ULT_OK); + CHECK(sceUltFinalize() == ULT_OK); +} + +void CleanupSetup() { + Runtime runtime; + Thread thread; + Create(runtime, thread); + failCleanupSetup.store(true); + std::int32_t status = -1; + bool threw = false; + try { + sceUltUlthreadJoin(&thread, &status); + } catch (const std::runtime_error&) { + threw = true; + } + CHECK(threw && status == -1 && joinCalls.load() == 0); + std::thread replacement([&] { Finish(runtime, thread); }); + replacement.join(); +} +#endif + } extern "C" { +#ifdef ULT_LIFECYCLE_INTERPOSE +int __real_pthread_mutex_lock(pthread_mutex_t* mutex); +int __real_pthread_mutex_unlock(pthread_mutex_t* mutex); +void* __real__ZN12ThreadCancel11CleanupMarkEv(); + +int __wrap_pthread_mutex_lock(pthread_mutex_t* mutex) { + if (actor == 2 && mutex == gMutex.native_handle()) { + actor = 0; + registryAttempt.release(); + Await(releaseRegistryAttempt); + } + return __real_pthread_mutex_lock(mutex); +} + +int __wrap_pthread_mutex_unlock(pthread_mutex_t* mutex) { + const int result = __real_pthread_mutex_unlock(mutex); + if (actor == 1 && mutex == gMutex.native_handle() && pauseSelection.exchange(false)) { + selected.release(); + Await(releaseSelection); + } + return result; +} + +void* __wrap__ZN12ThreadCancel11CleanupMarkEv() { + if (failCleanupSetup.exchange(false)) throw std::runtime_error("Injected cleanup setup failure"); + return __real__ZN12ThreadCancel11CleanupMarkEv(); +} +#endif + int APS5_VABI scePthreadCreate(Pthread* value, const PthreadAttr*, PthreadEntry entry, void* argument, const char*) { const int failure = createFailure; createFailure = 0; if (failure == 1) return 11; if (failure == 2) throw std::runtime_error("Injected prepublication creation failure"); +#ifdef ULT_LIFECYCLE_INTERPOSE + if (gateCreate) { + gateCreate = false; + creating.release(); + Await(releaseCreate); + } +#endif auto state = std::make_unique(); + state->generation = static_cast(argument)->_arg; auto* raw = state.get(); state->thread = std::thread([=] { raw->result = entry(argument); @@ -160,6 +302,14 @@ int APS5_VABI scePthreadCreate(Pthread* value, const PthreadAttr*, PthreadEntry int APS5_VABI scePthreadJoin(Pthread value, void** result) { const int call = joinCalls.fetch_add(1); +#ifdef ULT_LIFECYCLE_INTERPOSE + if (actor == 1) { + CHECK(value != nullptr); + const auto generation = reinterpret_cast(value)->generation; + originalGeneration.store(generation); + if (generation != 73) return 22; + } +#endif if (gateJoin) { if (call != 0) return 22; joining.release(); @@ -188,5 +338,10 @@ int main(int argc, char** argv) { else if (std::strcmp(argv[1], "tryjoin-owner") == 0) Ownership(true); else if (std::strcmp(argv[1], "join-error") == 0) JoinFailure(1); else if (std::strcmp(argv[1], "join-exception") == 0) JoinFailure(2); +#ifdef ULT_LIFECYCLE_INTERPOSE + else if (std::strcmp(argv[1], "publication") == 0) Publication(); + else if (std::strcmp(argv[1], "generation") == 0) Generation(); + else if (std::strcmp(argv[1], "cleanup-setup") == 0) CleanupSetup(); +#endif else CHECK(false); } diff --git a/docs/dev/debt/libSceUlt.md b/docs/dev/debt/libSceUlt.md index cba64e972b..3fe232a93c 100644 --- a/docs/dev/debt/libSceUlt.md +++ b/docs/dev/debt/libSceUlt.md @@ -5,7 +5,7 @@ - [Bound condition variables](../../../core/libs/prx/libSceUlt/Export.cpp) use the creation-time mutex, inferred from PPSA28061 callers of `_sceUltConditionVariableCreate`; the wait caller reloads only the condition argument. Error precedence, busy/state errors, FIFO signal selection and unsupported recursive waits have not been measured on PS5 hardware. Non-null options and waits at recursion depth above one throw. A live condition prevents destroying its bound mutex. Finalization follows the existing host mutex lifecycle: it invalidates conditions and wakes queued or reacquiring waiters with a state error. Scheduling priority and build-version validation are not implemented. - [libSceUlt ulthreads](../../../core/libs/prx/libSceUlt/Export.cpp) - Runtime option defaults still omit the native header, stack size, worker affinity and priority fields; affinity depends on unresolved process configuration queries. Host threads do not use the guest context or worker scheduler. Native concurrent-join error values and precedence remain unverified; the host retains running TryJoin `AGAIN` and competing committed-join `STATE` behavior. Guest TLS, cancellation state and cleanup handlers remain host-thread-affine; context migration has no established contract. -- [Ulthread completion](../../../core/libs/prx/libSceUlt/Export.cpp) is published before pthread destructors and native teardown finish, so TryJoin can still block in kernel join. Cancellation cleanup releases a pending join claim, but exceptions after kernel join commits cannot restore native joinability. Creation exceptions before handle publication remove the registration; after publication they retain it because a runner may already use its state. These are host lifecycle guarantees, not console measurements. +- [Ulthread completion](../../../core/libs/prx/libSceUlt/Export.cpp) is published before pthread destructors and native teardown finish, so TryJoin can still block in kernel join. ULT releases its join claim on cancellation or exception, but kernel wait allocation can leave native join ownership and wait registration stranded before commitment; exceptions after commitment cannot restore native joinability either. Mocked precommit failures test only ULT rollback. Creation exceptions before handle publication remove the registration; after publication they retain it because a runner may already use its state. These are host lifecycle guarantees, not console measurements. - [libSceUlt queues](../../../core/libs/prx/libSceUlt/Export.cpp) - Queue-data pool destruction and shared slot limits still require comparison with the native node allocator. - [`sceUltConditionVariableCreate` and `sceUltConditionVariableOptParamInitialize`](../../../core/libs/prx/libSceUlt/Export.cpp) (libSceUlt) are absent from the console export list, where only the underscored variants `_sceUltConditionVariableCreate` and `_sceUltConditionVariableOptParamInitialize` appear; their non-underscored signatures are assumed. Condition variable option parameter initialization only zeroes 128 bytes, and its layout without the common header remains unverified on hardware. - [Ult factory and creation exports](../../../core/libs/prx/libSceUlt/Export.cpp) without a leading underscore are absent from the supplied module; their signatures remain unresolved. Diagnostic signatures and synchronization argument lists are recovered. From 7385907173648e9f6c9f12d72decc8a718ab5ce4 Mon Sep 17 00:00:00 2001 From: Felipebmaitan Date: Sun, 25 Oct 2026 02:37:23 -0300 Subject: [PATCH 3/3] fix(ult): use patchable cleanup and retain consumed handler state --- core/libs/prx/libSceUlt/CMakeLists.txt | 4 ++-- core/libs/prx/libSceUlt/Export.cpp | 11 +++++----- core/libs/tests/UltThreadLifecycle.cpp | 28 +++++++++++++++++++++----- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/core/libs/prx/libSceUlt/CMakeLists.txt b/core/libs/prx/libSceUlt/CMakeLists.txt index ba901396bd..4718f1657d 100644 --- a/core/libs/prx/libSceUlt/CMakeLists.txt +++ b/core/libs/prx/libSceUlt/CMakeLists.txt @@ -7,11 +7,11 @@ if(BUILD_TESTING) target_include_directories(guest_ult_thread_lifecycle_tests PRIVATE ${LIBS_INCLUDE_DIR}) target_link_libraries(guest_ult_thread_lifecycle_tests PRIVATE libkernel libc) configure_windows_unwind(guest_ult_thread_lifecycle_tests) - set(ultLifecycleCases create-error create-exception published-exception join-owner tryjoin-owner join-error join-exception) + set(ultLifecycleCases create-error create-exception published-exception join-owner tryjoin-owner join-error join-exception join-cleanup-exception) if(NOT APPLE AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") target_compile_definitions(guest_ult_thread_lifecycle_tests PRIVATE ULT_LIFECYCLE_INTERPOSE=1) target_link_options(guest_ult_thread_lifecycle_tests PRIVATE - LINKER:--wrap=pthread_mutex_lock LINKER:--wrap=pthread_mutex_unlock LINKER:--wrap=_ZN12ThreadCancel11CleanupMarkEv) + LINKER:--wrap=pthread_mutex_lock LINKER:--wrap=pthread_mutex_unlock LINKER:--wrap=__pthread_cleanup_push_imp_nid_postfix) list(APPEND ultLifecycleCases publication generation cleanup-setup) endif() foreach(testCase IN LISTS ultLifecycleCases) diff --git a/core/libs/prx/libSceUlt/Export.cpp b/core/libs/prx/libSceUlt/Export.cpp index 65edfb4e65..77431e20d6 100644 --- a/core/libs/prx/libSceUlt/Export.cpp +++ b/core/libs/prx/libSceUlt/Export.cpp @@ -130,6 +130,7 @@ void* APS5_VABI ulthreadRunner(void* arg) { void APS5_VABI releaseUlthreadJoin(void* argument) { auto& state = *static_cast*>(argument); + if (!state) return; { std::lock_guard lock(gMutex); state->_joining = false; @@ -150,21 +151,21 @@ int joinUlthread(void* ulthread, std::int32_t* status, bool blocking) { state->_joining = true; } CleanupHandler cleanup{}; - void* mark = nullptr; bool cleanupRegistered = false; void* result = nullptr; int joined; try { - mark = ThreadCancel::CleanupMark(); __pthread_cleanup_push_imp_nid_postfix(releaseUlthreadJoin, &state, &cleanup); cleanupRegistered = true; joined = scePthreadJoin(state->_thread, &result); } catch (...) { - if (cleanupRegistered) ThreadCancel::CleanupRestore(mark); - releaseUlthreadJoin(&state); + if (state) { + if (cleanupRegistered) __pthread_cleanup_pop_imp_nid_postfix(1); + else releaseUlthreadJoin(&state); + } throw; } - ThreadCancel::CleanupRestore(mark); + __pthread_cleanup_pop_imp_nid_postfix(0); if (joined != 0) { releaseUlthreadJoin(&state); return ULT_ERROR_STATE; diff --git a/core/libs/tests/UltThreadLifecycle.cpp b/core/libs/tests/UltThreadLifecycle.cpp index ebc654adcb..c111d2ce48 100644 --- a/core/libs/tests/UltThreadLifecycle.cpp +++ b/core/libs/tests/UltThreadLifecycle.cpp @@ -1,5 +1,6 @@ #include "prx/libSceUlt/include/Api.hpp" #include "prx/libkernel/include/Api.hpp" +#include "prx/libkernel/Pthread/include/Cancel.hpp" #include #include #include @@ -38,6 +39,11 @@ std::binary_semaphore releaseJoin{0}; bool gateJoin = false; int createFailure = 0; int joinFailure = 0; +int sentinelExecutions = 0; + +void APS5_VABI Sentinel(void*) { + ++sentinelExecutions; +} #ifdef ULT_LIFECYCLE_INTERPOSE thread_local int actor = 0; @@ -139,6 +145,8 @@ void JoinFailure(int failure) { Runtime runtime; Thread thread; Create(runtime, thread); + void* const before = ThreadCancel::CleanupMark(); + if (failure == 3) pthread_cleanup_push_nid_postfix(Sentinel, nullptr); joinFailure = failure; std::int32_t status = -1; bool threw = false; @@ -148,10 +156,14 @@ void JoinFailure(int failure) { } catch (const std::runtime_error&) { threw = true; } - CHECK(threw == (failure == 2)); + CHECK(threw == (failure >= 2)); if (!threw) CHECK(result == ULT_ERROR_STATE); CHECK(status == -1 && hostThreads.load() == 1); - Finish(runtime, thread); + if (failure == 3) { + CHECK(sentinelExecutions == 1 && ThreadCancel::CleanupMark() == before); + std::thread replacement([&] { Finish(runtime, thread); }); + replacement.join(); + } else Finish(runtime, thread); } #ifdef ULT_LIFECYCLE_INTERPOSE @@ -248,7 +260,7 @@ extern "C" { #ifdef ULT_LIFECYCLE_INTERPOSE int __real_pthread_mutex_lock(pthread_mutex_t* mutex); int __real_pthread_mutex_unlock(pthread_mutex_t* mutex); -void* __real__ZN12ThreadCancel11CleanupMarkEv(); +void APS5_VABI __real___pthread_cleanup_push_imp_nid_postfix(void (APS5_VABI* routine)(void*), void* argument, void* info); int __wrap_pthread_mutex_lock(pthread_mutex_t* mutex) { if (actor == 2 && mutex == gMutex.native_handle()) { @@ -268,9 +280,9 @@ int __wrap_pthread_mutex_unlock(pthread_mutex_t* mutex) { return result; } -void* __wrap__ZN12ThreadCancel11CleanupMarkEv() { +void APS5_VABI __wrap___pthread_cleanup_push_imp_nid_postfix(void (APS5_VABI* routine)(void*), void* argument, void* info) { if (failCleanupSetup.exchange(false)) throw std::runtime_error("Injected cleanup setup failure"); - return __real__ZN12ThreadCancel11CleanupMarkEv(); + __real___pthread_cleanup_push_imp_nid_postfix(routine, argument, info); } #endif @@ -319,6 +331,11 @@ int APS5_VABI scePthreadJoin(Pthread value, void** result) { joinFailure = 0; if (failure == 1) return 22; if (failure == 2) throw std::runtime_error("Injected precommit join failure"); + if (failure == 3) { + __pthread_cleanup_pop_imp_nid_postfix(1); + __pthread_cleanup_pop_imp_nid_postfix(1); + throw std::runtime_error("Injected failure after cancellation cleanup"); + } CHECK(value != nullptr); std::unique_ptr state(reinterpret_cast(value)); state->thread.join(); @@ -338,6 +355,7 @@ int main(int argc, char** argv) { else if (std::strcmp(argv[1], "tryjoin-owner") == 0) Ownership(true); else if (std::strcmp(argv[1], "join-error") == 0) JoinFailure(1); else if (std::strcmp(argv[1], "join-exception") == 0) JoinFailure(2); + else if (std::strcmp(argv[1], "join-cleanup-exception") == 0) JoinFailure(3); #ifdef ULT_LIFECYCLE_INTERPOSE else if (std::strcmp(argv[1], "publication") == 0) Publication(); else if (std::strcmp(argv[1], "generation") == 0) Generation();