Skip to content

fix(libSceSystemService): give LoadExec's next executable the host standard error - #5699

Merged
adriadam10 merged 6 commits into
boykopovar:mainfrom
YossiYad:fix/loadexec-host-stderr
Oct 16, 2026
Merged

adriadam10 merged 6 commits into
boykopovar:mainfrom
YossiYad:fix/loadexec-host-stderr

Conversation

@YossiYad

@YossiYad YossiYad commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

What

#4792 keeps the host's standard error on Linux when a guest redirects descriptor 2: the first redirect keeps a duplicate, the host stderr and the terminate report write to it, and the guest's stream follows its redirect. The duplicate does not survive sceSystemServiceLoadExec: it is close-on-exec for execv, and Replace closes every descriptor above 2 before fexecve. The next executable starts with descriptor 2 still on the title's redirect and keeps that as its "host" standard error, so its runtime output, the terminate report included, goes into the title's file, or into a pipe whose reader is gone (SIGPIPE).

  • LoadExec.cpp Replace (argument list, fexecve): descriptor 2 gets the kept host descriptor after the guest shutdown and fflush, before the close_range calls.
  • Export.cpp StartHostExecutable (null list, execv): the same restore right before execv.
  • If no redirect kept anything, nothing changes. The title's buffered output is in its file first on both paths.
  • New guest_system_load_exec_standard_error test (Linux): a forked child with stderr on a pipe moves the guest's descriptor 2 to a second pipe with dup2, then starts itself again through sceSystemServiceLoadExec("/app0/eboot.bin", ...), once with {"--after-load-exec", NULL} (fexecve, the new image must see argc == 1 and argv[0] equal to the marker) and once with a null list (execv, argc == 1). The new image is recognised by an environment variable. What it writes to stderr must reach the first pipe and nothing the second.
  • The debt file gets a separate entry on which descriptors cross each path.

Titles.

Windows is not changed: both launch paths there use CreateProcessW and already pass the host standard error handle on (#4640; checked by @screamm).

Tested

Linux (Bazzite 44, GCC 16.2.1, Ninja, Release, -DBUILD_TESTING=ON), head 6473318a6 on main 92e50418f:

  • guest_system_load_exec_standard_error passes 3 of 3. With only LoadExec.cpp from main it aborts, and with only Export.cpp from main it aborts too, so each path is pinned.
  • loadexec_arguments, guest_system_relaunch, guest_nested_exit and the guest_system_exit set pass.
  • tools/check_conventions.py --base upstream/main passes.
  • Windows (by @screamm, MinGW GCC 15.2): build and ctest 836 of 836 pass; no Windows code changes.

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; no silent stubs added
  • 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 (VLC's relaunch also needs fix(libSceSystemService): park guest threads during LoadExec's shutdown as exit does #6237, which is independent)
  • Unblocks: PPSA99169 frontend after LoadExec; diagnostics of PPSA85300 after its restart
  • GPUs tested: n/a
  • AI-assisted: yes

@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 5978 core/libs/prx/libSceSystemService/CMakeLists.txt L52

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 5084 core/libs/prx/libSceSystemService/CMakeLists.txt
core/libs/prx/libSceSystemService/Export.cpp
docs/dev/debt/libSceSystemService.md
PR 6044 core/libs/prx/libSceSystemService/CMakeLists.txt
PR 6447 core/libs/prx/libSceSystemService/Export.cpp
docs/dev/debt/libSceSystemService.md

@milescoviello

Copy link
Copy Markdown
Contributor

A title where this shows: VLC for PS5 (PPSA85300). Its plat_restart relaunches itself with sceSystemServiceLoadExec("/app0/eboot.bin", NULL) (platform_ps5.c). Linux, RTX 4070 Ti, NVIDIA 595.99, GCC 13.3, Release, main d92a8da, a pad sequence that reaches the restart 18 s in, 60 s runs:

Not checked on Windows.

AI-assisted: yes

@adriadam10 adriadam10 self-assigned this Oct 15, 2026

@adriadam10 adriadam10 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Checked on current main merged with this head. Two problems:

  • core/libs/prx/libSceSystemService/Export.cpp:186: the descriptor 2 restore only runs in StartHostExecutable (execv). A call whose argument list has a first entry (RetroArch's {"", NULL}, any list) goes through Replace in LoadExec.cpp:160 (fexecve) before that, so the next image still inherits the title's redirect. The restore needs to happen on that path too, after the shutdown and fflush.
  • core/libs/tests/GuestSystemServiceLoadExecStandardError.cpp:25,43: the test passes {"--after-load-exec"}, which is now the whole guest argv (argc == 1, no program name in front). The re-executed image never sees argc == 2, forks again and loops, printing the host CPU line over and over until killed. Check the new image with argc == 1 and argv[0] equal to the marker, and keep a second case with a null list for the execv path, so both paths are pinned.

Once both paths restore it, the debt line in docs/dev/debt/libSceSystemService.md should also say that fexecve closes every descriptor above 2 while descriptor 2 gets the kept host one.

@milescoviello

Copy link
Copy Markdown
Contributor

A second title, on the other LoadExec path: RetroArch (PPSA99169). Its picker passes an argument list ({"", NULL}, then {"--ps5-mode=retroarch", NULL}), so the next image starts through LoadConvertedExecutable → Replace → fexecve in LoadExec.cpp, not StartHostExecutable. This PR's dup2 is not reached there.

Linux, RTX 4070 Ti, NVIDIA 595.99, GCC 13.3, Release, a pad sequence that picks the RetroArch frontend, 60 s runs:

  • main 1d1c761: 2 of 2 runs die with SIGPIPE (EXIT-141) 8 s in, in the third image. Under gdb the write is AnyPS5's own [memory] configured flexible memory line (fprintf from _configuredFlexibleSize() in libkernel) to fd 6. That is KeepHostStandardError's copy of a descriptor 2 that is the picker's pipe, whose read end the exec closed.
  • This head 7a4688c71 merged onto main: the same, 2 of 2.
  • The same restore also placed in Replace before the close_range calls: 2 of 2 runs reach the limit (3 images, about 2,550 log lines instead of about 1,160). This was a throwaway build:
    if (const int host = HostStandardError_nid_no_patch(); host >= 0 && ::dup2(host, 2) != 2) {
        throw std::system_error(errno, std::generic_category(), "LoadExec: giving the host standard error back to descriptor 2");
    }

I didn't look at the Windows CreateProcessW path. I can rerun RetroArch if you extend the PR.

AI-assisted: yes

@screamm

screamm commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Cross-validation on Windows 11 Pro 26220, NVIDIA RTX 4070 (596.21), MinGW GCC 15.2, PR head 7a4688c merged on main 8dc92ae with #6237 a03e023:

This is a Windows no-regression check only. The dup2 is in the Linux StartHostExecutable, and guest_system_load_exec_standard_error is registered only on Linux. After the merge, libSceSystemService.prx disassembles to the same code with and without this PR, and ctest -N lists no new test, so there is no negative control to run here.

PPSA85300 (VLC 0.3.1). Relinked with --windows-fixed-base 0x400000, 120 s runs. The restart is reached through APS5_PAD_INPUT_FILE (Cross, Right, Options in the first-launch setup -> plat_restart -> sceSystemServiceLoadExec("/app0/eboot.bin", NULL)).

On Windows the relaunched image's own output already reaches the console without this PR. The console log of each run has the start-up block twice ([time] scheduler tick raised ..., host CPU ..., Transferring control to ELF entry point), even though VLC had moved descriptor 2 to app0/vlc-ps5.log. libc keeps the host handle as the process's standard error handle (KeepStandardHandle_nid_no_patch), and both CreateProcessW paths pass GetStdHandle's handles, so the description's "Windows is not changed" holds.

The test suite on main + #6237 + this PR (-j8 --timeout 120 -E '^agc_driver_|^video_out_flip') passes 830/830. The host was shared with other jobs, but nothing timed out. tools/check_conventions.py --base origin/main passes and git diff --check is clean.

From reading current main I agree with @adriadam10's review. {"--after-load-exec", nullptr} goes through LoadConvertedExecutable -> Replace (fexecve), so the test never reaches the new dup2 in StartHostExecutable. The relaunched image sees argc == 1, argv[0] == "--after-load-exec" and forks again. That part I could not run here.

Side observation, not investigated: in all four runs the relaunched VLC appended to the same app0/vlc-ps5.log, and its start-up rename to vlc-ps5.old.log left no file. This may be the null-list CreateProcessW (bInheritHandles=TRUE, no handle list) passing the old process's file handle on, but I did not check.

No Windows concerns.

Written with Claude Code assistance.

…andard error

A guest dup2 onto descriptor 2 keeps a close-on-exec duplicate of the
host's standard error (boykopovar#4792), so the runtime's own output stays on the
console while the title's stderr goes where the title pointed it. The
duplicate did not survive LoadExec's execv, and the next executable
started with descriptor 2 still on the title's redirect: its runtime
output, terminate report included, went there.

RetroArch (PPSA99169) points stderr at app0/trace.txt and then starts
its picker. The picker's [gpu] and sceKernelDebugOutText lines landed
in trace.txt, and its abort on an unconverted es-de.bin showed up
nowhere; the message was found only in the core dump.

Before execv, the kept descriptor now goes back onto descriptor 2.
…d title

A forked child with stderr on a pipe moves the guest's descriptor 2 to a
second pipe with dup2, then starts itself again through
sceSystemServiceLoadExec("/app0/eboot.bin"). What the new image writes
to stderr must reach the first pipe and nothing the second. It fails
with the previous execv, where the text goes to the guest's pipe.
…adExec's fexecve

A LoadExec with an argument list replaces the process through Replace (fexecve), which runs before StartHostExecutable, so the next image still wrote to the title's redirect. Descriptor 2 now gets the kept host descriptor there too, after the shutdown and the flush and before close_range closes it.
…aths

The re-executed image is told apart by an environment variable. With an argument list (fexecve) it sees argc 1 and the marker as argv[0]; with a null list (execv) it sees only its path.
@YossiYad
YossiYad force-pushed the fix/loadexec-host-stderr branch from 7a4688c to 6473318 Compare October 15, 2026 15:17
@YossiYad

Copy link
Copy Markdown
Contributor Author

@adriadam10 thanks, both points were right. Rebased onto main 92e50418f, new head 6473318a6:

  • LoadExec.cpp Replace: descriptor 2 gets the kept host descriptor after the shutdown and fflush, before the close_range calls (which would otherwise close the kept descriptor itself). The execv path in Export.cpp keeps its restore.
  • The test now runs both paths. The re-executed image is recognised by an environment variable, not by its arguments. With {"--after-load-exec", NULL} (fexecve) it requires argc == 1 and argv[0] equal to the marker; with a null list (execv) it requires argc == 1. Each case checks that the new image wrote to the host pipe and nothing reached the guest redirect.
  • The debt line now says that fexecve closes every descriptor above 2 while descriptor 2 gets the kept host one, and that the execv path keeps the guest's descriptors open.

Linux, GCC 16.2.1, Release:

  • guest_system_load_exec_standard_error passes 3 of 3, and the other LoadExec tests (loadexec_arguments, guest_system_relaunch, guest_nested_exit, the guest_system_exit set) pass.
  • With only LoadExec.cpp from main the test aborts, and with only Export.cpp from main it aborts too, so each path is pinned.
  • tools/check_conventions.py --base upstream/main passes.

@milescoviello the restore is now in Replace in the same place as your throwaway build, if you want to rerun RetroArch.

Could you take another look and merge if it's fine now?

@milescoviello

Copy link
Copy Markdown
Contributor

RetroArch (PPSA99169) on the new head: this fixes it.

Linux (Ubuntu 24.04, GCC 13.3, Release, RTX 4070 Ti, NVIDIA 595.99), the same pad sequence that picks the RetroArch frontend, 60 s runs:

Build Result
main 92e5041 3 of 3 die with SIGPIPE (EXIT-141) 8 s in, during the third image (about 1,150 log lines)
main 92e5041 + this PR at 6473318 3 of 3 reach the limit (about 2,550 log lines)

With the PR, the third image (the frontend, started through Replace → fexecve) scans /app0/cores, reads its core metadata and creates its swapchain. On main the log stops right after the frontend opens /app0/cores. With the PR, the next line there is [memory] configured flexible memory, which is the write gdb caught in my earlier runs.

Not checked: Windows, since CreateProcessW doesn't go through Replace.

AI-assisted: yes

@screamm

screamm commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Rerun on Windows 11 Pro 26220 (RTX 4070 596.21, MinGW GCC 15.2): head 6473318a6 merged on main 44df21632 together with #6237.

  • Build passes (libs too); the only failing target is agc_driver_gpu_indirect_arguments_misaligned_writer_ahead_tests, which also fails on main here (65536 alignment, test(agc): align the indirect arguments block at run time #6452/fix(agc): allocate indirect arguments test block dynamically on Windows #6478).
  • guest_system_load_exec_standard_error: not built on Windows (registered under CMAKE_SYSTEM_NAME STREQUAL "Linux", uses fork/pipe), so there are no Windows runs and no negative control. guest_system_relaunch, --repeat until-fail:3: 3 of 3 pass.
  • ctest -j8 --timeout 120 -E '^agc_driver_|^video_out_flip': 836 of 836 pass, 2 skipped. tools/check_conventions.py --base origin/main and git diff --check pass.
  • On @milescoviello's point: on Windows neither launch path is changed. The argument-list path is the _WIN32 Replace in LoadExec.cpp (CreateProcessW, suspended, with GetStdHandle's standard handles duplicated into the child), and the null-list path is StartHostExecutable in Export.cpp (CreateProcessW, inheriting handles). The new dup2 calls are only in the Linux versions. Windows already hands the host standard error handle on, which is what my earlier VLC runs on 7a4688c71 showed, so I did not repeat them.

Not checked: the Linux paths, and the PPSA85300 / PPSA99169 title runs on this head.

Written with Claude Code assistance (validation by a subagent, reviewed in this session).

@milescoviello

Copy link
Copy Markdown
Contributor

@screamm thanks, that answers it: on Windows both launch paths already pass the host standard error on, so the dup2 calls only matter for the Linux paths, which my PPSA99169 runs covered.

AI-assisted: yes (Claude Code)

@YossiYad

Copy link
Copy Markdown
Contributor Author

@adriadam10 a reminder on this one when you have time. Head 6473318a6 has both changes from your review: Replace restores descriptor 2 before the close_range calls, and the test covers the fexecve path (argc == 1, argv[0] is the marker) and the execv path (null list). Since then:

  • @milescoviello: RetroArch (PPSA99169) on Linux runs to the limit 3 of 3 with this head, where main dies with SIGPIPE 8 s in.
  • @screamm: Windows builds and ctest passes 836 of 836. Windows code is unchanged.

The PR description now covers both paths. I can't request a review myself (no write access), so this comment is the request. Could you take another look?

@adriadam10 adriadam10 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-checked on current main merged with this head. Both points are fixed: Replace restores descriptor 2 before the close_range calls (LoadExec.cpp:157), and the test pins both the fexecve path (argc == 1, argv[0] is the marker) and the execv path. The new test passes, fails without the source changes, and the rest of the libSceSystemService ctest scope is green. The debt entry matches the code; merging.

@adriadam10
adriadam10 merged commit 6a6b171 into boykopovar:main Oct 16, 2026
9 checks passed
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.

4 participants