From 8ca528217c82ab8966a7c661ba92fa11975f5b26 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Tue, 17 Jan 2023 14:43:36 -0500 Subject: [PATCH] LibCore: Implement FileWatcher for Linux This implements FileWatcher using inotify filesystem events. Serenity's InodeWatcher is remarkably similar to inotify, so this is almost an identical implementation. The existing TestLibCoreFileWatcher test is added to Lagom (currently just for Linux). This does not implement BlockingFileWatcher as that is currently not used anywhere but on Serenity. --- Meta/Lagom/CMakeLists.txt | 4 + Tests/LibCore/TestLibCoreFileWatcher.cpp | 4 +- Userland/Libraries/LibCore/CMakeLists.txt | 4 +- .../Libraries/LibCore/FileWatcherLinux.cpp | 175 ++++++++++++++++++ 4 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 Userland/Libraries/LibCore/FileWatcherLinux.cpp diff --git a/Meta/Lagom/CMakeLists.txt b/Meta/Lagom/CMakeLists.txt index a143b030c1..0567b9882c 100644 --- a/Meta/Lagom/CMakeLists.txt +++ b/Meta/Lagom/CMakeLists.txt @@ -573,6 +573,10 @@ if (BUILD_LAGOM) # LibCore lagom_test(../../Tests/LibCore/TestLibCoreIODevice.cpp WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/../../Tests/LibCore) + if (LINUX AND NOT EMSCRIPTEN) + lagom_test(../../Tests/LibCore/TestLibCoreFileWatcher.cpp) + endif() + # Crypto file(GLOB LIBCRYPTO_TESTS CONFIGURE_DEPENDS "../../Tests/LibCrypto/*.cpp") foreach(source ${LIBCRYPTO_TESTS}) diff --git a/Tests/LibCore/TestLibCoreFileWatcher.cpp b/Tests/LibCore/TestLibCoreFileWatcher.cpp index 6e4b499634..2d384f1cb8 100644 --- a/Tests/LibCore/TestLibCoreFileWatcher.cpp +++ b/Tests/LibCore/TestLibCoreFileWatcher.cpp @@ -28,10 +28,10 @@ TEST_CASE(file_watcher_child_events) file_watcher->on_change = [&](Core::FileWatcherEvent const& event) { if (event_count == 0) { EXPECT_EQ(event.event_path, "/tmp/testfile"); - EXPECT_EQ(event.type, Core::FileWatcherEvent::Type::ChildCreated); + EXPECT(has_flag(event.type, Core::FileWatcherEvent::Type::ChildCreated)); } else if (event_count == 1) { EXPECT_EQ(event.event_path, "/tmp/testfile"); - EXPECT_EQ(event.type, Core::FileWatcherEvent::Type::ChildDeleted); + EXPECT(has_flag(event.type, Core::FileWatcherEvent::Type::ChildDeleted)); event_loop.quit(0); } diff --git a/Userland/Libraries/LibCore/CMakeLists.txt b/Userland/Libraries/LibCore/CMakeLists.txt index 9465e4b3b6..4deb6fed09 100644 --- a/Userland/Libraries/LibCore/CMakeLists.txt +++ b/Userland/Libraries/LibCore/CMakeLists.txt @@ -44,9 +44,11 @@ if (NOT ANDROID AND NOT WIN32 AND NOT EMSCRIPTEN) ) endif() -# FIXME: Implement Core::FileWatcher for Linux, macOS, *BSD, and Windows. +# FIXME: Implement Core::FileWatcher for macOS, *BSD, and Windows. if (SERENITYOS) list(APPEND SOURCES FileWatcherSerenity.cpp) +elseif (LINUX AND NOT EMSCRIPTEN) + list(APPEND SOURCES FileWatcherLinux.cpp) else() list(APPEND SOURCES FileWatcherUnimplemented.cpp) endif() diff --git a/Userland/Libraries/LibCore/FileWatcherLinux.cpp b/Userland/Libraries/LibCore/FileWatcherLinux.cpp new file mode 100644 index 0000000000..48d3998672 --- /dev/null +++ b/Userland/Libraries/LibCore/FileWatcherLinux.cpp @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "FileWatcher.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(AK_OS_LINUX) +static_assert(false, "This file must only be used for Linux"); +#endif + +namespace Core { + +static constexpr unsigned inode_watcher_flags_to_inotify_flags(InodeWatcherFlags flags) +{ + unsigned result = 0; + + if ((flags & InodeWatcherFlags::Nonblock) != InodeWatcherFlags::None) + result |= IN_NONBLOCK; + if ((flags & InodeWatcherFlags::CloseOnExec) != InodeWatcherFlags::None) + result |= IN_CLOEXEC; + + return result; +} + +static Optional get_event_from_fd(int fd, HashMap const& wd_to_path) +{ + static constexpr auto max_event_size = sizeof(inotify_event) + NAME_MAX + 1; + + // Note from INOTIFY(7) man page: + // + // Some systems cannot read integer variables if they are not properly aligned. On other + // systems, incorrect alignment may decrease performance. Hence, the buffer used for reading + // from the inotify file descriptor should have the same alignment as inotify_event. + alignas(alignof(inotify_event)) Array buffer; + ssize_t rc = ::read(fd, buffer.data(), buffer.size()); + + if (rc == 0) { + return {}; + } else if (rc < 0) { + dbgln_if(FILE_WATCHER_DEBUG, "get_event_from_fd: Reading from wd {} failed: {}", fd, strerror(errno)); + return {}; + } + + auto const* event = reinterpret_cast(buffer.data()); + FileWatcherEvent result; + + auto it = wd_to_path.find(event->wd); + if (it == wd_to_path.end()) { + dbgln_if(FILE_WATCHER_DEBUG, "get_event_from_fd: Got an event for a non-existent wd {}?!", event->wd); + return {}; + } + + auto const& path = it->value; + + if ((event->mask & IN_CREATE) != 0) + result.type |= FileWatcherEvent::Type::ChildCreated; + if ((event->mask & IN_DELETE) != 0) + result.type |= FileWatcherEvent::Type::ChildDeleted; + if ((event->mask & IN_DELETE_SELF) != 0) + result.type |= FileWatcherEvent::Type::Deleted; + if ((event->mask & IN_MODIFY) != 0) + result.type |= FileWatcherEvent::Type::ContentModified; + if ((event->mask & IN_ATTRIB) != 0) + result.type |= FileWatcherEvent::Type::MetadataModified; + + if (result.type == FileWatcherEvent::Type::Invalid) { + warnln("Unknown event type {:x} returned by the watch_file descriptor for {}", event->mask, path); + return {}; + } + + if (event->len > 0) { + StringView child_name { event->name, strlen(event->name) }; + result.event_path = LexicalPath::join(path, child_name).string(); + } else { + result.event_path = path; + } + + dbgln_if(FILE_WATCHER_DEBUG, "get_event_from_fd: got event from wd {} on '{}' type {}", fd, result.event_path, result.type); + return result; +} + +ErrorOr> FileWatcher::create(InodeWatcherFlags flags) +{ + auto watcher_fd = ::inotify_init1(inode_watcher_flags_to_inotify_flags(flags | InodeWatcherFlags::CloseOnExec)); + if (watcher_fd < 0) + return Error::from_errno(errno); + + auto notifier = TRY(Notifier::try_create(watcher_fd, Notifier::Event::Read)); + return adopt_nonnull_ref_or_enomem(new (nothrow) FileWatcher(watcher_fd, move(notifier))); +} + +FileWatcher::FileWatcher(int watcher_fd, NonnullRefPtr notifier) + : FileWatcherBase(watcher_fd) + , m_notifier(move(notifier)) +{ + m_notifier->on_ready_to_read = [this] { + auto maybe_event = get_event_from_fd(m_notifier->fd(), m_wd_to_path); + if (maybe_event.has_value()) { + auto event = maybe_event.value(); + on_change(event); + + if (has_flag(event.type, FileWatcherEvent::Type::Deleted)) { + auto result = remove_watch(event.event_path); + if (result.is_error()) { + dbgln_if(FILE_WATCHER_DEBUG, "on_ready_to_read: {}", result.error()); + } + } + } + }; +} + +FileWatcher::~FileWatcher() = default; + +ErrorOr FileWatcherBase::add_watch(DeprecatedString path, FileWatcherEvent::Type event_mask) +{ + if (m_path_to_wd.find(path) != m_path_to_wd.end()) { + dbgln_if(FILE_WATCHER_DEBUG, "add_watch: path '{}' is already being watched", path); + return false; + } + + unsigned inotify_mask = 0; + + if (has_flag(event_mask, FileWatcherEvent::Type::ChildCreated)) + inotify_mask |= IN_CREATE; + if (has_flag(event_mask, FileWatcherEvent::Type::ChildDeleted)) + inotify_mask |= IN_DELETE; + if (has_flag(event_mask, FileWatcherEvent::Type::Deleted)) + inotify_mask |= IN_DELETE_SELF; + if (has_flag(event_mask, FileWatcherEvent::Type::ContentModified)) + inotify_mask |= IN_MODIFY; + if (has_flag(event_mask, FileWatcherEvent::Type::MetadataModified)) + inotify_mask |= IN_ATTRIB; + + int watch_descriptor = ::inotify_add_watch(m_watcher_fd, path.characters(), inotify_mask); + if (watch_descriptor < 0) + return Error::from_errno(errno); + + m_path_to_wd.set(path, watch_descriptor); + m_wd_to_path.set(watch_descriptor, path); + + dbgln_if(FILE_WATCHER_DEBUG, "add_watch: watching path '{}' on InodeWatcher {} wd {}", path, m_watcher_fd, watch_descriptor); + return true; +} + +ErrorOr FileWatcherBase::remove_watch(DeprecatedString path) +{ + auto it = m_path_to_wd.find(path); + if (it == m_path_to_wd.end()) { + dbgln_if(FILE_WATCHER_DEBUG, "remove_watch: path '{}' is not being watched", path); + return false; + } + + if (::inotify_rm_watch(m_watcher_fd, it->value) < 0) + return Error::from_errno(errno); + + m_path_to_wd.remove(it); + m_wd_to_path.remove(it->value); + + dbgln_if(FILE_WATCHER_DEBUG, "remove_watch: stopped watching path '{}' on InodeWatcher {}", path, m_watcher_fd); + return true; +} + +}