Merge pull request #4819 from oneandonlydean/up4/fix-kqueue-test-flake

fix(libkernel): pair the inotify halves of a rename in a watched directory
This commit is contained in:
Dean 2026-10-13 12:03:30 +13:00 committed by GitHub
commit 90c683946f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 140 additions and 35 deletions

View File

@ -18,6 +18,7 @@
#include <io.h>
#include "prx/libkernel/File/include/DirectoryDescriptor.hpp"
#else
#include <dirent.h>
#include <fcntl.h>
#include <poll.h>
#include <sys/eventfd.h>
@ -331,18 +332,22 @@ int OpenHostWatch(int descriptor, std::uint32_t notes, HostWatch& host) {
struct HostWatch {
int notify = -1;
int target = -1;
int entries = -1;
bool directory = false;
struct stat last {};
};
struct HostEvent {
std::uint32_t mask;
std::uint32_t cookie;
};
void CloseHostWatch(HostWatch& host) {
if (host.notify >= 0) {
::close(host.notify);
host.notify = -1;
}
if (host.target >= 0) {
::close(host.target);
host.target = -1;
for (int* descriptor : {&host.notify, &host.target, &host.entries}) {
if (*descriptor >= 0) {
::close(*descriptor);
*descriptor = -1;
}
}
}
@ -376,9 +381,73 @@ std::uint32_t ModifyHint(HostWatch& host) {
return hint;
}
int ReadHostEvents(const HostWatch& host, std::vector<HostEvent>& events) {
alignas(inotify_event) char buffer[16384];
for (;;) {
const auto length = ::read(host.notify, buffer, sizeof(buffer));
if (length < 0) {
if (errno == EINTR) {
continue;
}
return errno == EAGAIN ? 0 : errno;
}
if (length == 0) {
return 0;
}
for (char* cursor = buffer; cursor < buffer + length;) {
const auto* event = reinterpret_cast<const inotify_event*>(cursor);
events.push_back({event->mask, event->cookie});
cursor += sizeof(inotify_event) + event->len;
}
}
}
std::unordered_map<std::uint32_t, int> CountMoves(const std::vector<HostEvent>& events) {
std::unordered_map<std::uint32_t, int> moves;
for (const auto& event : events) {
if ((event.mask & (IN_MOVED_FROM | IN_MOVED_TO)) != 0) {
++moves[event.cookie];
}
}
return moves;
}
bool HasUnpairedMove(const std::vector<HostEvent>& events, std::size_t first) {
const auto moves = CountMoves(events);
for (std::size_t index = first; index < events.size(); ++index) {
if ((events[index].mask & IN_MOVED_FROM) != 0 && moves.at(events[index].cookie) == 1) {
return true;
}
}
return false;
}
int WaitForRenamesInProgress(const HostWatch& host) {
char entries[1024];
if (::getdents64(host.entries, entries, sizeof(entries)) < 0 && errno != ENOENT) {
return errno;
}
return 0;
}
int ReadSettledHostEvents(const HostWatch& host, std::vector<HostEvent>& events) {
if (const int error = ReadHostEvents(host, events)) {
return error;
}
for (std::size_t settled = 0; HasUnpairedMove(events, settled);) {
settled = events.size();
if (const int error = WaitForRenamesInProgress(host)) {
return error;
}
if (const int error = ReadHostEvents(host, events)) {
return error;
}
}
return 0;
}
void RunHostWatch(std::shared_ptr<VnodeWatch> watch, HostWatch host) {
pollfd waits[2] = {{host.notify, POLLIN, 0}, {watch->StopEvent(), POLLIN, 0}};
alignas(inotify_event) char buffer[16384];
bool watching = true;
while (watching) {
if (::poll(waits, 2, -1) < 0) {
@ -391,48 +460,36 @@ void RunHostWatch(std::shared_ptr<VnodeWatch> watch, HostWatch host) {
if (waits[1].revents != 0) {
break;
}
const auto length = ::read(host.notify, buffer, sizeof(buffer));
if (length < 0) {
if (errno == EINTR || errno == EAGAIN) {
continue;
}
std::vector<HostEvent> events;
if (const int error = ReadSettledHostEvents(host, events)) {
CloseHostWatch(host);
throw std::runtime_error("EVFILT_VNODE: reading inotify failed, errno=" + std::to_string(errno));
}
std::unordered_map<std::uint32_t, int> moves;
for (char* cursor = buffer; cursor < buffer + length;) {
const auto* event = reinterpret_cast<const inotify_event*>(cursor);
if ((event->mask & (IN_MOVED_FROM | IN_MOVED_TO)) != 0) {
++moves[event->cookie];
}
cursor += sizeof(inotify_event) + event->len;
throw std::runtime_error("EVFILT_VNODE: reading inotify failed, errno=" + std::to_string(error));
}
const auto moves = CountMoves(events);
std::vector<std::uint32_t> hints;
for (char* cursor = buffer; cursor < buffer + length;) {
const auto* event = reinterpret_cast<const inotify_event*>(cursor);
const std::uint32_t link = (event->mask & IN_ISDIR) != 0 ? NOTE_LINK : 0;
if ((event->mask & IN_MODIFY) != 0) {
for (const auto& event : events) {
const std::uint32_t link = (event.mask & IN_ISDIR) != 0 ? NOTE_LINK : 0;
if ((event.mask & IN_MODIFY) != 0) {
hints.push_back(ModifyHint(host));
}
if ((event->mask & IN_ATTRIB) != 0) {
if ((event.mask & IN_ATTRIB) != 0) {
hints.push_back(AttributeHint(host));
}
if ((event->mask & (IN_CREATE | IN_DELETE)) != 0) {
if ((event.mask & (IN_CREATE | IN_DELETE)) != 0) {
hints.push_back(NOTE_WRITE | link);
}
if ((event->mask & (IN_MOVED_FROM | IN_MOVED_TO)) != 0) {
hints.push_back(moves[event->cookie] > 1 ? NOTE_WRITE : NOTE_WRITE | NOTE_EXTEND | link);
if ((event.mask & (IN_MOVED_FROM | IN_MOVED_TO)) != 0) {
hints.push_back(moves.at(event.cookie) > 1 ? NOTE_WRITE : NOTE_WRITE | NOTE_EXTEND | link);
}
if ((event->mask & IN_MOVE_SELF) != 0) {
if ((event.mask & IN_MOVE_SELF) != 0) {
hints.push_back(NOTE_RENAME);
}
if ((event->mask & IN_DELETE_SELF) != 0) {
if ((event.mask & IN_DELETE_SELF) != 0) {
hints.push_back(NOTE_DELETE);
}
if ((event->mask & IN_IGNORED) != 0) {
if ((event.mask & IN_IGNORED) != 0) {
watching = false;
}
cursor += sizeof(inotify_event) + event->len;
}
for (const auto hint : hints) {
watch->Deliver(hint);
@ -460,6 +517,14 @@ int OpenHostWatch(int descriptor, std::uint32_t, HostWatch& host) {
CloseHostWatch(host);
throw std::runtime_error("EVFILT_VNODE: opening the watched file failed, errno=" + std::to_string(error));
}
if (host.directory) {
host.entries = ::open(path.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (host.entries < 0) {
const int error = errno;
CloseHostWatch(host);
throw std::runtime_error("EVFILT_VNODE: opening the watched directory failed, errno=" + std::to_string(error));
}
}
host.notify = ::inotify_init1(IN_CLOEXEC | IN_NONBLOCK);
std::uint32_t mask = IN_MODIFY | IN_ATTRIB | IN_MOVE_SELF | IN_DELETE_SELF;
if (host.directory) {

View File

@ -330,6 +330,45 @@ static void RenameAndDeleteWatchedFile() {
Require(close_nid_postfix(file) == 0);
Require(close_nid_postfix(kq) == 0);
}
static std::uint32_t NotesThrough(int kq, std::uintptr_t ident, std::uint32_t last) {
std::uint32_t notes = 0;
KernelEvent events[4]{};
while ((notes & last) == 0) {
Require(Wait(kq, events, 4) == 1 && events[0].ident == ident);
notes |= events[0].fflags;
}
return notes;
}
static void MoveDirectoryEntries() {
const auto path = TemporaryName("moves");
const auto outside = path + "-outside";
Require(mkdir_nid_postfix(path.c_str(), 0755) == 0 && mkdir_nid_postfix(outside.c_str(), 0755) == 0);
const int child = open_nid_postfix((path + "/first.bin").c_str(), ReadWrite | Create, 0644);
Require(child >= 0 && close_nid_postfix(child) == 0);
const int directory = open_nid_postfix(path.c_str(), Directory, 0);
Require(directory >= 0);
const int kq = kqueue_nid_postfix();
KernelEvent events[4]{};
const auto ident = static_cast<std::uintptr_t>(directory);
Require(Apply(kq, Change(ident, EvfiltVnode, EvAdd | EvClear, NoteWrite | NoteExtend | NoteLink)) == 0);
for (int i = 0; i < 64; ++i) {
Require(sceKernelRename((path + "/first.bin").c_str(), (path + "/second.bin").c_str()) == SceOk);
Require(sceKernelRename((path + "/second.bin").c_str(), (path + "/first.bin").c_str()) == SceOk);
}
Require(mkdir_nid_postfix((path + "/sub").c_str(), 0755) == 0);
Require(NotesThrough(kq, ident, NoteLink) == (NoteWrite | NoteLink));
Require(sceKernelRename((path + "/first.bin").c_str(), (outside + "/first.bin").c_str()) == SceOk);
Require(Wait(kq, events, 4) == 1 && events[0].ident == ident && events[0].fflags == (NoteWrite | NoteExtend));
Require(sceKernelRename((outside + "/first.bin").c_str(), (path + "/first.bin").c_str()) == SceOk);
Require(Wait(kq, events, 4) == 1 && events[0].ident == ident && events[0].fflags == (NoteWrite | NoteExtend));
Require(sceKernelRename((path + "/sub").c_str(), (outside + "/sub").c_str()) == SceOk);
Require(Wait(kq, events, 4) == 1 && events[0].ident == ident && events[0].fflags == (NoteWrite | NoteExtend | NoteLink));
Require(close_nid_postfix(directory) == 0);
Require(close_nid_postfix(kq) == 0);
Require(std::filesystem::remove_all(path) == 2 && std::filesystem::remove_all(outside) == 2);
}
#endif
static void DirectoryWatch() {
@ -381,6 +420,7 @@ int main() {
FileWatch();
#ifndef _WIN32
RenameAndDeleteWatchedFile();
MoveDirectoryEntries();
#endif
DirectoryWatch();
Timeout();

View File

@ -94,7 +94,7 @@
- [JIT shared memory](../../../core/libs/prx/libkernel/DirectMemory/Jit.cpp) (libkernel) - `sceKernelJitCreateSharedMemory`, `sceKernelJitCreateAliasOfSharedMemory` and `sceKernelJitMapSharedMemory` take the signatures of [Orbital](https://github.com/AlexAltea/orbital)'s `tools/ida/db_types.json` (name, length, maximum protection, descriptor out; descriptor, maximum protection, descriptor out; descriptor, protection, address in and out), which ps4sdk, ps5-jar-loader and the call sites in RetroArch PPSA99169, XPSemu PPSA97358 and VLC PPSA85300 match; SharpProspero declares the alias as returning the descriptor. The object is a host anonymous file shared by its descriptors (a `memfd` on Linux, a delete-on-close temporary file mapped as an executable section on Windows), the alias is a duplicate descriptor, and every map maps the whole object, so writes through one view show through the others; a non-null address is a hint, as in `mmap` without `MAP_FIXED`. Not measured on the console: a length that is zero or not a multiple of 16 KiB and protection bits other than CPU read, write and execute are `EINVAL`; an alias or a map asking for more than the descriptor's maximum protection (write and execute counting as read) is `EACCES`, as FreeBSD 11's `vm_map_protect` treats a protection past `max_protection`; a descriptor that is not a JIT object is `EBADF`. The name is ignored, the objects do not count against the flexible memory budget, `mprotect` of a view is not limited to the maximum protection, and `mmap` of the descriptors is unsupported, as for `shm_open`. On Linux the `memfd` is created without `MFD_EXEC`, which Linux 6.3 added and older kernels reject with `EINVAL`, and relies on the protection of the mapping: the flag only sets the file's execute permission bits, and the direct memory `memfd` is mapped executable the same way; a host with `vm.memfd_noexec` set to 2 refuses the creation and the call throws
- [kqueue and kevent](../../../core/libs/prx/libkernel/Equeue/src/Kqueue.cpp) (libkernel) follow FreeBSD 11's [`kern_event.c`](https://github.com/freebsd/freebsd-src/blob/34bd34750187bae02a0086402c78c031c59dd8b3/sys/kern/kern_event.c) (`kqueue_kevent`, `kqueue_register`, `kqueue_scan`, the timer and user filters) over the event queue; nothing is measured on a console. The descriptor is a host null device registered as the kqueue: `close` and `sceKernelClose` close the queue and `dup`/`dup2` of it throw, but `read`, `write` and `fstat` act on the null device instead of failing with `EOPNOTSUPP` or reporting `S_IFIFO`. Read and write events are supported on guest sockets only, plus the timer, user and [vnode](../../../core/libs/prx/libkernel/Equeue/src/FileEvent.cpp) filters; other filters, `EV_DISABLE`, `EV_DISPATCH`, `EV_FORCEONESHOT`, `NOTE_LOWAT`, `EV_CLEAR` on a socket, `EVFILT_READ` on another kqueue, a `NOTE_NSECONDS` period above one second with a fraction (11.0 converts the fraction with the microsecond macro), any change to an existing timer (11.0 keeps the armed expiry and applies the new period after it, 11.4's `filt_timertouch` restarts it) and a negative `nevents` throw. A new user event keeps its `fflags` unmasked and is not triggered by `NOTE_TRIGGER`, because `filt_userattach` reads the already cleared `kn_fflags`. The timer limit `kq_calloutmax` (`ENOMEM`) is not enforced, and closing a kqueue wakes a thread waiting on it in `kevent` with `EBADF`, where `kqueue_drain` makes the close wait for that thread. A level-triggered event is reported once per call and moved behind the other events, as in `kqueue_scan`; the `sceKernel*Equeue` calls keep their own collection loop
- [EVFILT_VNODE, sceKernelAddFileEvent and sceKernelDeleteFileEvent](../../../core/libs/prx/libkernel/Equeue/src/FileEvent.cpp) (libkernel) watch the host file or directory behind the descriptor and turn host changes into FreeBSD 11 vnode hints, which [`filt_vfsvnode`](https://github.com/freebsd/freebsd-src/blob/34bd34750187bae02a0086402c78c031c59dd8b3/sys/kern/vfs_subr.c#L5032) adds whole when they share a bit with the requested `fflags`: a write that grows a file is `NOTE_WRITE | NOTE_EXTEND`, another write `NOTE_WRITE`, a shrinking truncate or an attribute change `NOTE_ATTRIB`, a higher link count `NOTE_LINK`, a lower one or a pending delete `NOTE_DELETE`, a new path `NOTE_RENAME`; in a directory, an entry created, removed or renamed is `NOTE_WRITE`, with `NOTE_LINK` for a subdirectory, and on Linux an entry moved from or to another directory adds `NOTE_EXTEND`; a directory registered with `NOTE_EXTEND` on Windows is accepted, as `vfs_kqfilter` does not check it, but never receives it, because the directory handle cannot tell a move between directories from a removal plus an addition. FreeBSD's hints depend on the filesystem and the console's filesystems are not known: a growing `ftruncate` reports `NOTE_WRITE | NOTE_EXTEND` where `vop_setattr_post` reports `NOTE_ATTRIB`, and on Windows `utimes` on a file reports `NOTE_WRITE`. Windows uses `FindFirstChangeNotification` on the parent directory (and on a watched directory itself) and compares `GetFileInformationByHandleEx` snapshots at least every 50 ms, because a write through an open handle is reported only when the handle is flushed or closed, so file hints can arrive 50 ms late; Linux uses inotify on the file and is not run. `NOTE_REVOKE`, `NOTE_OPEN`, `NOTE_CLOSE`, `NOTE_CLOSE_WRITE`, `NOTE_READ`, unknown bits and descriptors other than files, directories, pipes and sockets throw. `sceKernelAddFileEvent(eq, fd, watch, udata)` and `sceKernelDeleteFileEvent(eq, fd)` take the [OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/0a1aaf9d/include/orbis/libkernel.h#L35) and [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/5dfdb4b0/src/SharpProspero/Interop/Kernel/KernelEqueue.cs#L164) signatures; `watch` is taken as the `fflags` and the event is added with `EV_CLEAR`, neither confirmed on a title
- [EVFILT_VNODE, sceKernelAddFileEvent and sceKernelDeleteFileEvent](../../../core/libs/prx/libkernel/Equeue/src/FileEvent.cpp) (libkernel) watch the host file or directory behind the descriptor and turn host changes into FreeBSD 11 vnode hints, which [`filt_vfsvnode`](https://github.com/freebsd/freebsd-src/blob/34bd34750187bae02a0086402c78c031c59dd8b3/sys/kern/vfs_subr.c#L5032) adds whole when they share a bit with the requested `fflags`: a write that grows a file is `NOTE_WRITE | NOTE_EXTEND`, another write `NOTE_WRITE`, a shrinking truncate or an attribute change `NOTE_ATTRIB`, a higher link count `NOTE_LINK`, a lower one or a pending delete `NOTE_DELETE`, a new path `NOTE_RENAME`; in a directory, an entry created, removed or renamed is `NOTE_WRITE`, with `NOTE_LINK` for a subdirectory, and on Linux an entry moved from or to another directory adds `NOTE_EXTEND`; inotify queues the two halves of a rename one after the other, so a watcher woken by the first can read it alone, and a half read without the other is classified only after a `getdents64` on the watched directory, which takes the directory lock that `rename` holds until both halves are queued ([`do_renameat2`](https://github.com/torvalds/linux/blob/v6.8/fs/namei.c#L4975) around `vfs_rename` and its `fsnotify_move`, [`iterate_dir`](https://github.com/torvalds/linux/blob/v6.8/fs/readdir.c#L103)), an ordering taken from the kernel source, not from inotify(7), which promises none; a directory registered with `NOTE_EXTEND` on Windows is accepted, as `vfs_kqfilter` does not check it, but never receives it, because the directory handle cannot tell a move between directories from a removal plus an addition. FreeBSD's hints depend on the filesystem and the console's filesystems are not known: a growing `ftruncate` reports `NOTE_WRITE | NOTE_EXTEND` where `vop_setattr_post` reports `NOTE_ATTRIB`, and on Windows `utimes` on a file reports `NOTE_WRITE`. Windows uses `FindFirstChangeNotification` on the parent directory (and on a watched directory itself) and compares `GetFileInformationByHandleEx` snapshots at least every 50 ms, because a write through an open handle is reported only when the handle is flushed or closed, so file hints can arrive 50 ms late; Linux uses inotify on the file and is not run. `NOTE_REVOKE`, `NOTE_OPEN`, `NOTE_CLOSE`, `NOTE_CLOSE_WRITE`, `NOTE_READ`, unknown bits and descriptors other than files, directories, pipes and sockets throw. `sceKernelAddFileEvent(eq, fd, watch, udata)` and `sceKernelDeleteFileEvent(eq, fd)` take the [OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/0a1aaf9d/include/orbis/libkernel.h#L35) and [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/5dfdb4b0/src/SharpProspero/Interop/Kernel/KernelEqueue.cs#L164) signatures; `watch` is taken as the `fflags` and the event is added with `EV_CLEAR`, neither confirmed on a title
- [sem_timedwait](../../../core/libs/prx/libkernel/Pthread/Posix/Sem.cpp) (libkernel) consumes an available count before checking the timeout and waits indefinitely for a null timeout, following FreeBSD 11's `sem_timedwait`. Console behavior for null and invalid timeouts when a count is available is unverified.
- [tcsendbreak, tcdrain, tcflush, tcflow, tcgetpgrp, tcsetpgrp](../../../core/libs/prx/libkernel/File/src/Terminal.cpp) (libkernel) follow FreeBSD 11.4's [`termios.c`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/lib/libc/gen/termios.c): `tcflush` answers `EINVAL` for a queue other than `TCIFLUSH`, `TCOFLUSH` or `TCIOFLUSH` and `tcflow` for an action outside `TCOOFF`..`TCION` before the descriptor is looked at, and every other call fails as its terminal ioctl does on a descriptor that is not a terminal, with the errors `tcgetattr` gives, so `tcsendbreak` never reaches its 0.4 s pause. FreeBSD's `pipe_ioctl` answers `TIOCGPGRP` and `TIOCSPGRP` on a pipe or FIFO with the descriptor's SIGIO owner, which is not modelled, so `tcgetpgrp` and `tcsetpgrp` throw on a host pipe or FIFO above descriptor 2; descriptors 0 to 2 are taken as non-terminals that are not pipes, whatever the host attaches to them, so a pipe the guest creates on a freed descriptor 0 to 2 gets `ENOTTY` there. The PS5 `libkernel` is assumed to use this FreeBSD code, and `tcdrain` is not a cancellation point here
- [Windows file timestamps](../../../core/libs/prx/libkernel/File/include/WindowsFileTime.hpp) use the host filesystem's timestamp range and resolution. `utimes` and `futimes` reject timestamps outside nonnegative signed 64-bit Windows file-time ticks with `EINVAL`, including exactly 1601-01-01 00:00:00 UTC, whose zero value means "leave unchanged" to `SetFileTime`. Guest directory descriptors still resolve through their recorded path. Filesystems with coarser timestamp resolution cannot retain every guest microsecond