Skip to content

fix(libc): read characters outside the BMP as surrogate pairs in fgetwc - #10929

Merged
adriadam10 merged 1 commit into
boykopovar:mainfrom
Aspct3434:claude/modest-curie-705h76
Oct 29, 2026
Merged

adriadam10 merged 1 commit into
boykopovar:mainfrom
Aspct3434:claude/modest-curie-705h76

Conversation

@Aspct3434

@Aspct3434 Aspct3434 commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

What

fputws and wprintf write a UTF-16 surrogate pair as one four-byte UTF-8 sequence, but fgetwc decoded that sequence into a single code point above 0xFFFF, which is not a 16-bit guest wchar_t value, and fgetws stored it truncated: fputws(u"A\U0001F600\n") read back as A, U+F600, newline.

fgetwc now returns the high surrogate and keeps the low one in the stream for the next read, so fgetws stores the pair, also when the buffer fills between the two halves. A successful seek discards it, as FreeBSD's fseeko resets the stream's mbstate. Sequences that decode to an overlong form or above U+10FFFF, and lead bytes F8 to FF, fail with EILSEQ instead of returning made-up characters; the three-byte surrogates that fputwc writes for a single unit are still read back as that unit.

Claude-Session: https://claude.ai/code/session_01QegaHM5QeCynUQ2ZLWHE4b

ungetwc after a high surrogate: the pushed-back character is now read before the queued low half, which follows it.

Tested

Linux and Windows 11 (WinLibs GCC 15.2.0) runs of guest_standard_streams by other testers on the PR thread; the new CheckSupplementaryWideCharacters cases, including getwc, ungetwc(L'X'), getwc, fail on main. No title tested.

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 (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

@MarcZX10 MarcZX10 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.

Found one blocking stream-ordering regression in the pending-surrogate path. fgetwc stores the low surrogate after returning the high half, and the next fgetwc returns that queued unit before reading the underlying stream. If a caller then successfully calls ungetwc(X, stream), ungetwc_nid_postfix pushes X into the host stream, but the next fgetwc still returns the queued low surrogate first. That violates the pushback guarantee that the next wide-character read returns the successfully pushed-back character. Please add a regression case that reads the high half of a supplementary character, pushes back a BMP character, and verifies the order of the next reads; preserve the queued low half after the pushed-back character as appropriate.

@MarcZX10

Copy link
Copy Markdown
Contributor

The current hosted Conventions run also fails on the PR description: What and Tested are empty, checklist items are unchecked, Depends on is not filled, and AI-assisted has no yes/no answer. Please fill the existing template; the workflow log lists these as 14 findings.

@github-actions

github-actions Bot commented Oct 25, 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 10405 core/libs/prx/libc/include/FileStream.hpp L72, L176
core/libs/prx/libc/src/StandardStreams.cpp L100-122, L124
PR 11895 docs/dev/debt/libc.md L119-120

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 5021 core/libs/prx/libc/include/FileStream.hpp
core/libs/prx/libc/src/StandardStreams.cpp
docs/dev/debt/libc.md
PR 7081 core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md
PR 7386 docs/dev/debt/libc.md
PR 7447 core/libs/prx/libc/include/FileStream.hpp
core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md
PR 7715 docs/dev/debt/libc.md
PR 8150 core/libs/prx/libc/src/StandardStreams.cpp
docs/dev/debt/libc.md
PR 9371 docs/dev/debt/libc.md
PR 9447 docs/dev/debt/libc.md
PR 9526 core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md
PR 9688 core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md
PR 10253 docs/dev/debt/libc.md
PR 10324 core/libs/prx/libc/include/FileStream.hpp
core/libs/prx/libc/src/FileAndHeap.cpp
core/libs/prx/libc/src/StandardStreams.cpp
docs/dev/debt/libc.md
PR 10345 docs/dev/debt/libc.md
PR 11332 docs/dev/debt/libc.md
PR 11625 docs/dev/debt/libc.md
PR 11892 docs/dev/debt/libc.md
PR 12271 core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md
PR 12560 core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md
PR 12598 core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md
PR 12682 core/libs/prx/libc/include/FileStream.hpp
core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md
PR 12688 core/libs/prx/libc/include/FileStream.hpp
core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md
PR 12700 core/libs/prx/libc/include/FileStream.hpp
core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md

@wachi35676

Copy link
Copy Markdown
Contributor

Runtime confirmation of MarcZX10's ungetwc point at the current head 16a21bb3e67f, merged onto main 90f918fa7. Windows 11, WinLibs GCC 15.2.0, Release.

  • guest_standard_streams passes. With main's FileStream.hpp/FileAndHeap.cpp/StandardStreams.cpp, it fails, so the new cases do test the change.
  • Reproduction: fputws(u"\U0001F600Z"), rewind, getwc, ungetwc(L'X'), then three getwc:
first getwc ungetwc('X') next three getwc
this PR 0xd83d 0x58 0xde00, 0x58, 0x5a
main 0x1f600 0x58 0x58, 0x5a, EOF

The pushed-back X should come back on the next read (C11 7.29.3.10). Here the queued low surrogate comes first. ungetwc probably needs to drop or re-queue the pending low half, which also decides what happens to the rest of that character. The sequence above would make a direct regression case.

AI-assisted: yes (Claude Code); results above were run on this machine.

@TechLevelZero

TechLevelZero commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Tested 16a21bb3e merged onto main b90d009d9, with the tests tools/select_tests.py selects, on main and on the merge (Linux, Vulkan validation layer on, PR-only failures rerun alone 3 times on each side):

Hardware Driver Result
Ryzen 7 9700X iGPU, RDNA2 Mesa 26.0.8-1ubuntu0.3 pass, no PR-only failures (2228 tests)
RX 9070 XT, RDNA4 Mesa 26.0.8-1ubuntu0.3 pass, no PR-only failures (2228 tests)
lavapipe on Intel Xeon Gold 6252 Mesa 26.0.8-1ubuntu0.3 (LLVM 21.1.8) pass, no PR-only failures (2228 tests)
lavapipe on AMD EPYC 7281 Mesa 26.0.8-1ubuntu0.3 (LLVM 21.1.8) pass, no PR-only failures (2228 tests)
NVIDIA A800, RHEL 10.2 host 610.57.04 pass, no PR-only failures (2228 tests)

Corrected: an earlier version of this comment listed guest_socket_options_multicast as failing only with the PR. That came from our harness running the PR side without network and main with it, not from this change.

Test suite only; no title was run.

AI-assisted: yes (Claude Code)

@adriadam10 adriadam10 self-assigned this Oct 26, 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.

Thanks, the decoding and the new CheckSupplementaryWideCharacters cases look right (they fail on main). Two things block this:

  • StandardStreams.cpp:104: after fgetwc returns the high surrogate, the queued low half is returned by the next fgetwc before anything the caller pushed back. ungetwc_nid_postfix (StandardStreams.cpp:130) only calls ungetc on the host stream and ignores lowSurrogate, so getwc (0xd83d), ungetwc(L'X'), then getwc returns 0xde00 instead of X (reproduced by @wachi35676, same point as @MarcZX10's review; C11 7.29.3.10). Fix it so the pushed-back character is read next and the low half still follows it, and add that sequence as a regression case next to line 302 of GuestStandardStreams.cpp.
  • The PR body is empty: What, Tested, Depends on, Fixes, Unblocks and the checklist and AI-assisted lines are unfilled, so Conventions fails. Please fill the existing template.

@maskym

maskym commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Windows result for head 16a21bb3e6, merged into main 1089f2bbfd (the PR's Tested section names no Windows run).

Host: Windows 11 26300, Intel i7-12700K, WinLibs GCC 15.2.0 (x86_64-ucrt-posix-seh, r6), Release, BUILD_TESTING=ON, Ninja. long double is the 80-bit x87 type on this toolchain, as on the console. wchar_t is 16 bits on this host, so the surrogate-pair path is the one exercised.

The tree also carried the other open libc PRs #11260, #11262, #11266, #11632, #11634, #11278, #11270, #11283, all merged cleanly, so the run is a joint one; the tests of this PR are guest_standard_streams, guest_standard_streams_closed_stdout, guest_standard_streams_exclusive_stdout, guest_standard_streams_update_stdout. Every test in the set passed: ctest --test-dir build -R "math|libc|...|stdio" 32/32 and ctest -R "guest_dinkumware_internals|guest_standard_streams" 5/5, guest_standard_streams in 0.37 s. Build clean. Not run on a PS5.

AI-assisted: yes (Claude Code), the runs and the numbers above are from this machine.

fputws and wprintf write a UTF-16 surrogate pair as one four-byte
UTF-8 sequence, but fgetwc decoded that sequence into a single code
point above 0xFFFF, which is not a 16-bit guest wchar_t value, and
fgetws stored it truncated: fputws(u"A\U0001F600\n") read back as
A, U+F600, newline.

fgetwc now returns the high surrogate and keeps the low one in the
stream for the next read, so fgetws stores the pair, also when the
buffer fills between the two halves. A successful seek discards it,
as FreeBSD's fseeko resets the stream's mbstate. Sequences that
decode to an overlong form or above U+10FFFF, and lead bytes F8 to
FF, fail with EILSEQ instead of returning made-up characters; the
three-byte surrogates that fputwc writes for a single unit are still
read back as that unit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QegaHM5QeCynUQ2ZLWHE4b
@adriadam10
adriadam10 force-pushed the claude/modest-curie-705h76 branch from 16a21bb to 84aec8b Compare October 28, 2026 13:50

@MarcZX10 MarcZX10 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.

Re-checked — approved

The pending-surrogate pushback issue is fixed on the current head 84aec8b0ac56f402e4072c59495abf56211be41c. FileStream now tracks successful pushbacks while a low surrogate is queued; TakeLowSurrogate() defers that low half until the pushed-back byte has been consumed, and NoteWideRead() decrements the pending count. The regression covers getwc returning the high half, ungetwc(L'X'), then reading X, the low half, and the following newline. I also checked that reposition/reopen/reset paths clear the new state. This resolves my earlier functional finding.

The author documents the required template fields and tests; Linux and Windows focused runs are reported on the PR thread, and hosted Windows/conventions/policy checks pass. The current-main merge preview is clean; source/test paths have no later main-side edits, though the shared debt entry was updated and the preview integrates it cleanly. I did not rerun the stream tests locally.

@adriadam10
adriadam10 merged commit df53927 into boykopovar:main Oct 29, 2026
17 checks passed
@adriadam10

Copy link
Copy Markdown
Collaborator

Thanks for the fix. Both points of the earlier request are now done on your branch:

  • ungetwc after the high surrogate: the pushed-back character is read next and the queued low half follows it (FileStream::pushedBack). Regression case added in CheckSupplementaryWideCharacters (getwc → 0xd83d, ungetwc(L'X'), then X, 0xde00, \n).
  • PR body filled from the template.
    Also rebased on main (conflicts in FileStream.hpp and docs/dev/debt/libc.md). guest_standard_streams and the rest of the libc/libs tests pass; merged.

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.

7 participants