Skip to content

fix(libkernel): answer a second scePthreadJoin with ESRCH - #4481

Merged
1 commit merged into
boykopovar:mainfrom
Roojool:contrib/g04-pthread-join
Oct 17, 2026
Merged

1 commit merged into
boykopovar:mainfrom
Roojool:contrib/g04-pthread-join

Conversation

@Roojool

@Roojool Roojool commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

What

A second scePthreadJoin on the same thread now returns ESRCH (0x80020003) instead of reading freed memory.

On a PS5 (firmware 13.60) scePthreadJoin answers ESRCH for a thread that was already joined — measured by #4160's author and recorded in its TechnicalDebt entry: "here a joined thread is released, so a second join reads freed memory". #4160 deliberately left that follow-up: keeping the thread for the second answer needs the thread lifetime to change. This is that change:

  • A joined thread stays in a JOINED state instead of being released at join; exactly one joiner wins (atomic exchange) and the others get ESRCH.
  • The retained object is recycled when scePthreadCreate publishes over the same guest handle storage, so a create/join loop over one storage holds one object; libSceJobManager and libSceUlt join patterns are covered.
  • scePthreadDetach on a joined thread answers EINVAL, as FreeBSD does on a thread that is not joinable; the console answer for a detach after a join is unverified. It is required so the retained object is not released twice. Recorded in TechnicalDebt.
  • The posix layer is unchanged: PosixThread::ToErrno already maps 0x80020003 to 3, FreeBSD's ESRCH for a dead pthread_t; the test pins it.
  • Not changed: join-self EDEADLK, join-after-detach/detach-twice EINVAL, the zeroed attribute — all already correct since fix(libkernel): join the calling thread and create from an unset attribute as the console does #4160.

Tested

  • New guest_pthread_second_join: create -> join 0 (retval read) -> second join 0x80020003 with *retval untouched -> detach 0x80020016 -> posix pthread_join 3 -> 64 create/join/second-join rounds over the same storage (recycle path) -> join-after-detach and detach-twice EINVAL.
  • Unmodified main (9aa5fd2): the test fails 3/3 on Windows (abort 0xc0000409, freed nativeHandle) and 2/2 on WSL Linux (SIGSEGV) — the use-after-free.
  • Independent validation in the review of this PR: the control (the parent revision's Thread.cpp with this PR's test) crashes 4/4 runs — SIGSEGV, exit 139, backtrace into scePthreadJoin — and the head passes 5/5; the 42 neighbouring thread tests pass at the head.
  • Fixed branch: Windows (WinLibs GCC 15.2.0, Release) new test 3/3 and 51/51 thread-related neighbors (guest_pthread_join_self, pthread/thread/cond/sem/ult/ampr/libc/signals/cpumask suites); WSL2 Linux (g++ 15.2.0, Release, -j4) 17/17 including the new test.
  • python tools/check_conventions.py --base 9aa5fd290 passes.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw; silent stubs are listed in TechnicalDebt
  • No notes, investigation .md files or images added to the repository
  • No new third-party code
  • Generic behaviour, not specific to one title
  • Depends on: none (follow-up to merged fix(libkernel): join the calling thread and create from an unset attribute as the console does #4160)
  • AI-assisted: yes

Console semantics are #4160's author's measurements on PS5 firmware 13.60; no console was run for this change. Residuals (a guest that joins into a fresh storage location every time retains those objects; joining a detached handle after it ended; a handle value copied to a second storage and joined there after a recycle) are recorded in TechnicalDebt.

Copilot AI balanced review requested due to automatic review settings October 12, 2026 13:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Dextroid17 Dextroid17 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The control crashes, and the backtrace localises it — a second join dereferences the released object rather than merely failing. Head dc30882e1, parent 33ae282e4, in a worktree:

head      guest_pthread_second_join → 1/1 passed (ctest exit 0); direct binary 5/5 runs exit 0
control   parent's Pthread/src/Thread.cpp, this PR's test kept → SIGSEGV 4/4 runs, exit 139 (core dumped)
          gdb: #0 libc · #1 scePthreadJoin () from libs/unpatched/libkernel.prx · #2 main

That turns the debt entry from a description into a measured crash, and it is the strongest single line available to the body. Isolation check: the neighbouring thread targets are clean at the head — ctest -R 'guest_(pthread|thread|cond|sem|semaphore|posix_sem|libc_threads|ult|ampr_threads)' is 100% tests passed out of 42.

Smaller points:

  • The console record predates this: docs/dev/TechnicalDebt.md:487 comes from #4160 (0b7e4ddf9) and says a second join returns ESRCH (0x80020003) on a PS5 (firmware 13.60). The clause this PR replaces — "here a joined thread is released, so a second join reads freed memory" — is the parent revision's line 487, so the debt change reads best as a rewrite of that one sentence, which is what it does.
  • On the EINVAL: the accurate version is the hedge the debt line already uses. FreeBSD's pthread_detach returns it for a thread that is not joinable — THR_FLAGS_DETACHED set or pthread->joiner != NULL (lib/libthr/thread/thr_detach.c:55-59) — and a completed pthread_join itself sets THR_FLAGS_DETACHED and collects the thread (thr_join.c:141-143), while pthread->joiner covers only an in-flight joiner (a concurrent second joiner gets ENOTSUP, thr_join.c:96-98). Reading the citation as "FreeBSD's answer for a joined thread" puts the weight on the clause that does not apply to a finished join.
  • "Exactly one joiner wins" reads as designed: _joined is a std::atomic<bool>, probed with load(acquire) at Thread.cpp:653 and taken with exchange(true, acq_rel) at :660, both loser paths returning SCE_KERNEL_ERROR_ESRCH before the retval store at :664/:668 — and the test pins that (GuestPthreadSecondJoin.cpp:41-42, again at :45 and :62).
  • On the retained object: I read joinedThreads as keyed by the address of the guest handle slot (_handleStorage, with using Pthread = PthreadPrivate*), released only by RecycleJoinedThread called from scePthreadCreate — which matches what you and the debt line describe, so the open item is the third residual rather than the first. The body says all three residuals are recorded in TechnicalDebt, but only two are: on this head, grep -in "second storage\|handle value copied" docs/dev/TechnicalDebt.md finds nothing for "a handle value copied to a second storage and joined there after a recycle". It has a consequence worth recording: joining a copied handle retains the object under the original storage key (Thread.cpp:423), so a later scePthreadCreate over that original storage calls ReleaseThread on an object the copy still points at — the same freed-memory read this PR removes, reachable through the copy. Either a debt line or a Require in GuestPthreadSecondJoin.cpp would close it.

Nit: core/libs/tests/GuestPthreadSecondJoin.cpp has no trailing newline (\ No newline at end of file); tools/check_conventions.py does not flag it, but it stands out in a new file.

Written with AI assistance.

Hermes Agent 🤖 at your service

Copilot AI balanced review requested due to automatic review settings October 12, 2026 16:32
@Roojool
Roojool force-pushed the contrib/g04-pthread-join branch from dc30882 to 57105eb Compare October 12, 2026 16:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Roojool

Roojool commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the control run — quoting the measured crash into the PR body now.

Follow-ups pushed (head 57105ebfb):

  • The copied-handle residual is recorded: the debt entry now states that a handle value copied to a second storage and joined there retains the object under the original storage key, so a later scePthreadCreate over that original storage releases an object the copy still points at, and the copy reads freed memory.
  • GuestPthreadSecondJoin.cpp ends with a newline.
  • The EINVAL wording in the PR body follows the debt line's hedge: FreeBSD's pthread_detach answers EINVAL for a thread that is not joinable (THR_FLAGS_DETACHED set or a joiner in flight); the console answer for a detach after a join is unverified.

AI-assisted: yes.

@Dextroid17

Copy link
Copy Markdown
Contributor

All three follow-ups are in the head. Read at 57105ebfb — read, not rebuilt here.

  • The third residual is recorded, with its consequence. docs/dev/TechnicalDebt.md:487 now ends: "a later scePthreadCreate over that original storage releases an object the copy still points at, and the copy reads freed memory". That is the clause whose absence I found on the previous head, and it is the one that closes the residual rather than describing it.
  • The EINVAL wording is the hedged form the debt line already used: "as FreeBSD does on a thread that is not joinable; the console answer for a detach after a join is unverified."
  • core/libs/tests/GuestPthreadSecondJoin.cpp ends with a newline now.

Nothing else from me. The measured crash is the line I would keep at the top of the body — it is reproduceable from the parent's Thread.cpp plus this test alone, which is what makes the debt entry evidence rather than description.

Written with AI assistance.

@github-actions github-actions Bot added needs rebase Conflicts with main, rebase needed labels Oct 12, 2026
@oneandonlydean oneandonlydean self-assigned this Oct 12, 2026
Copilot AI balanced review requested due to automatic review settings October 12, 2026 19:13
@Roojool
Roojool force-pushed the contrib/g04-pthread-join branch from 57105eb to a2dfbfd Compare October 12, 2026 19:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 12, 2026
@Ondra97

Ondra97 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Tested change: 20e5499ac. Full Release Windows build (MinGW GCC 15.2) exits 0 and 11/11 thread/TLS tests pass, including repeated native/adopted cancellation, event cleanup and handle-count checks. No game or console run.

I have a tested Windows native-join cancellation fix on main c952c2b. It waits on the joinee's native handle and the joiner's own persistent cancellation event, preserving the return-value output and allowing the joinee to be joined later. The regression holds a native FLS destructor after guest _finished, requests cancellation only once the native wait is reached, and passes with the fix; the original code completes normally and overwrites the output.

This overlaps the _joined.exchange(true) before the native wait in this PR: cancellation in that phase would leave the joinee permanently claimed and a retry would return ESRCH. The lifecycle needs a temporary join claim that rolls back on cancellation, or the final claim after the cancellable wait with appropriate concurrency handling. #4533 inherits this lifecycle.

Could we agree to land the cancellation correction first, then preserve its cancellation/retry regression while adapting this lifecycle work? Alternatively, please propose a sequence that keeps the canceled joinee joinable. I am holding publication until the order is agreed, following CONTRIBUTING's overlap rule.

AI-assisted: yes.

@Roojool

Roojool commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for checking the interaction. I read your 20e5499 change and agree that our _joined claim before the native wait needs to account for cancellation. Let's land your cancellation correction first. Once your PR is published, I will declare it as a dependency of #4481 and adapt the join lifecycle while preserving your canceled-join/retry regression. #4533 will then be restacked on the updated #4481. I have not tested the combined changes yet; the combined lifecycle will need Windows and Linux tests and an independent review before pushing.

AI-assisted: yes.

@Ondra97

Ondra97 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Thanks for confirming the order. We are still waiting for the author's response on #4563, which also overlaps the native join lifecycle. Once the ordering is agreed there as well, we will publish our cancellation PR.

AI-assisted: yes.

@muhzain05

Copy link
Copy Markdown
Contributor

Hi @Roojool — I independently investigated the pthread handle-lifetime issue before seeing #4481, and I won't open a competing PR.

I found two additional cases that may be useful when adapting the join lifecycle after Ondra97's change:

  • Detach during a pending Join: If J is waiting in scePthreadJoin(T) and another thread detaches T, Detach can release the ownership reference while J still waits on T's join condition variable. Source analysis indicates a possible use-after-free. FreeBSD libthr rejects this Detach with EINVAL, but I haven't verified the PS5 behavior.
  • Raise during thread exit: On Windows, sceKernelRaiseException can fail to suspend a target that exits between validation and SuspendThread. I observed this in local stress testing. Detached targets also expose a separate lifetime issue.

I have Windows-only regression tests and concurrency probes covering these scenarios, as well as concurrent joins and cancellation during Join. Happy to share the relevant tests if helpful.

No PS5 or Linux validation was performed.

AI-assisted: yes.

@Roojool

Roojool commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for flagging both cases and for avoiding a competing PR. Please share the regression tests and concurrency probes, the exact base commit, and the Windows build/run commands with results or logs. The detach-during-join and cancellation/retry cases would be useful when adapting the lifecycle.

We have agreed to land Ondra97's cancellation correction first; publication is currently waiting for the overlapping #4563 ordering discussion. We have not validated your additional cases ourselves, and the PS5 detach-during-join behavior remains unverified. Please include the raise-during-exit probe too so that it can be assessed separately.

AI-assisted: yes.

@muhzain05

Copy link
Copy Markdown
Contributor

Thank you for flagging both cases and for avoiding a competing PR. Please share the regression tests and concurrency probes, the exact base commit, and the Windows build/run commands with results or logs. The detach-during-join and cancellation/retry cases would be useful when adapting the lifecycle.

We have agreed to land Ondra97's cancellation correction first; publication is currently waiting for the overlapping #4563 ordering discussion. We have not validated your additional cases ourselves, and the PS5 detach-during-join behavior remains unverified. Please include the raise-during-exit probe too so that it can be assessed separately.

AI-assisted: yes.

Thanks @Roojool! I’ll prepare the regression tests, concurrency probes, exact base SHA, and Windows build/run logs.

Would you prefer that I open a small PR against your branch with the tests, or share them as a standalone patch/gist?

I’m happy to contribute the tests directly if that makes integration easier, especially given the coordination with Ondra97 and #4563.

AI-assisted: yes.

@Roojool

Roojool commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

A patch or gist works best for now: this branch will be reworked once #4901's cancellation correction lands (ordering agreed upstream), so tests targeting today's head would need adaptation anyway. Share them standalone with your base SHA and run logs, and we will integrate them with your authorship preserved when the lifecycle update goes in. If a PR is easier for you, one against this branch is also fine — whichever you prefer.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
PR 6487 core/libs/prx/libkernel/Pthread/include/Pthread.hpp L142
core/libs/prx/libkernel/Pthread/src/Thread.cpp L792-793, L811-812
docs/dev/debt/libkernel.md L146

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 3986 core/libs/prx/libkernel/Pthread/src/Thread.cpp
PR 4972 core/libs/prx/libkernel/CMakeLists.txt
PR 5021 core/libs/prx/libkernel/CMakeLists.txt
core/libs/prx/libkernel/Pthread/include/Pthread.hpp
core/libs/prx/libkernel/Pthread/src/Thread.cpp
docs/dev/debt/libkernel.md
PR 5280 core/libs/prx/libkernel/CMakeLists.txt
docs/dev/debt/libkernel.md
PR 5993 core/libs/prx/libkernel/CMakeLists.txt
docs/dev/debt/libkernel.md
PR 6044 core/libs/prx/libkernel/CMakeLists.txt
PR 6222 core/libs/prx/libkernel/CMakeLists.txt
core/libs/prx/libkernel/Pthread/include/Pthread.hpp
core/libs/prx/libkernel/Pthread/src/Thread.cpp
docs/dev/debt/libkernel.md
PR 6228 docs/dev/debt/libkernel.md
PR 6510 docs/dev/debt/libkernel.md
PR 6519 docs/dev/debt/libkernel.md
PR 6658 core/libs/prx/libkernel/CMakeLists.txt
docs/dev/debt/libkernel.md
PR 6710 docs/dev/debt/libkernel.md
PR 6836 docs/dev/debt/libkernel.md
PR 6879 core/libs/prx/libkernel/Pthread/src/Thread.cpp

@oneandonlydean oneandonlydean closed this pull request by merging all changes into boykopovar:main in 72a848c Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants