Skip to content

fix(libc): fail reads on a write-only stream with EBADF on Windows - #14878

Merged
oneandonlydean merged 1 commit into
boykopovar:mainfrom
roboterspinne:fix/libc-write-only-stream-reads
Nov 3, 2026
Merged

oneandonlydean merged 1 commit into
boykopovar:mainfrom
roboterspinne:fix/libc-write-only-stream-reads

Conversation

@roboterspinne

@roboterspinne roboterspinne commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

What

On Windows, a guest read from a stream that is open only for writing ("w" or "a"; fdopen/freopen with a write-only mode set the same UCRT flags and were not probed) reaches the UCRT. The UCRT serves the read from the unused part of the write buffer. After fwrite("abc") on a "wb" or "ab" stream, fread(buf, 1, 4) returns 4 zero bytes, fgetc and getc_unlocked return 0, and fgets returns the buffer. After fwrite("12 "), fscanf("%d") returns 0 instead of EOF. In every case ferror stays 0 and errno is not set. fclose then writes the consumed bytes as well, so the 3-byte file ends up 4, 5, 7 or 10 bytes long. SwitchToReadingUnlocked only acts on update streams; on any other stream it returns true (core/libs/prx/libc/include/FileStream.hpp:293 on e0caf13; line numbers below refer to it), so a stream whose UCRT flags have neither _IOREAD (0x1) nor _IOUPDATE (0x4) goes straight to the host. FreeBSD 11.4's __srefill handles a stream that has neither __SRD nor __SRW by setting errno = EBADF and __SERR and returning EOF, and it does not touch pending output. POSIX.1-2008 lists EBADF for fgetc on a stream that is not open for reading. glibc's _IO_new_file_underflow (_IO_NO_READS) already does this for fread, fgetc, getc_unlocked and fgets, but its __vfscanf_internal (ARGCHECK, glibc 2.39 stdio-common/vfscanf-internal.c) returns EOF with EBADF without setting the error indicator, so Linux fscanf still differs from FreeBSD there; this PR records that in docs/dev/debt/libc.md and leaves the Linux code unchanged. macOS's BSD stdio is expected to behave like FreeBSD (not run). #11560 (a7b5208) added the opposite direction for Windows: SwitchToWritingUnlocked fails a write on a read-only stream with errno 9 and the error indicator (FileStream.hpp:329-334, tested at core/libs/tests/FileStreams.cpp:447-457).

Tested

Windows 11, WinLibs GCC 15.2.0, build directory configured with Ninja, Release and BUILD_TESTING=ON, on this branch based on e0caf13:

  • Baseline, untouched code (file_streams_tests already built from it): cmake --build build --parallel 4 --target guest_stream_purge_tests file_position_tests guest_wide_stream_scan_tests guest_standard_stream_tests file_stream_status_tests guest_append_streams_tests guest_host_stream_tests, then ctest --test-dir build -R '^(file_streams|file_position|guest_standard_streams.*|file_stream_status|guest_wide_stream_scan|guest_stream_purge|guest_append_streams|guest_host_streams)$' --output-on-failure passed 12/12.
  • Counter-check, new test with the library of e0caf13: cmake --build build --target file_streams_tests, then ctest --test-dir build -R '^file_streams$' --output-on-failure failed with what(): fread on a write-only stream (exit 0xc0000409). The Windows-only ungetc assertion already passes before the change; it makes sure the new EBADF path does not reach ungetc.
  • With the change: the same cmake --build command with file_streams_tests added, then the same ctest command: 12/12 pass (file_streams 0.34 s).
  • Standalone probes loaded the new libc.prx and called its exports. For "w" and "a" after fwrite("abc"), fread returned 0, fgetc and getc_unlocked -1 and fgets NULL, each with ferror 1 and errno 9, and the file was 3 bytes long after fclose. ungetc('q') on a "w" stream returned -1 with ferror 0 and errno 0. After fwrite("12 ") on a "w" stream, fscanf("%d") returned -1 with ferror 1 and errno 9, and the file stayed 3 bytes. With the libc.prx of 73f4f7e (none of the read paths above changed up to e0caf13; FileAndHeap.cpp changed only in fopen_s/freopen_s), the same probes reported fread 4 (file 7 bytes), fgetc 0 (4 bytes), fgets success (10 bytes), getc_unlocked 0 (4 bytes) and fscanf 0 (5 bytes), all with ferror 0 and errno 0, and ungetc -1 with ferror 0 and errno 0.
  • Not run here: the Linux and macOS builds. The assertions outside _WIN32 were checked against the glibc 2.39 sources (libio/fileops.c _IO_new_file_underflow, stdio-common/vfscanf-internal.c ARGCHECK) and FreeBSD's __srefill; macOS is expected from the BSD sources only.
  • python tools/check_conventions.py --base e0caf13600 --head HEAD with this title and description passed.

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 (Claude Code)

@github-actions

github-actions Bot commented Nov 1, 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 14326 docs/dev/debt/libc.md L204

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 5303 docs/dev/debt/libc.md
PR 6819 docs/dev/debt/libc.md
PR 7081 docs/dev/debt/libc.md
PR 7386 core/libs/tests/FileStreams.cpp
docs/dev/debt/libc.md
PR 7447 core/libs/prx/libc/include/FileStream.hpp
docs/dev/debt/libc.md
PR 7715 docs/dev/debt/libc.md
PR 8150 core/libs/prx/libc/src/StandardStreams.cpp
core/libs/tests/FileStreams.cpp
docs/dev/debt/libc.md
PR 8965 docs/dev/debt/libc.md
PR 9340 docs/dev/debt/libc.md
PR 9371 docs/dev/debt/libc.md
PR 9447 docs/dev/debt/libc.md
PR 9526 docs/dev/debt/libc.md
PR 9929 docs/dev/debt/libc.md
PR 10113 docs/dev/debt/libc.md
PR 10226 docs/dev/debt/libc.md
PR 10245 docs/dev/debt/libc.md
PR 10250 docs/dev/debt/libc.md
PR 10253 docs/dev/debt/libc.md
PR 10265 docs/dev/debt/libc.md
PR 10274 docs/dev/debt/libc.md
PR 10325 docs/dev/debt/libc.md
PR 10328 docs/dev/debt/libc.md
PR 10345 docs/dev/debt/libc.md
PR 10349 docs/dev/debt/libc.md
PR 10405 core/libs/prx/libc/include/FileStream.hpp
core/libs/prx/libc/src/StandardStreams.cpp
docs/dev/debt/libc.md
PR 10506 docs/dev/debt/libc.md
PR 10510 docs/dev/debt/libc.md
PR 10512 docs/dev/debt/libc.md
PR 10565 docs/dev/debt/libc.md
PR 10987 docs/dev/debt/libc.md
PR 11260 docs/dev/debt/libc.md
PR 11262 docs/dev/debt/libc.md
PR 11266 docs/dev/debt/libc.md
PR 11332 docs/dev/debt/libc.md
PR 11632 docs/dev/debt/libc.md
PR 11634 docs/dev/debt/libc.md
PR 11724 docs/dev/debt/libc.md
PR 11726 docs/dev/debt/libc.md
PR 11831 core/libs/prx/libc/include/FileStream.hpp
core/libs/tests/FileStreams.cpp
docs/dev/debt/libc.md
PR 11907 docs/dev/debt/libc.md
PR 12122 docs/dev/debt/libc.md
PR 12397 docs/dev/debt/libc.md
PR 12514 docs/dev/debt/libc.md
PR 12560 docs/dev/debt/libc.md
PR 12574 docs/dev/debt/libc.md
PR 12593 docs/dev/debt/libc.md
PR 12622 docs/dev/debt/libc.md
PR 12627 docs/dev/debt/libc.md
PR 12682 core/libs/prx/libc/include/FileStream.hpp
docs/dev/debt/libc.md
PR 12688 core/libs/prx/libc/include/FileStream.hpp
docs/dev/debt/libc.md
PR 12700 core/libs/prx/libc/include/FileStream.hpp
docs/dev/debt/libc.md
PR 12965 core/libs/prx/libc/include/FileStream.hpp
docs/dev/debt/libc.md
PR 13119 core/libs/tests/FileStreams.cpp
docs/dev/debt/libc.md
PR 13390 core/libs/prx/libc/include/FileStream.hpp
docs/dev/debt/libc.md
PR 13804 docs/dev/debt/libc.md
PR 14212 docs/dev/debt/libc.md
PR 14559 core/libs/prx/libc/src/StandardStreams.cpp
core/libs/tests/FileStreams.cpp
PR 14560 core/libs/prx/libc/src/StandardStreams.cpp
core/libs/tests/FileStreams.cpp
PR 14585 docs/dev/debt/libc.md
PR 14610 docs/dev/debt/libc.md
PR 14863 core/libs/prx/libc/src/StandardStreams.cpp

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

Windows 11, WinLibs GCC 15.2.0 r7, Release (UCRT). Head e700ddb merged into upstream/main 93dbd0f:

  • file_streams passes.
  • Negative control: with main's FileStream.hpp and StandardStreams.cpp, file_streams aborts with fread on a write-only stream, the first new check. So main still serves the read from the write buffer as described.

Checking the UCRT _IOREAD/_IOUPDATE bits before the host is asked matches __srefill's "neither __SRD nor __SRW" case. Leaving the glibc fscanf difference as a debt entry, rather than special-casing Linux, seems right.

@TechLevelZero

Copy link
Copy Markdown
Contributor

✅ AMD GPU test: passed

PR head e700ddb merged onto main a4eca2a · tested 2026-11-02 23:19 UTC · 33 min

GPU AMD Radeon RX 6300 (RADV NAVI24), driver Mesa 25.2.8-0ubuntu0.24.04.4, Vulkan 1.4.318
Host Ubuntu 26.04.1 LTS, kernel 7.0.0-42-generic, container ubuntu:24.04
Logs build and test logs for this run
Build ✅ passed (Release, -DANYPS5_ENABLE_SPIRV_TOOLS=ON)
Tests ✅ 3,469 passed · 13 failed (0 new, 12 also fail on main, 1 unstable on main) · 59 skipped (ANYPS5_REQUIRE_VULKAN=1)
Test setup CPU-only tests without a GPU; GPU tests (and any CPU-only test that failed or skipped without one) once on the GPU with VK_LAYER_KHRONOS_validation and ANYPS5_NO_SHADER_CACHE=1; main ran the same way
Test selection 3,541 of 3,591 tests run: the ones this change can reach, chosen by tools/select_tests.py from main's build graph
Vulkan validation ✅ no new validation IDs vs main (1306 tests run on the GPU)
Slow tests (>30 s, GPU tests timed with validation layers) ⚠️ agc_driver_pipeline_journal 120.6 s, ⚠️ agc_driver_graphics_maps_fallback 100.3 s, ⚠️ agc_driver_graphics 96.4 s, ⚠️ public_headers 68.0 s, ⚠️ agc_driver_bda_device 43.6 s
Unstable on main (1, these also failed in recent main runs on this machine; not counted against this PR)
agc_driver_pipeline_journal: failed in 4 of the last 6 main runs
Failing on main too (12, not counted against this PR)
agc_driver_guest_memory_lifetime_direct
agc_driver_image_table
agc_driver_native_depth_bda_clear
agc_driver_native_depth_buffer_clear
agc_driver_native_depth_clears
agc_driver_native_multisample_depth
agc_driver_submit_progress
agc_idle_publish_wait
agc_submit_progress
agc_submit_progress_no_timeline
guest_mmap_executable
guest_socket_options_multicast
Validation IDs already present on main (4 IDs in 750 tests)
VUID-VkDeviceCreateInfo-pNext-pNext (750 tests): agc_compute_pipeline_replay, agc_driver, agc_driver_address_passes, agc_driver_address_passes_draw_transitions, agc_driver_address_passes_targets_split, agc_driver_alias_copy_label, agc_driver_alpha_to_coverage, agc_driver_array_layer_target, and 742 more
VUID-vkCmdCopyImage-srcImage-01548 (11 tests): agc_driver_depth_clear_hle, agc_driver_depth_plane_buffer_memory, agc_driver_depth_plane_cpu_write_after_memo, agc_driver_depth_plane_cpu_write_after_page_unwatch, agc_driver_depth_plane_cpu_write_after_partial_unwatch, agc_driver_depth_plane_partial_store, agc_driver_depth_plane_partial_store_unimported, agc_driver_depth_storage_dispatch, and 3 more
VUID-vkCmdCopyImage-srcImage-01551 (11 tests): agc_driver_depth_clear_hle, agc_driver_depth_plane_buffer_memory, agc_driver_depth_plane_cpu_write_after_memo, agc_driver_depth_plane_cpu_write_after_page_unwatch, agc_driver_depth_plane_cpu_write_after_partial_unwatch, agc_driver_depth_plane_partial_store, agc_driver_depth_plane_partial_store_unimported, agc_driver_depth_storage_dispatch, and 3 more
VUID-VkPipelineColorBlendStateCreateInfo-pAttachments-00605 (2 tests): agc_driver_prepared_exports, agc_driver_srgb8_color_target
Skipped (59)
agc_driver_bda_reads_shadowed_results: skipped, the device reports watched host memory written when it imports or reads it
agc_driver_bda_reads_shadowed_results_per_build: skipped, the device reports watched host memory written when it imports or reads it
agc_driver_copied_writer_label_order: skipped, the device reports a minStorageBufferOffsetAlignment of 4, so the dispatch binds its record at +0x1004 in place and nothing is written back; the test needs an alignment above 4
agc_driver_copied_writer_label_order_drain: skipped, the device reports a minStorageBufferOffsetAlignment of 4, so the dispatch binds its record at +0x1004 in place and nothing is written back; the test needs an alignment above 4
agc_driver_float16_unused_nan_payloads: skipped, the device has no native f16 conversions with RTE rounding and kept f16 denormals
agc_driver_image_table_vouched_lookups: skipped, the device reports 4 of 4 host-imported pages written after a GPU read, so a watched imported table cannot be vouched for
agc_driver_linear_target_direct_write_completion: skipped, the device does not keep host imports write-watched, so no completion stamp over the target is tracked
agc_driver_ngg_vertex_wave_reduction: skipped, s3 describes a 32-lane wave only on 32-lane subgroups, and the device has 64 lanes
agc_driver_scattered_runs: skipped, host imports are unavailable: /dev/udmabuf is not accessible
agc_driver_sparse_dma_buf_import: skipped, host imports are unavailable: /dev/udmabuf is not accessible
agc_driver_unit_shadow: skipped, the device does not import watched host memory for unit shadows
agc_driver_unit_shadow_budget: skipped, the device does not import watched host memory for unit shadows
agc_driver_vertex_elected_atomic_max: skipped, the device runs vertex programs in subgroups, so they keep their cross-lane reductions
agc_host_import_sparse: skipped, host imports are unavailable: /dev/udmabuf is not accessible
agc_import_settle_initial_dcc: skipped, the device has no deferred settle of host imports: imports under 1 MiB wait for one only on Windows
agc_shadowed_key_stores: skipped, the device imports no watched host memory under a unit shadow
agc_small_tiled_shadow_label: skipped, host imports are unavailable: the test block is not imported and watched
agc_storage_clear_hand_over: skipped, the device imports no watched host memory for the clear-key hand-over
agc_unit_shadow: skipped, the device does not import watched host memory for unit shadows
agc_unit_shadow_completion_store: skipped, the device reports watched host memory written when it imports or reads it
guest_http_uri_locale: 
guest_net_ctl: skipped, the host has no IPv4 address
guest_video_out_color_space: skipped, no display or Vulkan device: SDL_InitSubSystem(VIDEO | GAMECONTROLLER) failed: wayland,x11 not available
guest_video_out_hdr: skipped, no display or Vulkan device: SDL_InitSubSystem(VIDEO | GAMECONTROLLER) failed: wayland,x11 not available
guest_video_out_high_frame_rate: skipped, no display or Vulkan device: SDL_InitSubSystem(VIDEO | GAMECONTROLLER) failed: wayland,x11 not available
guest_video_out_output_mode: skipped, no display or Vulkan device: SDL_InitSubSystem(VIDEO | GAMECONTROLLER) failed: wayland,x11 not available
guest_video_out_resolution_status: skipped, no display or Vulkan device: SDL_InitSubSystem(VIDEO | GAMECONTROLLER) failed: wayland,x11 not available
guest_video_out_wqhd_detection: skipped, no display or Vulkan device: SDL_InitSubSystem(VIDEO | GAMECONTROLLER) failed: wayland,x11 not available
video_out_flip_aftergpu: skipped, no display or Vulkan device: DisplayWindow: Vulkan support is either not configured in SDL or not available in current SDL video driver (offscreen) or platform
video_out_flip_backtoback: skipped, no display or Vulkan device: DisplayWindow: Vulkan support is either not configured in SDL or not available in current SDL video driver (offscreen) or platform
video_out_flip_controls: skipped, no display or Vulkan device: DisplayWi
Reproduce
git fetch origin e700ddb53ae0f7612b8ed8a6ac2369c7f0126954 a4eca2a056bf3bbbec98ab42e9d28163e4ea9cb8
git checkout a4eca2a056bf3bbbec98ab42e9d28163e4ea9cb8 && git merge --no-ff e700ddb53ae0f7612b8ed8a6ac2369c7f0126954
git submodule update --init --recursive
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DANYPS5_ENABLE_SPIRV_TOOLS=ON
cmake --build build && cmake --build build --target libs
VK_DRIVER_FILES=/nonexistent/icd.json ctest --test-dir build -E '^(agc|shader|recompiler|videoout|video_out|guest_video_out|vulkan)|gpu' --timeout 120 --parallel 12 --output-on-failure
ANYPS5_REQUIRE_VULKAN=1 ANYPS5_NO_SHADER_CACHE=1 VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation \
  ctest --test-dir build -R '^(agc|shader|recompiler|videoout|video_out|guest_video_out|vulkan)|gpu' --timeout 120 --parallel 16 --output-on-failure
# failures are then re-run alone: --timeout 360 --parallel 2; new validation IDs: the test alone, twice, --parallel 1

Automated build and test on a contributor's AMD machine; it does not replace upstream CI. A new comment is posted for each new push. Full logs.

@oneandonlydean oneandonlydean self-assigned this Nov 3, 2026
@oneandonlydean
oneandonlydean merged commit 8645dae into boykopovar:main Nov 3, 2026
21 checks passed
@roboterspinne
roboterspinne deleted the fix/libc-write-only-stream-reads branch November 3, 2026 10:46
AlexWhitehouse added a commit to AlexWhitehouse/AnyPS5 that referenced this pull request Nov 4, 2026
…eeBSD does

On Linux, fscanf, vfscanf, scanf and vscanf on a stream opened only for
writing returned EOF with EBADF but left the error indicator clear:
SwitchToReadingUnlocked let the stream through to glibc's
__vfscanf_internal, which rejects _IO_NO_READS without setting
_IO_ERR_SEEN. FreeBSD 11.4's __srefill sets EBADF and __SERR. The glibc
branch now fails an unreadable stream the way the Windows branch does
since boykopovar#14878: errno 9, the guest error indicator, and false.

OpenForReading gains a glibc branch (__freadable), so ungetc and ungetwc
return EOF on such a stream without touching errno or the error
indicator, as FreeBSD's __ungetc does and as Windows already did.
Before, glibc pushed the character back onto the write-only stream.

file_streams now checks fscanf, vfscanf, fgetwc, ungetc, ungetwc and
clearerr on write-only streams on every host, not only on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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