Skip to content

fix(libc): open a directory with fopen in a read mode on Windows - #8550

Merged
oneandonlydean merged 1 commit into
boykopovar:mainfrom
Retr0G28:fix/fopen-directory-read-windows
Oct 22, 2026
Merged

oneandonlydean merged 1 commit into
boykopovar:mainfrom
Retr0G28:fix/fopen-directory-read-windows

Conversation

@Retr0G28

@Retr0G28 Retr0G28 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What

On Windows, fopen of a directory in a read mode (r, rb, re) failed with EACCES, because CreateFileW without FILE_FLAG_BACKUP_SEMANTICS refuses a directory. FreeBSD 11 fopen opens the path with _open (lib/libc/stdio/fopen.c), and vn_open_vnode refuses a directory only for FWRITE or O_TRUNC (sys/kern/vfs_vnops.c), so the call succeeds there. Linux hosts already open it. #8192 already made the write modes fail with EISDIR.

When the read-mode open fails with EACCES and the path is a directory, fopen now opens it as a libkernel directory descriptor (File::OpenDirectoryDescriptor), the same kind of descriptor open returns for a directory, and wraps it in the stream. libc cannot link libkernel, since libkernel links libc, so libkernel registers its open and forget functions in libc when it loads (LibcRegisterDirectoryStreams_nid_no_patch). fclose and freopen remove the descriptor from libkernel's table before closing it, so a later file that gets the same number is not taken for a directory.

The stream then behaves as a directory stream does on a Linux host:

  • a read (fread, fgetc, getc_unlocked, fgets, fscanf, vfscanf, and the wide and getc forms built on them) returns 0, EOF or NULL, sets the error indicator and sets errno to EISDIR. The Windows CRT already fails the read and sets the indicator (ReadFile on a directory handle fails with ERROR_INVALID_FUNCTION), but reports EINVAL, or leaves errno alone in fscanf, so a failed read on a directory stream with the indicator set reports EISDIR;
  • a write fails with EBADF and sets the error indicator, as on any read-only stream;
  • fseek, ftell, ungetc, fileno, feof, ferror, clearerr, setvbuf and the lock functions work, and fflush returns 0, as FreeBSD does on a stream that is not open for writing.

FreeBSD 11's own result for a read depends on the filesystem: tmpfs_read fails with EISDIR (sys/fs/tmpfs/tmpfs_vnops.c), while ffs_read returns the raw directory blocks (sys/ufs/ffs/ffs_vnops.c). What the console's filesystems return is unverified, which the entry in docs/dev/debt/libc.md records. That entry is also cut to the part that is still open: freopen onto a directory in a read mode still fails with EACCES.

Tests:

  • guest_filesystem opens a directory with r, rb and re on every host. For each mode it checks:

    • fileno plus sceKernelFstat report a directory;
    • feof, ferror and fflush return 0;
    • fread, fgetc, fgets and fscanf fail with EISDIR and set the error indicator, which clearerr clears;
    • fseek and ftell succeed at 0;
    • fputc fails with EBADF.

    On Windows it then closes the stream and checks that open of a regular file reuses the descriptor number and reads the file's data.

  • file_streams expected the old Windows EACCES for rb. It now checks wb and EISDIR on every host. That test links only libc, so no directory streams are registered in it.

Tested

Windows 11, WinLibs MinGW-w64 GCC 15.2.0 r7, CMake + Ninja, Release, -DBUILD_TESTING=ON, rebased on main 47fa6ee.

  • With main's libc, guest_filesystem fails at the fopen check: Filesystem check failed at line 609 (guest errno 13, EACCES).
  • With this PR, guest_filesystem passes. If fclose does not remove the descriptor from libkernel's table, guest_filesystem fails at the reuse check: read on the new file returns guest errno 21.
  • The Windows CRT results above (EINVAL from reads, EBADF from writes, fseek/ftell/ungetc succeeding) were measured with a standalone program on a _fdopen stream over a directory handle.
  • Full Windows build with -k 0: the only failure is the MinGW alignment error in ImageArrayWindowPastLayerLimit.cpp that is already on main (fixed by test(agc): align the array window test's texels at run time #8310). ctest -R "fopen|stdio|stream|file|directory|libc|scanf|printf|random|errors|dup|fcntl" -E "^agc_|guest_host_libc|windows_working_directory": 60 of 60 pass (the two excluded tests need the relinker binary); a test that timed out under parallel load passes when run alone.
  • python tools/check_conventions.py --base origin/main passes.
  • Not tested on Linux: no Linux machine here. The guest_filesystem block now runs there too. It expects glibc's behaviour on a directory stream (EISDIR from read, EBADF from a write to a read-only stream, lseek on a directory succeeding), so the Linux CI run is the check.
  • No title or homebrew run.

Overlap (tools/pr_overlap.py):

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

@github-actions

github-actions Bot commented Oct 20, 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 7447 core/libs/prx/libc/include/FileStream.hpp L277-278
PR 8520 core/libs/prx/libkernel/File/src/DirectoryDescriptor.cpp L6-14, L64-65

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 3965 docs/dev/debt/libc.md
PR 4627 core/libs/prx/libc/include/FileStream.hpp
core/libs/prx/libc/src/StandardStreams.cpp
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 6044 core/libs/tests/FileStreams.cpp
core/libs/tests/GuestFilesystem.cpp
PR 6469 docs/dev/debt/libc.md
PR 6819 docs/dev/debt/libc.md
PR 6879 docs/dev/debt/libc.md
PR 7081 core/libs/prx/libc/src/FileAndHeap.cpp
docs/dev/debt/libc.md
PR 7201 core/libs/prx/libc/include/FileStream.hpp
core/libs/prx/libc/src/FileAndHeap.cpp
core/libs/prx/libc/src/StandardStreams.cpp
core/libs/prx/libkernel/File/src/DirectoryDescriptor.cpp
PR 7648 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 8658 core/libs/prx/libc/include/FileStream.hpp
core/libs/prx/libc/src/FileAndHeap.cpp
core/libs/prx/libc/src/StandardStreams.cpp
core/libs/prx/libkernel/File/src/DirectoryDescriptor.cpp
PR 8726 core/libs/prx/libkernel/File/src/DirectoryDescriptor.cpp
PR 8965 docs/dev/debt/libc.md
PR 9408 docs/dev/debt/libc.md

@oneandonlydean

Copy link
Copy Markdown
Collaborator

@Retr0G28 Thanks. The FreeBSD references and the descriptor-reuse check make this easy to follow, and the registration from libkernel follows the existing LibcExit_nid_no_patch direction.

One request, about what a caller sees after the fopen succeeds. Today on Windows, fopen of a directory in a read mode returns NULL with EACCES, and code that probes a path that way just moves on. With this change the fopen succeeds, and then fread, fgetc, fgets, fseek, ftell or fprintf on the stream throw. So a caller that opens a path, then reads or measures it, goes from an error it can handle to a stop. On Linux the same stream already reads with EISDIR (the host's read refuses a directory), and FreeBSD's tmpfs_read does the same, as you noted.

Could the read functions on a directory stream fail the way the Linux host does instead of throwing: return 0 or EOF, set the stream's error flag, and set errno to EISDIR? Then a directory stream behaves the same on both hosts. If you'd rather keep the throw, a title or homebrew that needs fopen of a directory to succeed would show that the trade is worth it. The PR currently lists none.

After that, the bar is a full CI run with guest_filesystem and file_streams passing on Linux and Windows. #8520 also edits the top of DirectoryDescriptor.cpp, so whichever merges second needs the small rebase you mentioned.

@Retr0G28
Retr0G28 force-pushed the fix/fopen-directory-read-windows branch from c0a66be to 40764df Compare October 21, 2026 01:30
@Retr0G28

Copy link
Copy Markdown
Contributor Author

@oneandonlydean Thanks, agreed: a stop where the caller had an error it could handle is the wrong trade, so the throws are gone. Rebased on main (40764df).

A directory stream now behaves the way it does on a Linux host:

  • Reads (fread, fgetc, getc_unlocked, fgets, fscanf, vfscanf, and the wide and getc forms built on them) return 0, EOF or NULL, set the error indicator, and set errno to EISDIR.
  • Writes fail with EBADF and set the indicator.
  • fseek, ftell and ungetc work.

Most of this comes from the host for free. I measured a _fdopen stream over a directory handle: the CRT already fails reads and sets the indicator (ReadFile gives ERROR_INVALID_FUNCTION), fails writes with EBADF, and lets seeks succeed. The difference is errno: reads report EINVAL, or leave errno unchanged in fscanf. So after a failed read on a directory stream with the indicator set, errno becomes EISDIR. A directory read never reaches end of file, so the indicator can only mean the refused read.

The guest_filesystem block now runs on every host and checks the read, write and seek results above. Only the descriptor-reuse check stays Windows-only. I have no Linux machine, so the Linux leg of the CI run is the check there. The debt entry now records that what the console's filesystems return for a directory read is unverified (FreeBSD 11 tmpfs gives EISDIR, FFS returns the raw blocks).

@github-actions github-actions Bot added needs rebase Conflicts with main, rebase needed and removed needs rebase Conflicts with main, rebase needed labels Oct 21, 2026
@Retr0G28
Retr0G28 force-pushed the fix/fopen-directory-read-windows branch from 40764df to f6e4c06 Compare October 21, 2026 02:13
@github-actions github-actions Bot added needs rebase Conflicts with main, rebase needed and removed needs rebase Conflicts with main, rebase needed labels Oct 21, 2026
FreeBSD 11 fopen opens the path with _open, and vn_open_vnode refuses a
directory only for FWRITE or O_TRUNC, so fopen of a directory in a read
mode succeeds; Linux hosts already open it. On Windows CreateFileW
without FILE_FLAG_BACKUP_SEMANTICS refused it with EACCES.

fopen now opens such a path as a libkernel directory descriptor, which
libkernel registers in libc when it loads, and fclose and freopen remove
it from libkernel's table. Reads on the stream fail with EISDIR and set
the error indicator, as on a Linux host and FreeBSD's tmpfs: the Windows
CRT fails them already but reports EINVAL. Writes fail with EBADF, and
fseek, ftell and ungetc work, as on any read-only stream.
@Retr0G28
Retr0G28 force-pushed the fix/fopen-directory-read-windows branch from f6e4c06 to 3622674 Compare October 21, 2026 11:59
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 21, 2026
@cadenasynyster

Copy link
Copy Markdown
Contributor

Independent Windows run of this PR (head 3622674).

Environment: Windows 11 (10.0.26300), Ryzen 7 9800X3D, WinLibs GCC 15.2.0 r7 (MinGW), Release, BUILD_TESTING=ON. I built only the libs and the five test targets below.

Results:

  • PR head: guest_filesystem, file_streams, guest_fopen_long_host_path, guest_posix_filesystem and guest_directory all pass (5/5).
  • Baseline: I kept the PR's tests and CMake and replaced the five production files (FileStream.hpp, FileAndHeap.cpp, Formatting.cpp, StandardStreams.cpp, DirectoryDescriptor.cpp) with their merge-base versions, then rebuilt the libs and guest_filesystem. guest_filesystem fails with exit code 0xc0000409. I did not capture which check inside the test failed, so I can't confirm it is the line 609 failure described in the PR. I restored the PR's files afterwards.

Not run: Linux/macOS, the rest of the 60-test selection in the description, the full repository CTest, and any title.

AI-assisted: yes

@oneandonlydean

Copy link
Copy Markdown
Collaborator

@Retr0G28 thanks, this is what I hoped for: reading a directory stream now fails with EISDIR and sets the error indicator instead of throwing, and the new cases cover fread, fgetc, fgets and fscanf. Sorry for the slow reply. I'm sending it for a full CI run.

@oneandonlydean
oneandonlydean merged commit 746bdd8 into boykopovar:main Oct 22, 2026
18 of 19 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.

3 participants