diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54bff43..def45bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,11 @@ name: CI # run under node (checks/emscripten); a program that starts one # fails at LINK naming the openkal symbol (checks/absent) # -# The library is built with the released mcpp, 2026.11.7.2, which resolves -# within the platform's version group (an earlier mcpp takes the newest member -# of a range and then refuses it against the group). The ok images +# The library is built with the released mcpp, 2026.11.9.1: its tests are +# mtest's, and mtest uses mcpplibs.os, so the package being built has to stand +# for itself in its tests' graph, which mcpp does from 2026.11.9.1; it also +# resolves within the platform's version group (an earlier mcpp takes the newest +# member of a range and then refuses it against the group). The ok images # (MCPP_PLATFORM) and checks/hosted select openkal-platform 0.24 and its # OpenKal 0.24 implementation, musl 0.28 and C++ runtime 0.19 group. The library # itself needs 2026.10.27.1 or later, the first that selects a platform @@ -37,7 +39,7 @@ env: MCPP_SOURCE_REF: ${{ github.event.inputs.mcpp_ref || vars.MCPP_SOURCE_REF || '' }} # 2026.11.3.1: the std module compiled with the platform's words (openkal-platform 0.23.0's # `-fcf-protection=full` refused every `import std` of an earlier mcpp's). - MCPP_INSTALL: ${{ (github.event.inputs.mcpp_ref || vars.MCPP_SOURCE_REF) && 'mcpp' || 'mcpp@2026.11.7.2' }} + MCPP_INSTALL: ${{ (github.event.inputs.mcpp_ref || vars.MCPP_SOURCE_REF) && 'mcpp' || 'mcpp@2026.11.9.1' }} XLINGS_VERSION: v2026.10.26.1 XLINGS_NON_INTERACTIVE: '1' @@ -79,7 +81,10 @@ jobs: - name: The mcpp under cross-validation run: | + # The configured mirror (GLOBAL), then CN (GitCode first) from the fourth attempt: a cached index + # can predate the release, and xlings keeps it when an anonymous refresh fails. for attempt in 1 2 3 4 5 6; do + [ "$attempt" = 4 ] && { xlings config --mirror CN > /dev/null 2>&1 || true; } xlings update > /dev/null 2>&1 || true if xlings install "$MCPP_INSTALL" -y -g; then break; fi [ "$attempt" = 6 ] && { echo "::error::mcpp could not be installed"; exit 1; } @@ -113,7 +118,7 @@ jobs: run: | profile='${{ matrix.profile }}' mcpp test --profile "$profile" 2>&1 | tee tests.log - for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test; do + for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test watch_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not run or did not pass"; exit 1; } done @@ -177,7 +182,10 @@ jobs: - name: The mcpp under cross-validation run: | + # The configured mirror (GLOBAL), then CN (GitCode first) from the fourth attempt: a cached index + # can predate the release, and xlings keeps it when an anonymous refresh fails. for attempt in 1 2 3 4 5 6; do + [ "$attempt" = 4 ] && { xlings config --mirror CN > /dev/null 2>&1 || true; } xlings update > /dev/null 2>&1 || true if xlings install "$MCPP_INSTALL" -y -g; then break; fi [ "$attempt" = 6 ] && { echo "::error::mcpp could not be installed"; exit 1; } @@ -211,7 +219,7 @@ jobs: run: | profile='${{ matrix.profile }}' mcpp test --profile "$profile" ${{ matrix.target }} 2>&1 | tee tests.log - for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test library_test; do + for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test library_test watch_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not run or did not pass"; exit 1; } done @@ -263,7 +271,7 @@ jobs: run: | set -euo pipefail mcpp test --target x86_64-openkal 2>&1 | tee tests.log - for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test; do + for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test watch_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not pass as an image"; exit 1; } done mcpp test --target aarch64-openkal --no-run @@ -382,7 +390,10 @@ jobs: | bash -s "$XLINGS_VERSION" echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" export PATH="$HOME/.xlings/subos/current/bin:$PATH" + # The configured mirror (GLOBAL), then CN (GitCode first) from the fourth attempt: a cached index + # can predate the release, and xlings keeps it when an anonymous refresh fails. for attempt in 1 2 3 4 5 6; do + [ "$attempt" = 4 ] && { xlings config --mirror CN > /dev/null 2>&1 || true; } xlings update > /dev/null 2>&1 || true if xlings install "$MCPP_INSTALL" -y -g; then break; fi [ "$attempt" = 6 ] && { echo "::error::mcpp could not be installed"; exit 1; } @@ -413,7 +424,7 @@ jobs: run: | set -euo pipefail mcpp test --target wasm32-emscripten --profile '${{ matrix.profile }}' 2>&1 | tee tests.log - for name in platform_test fs_test dirs_terminal_test linux_test library_test; do + for name in platform_test fs_test dirs_terminal_test linux_test library_test watch_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not run or did not pass"; exit 1; } done - name: The compile-time constants of Emscripten (R3) diff --git a/.github/workflows/unit-lifecycle.yml b/.github/workflows/unit-lifecycle.yml index 8107a01..af21d0f 100644 --- a/.github/workflows/unit-lifecycle.yml +++ b/.github/workflows/unit-lifecycle.yml @@ -52,7 +52,7 @@ jobs: - name: Install the actual native compiler run: | xlings update - xlings install mcpp@2026.11.7.2 -y -g + xlings install mcpp@2026.11.9.1 -y -g spec='${{ matrix.toolchain }}' case "$spec" in msvc@system) mcpp toolchain default msvc ;; diff --git a/CHANGELOG.md b/CHANGELOG.md index eff0318..e323611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.4.2 + +- **Which names changed (`fs::watcher`).** A watcher holds names --- files and + directories, which need not exist yet --- and `changed(wait)` answers which + of them changed since it last answered, each once and as it was added: a + file's content, kind or node (a file replaced by a rename over it, as editors + save), a name made or removed, a directory's entries. The system's notice says + when to look --- inotify on Linux and kqueue on macOS by the kernel's calls, + `ReadDirectoryChangesW` on Windows --- and each name's stamp, kind and + identity say what changed, so every system answers alike: a notice of another + name, or one write a system tells as several notices, tells nothing more. A + write that leaves the size, and the time within the volume's resolution, as + they were is not told. An ok image and Emscripten have no notice, and the + watcher looks when asked (`notified()` false). `native_handle()` is the descriptor an event + loop waits upon on Linux and macOS (mcpplibs/os#21). +- **The tests are mtest's.** Each test file declares an object of + `mcpplibs.mtest` (0.5.2, a `[dev-dependencies]` entry) and no `main`; a test + that starts copies of itself declares what a copy does (`child`), and its + cases are reported, listed and selected as mtest's. The suites of + `checks/hosted` and `checks/emscripten` include the same files. Built with + mcpp 2026.11.9.1, under which the package being built stands for itself in its + tests' graph: mtest, which uses mcpplibs.os, is built against the os under + test. + ## 0.4.1 - **A standard stream kept (`stdio::capture`).** `capture::begin(stream::error)` diff --git a/README.md b/README.md index 44050a0..1302981 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ dropped (0.2.8). | `mcpplibs.os.platform` | `PLATFORM`, `ARCH`, `is_linux` / `is_macos` / `is_windows` / `is_posix` / `is_openkal` / `is_emscripten`, `exe_suffix`, `path_list_separator`; `host()`, a constant expression on a native target; `can_run_on(p)` and `on

(f)`, a platform question that holds in a native build and an image alike | | `mcpplibs.os.error` | `errc` (openkal's closed set of conditions), `error`, `result` | | `mcpplibs.os.env` | variables (names without case on Windows), `NAME=value` entries as overrides (`assignments`), arguments, the program's own image, lists of directories in the host's separator (`split_list`, `join_list`), PATH search with PATHEXT | -| `mcpplibs.os.fs` | `write_atomic`, `publish` (no replace), `append`, `sync`, `caps`, `lock`, links, `set_executable`, `stamp_of`, `identity_of`, `owner_of`, `writable`, `locate`, `absolute`, `names_a_place`, `canonical`, a bounded `read`; what a volume does, measured (`measure`, `volume_type`), and the operations that follow it (`place`, `exchange`); names in the host's rules without the file system: `generic`, `native`, `extended`, `is_absolute`, `same`, `within` | +| `mcpplibs.os.fs` | `write_atomic`, `publish` (no replace), `append`, `sync`, `caps`, `lock`, links, `set_executable`, `stamp_of`, `identity_of`, `owner_of`, `writable`, `locate`, `absolute`, `names_a_place`, `canonical`, a bounded `read`; what a volume does, measured (`measure`, `volume_type`), and the operations that follow it (`place`, `exchange`); names in the host's rules without the file system: `generic`, `native`, `extended`, `is_absolute`, `same`, `within`; which names changed (`watcher`: inotify, kqueue, `ReadDirectoryChangesW`; a look when asked in an image and on Emscripten) | | `mcpplibs.os.process` | `spawn`, `run` (and with bounds: a hard bound, a grace, a drain, output limits), `exec`, a command line joined as it is for the command interpreter (`one_string`), pipes, both outputs on one pipe, output onto this program's error output, units and `end_unit`, declared units, observation and retirement, bound lifetime, bounded waits and reads; `self`, `describe`, `alive`, `threads` | | `mcpplibs.os.interrupt` | `requested`, `wait`, `end_as_requested`, `end_as_output_closed` | | `mcpplibs.os.stdio` | the three streams as bytes; a bounded read of input; the output sent to the error stream for a while (`output_to_error`) | diff --git a/checks/emscripten/mcpp.toml b/checks/emscripten/mcpp.toml index b9c402d..13ee339 100644 --- a/checks/emscripten/mcpp.toml +++ b/checks/emscripten/mcpp.toml @@ -11,3 +11,7 @@ version = "0.1.0" [dependencies] os = { path = "../.." } + +# The tests are mtest's (tests/*.cpp of the package, each reached by an include). +[dev-dependencies] +mcpplibs.mtest = "0.5.2" diff --git a/checks/emscripten/tests/dirs_terminal_test.cpp b/checks/emscripten/tests/dirs_terminal_test.cpp index da9259e..023717b 100644 --- a/checks/emscripten/tests/dirs_terminal_test.cpp +++ b/checks/emscripten/tests/dirs_terminal_test.cpp @@ -1,6 +1,6 @@ // dirs_terminal_test.cpp of mcpplibs.os, compiled for Emscripten. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/dirs_terminal_test.cpp" diff --git a/checks/emscripten/tests/facts_test.cpp b/checks/emscripten/tests/facts_test.cpp index 036c979..472a28a 100644 --- a/checks/emscripten/tests/facts_test.cpp +++ b/checks/emscripten/tests/facts_test.cpp @@ -1,6 +1,6 @@ // facts_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/facts_test.cpp" diff --git a/checks/emscripten/tests/fs_test.cpp b/checks/emscripten/tests/fs_test.cpp index c982d02..958385d 100644 --- a/checks/emscripten/tests/fs_test.cpp +++ b/checks/emscripten/tests/fs_test.cpp @@ -1,6 +1,6 @@ // fs_test.cpp of mcpplibs.os, compiled for Emscripten. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/fs_test.cpp" diff --git a/checks/emscripten/tests/library_test.cpp b/checks/emscripten/tests/library_test.cpp index 077a116..242b3b7 100644 --- a/checks/emscripten/tests/library_test.cpp +++ b/checks/emscripten/tests/library_test.cpp @@ -1,6 +1,6 @@ // library_test.cpp of mcpplibs.os, compiled for Emscripten. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/library_test.cpp" diff --git a/checks/emscripten/tests/linux_test.cpp b/checks/emscripten/tests/linux_test.cpp index fe2d15a..b84e079 100644 --- a/checks/emscripten/tests/linux_test.cpp +++ b/checks/emscripten/tests/linux_test.cpp @@ -1,6 +1,6 @@ // linux_test.cpp of mcpplibs.os, compiled for Emscripten. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/linux_test.cpp" diff --git a/checks/emscripten/tests/platform_test.cpp b/checks/emscripten/tests/platform_test.cpp index a9a789d..e5e6876 100644 --- a/checks/emscripten/tests/platform_test.cpp +++ b/checks/emscripten/tests/platform_test.cpp @@ -1,6 +1,6 @@ // platform_test.cpp of mcpplibs.os, compiled for Emscripten. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/platform_test.cpp" diff --git a/checks/emscripten/tests/watch_test.cpp b/checks/emscripten/tests/watch_test.cpp new file mode 100644 index 0000000..9c8f4a3 --- /dev/null +++ b/checks/emscripten/tests/watch_test.cpp @@ -0,0 +1,6 @@ +// watch_test.cpp of mcpplibs.os, compiled for Emscripten. The imports are restated +// because the dependency scan reads this file and not the one it includes. +import std; +import mcpplibs.os; +import mcpplibs.mtest; +#include "../../../tests/watch_test.cpp" diff --git a/checks/hosted/mcpp.toml b/checks/hosted/mcpp.toml index 09bb451..d590063 100644 --- a/checks/hosted/mcpp.toml +++ b/checks/hosted/mcpp.toml @@ -18,3 +18,7 @@ platform = "openkal-platform@0.24.4" # The tests are the package's own, each reached by an include from # tests/.cpp here (a test program is a file of the package that runs it). # CI checks that every test of the package has its counterpart. + +# The tests are mtest's (tests/*.cpp of the package, each reached by an include). +[dev-dependencies] +mcpplibs.mtest = "0.5.2" diff --git a/checks/hosted/tests/dirs_terminal_test.cpp b/checks/hosted/tests/dirs_terminal_test.cpp index 1a50a0f..23da8c6 100644 --- a/checks/hosted/tests/dirs_terminal_test.cpp +++ b/checks/hosted/tests/dirs_terminal_test.cpp @@ -1,6 +1,6 @@ // dirs_terminal_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/dirs_terminal_test.cpp" diff --git a/checks/hosted/tests/facts_test.cpp b/checks/hosted/tests/facts_test.cpp index 036c979..472a28a 100644 --- a/checks/hosted/tests/facts_test.cpp +++ b/checks/hosted/tests/facts_test.cpp @@ -1,6 +1,6 @@ // facts_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/facts_test.cpp" diff --git a/checks/hosted/tests/fs_test.cpp b/checks/hosted/tests/fs_test.cpp index 8560ba7..4529ea7 100644 --- a/checks/hosted/tests/fs_test.cpp +++ b/checks/hosted/tests/fs_test.cpp @@ -1,6 +1,6 @@ // fs_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/fs_test.cpp" diff --git a/checks/hosted/tests/ipc_test.cpp b/checks/hosted/tests/ipc_test.cpp index 7d11adb..2922d4d 100644 --- a/checks/hosted/tests/ipc_test.cpp +++ b/checks/hosted/tests/ipc_test.cpp @@ -1,6 +1,6 @@ // ipc_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/ipc_test.cpp" diff --git a/checks/hosted/tests/library_test.cpp b/checks/hosted/tests/library_test.cpp index 9bfc51d..f7a3250 100644 --- a/checks/hosted/tests/library_test.cpp +++ b/checks/hosted/tests/library_test.cpp @@ -1,6 +1,6 @@ // library_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/library_test.cpp" diff --git a/checks/hosted/tests/linux_test.cpp b/checks/hosted/tests/linux_test.cpp index 4022ab8..ffb9221 100644 --- a/checks/hosted/tests/linux_test.cpp +++ b/checks/hosted/tests/linux_test.cpp @@ -1,6 +1,6 @@ // linux_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/linux_test.cpp" diff --git a/checks/hosted/tests/platform_test.cpp b/checks/hosted/tests/platform_test.cpp index 4ec5c7d..93c5d61 100644 --- a/checks/hosted/tests/platform_test.cpp +++ b/checks/hosted/tests/platform_test.cpp @@ -1,6 +1,6 @@ // platform_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/platform_test.cpp" diff --git a/checks/hosted/tests/process_test.cpp b/checks/hosted/tests/process_test.cpp index 002e493..5640f0b 100644 --- a/checks/hosted/tests/process_test.cpp +++ b/checks/hosted/tests/process_test.cpp @@ -1,6 +1,6 @@ // process_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated // because the dependency scan reads this file and not the one it includes. -#include "../../../tests/check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; #include "../../../tests/process_test.cpp" diff --git a/checks/hosted/tests/unit_test.cpp b/checks/hosted/tests/unit_test.cpp index 8a922e6..d9c8712 100644 --- a/checks/hosted/tests/unit_test.cpp +++ b/checks/hosted/tests/unit_test.cpp @@ -1,7 +1,7 @@ // Restate imports for the dependency scanner, as the other hosted wrappers do. -#include "../../../tests/check.hpp" import std; import mcpplibs.os.process; import mcpplibs.os.ipc; import mcpplibs.os.env; +import mcpplibs.mtest; #include "../../../tests/unit_test.cpp" diff --git a/checks/hosted/tests/watch_test.cpp b/checks/hosted/tests/watch_test.cpp new file mode 100644 index 0000000..e28b91e --- /dev/null +++ b/checks/hosted/tests/watch_test.cpp @@ -0,0 +1,6 @@ +// watch_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated +// because the dependency scan reads this file and not the one it includes. +import std; +import mcpplibs.os; +import mcpplibs.mtest; +#include "../../../tests/watch_test.cpp" diff --git a/mcpp.toml b/mcpp.toml index 7d92b89..4ff7318 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "os" -version = "0.4.1" +version = "0.4.2" description = "mcpplibs.os: a C++ operating-system library over openkal --- processes, files, environment, terminals, directories and local IPC, with compile-time platform constants and a few platform modules." license = "Apache-2.0" authors = ["mcpplibs"] @@ -105,7 +105,7 @@ sources = ["src/windows/posix/stdio.cpp"] # ownership are the C library's (src/posix/fs.cpp), and the rest is # src/emscripten/. [target.'cfg(os = "emscripten")'.build] -sources = ["src/targets/emscripten.cppm", "src/emscripten/*.cpp", "src/posix/fs.cpp"] +sources = ["src/targets/emscripten.cppm", "src/emscripten/*.cpp", "src/posix/fs.cpp", "src/watch/none.cpp"] # AN OK IMAGE (`-openkal', openkal SPEC clause 12): no implementation # beneath --- the loader binds the image's `kal_*' --- and the platform modules @@ -114,8 +114,15 @@ sources = ["src/targets/emscripten.cppm", "src/emscripten/*.cpp", "src/posix/fs. # effective user from the system's own programs, macOS' translation as never. # `host()' and the conventions are answered when the image is loaded. [target.'cfg(os = "openkal")'.build] -sources = ["src/targets/openkal.cppm", "src/openkal/*.cpp", "src/linux/linux.cpp"] +sources = ["src/targets/openkal.cppm", "src/openkal/*.cpp", "src/linux/linux.cpp", "src/watch/none.cpp"] # The C library, the C++ run-time and the loader of an image are its # platform's: mcpp 2026.10.27.1 and later select openkal-platform for # `-openkal` (mcpp SPEC-013), so this package declares none of them. + +# The tests are mtest's: each file of tests/ declares an object and no `main'. +# mtest uses this package, and its tests build it against the os under test +# (mcpp 2026.11.9.1: the package being built stands for itself in its tests' +# graph). +[dev-dependencies] +mcpplibs.mtest = "0.5.2" diff --git a/src/fs.cppm b/src/fs.cppm index 99495df..7826511 100644 --- a/src/fs.cppm +++ b/src/fs.cppm @@ -238,6 +238,58 @@ result identity_of(const std::filesystem::path& node); result rename(const std::filesystem::path& from, const std::filesystem::path& to, bool replace = true); +// --- changes ------------------------------------------------------------------- + +// TOLD WHICH NAMES CHANGED (0.4.2). A watcher holds names --- files and +// directories, which need not exist yet --- and answers which of them changed +// since it last answered: a file's content, its kind or its node (a file +// replaced by a rename over it, as editors save), a name created or removed, a +// directory's entries made, removed or renamed. A directory's entries are not +// watched below it, nor their content: a file to be watched is added itself. +// +// THE SYSTEM'S NOTICE SAYS WHEN TO LOOK; THE NAMES' OWN STATE SAYS WHAT +// CHANGED: a name's stamp (size and time of its last write), its kind and its +// node. Where the system gives notice (inotify, kqueue, ReadDirectoryChangesW) +// the watcher sleeps until it does, and a notice of another name, or one write +// told as several notices, reports nothing more; every system answers alike. +// Where it gives none (an ok image, Emscripten) the watcher looks when asked, +// and a wait looks every `kPollInterval'. A write that leaves the size, and the +// time within the volume's resolution (FAT's two seconds), as they were is not +// told. +class watcher { +public: + static result open(); + + watcher(watcher&&) noexcept; + watcher& operator=(watcher&&) noexcept; + watcher(const watcher&) = delete; + watcher& operator=(const watcher&) = delete; + ~watcher(); + + // A name watched from now; one added again is kept once. Its directory + // need not exist: the nearest that does is watched until it does. + result add(const std::filesystem::path& name); + void remove(const std::filesystem::path& name); + + // The names added that changed since the last answer, each once and as it + // was added, in the order added; waiting at most `wait' for the first. + std::vector changed(std::chrono::milliseconds wait = std::chrono::milliseconds{0}); + + // Whether the system gives notice; false: the watcher looks when asked. + bool notified() const noexcept; + // What an event loop waits upon, readable when the system has a notice and + // `changed()' has something to look at: a descriptor on Linux and macOS; + // nothing elsewhere. + std::optional native_handle() const noexcept; + + static constexpr std::chrono::milliseconds kPollInterval{50}; + +private: + struct state; + explicit watcher(std::unique_ptr s) noexcept; + std::unique_ptr s_; +}; + } // namespace mcpplibs::os::fs // Each system's own (src//fs.cpp). @@ -249,4 +301,21 @@ bool exchange_unsupported(int condition); std::string volume_type(const std::filesystem::path& directory); owner owner_of(const std::filesystem::path& node); bool writable(const std::filesystem::path& node); + +// THE SYSTEM'S NOTICE OF CHANGE, for `watcher' (src//watch.cpp; none in an +// ok image and on Emscripten, src/watch/none.cpp): when to look, not what +// changed. +struct notifier; +// Null where the system gives no notice, or it cannot be had now. +notifier* notifier_open(); +void notifier_close(notifier* n) noexcept; +// Notices of a node from now (generic spelling): a directory's entries and +// theirs, or a file itself, which a system that watches its directory needs +// nothing more for. Watched again, the node is watched afresh (a node replaced +// under its name). +bool notifier_watch(notifier& n, const std::string& node, bool directory); +void notifier_unwatch(notifier& n, const std::string& node); +// Whether a notice is there, or arrives within `wait'; those there are read. +bool notifier_wait(notifier& n, std::chrono::milliseconds wait); +std::optional notifier_handle(const notifier& n) noexcept; } // namespace mcpplibs::os::fs::sys diff --git a/src/linux/arch/aarch64.cpp b/src/linux/arch/aarch64.cpp index ad7ad1b..6158eda 100644 --- a/src/linux/arch/aarch64.cpp +++ b/src/linux/arch/aarch64.cpp @@ -31,7 +31,9 @@ constexpr long nr_getrusage = 165, nr_sysinfo = 179, nr_statfs = 43, nr_renameat2 = 276, - nr_geteuid = 175, nr_umask = 166; + nr_geteuid = 175, nr_umask = 166, + nr_inotify_init1 = 26, nr_inotify_add_watch = 27, nr_inotify_rm_watch = 28, + nr_read = 63, nr_ppoll = 73; constexpr long f_dupfd_cloexec = 1030, sig_unblock = 1; @@ -100,4 +102,25 @@ long renameat2(int from_dir, const char* from, int to_dir, const char* to, unsig static_cast(flags)); } +long inotify_open() { + return call(nr_inotify_init1, 04000 /* IN_NONBLOCK */ | 02000000 /* IN_CLOEXEC */); +} + +long inotify_watch(int fd, const char* path, unsigned mask) { + return call(nr_inotify_add_watch, fd, reinterpret_cast(path), static_cast(mask)); +} + +long inotify_unwatch(int fd, int watch) { return call(nr_inotify_rm_watch, fd, watch); } + +long read(int fd, void* bytes, unsigned long count) { + return call(nr_read, fd, reinterpret_cast(bytes), static_cast(count)); +} + +long wait_readable(int fd, long milliseconds) { + // ppoll: one `struct pollfd' (fd, POLLIN) and a `struct timespec', or none for no bound. + struct { int fd; short events, revents; } entry{ fd, 1, 0 }; + long timeout[2] = { milliseconds / 1000, (milliseconds % 1000) * 1000000 }; + return call(nr_ppoll, reinterpret_cast(&entry), 1, milliseconds < 0 ? 0 : reinterpret_cast(timeout), 0, 8); +} + } // namespace mcpplibs::os::linux_kernel diff --git a/src/linux/arch/riscv64.cpp b/src/linux/arch/riscv64.cpp index 2583318..8f3fed6 100644 --- a/src/linux/arch/riscv64.cpp +++ b/src/linux/arch/riscv64.cpp @@ -31,7 +31,9 @@ constexpr long nr_getrusage = 165, nr_sysinfo = 179, nr_statfs = 43, nr_renameat2 = 276, - nr_geteuid = 175, nr_umask = 166; + nr_geteuid = 175, nr_umask = 166, + nr_inotify_init1 = 26, nr_inotify_add_watch = 27, nr_inotify_rm_watch = 28, + nr_read = 63, nr_ppoll = 73; constexpr long f_dupfd_cloexec = 1030, sig_unblock = 1; @@ -100,4 +102,25 @@ long renameat2(int from_dir, const char* from, int to_dir, const char* to, unsig static_cast(flags)); } +long inotify_open() { + return call(nr_inotify_init1, 04000 /* IN_NONBLOCK */ | 02000000 /* IN_CLOEXEC */); +} + +long inotify_watch(int fd, const char* path, unsigned mask) { + return call(nr_inotify_add_watch, fd, reinterpret_cast(path), static_cast(mask)); +} + +long inotify_unwatch(int fd, int watch) { return call(nr_inotify_rm_watch, fd, watch); } + +long read(int fd, void* bytes, unsigned long count) { + return call(nr_read, fd, reinterpret_cast(bytes), static_cast(count)); +} + +long wait_readable(int fd, long milliseconds) { + // ppoll: one `struct pollfd' (fd, POLLIN) and a `struct timespec', or none for no bound. + struct { int fd; short events, revents; } entry{ fd, 1, 0 }; + long timeout[2] = { milliseconds / 1000, (milliseconds % 1000) * 1000000 }; + return call(nr_ppoll, reinterpret_cast(&entry), 1, milliseconds < 0 ? 0 : reinterpret_cast(timeout), 0, 8); +} + } // namespace mcpplibs::os::linux_kernel diff --git a/src/linux/arch/x86_64.cpp b/src/linux/arch/x86_64.cpp index 1e220e8..dd21244 100644 --- a/src/linux/arch/x86_64.cpp +++ b/src/linux/arch/x86_64.cpp @@ -31,7 +31,9 @@ constexpr long nr_getrusage = 98, nr_sysinfo = 99, nr_statfs = 137, nr_renameat2 = 316, - nr_geteuid = 107, nr_umask = 95; + nr_geteuid = 107, nr_umask = 95, + nr_inotify_init1 = 294, nr_inotify_add_watch = 254, nr_inotify_rm_watch = 255, + nr_read = 0, nr_ppoll = 271; constexpr long f_dupfd_cloexec = 1030, sig_unblock = 1; @@ -100,4 +102,25 @@ long renameat2(int from_dir, const char* from, int to_dir, const char* to, unsig static_cast(flags)); } +long inotify_open() { + return call(nr_inotify_init1, 04000 /* IN_NONBLOCK */ | 02000000 /* IN_CLOEXEC */); +} + +long inotify_watch(int fd, const char* path, unsigned mask) { + return call(nr_inotify_add_watch, fd, reinterpret_cast(path), static_cast(mask)); +} + +long inotify_unwatch(int fd, int watch) { return call(nr_inotify_rm_watch, fd, watch); } + +long read(int fd, void* bytes, unsigned long count) { + return call(nr_read, fd, reinterpret_cast(bytes), static_cast(count)); +} + +long wait_readable(int fd, long milliseconds) { + // ppoll: one `struct pollfd' (fd, POLLIN) and a `struct timespec', or none for no bound. + struct { int fd; short events, revents; } entry{ fd, 1, 0 }; + long timeout[2] = { milliseconds / 1000, (milliseconds % 1000) * 1000000 }; + return call(nr_ppoll, reinterpret_cast(&entry), 1, milliseconds < 0 ? 0 : reinterpret_cast(timeout), 0, 8); +} + } // namespace mcpplibs::os::linux_kernel diff --git a/src/linux/kernel.h b/src/linux/kernel.h index c98a82f..789195d 100644 --- a/src/linux/kernel.h +++ b/src/linux/kernel.h @@ -42,4 +42,16 @@ long sysinfo(sysinfo_t* out); long statfs_type(const char* path, unsigned long long* type); long renameat2(int from_dir, const char* from, int to_dir, const char* to, unsigned flags); +// inotify (0.4.2, `fs::watcher'): an instance that does not block a read and is +// closed on exec, the watch of a node and its removal, its records read, and a +// wait for it to be readable of at most `milliseconds' (negative: no bound). +inline constexpr unsigned in_modify = 0x2, in_attrib = 0x4, in_close_write = 0x8, in_moved_from = 0x40, + in_moved_to = 0x80, in_create = 0x100, in_delete = 0x200, in_delete_self = 0x400, + in_move_self = 0x800, in_q_overflow = 0x4000, in_ignored = 0x8000, in_onlydir = 0x1000000; +long inotify_open(); +long inotify_watch(int fd, const char* path, unsigned mask); +long inotify_unwatch(int fd, int watch); +long read(int fd, void* bytes, unsigned long count); +long wait_readable(int fd, long milliseconds); + } // namespace mcpplibs::os::linux_kernel diff --git a/src/linux/watch.cpp b/src/linux/watch.cpp new file mode 100644 index 0000000..dd6b155 --- /dev/null +++ b/src/linux/watch.cpp @@ -0,0 +1,70 @@ +// Linux's notice of change for `fs::watcher': inotify, by the kernel's calls +// (src/linux/kernel.h). A directory is watched for its entries and their +// content, which covers a file in it; a file needs nothing more. The watch of a +// directory removed is dropped by the kernel, and the watcher asks again of +// what its name names when it changes. +module; +#include "kernel.h" +#include "../posix/kernel.h" + +module mcpplibs.os.fs; + +import std; + +namespace mcpplibs::os::fs::sys { + +namespace k = mcpplibs::os::linux_kernel; + +struct notifier { + int fd = -1; + std::map directories; // watch -> directory +}; + +namespace { + +constexpr unsigned kMask = k::in_modify | k::in_attrib | k::in_close_write | k::in_moved_from | k::in_moved_to + | k::in_create | k::in_delete | k::in_delete_self | k::in_move_self | k::in_onlydir; + +} // namespace + +notifier* notifier_open() { + const long fd = k::inotify_open(); + if (fd < 0) return nullptr; + auto* n = new notifier; + n->fd = static_cast(fd); + return n; +} + +void notifier_close(notifier* n) noexcept { + if (!n) return; + mcpplibs::os::kernel::close(n->fd); + delete n; +} + +bool notifier_watch(notifier& n, const std::string& node, bool directory) { + if (!directory) return true; + const long watch = k::inotify_watch(n.fd, node.c_str(), kMask); + if (watch < 0) return false; + n.directories[static_cast(watch)] = node; + return true; +} + +void notifier_unwatch(notifier& n, const std::string& node) { + const auto it = std::ranges::find_if(n.directories, [&](const auto& w) { return w.second == node; }); + if (it == n.directories.end()) return; + k::inotify_unwatch(n.fd, it->first); + n.directories.erase(it); +} + +bool notifier_wait(notifier& n, std::chrono::milliseconds wait) { + if (wait.count() > 0 && k::wait_readable(n.fd, static_cast(wait.count())) <= 0) return false; + // The records are read and dropped: the watcher looks at its names. + alignas(8) char bytes[16384]; + bool any = false; + while (k::read(n.fd, bytes, sizeof bytes) > 0) any = true; + return any; +} + +std::optional notifier_handle(const notifier& n) noexcept { return n.fd; } + +} // namespace mcpplibs::os::fs::sys diff --git a/src/macos/arch/aarch64.cpp b/src/macos/arch/aarch64.cpp index 7a28eb3..8607cbf 100644 --- a/src/macos/arch/aarch64.cpp +++ b/src/macos/arch/aarch64.cpp @@ -7,26 +7,27 @@ namespace { -long call(long n, long a = 0, long b = 0, long c = 0, long d = 0, long e = 0) { +long call(long n, long a = 0, long b = 0, long c = 0, long d = 0, long e = 0, long f = 0) { register long x16 __asm__("x16") = n; register long x0 __asm__("x0") = a; register long x1 __asm__("x1") = b; register long x2 __asm__("x2") = c; register long x3 __asm__("x3") = d; register long x4 __asm__("x4") = e; + register long x5 __asm__("x5") = f; long failed; // The carry flag reports a failure and x0 is then the error number; x1 // comes back too, so it is an output as well. __asm__ __volatile__("svc #0x80\n\tcset %2, cs" : "+r"(x0), "+r"(x1), "=r"(failed) - : "r"(x16), "r"(x2), "r"(x3), "r"(x4) + : "r"(x16), "r"(x2), "r"(x3), "r"(x4), "r"(x5) : "memory", "cc"); return failed ? -x0 : x0; } constexpr long nr_getrusage = 117, nr_open = 5, nr_write = 4, nr_close = 6, nr_getpid = 20, nr_kill = 37, nr_sigaction = 46, nr_sigprocmask = 48, nr_dup2 = 90, nr_fcntl = 92, nr_statfs64 = 345, nr_renameatx_np = 488, - nr_geteuid = 25, nr_umask = 60; + nr_geteuid = 25, nr_umask = 60, nr_kqueue = 362, nr_kevent = 363; constexpr long f_dupfd_cloexec = 67, sig_unblock = 2; @@ -85,4 +86,18 @@ long renameatx_np(int from_dir, const char* from, int to_dir, const char* to, un static_cast(flags)); } +long kqueue() { return call(nr_kqueue); } + +long kevent(int kq, const kevent_t* changes, int count, kevent_t* events, int capacity, long milliseconds) { + long timeout[2] = { milliseconds / 1000, (milliseconds % 1000) * 1000000 }; + return call(nr_kevent, kq, reinterpret_cast(changes), count, reinterpret_cast(events), capacity, + milliseconds < 0 ? 0 : reinterpret_cast(timeout)); +} + +long open_events(const char* path) { + // open(path, O_EVTONLY | O_CLOEXEC): a descriptor for notices only, which + // does not keep a volume from being unmounted. + return call(nr_open, reinterpret_cast(path), 0x8000 | 0x1000000); +} + } // namespace mcpplibs::os::macos_kernel diff --git a/src/macos/arch/x86_64.cpp b/src/macos/arch/x86_64.cpp index 014aa79..a200ac4 100644 --- a/src/macos/arch/x86_64.cpp +++ b/src/macos/arch/x86_64.cpp @@ -7,23 +7,24 @@ namespace { -long call(long n, long a = 0, long b = 0, long c = 0, long d = 0, long e = 0) { +long call(long n, long a = 0, long b = 0, long c = 0, long d = 0, long e = 0, long f = 0) { long r; unsigned char failed; register long r10 __asm__("r10") = d; register long r8 __asm__("r8") = e; + register long r9 __asm__("r9") = f; // The class of the ordinary calls (2) in the number's high bits; the // carry flag reports a failure and the result is then the error number. __asm__ __volatile__("syscall" : "=a"(r), "+d"(c), "=@ccc"(failed) - : "a"(n | 0x2000000L), "D"(a), "S"(b), "r"(r10), "r"(r8) + : "a"(n | 0x2000000L), "D"(a), "S"(b), "r"(r10), "r"(r8), "r"(r9) : "rcx", "r11", "memory", "cc"); return failed ? -r : r; } constexpr long nr_getrusage = 117, nr_open = 5, nr_write = 4, nr_close = 6, nr_getpid = 20, nr_kill = 37, nr_sigaction = 46, nr_sigprocmask = 48, nr_dup2 = 90, nr_fcntl = 92, nr_statfs64 = 345, nr_renameatx_np = 488, - nr_geteuid = 25, nr_umask = 60; + nr_geteuid = 25, nr_umask = 60, nr_kqueue = 362, nr_kevent = 363; constexpr long f_dupfd_cloexec = 67, sig_unblock = 2; @@ -82,4 +83,18 @@ long renameatx_np(int from_dir, const char* from, int to_dir, const char* to, un static_cast(flags)); } +long kqueue() { return call(nr_kqueue); } + +long kevent(int kq, const kevent_t* changes, int count, kevent_t* events, int capacity, long milliseconds) { + long timeout[2] = { milliseconds / 1000, (milliseconds % 1000) * 1000000 }; + return call(nr_kevent, kq, reinterpret_cast(changes), count, reinterpret_cast(events), capacity, + milliseconds < 0 ? 0 : reinterpret_cast(timeout)); +} + +long open_events(const char* path) { + // open(path, O_EVTONLY | O_CLOEXEC): a descriptor for notices only, which + // does not keep a volume from being unmounted. + return call(nr_open, reinterpret_cast(path), 0x8000 | 0x1000000); +} + } // namespace mcpplibs::os::macos_kernel diff --git a/src/macos/kernel.h b/src/macos/kernel.h index e23cca4..79531c4 100644 --- a/src/macos/kernel.h +++ b/src/macos/kernel.h @@ -27,4 +27,23 @@ inline constexpr unsigned rename_swap = 0x2; // RENAME_SWAP long statfs(const char* path, statfs_t* out); long renameatx_np(int from_dir, const char* from, int to_dir, const char* to, unsigned flags); +// kqueue (0.4.2, `fs::watcher'): `struct kevent' (sys/event.h), a node's +// notices (EVFILT_VNODE), a queue, its changes and events with a wait of at +// most `milliseconds' (negative: no bound), and a descriptor for notices only. +struct kevent_t { + unsigned long ident; + short filter; + unsigned short flags; + unsigned int fflags; + long data; + void* udata; +}; +inline constexpr short evfilt_vnode = -4; +inline constexpr unsigned short ev_add = 0x1, ev_delete = 0x2, ev_enable = 0x4, ev_clear = 0x20; +inline constexpr unsigned note_delete = 0x1, note_write = 0x2, note_extend = 0x4, note_attrib = 0x8, note_link = 0x10, + note_rename = 0x20, note_revoke = 0x40; +long kqueue(); +long kevent(int kq, const kevent_t* changes, int count, kevent_t* events, int capacity, long milliseconds); +long open_events(const char* path); + } // namespace mcpplibs::os::macos_kernel diff --git a/src/macos/watch.cpp b/src/macos/watch.cpp new file mode 100644 index 0000000..afdb11e --- /dev/null +++ b/src/macos/watch.cpp @@ -0,0 +1,88 @@ +// macOS's notice of change for `fs::watcher': kqueue's notices of a node +// (EVFILT_VNODE), by the kernel's calls (src/macos/kernel.h). A directory's +// notice is of its entries; a file is watched itself for its content, and its +// directory for a file made, removed or renamed over it. +module; +#include "kernel.h" +#include "../posix/kernel.h" + +module mcpplibs.os.fs; + +import std; + +namespace mcpplibs::os::fs::sys { + +namespace k = mcpplibs::os::macos_kernel; + +struct notifier { + int queue = -1; + struct node { + std::string name; + bool directory = false; + }; + std::map nodes; // descriptor -> node +}; + +namespace { + +constexpr unsigned kNotes = k::note_delete | k::note_write | k::note_extend | k::note_attrib | k::note_link + | k::note_rename | k::note_revoke; +constexpr unsigned kGone = k::note_delete | k::note_rename | k::note_revoke; + +void forget(notifier& n, int fd) { + mcpplibs::os::kernel::close(fd); + n.nodes.erase(fd); +} + +} // namespace + +notifier* notifier_open() { + const long queue = k::kqueue(); + if (queue < 0) return nullptr; + auto* n = new notifier; + n->queue = static_cast(queue); + return n; +} + +void notifier_close(notifier* n) noexcept { + if (!n) return; + for (const auto& [fd, node] : n->nodes) mcpplibs::os::kernel::close(fd); + mcpplibs::os::kernel::close(n->queue); + delete n; +} + +bool notifier_watch(notifier& n, const std::string& node, bool directory) { + notifier_unwatch(n, node); + const long fd = k::open_events(node.c_str()); + if (fd < 0) return false; + const k::kevent_t change{ static_cast(fd), k::evfilt_vnode, + static_cast(k::ev_add | k::ev_enable | k::ev_clear), kNotes, 0, nullptr }; + if (k::kevent(n.queue, &change, 1, nullptr, 0, 0) < 0) { + mcpplibs::os::kernel::close(static_cast(fd)); + return false; + } + n.nodes[static_cast(fd)] = { node, directory }; + return true; +} + +void notifier_unwatch(notifier& n, const std::string& node) { + const auto it = std::ranges::find_if(n.nodes, [&](const auto& e) { return e.second.name == node; }); + if (it != n.nodes.end()) forget(n, it->first); +} + +bool notifier_wait(notifier& n, std::chrono::milliseconds wait) { + std::array events{}; + const long got = k::kevent(n.queue, nullptr, 0, events.data(), static_cast(events.size()), + static_cast(wait.count())); + for (long i = 0; i < got; ++i) { + // A node removed or renamed away is watched no more; the watcher asks + // again of what its name names now. + const auto& e = events[static_cast(i)]; + if (e.fflags & kGone) forget(n, static_cast(e.ident)); + } + return got > 0; +} + +std::optional notifier_handle(const notifier& n) noexcept { return n.queue; } + +} // namespace mcpplibs::os::fs::sys diff --git a/src/watch.cpp b/src/watch.cpp new file mode 100644 index 0000000..2826254 --- /dev/null +++ b/src/watch.cpp @@ -0,0 +1,164 @@ +// mcpplibs.os.fs: the watcher, the same on every system. What a system adds is +// its notice (`sys::notifier', src//watch.cpp); what changed is decided +// here, from each name's state, so every system answers alike. +module mcpplibs.os.fs; + +import std; +import mcpplibs.os.error; + +namespace mcpplibs::os::fs { + +namespace { + +// What a name is now: whether it is there, a directory or not, its stamp and +// the node it names. Two states differ exactly when the name changed in a way +// a watcher tells. +struct snapshot { + bool exists = false; + bool directory = false; + stamp st{}; + identity id{}; + bool operator==(const snapshot&) const = default; +}; + +snapshot snapshot_of(const std::string& name) { + snapshot s; + std::error_code ec; + const auto path = from_utf8(name); + const auto status = std::filesystem::status(path, ec); + if (ec || !std::filesystem::exists(status)) return s; + s.exists = true; + s.directory = std::filesystem::is_directory(status); + if (auto st = stamp_of(path)) s.st = *st; + if (auto id = identity_of(path)) s.id = *id; + return s; +} + +std::string parent_of(const std::string& name) { + const auto slash = name.rfind('/'); + if (slash == std::string::npos) return name; + // The root keeps its separator (`/', `C:/'). + if (slash == 0 || (slash > 0 && name[slash - 1] == ':')) return name.substr(0, slash + 1); + return name.substr(0, slash); +} + +// The nearest directory that exists: the name's own, or the closest above it. +std::string existing_directory(std::string name) { + std::error_code ec; + while (!std::filesystem::is_directory(from_utf8(name), ec)) { + const auto up = parent_of(name); + if (up == name) break; + name = up; + } + return name; +} + +} // namespace + +struct watcher::state { + struct name { + std::filesystem::path given; // as added: what `changed' answers + std::string key; // absolute, generic + snapshot last; + std::vector armed; // the nodes notices are asked of for it + }; + sys::notifier* notifier = nullptr; + std::vector names; + + ~state() { if (notifier) sys::notifier_close(notifier); } + + // Notices of the name from now: its directory --- the nearest that exists + // --- for a name made, removed or renamed over, and the name itself. + void arm(name& n) { + if (!notifier) return; + std::vector nodes; + nodes.push_back(existing_directory(parent_of(n.key))); + if (n.last.exists) nodes.push_back(n.key); + for (const auto& node : n.armed) + if (std::ranges::find(nodes, node) == nodes.end()) sys::notifier_unwatch(*notifier, node); + for (const auto& node : nodes) { + std::error_code ec; + sys::notifier_watch(*notifier, node, std::filesystem::is_directory(from_utf8(node), ec)); + } + n.armed = std::move(nodes); + } + + // What changed: each name whose state differs from the last answer's. A + // system's notice only says to look; one write the system tells as several + // notices, or a notice of another name, changes nothing here. + std::vector evaluate() { + std::vector out; + for (auto& n : names) { + const auto now = snapshot_of(n.key); + const bool changed = now != n.last; + const bool node_changed = now.exists != n.last.exists || now.id != n.last.id; + n.last = now; + if (!changed) continue; + out.push_back(n.given); + if (node_changed) arm(n); + } + return out; + } +}; + +watcher::watcher(std::unique_ptr s) noexcept : s_(std::move(s)) {} +watcher::watcher(watcher&&) noexcept = default; +watcher& watcher::operator=(watcher&&) noexcept = default; +watcher::~watcher() = default; + +result watcher::open() { + auto s = std::make_unique(); + s->notifier = sys::notifier_open(); + return watcher(std::move(s)); +} + +result watcher::add(const std::filesystem::path& name) { + const std::string key = generic(fs::absolute(name)); + if (std::ranges::any_of(s_->names, [&](const auto& n) { return same(from_utf8(n.key), from_utf8(key)); })) + return {}; + state::name n{ name, key, snapshot_of(key), {} }; + s_->arm(n); + s_->names.push_back(std::move(n)); + return {}; +} + +void watcher::remove(const std::filesystem::path& name) { + const std::string key = generic(fs::absolute(name)); + auto it = std::ranges::find_if(s_->names, [&](const auto& n) { return same(from_utf8(n.key), from_utf8(key)); }); + if (it == s_->names.end()) return; + // A node another name still asks of stays watched. + for (const auto& node : it->armed) { + const bool shared = std::ranges::any_of(s_->names, [&](const auto& other) { + return &other != &*it && std::ranges::find(other.armed, node) != other.armed.end(); + }); + if (!shared && s_->notifier) sys::notifier_unwatch(*s_->notifier, node); + } + s_->names.erase(it); +} + +std::vector watcher::changed(std::chrono::milliseconds wait) { + using clock = std::chrono::steady_clock; + const auto deadline = clock::now() + wait; + auto remaining = [&] { + return std::max(std::chrono::milliseconds{0}, + std::chrono::duration_cast(deadline - clock::now())); + }; + // What is there now, then what arrives: a notice of another name in a + // watched directory is no change of these, and the wait goes on. + if (s_->notifier) sys::notifier_wait(*s_->notifier, std::chrono::milliseconds{0}); + auto out = s_->evaluate(); + while (out.empty() && remaining() > std::chrono::milliseconds{0}) { + if (s_->notifier) sys::notifier_wait(*s_->notifier, remaining()); + else std::this_thread::sleep_for(std::min(kPollInterval, remaining())); + out = s_->evaluate(); + } + return out; +} + +bool watcher::notified() const noexcept { return s_->notifier != nullptr; } + +std::optional watcher::native_handle() const noexcept { + return s_->notifier ? sys::notifier_handle(*s_->notifier) : std::nullopt; +} + +} // namespace mcpplibs::os::fs diff --git a/src/watch/none.cpp b/src/watch/none.cpp new file mode 100644 index 0000000..826f23d --- /dev/null +++ b/src/watch/none.cpp @@ -0,0 +1,18 @@ +// No notice of change: an ok image, whose system is openkal's and names no +// watch, and Emscripten. `fs::watcher' then looks when asked. +module mcpplibs.os.fs; + +import std; + +namespace mcpplibs::os::fs::sys { + +struct notifier {}; + +notifier* notifier_open() { return nullptr; } +void notifier_close(notifier*) noexcept {} +bool notifier_watch(notifier&, const std::string&, bool) { return false; } +void notifier_unwatch(notifier&, const std::string&) {} +bool notifier_wait(notifier&, std::chrono::milliseconds) { return false; } +std::optional notifier_handle(const notifier&) noexcept { return std::nullopt; } + +} // namespace mcpplibs::os::fs::sys diff --git a/src/windows/watch.cpp b/src/windows/watch.cpp new file mode 100644 index 0000000..3aff34c --- /dev/null +++ b/src/windows/watch.cpp @@ -0,0 +1,124 @@ +// Windows's notice of change for `fs::watcher': a directory's changes read +// overlapped (ReadDirectoryChangesW), one event per directory. A directory is +// watched for its entries and their content, which covers a file in it; a file +// needs nothing more. One write may complete several reads; the watcher looks +// at its names, so that is told once. +module; +#include "win32.h" + +module mcpplibs.os.fs; + +import std; + +#include "text.h" + +namespace mcpplibs::os::fs::sys { + +struct notifier { + struct directory { + std::string name; + okos_handle handle = nullptr; + okos_handle event = nullptr; + okos_overlapped overlapped{}; + std::vector buffer = std::vector(16384); // 64 KiB, DWORD-aligned + }; + std::list directories; // stable: the system writes into each one's buffer +}; + +namespace { + +// FILE_NOTIFY_CHANGE_FILE_NAME | DIR_NAME | ATTRIBUTES | SIZE | LAST_WRITE. +constexpr okos_dword kFilter = 0x1 | 0x2 | 0x4 | 0x8 | 0x10; +constexpr okos_dword kIncomplete = 996, kTimeout = 0x102; + +okos_handle invalid() { return reinterpret_cast(static_cast(-1)); } + +bool request(notifier::directory& d) { + late::ResetEvent(d.event); + d.overlapped = okos_overlapped{}; + d.overlapped.event = d.event; + return late::ReadDirectoryChangesW(d.handle, d.buffer.data(), static_cast(d.buffer.size() * 4), 0, + kFilter, nullptr, &d.overlapped, nullptr) != 0; +} + +void close(notifier::directory& d) { + late::CancelIoEx(d.handle, &d.overlapped); + okos_dword bytes = 0; + late::GetOverlappedResult(d.handle, &d.overlapped, &bytes, 1); + CloseHandle(d.handle); + CloseHandle(d.event); +} + +// Whether a directory's read completed --- a change, or the directory gone --- +// each such read reissued. What changed is not read: the watcher looks. +bool harvest(notifier& n) { + bool any = false; + for (auto& d : n.directories) { + okos_dword bytes = 0; + if (!late::GetOverlappedResult(d.handle, &d.overlapped, &bytes, 0) && GetLastError() == kIncomplete) continue; + any = true; + request(d); + } + return any; +} + +} // namespace + +notifier* notifier_open() { return new notifier; } + +void notifier_close(notifier* n) noexcept { + if (!n) return; + for (auto& d : n->directories) close(d); + delete n; +} + +bool notifier_watch(notifier& n, const std::string& node, bool directory) { + if (!directory) return true; + notifier_unwatch(n, node); + // FILE_LIST_DIRECTORY; shared every way; OPEN_EXISTING; BACKUP_SEMANTICS | OVERLAPPED. + const okos_handle h = CreateFileW(utf16_from_utf8(node).c_str(), 0x1, 0x7, nullptr, 3, 0x02000000 | 0x40000000, nullptr); + if (h == invalid() || h == nullptr) return false; + auto& d = n.directories.emplace_back(); + d.name = node; + d.handle = h; + d.event = late::CreateEventW(nullptr, 1, 0, nullptr); + if (!d.event || !request(d)) { + CloseHandle(h); + if (d.event) CloseHandle(d.event); + n.directories.pop_back(); + return false; + } + return true; +} + +void notifier_unwatch(notifier& n, const std::string& node) { + const auto it = std::ranges::find_if(n.directories, [&](const auto& d) { return d.name == node; }); + if (it == n.directories.end()) return; + close(*it); + n.directories.erase(it); +} + +bool notifier_wait(notifier& n, std::chrono::milliseconds wait) { + using clock = std::chrono::steady_clock; + const auto deadline = clock::now() + wait; + for (;;) { + if (harvest(n)) return true; + const auto left = std::chrono::duration_cast(deadline - clock::now()); + if (left.count() <= 0 || n.directories.empty()) return false; + // One wait upon every directory's event where the system takes them all + // (64); beyond, a look every few milliseconds. + if (n.directories.size() <= 64) { + std::vector events; + for (const auto& d : n.directories) events.push_back(d.event); + if (late::WaitForMultipleObjects(static_cast(events.size()), events.data(), 0, + static_cast(left.count())) == kTimeout) + return false; + } else { + std::this_thread::sleep_for(std::min(left, std::chrono::milliseconds{10})); + } + } +} + +std::optional notifier_handle(const notifier&) noexcept { return std::nullopt; } + +} // namespace mcpplibs::os::fs::sys diff --git a/src/windows/win32.h b/src/windows/win32.h index 8855fe1..1c7ff16 100644 --- a/src/windows/win32.h +++ b/src/windows/win32.h @@ -62,6 +62,13 @@ __declspec(dllimport) okos_handle __stdcall GetStdHandle(okos_dword); __declspec(dllimport) okos_bool __stdcall GetHandleInformation(okos_handle, okos_dword*); } +// OVERLAPPED (0.4.2, `fs::watcher'): two pointer-sized words, two DWORDs, the event. +struct okos_overlapped { + unsigned long long internal, internal_high; + okos_dword offset, offset_high; + okos_handle event; +}; + namespace late { template @@ -89,6 +96,34 @@ inline okos_bool K32EnumProcessModules(okos_handle a0, okos_handle* a1, okos_dwo static const auto f = find("K32EnumProcessModules"); return f(a0, a1, a2, a3); } +// 0.4.2, `fs::watcher': a directory's changes (ReadDirectoryChangesW) read +// overlapped, an event per directory and a wait upon several. +inline okos_bool ReadDirectoryChangesW(okos_handle a0, void* a1, okos_dword a2, okos_bool a3, okos_dword a4, okos_dword* a5, + okos_overlapped* a6, void* a7) { + static const auto f = find("ReadDirectoryChangesW"); + return f(a0, a1, a2, a3, a4, a5, a6, a7); +} +inline okos_handle CreateEventW(void* a0, okos_bool a1, okos_bool a2, const okos_wchar* a3) { + static const auto f = find("CreateEventW"); + return f(a0, a1, a2, a3); +} +inline okos_bool ResetEvent(okos_handle a0) { + static const auto f = find("ResetEvent"); + return f(a0); +} +inline okos_dword WaitForMultipleObjects(okos_dword a0, const okos_handle* a1, okos_bool a2, okos_dword a3) { + static const auto f = find("WaitForMultipleObjects"); + return f(a0, a1, a2, a3); +} +inline okos_bool GetOverlappedResult(okos_handle a0, okos_overlapped* a1, okos_dword* a2, okos_bool a3) { + static const auto f = find("GetOverlappedResult"); + return f(a0, a1, a2, a3); +} +inline okos_bool CancelIoEx(okos_handle a0, okos_overlapped* a1) { + static const auto f = find("CancelIoEx"); + return f(a0, a1); +} inline okos_bool GetVolumePathNameW(const okos_wchar* a0, okos_wchar* a1, okos_dword a2) { static const auto f = find("GetVolumePathNameW"); return f(a0, a1, a2); diff --git a/tests/check.hpp b/tests/check.hpp deleted file mode 100644 index 047557d..0000000 --- a/tests/check.hpp +++ /dev/null @@ -1,8 +0,0 @@ -// A test reports each observation and exits with the number that did not hold. -#pragma once -#include -inline int g_failures = 0; -inline void check(bool held, const char* what) { - std::printf(" %s %s\n", held ? "held" : "FAIL", what); - if (!held) ++g_failures; -} diff --git a/tests/dirs_terminal_test.cpp b/tests/dirs_terminal_test.cpp index 40149a9..14e0342 100644 --- a/tests/dirs_terminal_test.cpp +++ b/tests/dirs_terminal_test.cpp @@ -1,22 +1,20 @@ // Conventions of the host, and the terminal's colour policy. -#include "check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; namespace os = mcpplibs::os; +using namespace mcpplibs::mtest; -int main() { - check(os::dirs::home().has_value(), "a home directory is named"); - check(os::dirs::config().has_value(), "a configuration directory is named"); - check(os::dirs::cache().has_value(), "a cache directory is named"); - check(!os::dirs::temp().empty(), "a temporary directory is named"); +UTest dirs_terminal_tests{"dirs_terminal", [] { + expect(os::dirs::home().has_value()) << "a home directory is named"; + expect(os::dirs::config().has_value()) << "a configuration directory is named"; + expect(os::dirs::cache().has_value()) << "a cache directory is named"; + expect(!os::dirs::temp().empty()) << "a temporary directory is named"; if constexpr (os::is_linux) { - check(os::dirs::cache()->generic_string().ends_with(".cache") || os::env::get("XDG_CACHE_HOME"), - "Linux follows XDG"); + expect(os::dirs::cache()->generic_string().ends_with(".cache") || os::env::get("XDG_CACHE_HOME")) << "Linux follows XDG"; } // Not a terminal here (CI); colour follows NO_COLOR and CLICOLOR_FORCE. const bool forced = os::env::get("CLICOLOR_FORCE").value_or("0") != "0"; - check(os::terminal::color(os::terminal::stream::out) == (forced || os::terminal::interactive(os::terminal::stream::out)), - "colour is written to a terminal or when forced"); - return g_failures; -} + expect(os::terminal::color(os::terminal::stream::out) == (forced || os::terminal::interactive(os::terminal::stream::out))) << "colour is written to a terminal or when forced"; +}}; diff --git a/tests/facts_test.cpp b/tests/facts_test.cpp index 97ec193..635de49 100644 --- a/tests/facts_test.cpp +++ b/tests/facts_test.cpp @@ -2,11 +2,12 @@ // native build and an image, asked through `on

', so that this one source // links and answers on every row. The values are the CI runners': Windows // runs elevated, Linux runs under no translator, macOS runs images natively. -#include "check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; namespace os = mcpplibs::os; +using namespace mcpplibs::mtest; static_assert(os::can_run_on(os::PLATFORM) || os::is_openkal, "a native build runs on its own system"); static_assert(!os::can_run_on(os::platform::openkal), "an image is a target, never a system it runs upon"); @@ -24,29 +25,28 @@ void check_effective_user(std::integral_constant, const char* s auto id = os::process::run({.program = "/usr/bin/id", .args = {"-u"}, .err = os::process::stdio::null}); const std::string named = id ? std::string(id->out.substr(0, id->out.find_first_of("\r\n"))) : "(id did not run)"; std::printf(" note effective_user() = %u, id -u = %s\n", uid, named.c_str()); - check(std::to_string(uid) == named, std::format("{}: the effective user is the one `id -u' names", system).c_str()); + expect(std::to_string(uid) == named) << std::format("{}: the effective user is the one `id -u' names", system).c_str(); } -int main() { +UTest facts_tests{"facts", [] { const bool ci = os::env::get("GITHUB_ACTIONS").has_value(); int asked = 0; os::on([&](auto p) { ++asked; - check(!os::linux::translator().has_value() || !ci, "Linux: the runner is no translator"); + expect(!os::linux::translator().has_value() || !ci) << "Linux: the runner is no translator"; check_effective_user(p, "Linux"); }); os::on([&](auto p) { ++asked; - check(!os::macos::translated() || !os::is_openkal, "macOS: an image is never translated"); + expect(!os::macos::translated() || !os::is_openkal) << "macOS: an image is never translated"; check_effective_user(p, "macOS"); }); os::on([&](auto) { ++asked; - check(os::windows::elevated() || !ci, "Windows: the runner's token is the administrator's"); + expect(os::windows::elevated() || !ci) << "Windows: the runner's token is the administrator's"; const auto dev = os::windows::on_dev_drive(std::filesystem::current_path()); std::printf(" note on_dev_drive(cwd) = %s\n", dev ? (*dev ? "true" : "false") : "not known"); - check(!dev.value_or(false) || !ci, "Windows: the runner's work directory is on no developer volume"); + expect(!dev.value_or(false) || !ci) << "Windows: the runner's work directory is on no developer volume"; }); - check(asked == (os::is_emscripten ? 0 : 1), "exactly one platform answers where the build runs"); - return g_failures; -} + expect(asked == (os::is_emscripten ? 0 : 1)) << "exactly one platform answers where the build runs"; +}}; diff --git a/tests/fs_test.cpp b/tests/fs_test.cpp index d7702da..c82da3f 100644 --- a/tests/fs_test.cpp +++ b/tests/fs_test.cpp @@ -1,9 +1,10 @@ // mcpplibs.os.fs upon a scratch directory. -#include "check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; namespace os = mcpplibs::os; +using namespace mcpplibs::mtest; namespace stdfs = std::filesystem; // A name no other run of this test uses at once: this program's identifier, @@ -14,150 +15,143 @@ static std::string run_tag() { else return std::to_string(os::process::self()->id); } -int main() { +UTest fs_tests{"fs", [] { const auto dir = stdfs::temp_directory_path() / std::format("mcpplibs-os-fs-{}", run_tag()); stdfs::remove_all(dir); stdfs::create_directories(dir); const auto l = os::fs::locate(dir); - check(l.has_value(), "the scratch directory lies beneath a supplied directory"); + expect(l.has_value()) << "the scratch directory lies beneath a supplied directory"; const auto file = dir / "a.txt"; - check(os::fs::write(file, "one").has_value(), "a file is written"); - check(os::fs::read(file).value_or("") == "one", "and read back"); - check(os::fs::write_atomic(file, "two").has_value(), "and replaced all at once"); - check(os::fs::read(file).value_or("") == "two", "and the replacement is read"); + expect(os::fs::write(file, "one").has_value()) << "a file is written"; + expect(os::fs::read(file).value_or("") == "one") << "and read back"; + expect(os::fs::write_atomic(file, "two").has_value()) << "and replaced all at once"; + expect(os::fs::read(file).value_or("") == "two") << "and the replacement is read"; // 0.2.1: a read with a bound. - check(os::fs::read(file, 3).value_or("") == "two", "a file within the bound is read whole"); + expect(os::fs::read(file, 3).value_or("") == "two") << "a file within the bound is read whole"; { const auto over = os::fs::read(file, 2); - check(!over && over.error().code == os::errc::too_large, "and one past it is refused as too large"); + expect(!over && over.error().code == os::errc::too_large) << "and one past it is refused as too large"; } // 0.2.1: the name a node is known by. { const auto canonical = os::fs::canonical(dir / "." / "sub" / ".." / file.filename()); - check(os::fs::same(canonical, os::fs::canonical(file)) && os::fs::is_absolute(canonical), - "a name and another of the same node have one canonical name"); + expect(os::fs::same(canonical, os::fs::canonical(file)) && os::fs::is_absolute(canonical)) << "a name and another of the same node have one canonical name"; const auto missing = os::fs::canonical(dir / "not-there" / "deeper"); - check(os::fs::generic(missing).ends_with("not-there/deeper"), "and the part that does not exist is kept as written"); + expect(os::fs::generic(missing).ends_with("not-there/deeper")) << "and the part that does not exist is kept as written"; if constexpr (os::is_posix) { std::error_code ec; stdfs::create_directory_symlink(dir, dir.parent_path() / std::format("{}-link", dir.filename().string()), ec); if (!ec) { const auto through = os::fs::canonical(dir.parent_path() / std::format("{}-link", dir.filename().string()) / file.filename()); - check(os::fs::same(through, os::fs::canonical(file)), "a link is followed"); + expect(os::fs::same(through, os::fs::canonical(file))) << "a link is followed"; stdfs::remove(dir.parent_path() / std::format("{}-link", dir.filename().string()), ec); } } } const auto caps = os::fs::caps(dir); - check(caps.has_value(), "the volume says what it can do"); + expect(caps.has_value()) << "the volume says what it can do"; const auto pub = dir / "published.txt"; auto first = os::fs::publish(pub, "first"); - check(first.has_value(), "a name nobody has taken is published"); - if (caps && caps->rename_no_replace) check(first && first->atomic, "and atomically where the volume can"); + expect(first.has_value()) << "a name nobody has taken is published"; + if (caps && caps->rename_no_replace) expect(first && first->atomic) << "and atomically where the volume can"; auto second = os::fs::publish(pub, "second"); - check(!second && second.error().code == os::errc::exists, "a published name is not replaced"); - check(os::fs::read(pub).value_or("") == "first", "and keeps what was published first"); + expect(!second && second.error().code == os::errc::exists) << "a published name is not replaced"; + expect(os::fs::read(pub).value_or("") == "first") << "and keeps what was published first"; // Locks where the volume has them (every native volume here); a volume // without them (MEMFS, Emscripten) refuses rather than pretending. if (!caps || caps->locks) { auto a = os::fs::lock::acquire(dir / "lock", os::fs::lock::kind::exclusive, false); - check(a.has_value() && a->held(), "an exclusive lock is taken"); + expect(a.has_value() && a->held()) << "an exclusive lock is taken"; } else { auto a = os::fs::lock::acquire(dir / "lock", os::fs::lock::kind::exclusive, false); - check(!a && a.error().code == os::errc::not_supported, "a volume without locks refuses one"); + expect(!a && a.error().code == os::errc::not_supported) << "a volume without locks refuses one"; } auto again = os::fs::lock::acquire(dir / "lock", os::fs::lock::kind::exclusive, false); - check(again.has_value() || (caps && !caps->locks), "and is taken again once released"); + expect(again.has_value() || (caps && !caps->locks)) << "and is taken again once released"; // A lock held for the rest of the program: the directory is removed below // while it is held, where the volume removes a held name at once. const auto s = os::fs::stamp_of(file); - check(s && s->size == 3, "a stamp reports the size"); + expect(s && s->size == 3) << "a stamp reports the size"; const auto i1 = os::fs::identity_of(file); const auto i2 = os::fs::identity_of(dir / "." / "a.txt"); - check(i1 && i2 && *i1 == *i2, "two names of one node have one identity"); + expect(i1 && i2 && *i1 == *i2) << "two names of one node have one identity"; if (caps && caps->make_links) { stdfs::create_directories(dir / "target"); const auto made = os::fs::create_link("target", dir / "link", true); - check(made.has_value() || made.error().code == os::errc::permission, "a directory link is made where the volume can"); + expect(made.has_value() || made.error().code == os::errc::permission) << "a directory link is made where the volume can"; if (made) { const auto t = os::fs::read_link(dir / "link"); - check(t && t->find("target") != std::string::npos, "and reads back its target"); - check(stdfs::exists(dir / "link" / "."), "and reaches the directory"); + expect(t && t->find("target") != std::string::npos) << "and reads back its target"; + expect(stdfs::exists(dir / "link" / ".")) << "and reaches the directory"; } } if (!caps || caps->rename_no_replace) { - check(os::fs::rename(file, dir / "b.txt", false).has_value(), "a rename that does not replace renames to a free name"); + expect(os::fs::rename(file, dir / "b.txt", false).has_value()) << "a rename that does not replace renames to a free name"; } else { // A volume that cannot test and rename as one act refuses, and renames nothing. const auto r = os::fs::rename(file, dir / "b.txt", false); - check(!r && r.error().code == os::errc::not_supported && stdfs::exists(file) && !stdfs::exists(dir / "b.txt"), - "a rename that does not replace is refused where the volume cannot, and renames nothing"); - check(os::fs::rename(file, dir / "b.txt", true).has_value(), "and a rename that replaces renames"); + expect(!r && r.error().code == os::errc::not_supported && stdfs::exists(file) && !stdfs::exists(dir / "b.txt")) << "a rename that does not replace is refused where the volume cannot, and renames nothing"; + expect(os::fs::rename(file, dir / "b.txt", true).has_value()) << "and a rename that replaces renames"; } - check(os::fs::names_a_place("bin/tool") && !os::fs::names_a_place("tool"), - "a name with a separator says where a file is, a bare one does not"); - check(os::fs::names_a_place("C:tool") == (os::host() == os::platform::windows) - && os::fs::names_a_place("bin\\tool") == (os::host() == os::platform::windows), - "a drive and a backslash say where a file is on Windows and nowhere else"); + expect(os::fs::names_a_place("bin/tool") && !os::fs::names_a_place("tool")) << "a name with a separator says where a file is, a bare one does not"; + expect(os::fs::names_a_place("C:tool") == (os::host() == os::platform::windows) + && os::fs::names_a_place("bin\\tool") == (os::host() == os::platform::windows)) << "a drive and a backslash say where a file is on Windows and nowhere else"; { // Names in the host's rules, without the file system. const bool windows = os::host() == os::platform::windows; - check(os::fs::generic("a/./b/../c/") == "a/c", "a name is spelt with its dots resolved and no trailing separator"); - check(os::fs::generic("/x/../../y") == "/y", "and nothing is above the root"); - check(os::fs::is_absolute("/usr/bin") == !windows, "a leading separator is a root on POSIX"); + expect(os::fs::generic("a/./b/../c/") == "a/c") << "a name is spelt with its dots resolved and no trailing separator"; + expect(os::fs::generic("/x/../../y") == "/y") << "and nothing is above the root"; + expect(os::fs::is_absolute("/usr/bin") == !windows) << "a leading separator is a root on POSIX"; if (windows) { - check(os::fs::generic("C:\\Users\\x\\") == "C:/Users/x", "a Windows name is spelt with forward separators"); - check(os::fs::native("C:/Users/x") == "C:\\Users\\x", "and the host's separator for a person or a program"); - check(os::fs::is_absolute("C:/x") && !os::fs::is_absolute("C:x") && os::fs::is_absolute("//srv/share/x"), - "a drive and a separator, or a share, is a root; a drive alone is not"); - check(os::fs::same("C:/Users/X", "c:\\users\\x"), "names are the same without regard to case"); + expect(os::fs::generic("C:\\Users\\x\\") == "C:/Users/x") << "a Windows name is spelt with forward separators"; + expect(os::fs::native("C:/Users/x") == "C:\\Users\\x") << "and the host's separator for a person or a program"; + expect(os::fs::is_absolute("C:/x") && !os::fs::is_absolute("C:x") && os::fs::is_absolute("//srv/share/x")) << "a drive and a separator, or a share, is a root; a drive alone is not"; + expect(os::fs::same("C:/Users/X", "c:\\users\\x")) << "names are the same without regard to case"; } else { - check(os::fs::native("a/b") == "a/b", "a POSIX name is its own native spelling"); - check(os::fs::same("/a/b", "/a/./b/") && (os::fs::same("/a/B", "/a/b") == (os::host() == os::platform::macos)), - "names compare by components, and by case where the host's names do"); + expect(os::fs::native("a/b") == "a/b") << "a POSIX name is its own native spelling"; + expect(os::fs::same("/a/b", "/a/./b/") && (os::fs::same("/a/B", "/a/b") == (os::host() == os::platform::macos))) << "names compare by components, and by case where the host's names do"; } - check(os::fs::within("/a/b/c", "/a/b") && os::fs::within("/a/b", "/a/b") && !os::fs::within("/a/bc", "/a/b"), - "a name is within a directory by components"); + expect(os::fs::within("/a/b/c", "/a/b") && os::fs::within("/a/b", "/a/b") && !os::fs::within("/a/bc", "/a/b")) << "a name is within a directory by components"; const char sep = os::conventions().path_list_separator; const std::string list = std::string("/x") + sep + sep + " /y " + sep + (windows ? "\"C:/with space\"" : "/z"); const auto parts = os::env::split_list(list); - check(parts.size() == 3 && parts[0] == "/x" && parts[1] == "/y", "a list splits at the host's separator and skips empty entries"); - if (windows) check(parts.size() == 3 && parts[2] == "C:/with space", "and an entry quoted whole is unquoted"); + expect(parts.size() == 3 && parts[0] == "/x" && parts[1] == "/y") << "a list splits at the host's separator and skips empty entries"; + if (windows) expect(parts.size() == 3 && parts[2] == "C:/with space") << "and an entry quoted whole is unquoted"; const std::vector dirs{ "/p", "/q" }; - check(os::env::split_list(os::env::join_list(dirs)).size() == 2, "and joining is its inverse"); + expect(os::env::split_list(os::env::join_list(dirs)).size() == 2) << "and joining is its inverse"; } { // Names the Windows code page cannot spell, both ways through the // system: UTF-8 in and out on every host, never the ANSI code page. const std::string name = "\xE4\xB8\xAD\xE6\x96\x87 \xE3\x81\xAB\xE3\x81\xBB\xE3\x82\x93 \xD0\xBA\xD0\xB8\xD1\x80 \xE0\xA4\x95"; - check(os::fs::utf8(os::fs::from_utf8(name)) == name, "a UTF-8 name survives the trip through a path"); + expect(os::fs::utf8(os::fs::from_utf8(name)) == name) << "a UTF-8 name survives the trip through a path"; const auto sub = dir / os::fs::from_utf8(name); stdfs::create_directories(sub); const auto f = sub / os::fs::from_utf8(name + ".txt"); - check(os::fs::write(f, "\xE4\xBD\xA0\xE5\xA5\xBD").has_value(), "a file whose name is not ASCII is written"); - check(os::fs::read(f).value_or("") == "\xE4\xBD\xA0\xE5\xA5\xBD", "and read back"); - check(stdfs::exists(f), "and the system sees it under that name"); - check(os::fs::write_atomic(f, "again").has_value() && os::fs::read(f).value_or("") == "again", "replaced all at once"); + expect(os::fs::write(f, "\xE4\xBD\xA0\xE5\xA5\xBD").has_value()) << "a file whose name is not ASCII is written"; + expect(os::fs::read(f).value_or("") == "\xE4\xBD\xA0\xE5\xA5\xBD") << "and read back"; + expect(stdfs::exists(f)) << "and the system sees it under that name"; + expect(os::fs::write_atomic(f, "again").has_value() && os::fs::read(f).value_or("") == "again") << "replaced all at once"; const auto moved = sub / os::fs::from_utf8("\xE6\x96\xB0 " + name); - check(os::fs::rename(f, moved, caps && !caps->rename_no_replace).has_value() && stdfs::exists(moved), "and renamed"); + expect(os::fs::rename(f, moved, caps && !caps->rename_no_replace).has_value() && stdfs::exists(moved)) << "and renamed"; const auto missing = os::fs::read(sub / os::fs::from_utf8("\xE6\xB2\xA1\xE6\x9C\x89")); - check(!missing && missing.error().message().contains("\xE6\xB2\xA1\xE6\x9C\x89"), "an error names the file in UTF-8"); - check(os::fs::generic_utf8(os::fs::absolute(sub)).ends_with(name), "absolute keeps the name"); + expect(!missing && missing.error().message().contains("\xE6\xB2\xA1\xE6\x9C\x89")) << "an error names the file in UTF-8"; + expect(os::fs::generic_utf8(os::fs::absolute(sub)).ends_with(name)) << "absolute keeps the name"; } if (caps && caps->remove_open) { std::error_code ec; stdfs::remove_all(dir, ec); - check(!ec && !stdfs::exists(dir), "a directory holding a file held open is removed"); + expect(!ec && !stdfs::exists(dir)) << "a directory holding a file held open is removed"; } else { if (again) again->release(); stdfs::remove_all(dir); @@ -167,40 +161,36 @@ int main() { const auto dir = std::filesystem::temp_directory_path() / std::format("mcpplibs-os-fs2-{}", run_tag()); std::filesystem::create_directories(dir); - check(os::fs::append(dir / "log", "a").has_value() && os::fs::append(dir / "log", "b").has_value() - && os::fs::read(dir / "log").value_or("") == "ab", - "appends land whole at the end, the file created by the first"); - check(os::fs::sync(dir / "log").has_value() && os::fs::sync(dir).has_value(), - "a file and a directory are made durable"); + expect(os::fs::append(dir / "log", "a").has_value() && os::fs::append(dir / "log", "b").has_value() + && os::fs::read(dir / "log").value_or("") == "ab") << "appends land whole at the end, the file created by the first"; + expect(os::fs::sync(dir / "log").has_value() && os::fs::sync(dir).has_value()) << "a file and a directory are made durable"; const auto b = os::fs::measure(dir); - check(b.measured, "a writable directory's volume is measured"); + expect(b.measured) << "a writable directory's volume is measured"; // The kind of a volume is the system's to name; an image is not told it. if constexpr (!os::is_openkal) - check(!b.type.empty() && b.type != "0x0", "and its kind is named by the system"); + expect(!b.type.empty() && b.type != "0x0") << "and its kind is named by the system"; (void)os::fs::write(dir / "x", "x"); - check(os::fs::place(dir / "x", dir / "y").has_value() && os::fs::read(dir / "y").value_or("") == "x" - && !std::filesystem::exists(dir / "x"), - "an entry is placed under a free name"); + expect(os::fs::place(dir / "x", dir / "y").has_value() && os::fs::read(dir / "y").value_or("") == "x" + && !std::filesystem::exists(dir / "x")) << "an entry is placed under a free name"; (void)os::fs::write(dir / "z", "z"); const auto refused = os::fs::place(dir / "z", dir / "y"); - check(!refused && os::fs::read(dir / "y").value_or("") == "x", "and never over one that is there"); + expect(!refused && os::fs::read(dir / "y").value_or("") == "x") << "and never over one that is there"; (void)os::fs::write(dir / "p", "p"); (void)os::fs::write(dir / "q", "q"); - check(os::fs::exchange(dir / "p", dir / "q").has_value() - && os::fs::read(dir / "p").value_or("") == "q" && os::fs::read(dir / "q").value_or("") == "p", - "two entries trade places"); + expect(os::fs::exchange(dir / "p", dir / "q").has_value() + && os::fs::read(dir / "p").value_or("") == "q" && os::fs::read(dir / "q").value_or("") == "p") << "two entries trade places"; if (!os::is_openkal) { - check(os::fs::owner_of(dir) == os::fs::owner::this_user, "a directory this program made is its user's"); - check(os::fs::writable(dir), "and this user may write it"); + expect(os::fs::owner_of(dir) == os::fs::owner::this_user) << "a directory this program made is its user's"; + expect(os::fs::writable(dir)) << "and this user may write it"; } std::error_code ec; std::filesystem::remove_all(dir, ec); } - check(os::fs::windows_extended("C:/a/./b/../c") == "\\\\?\\C:\\a\\c", "a drive name in the long spelling, resolved"); - check(os::fs::windows_extended("//server/share/x") == "\\\\?\\UNC\\server\\share\\x", "a share in the long spelling"); - check(os::fs::windows_extended("C:/..") == "\\\\?\\C:\\", "and `..' never climbs above the drive"); - check(os::fs::windows_extended("rel/x") == "rel/x", "a relative name is not this rule's"); - if (os::host() != os::platform::windows) check(os::fs::extended("/a/b") == "/a/b", "elsewhere the name is itself"); + expect(os::fs::windows_extended("C:/a/./b/../c") == "\\\\?\\C:\\a\\c") << "a drive name in the long spelling, resolved"; + expect(os::fs::windows_extended("//server/share/x") == "\\\\?\\UNC\\server\\share\\x") << "a share in the long spelling"; + expect(os::fs::windows_extended("C:/..") == "\\\\?\\C:\\") << "and `..' never climbs above the drive"; + expect(os::fs::windows_extended("rel/x") == "rel/x") << "a relative name is not this rule's"; + if (os::host() != os::platform::windows) expect(os::fs::extended("/a/b") == "/a/b") << "elsewhere the name is itself"; // THE LONG SPELLING NAMES THE SAME FILE (0.3.1). Every operation that // locates a name accepts the name `extended' produces. @@ -210,16 +200,14 @@ int main() { stdfs::create_directories(base, ec); const auto plain = base / "a.txt"; const auto longer = os::fs::extended(plain); - check(longer != plain, "the long spelling is produced"); + expect(longer != plain) << "the long spelling is produced"; const auto at = os::fs::locate(longer); - check(at && at->relative.find(':') == std::string::npos, "it is located beneath a drive, not beneath the shares"); - check(os::fs::write(longer, "one").has_value(), "a file is written through it"); - check(os::fs::read(longer).value_or("") == "one" && os::fs::read(plain).value_or("") == "one", - "and read through either spelling"); - check(os::fs::stamp_of(longer).has_value() && os::fs::identity_of(longer).has_value(), "its stamp and identity are read"); + expect(at && at->relative.find(':') == std::string::npos) << "it is located beneath a drive, not beneath the shares"; + expect(os::fs::write(longer, "one").has_value()) << "a file is written through it"; + expect(os::fs::read(longer).value_or("") == "one" && os::fs::read(plain).value_or("") == "one") << "and read through either spelling"; + expect(os::fs::stamp_of(longer).has_value() && os::fs::identity_of(longer).has_value()) << "its stamp and identity are read"; const auto moved = os::fs::extended(base / "b.txt"); - check(os::fs::rename(longer, moved, true).has_value() && stdfs::exists(base / "b.txt"), "and it is renamed"); + expect(os::fs::rename(longer, moved, true).has_value() && stdfs::exists(base / "b.txt")) << "and it is renamed"; stdfs::remove_all(base, ec); } - return g_failures; -} +}}; diff --git a/tests/ipc_test.cpp b/tests/ipc_test.cpp index e2eeae3..cce575e 100644 --- a/tests/ipc_test.cpp +++ b/tests/ipc_test.cpp @@ -1,17 +1,18 @@ // mcpplibs.os.ipc and .net: a listener and a connection in one program. -#include "check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; namespace os = mcpplibs::os; +using namespace mcpplibs::mtest; -int main() { +UTest ipc_tests{"ipc", [] { const auto dir = std::filesystem::temp_directory_path(); auto caps = os::fs::caps(dir); if (caps && caps->local_endpoint) { const auto name = std::format("os-ipc-{}", os::process::self()->id); auto l = os::ipc::listener::listen(dir, name); - check(l.has_value(), "a listener is opened at a name"); + expect(l.has_value()) << "a listener is opened at a name"; if (l) { std::jthread server([&] { auto c = l->accept(); @@ -20,28 +21,28 @@ int main() { if (got) (void)c->write("re:" + *got); }); auto c = os::ipc::connection::connect(dir, name); - check(c.has_value(), "a connection is made at the name"); + expect(c.has_value()) << "a connection is made at the name"; if (c) { - check(c->write("ping").has_value(), "bytes are written"); + expect(c->write("ping").has_value()) << "bytes are written"; auto r = c->read(); - check(r && *r == "re:ping", "and the answer is read"); + expect(r && *r == "re:ping") << "and the answer is read"; } } auto inst = os::ipc::single_instance(name + "-single", dir); - check(inst && std::holds_alternative(*inst), "the first instance listens"); + expect(inst && std::holds_alternative(*inst)) << "the first instance listens"; auto second = os::ipc::single_instance(name + "-single", dir); - check(second && std::holds_alternative(*second), "a second one connects to it"); + expect(second && std::holds_alternative(*second)) << "a second one connects to it"; } else { std::printf(" (the temporary directory's volume holds no local endpoint)\n"); } { auto l = os::net::loopback_listener::listen(); - check(l && l->port() != 0, "a loopback listener is given a port"); + expect(l && l->port() != 0) << "a loopback listener is given a port"; if (l) { std::jthread server([&] { auto c = l->accept(); if (c) (void)c->write("hi"); }); auto c = os::net::connect_loopback(l->port()); auto r = c ? c->read() : os::result{}; - check(r && *r == "hi", "and a loopback connection reads what was written"); + expect(r && *r == "hi") << "and a loopback connection reads what was written"; } } { @@ -53,19 +54,18 @@ int main() { std::jthread server([&] { if (auto c = l->accept()) accepted.emplace(std::move(*c)); }); auto c = os::net::connect_loopback(l->port()); server.join(); - check(c && accepted, "a connection is accepted"); + expect(c && accepted) << "a connection is accepted"; if (c && accepted) { std::atomic woken{ false }; std::jthread reader([&] { auto r = c->read(); woken = r && r->empty(); }); std::this_thread::sleep_for(std::chrono::milliseconds(100)); - check(c->write("while a read waits").has_value(), "a write proceeds while a read on the same connection waits"); + expect(c->write("while a read waits").has_value()) << "a write proceeds while a read on the same connection waits"; auto echoed = accepted->read(); - check(echoed && *echoed == "while a read waits", "and arrives"); + expect(echoed && *echoed == "while a read waits") << "and arrives"; c->shutdown(); reader.join(); - check(woken.load(), "a shutdown from another thread wakes the reader with the end"); + expect(woken.load()) << "a shutdown from another thread wakes the reader with the end"; } } } - return g_failures; -} +}}; diff --git a/tests/library_test.cpp b/tests/library_test.cpp index 5221d73..5465718 100644 --- a/tests/library_test.cpp +++ b/tests/library_test.cpp @@ -1,42 +1,42 @@ // Shared libraries at run time: a system library opened by name and its // functions called; a library the process already has, found without loading // it; a symbol of the program; and a library that is not there, an error. -#include "check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; namespace os = mcpplibs::os; +using namespace mcpplibs::mtest; -int main() { +UTest library_tests{"library", [] { if (!os::library::supported()) { // An ok image, or a program linked statically (aarch64 Linux's GCC links musl statically). auto none = os::library::open("anything"); - check(!none && none.error().code == os::errc::not_supported, "a static program loads no library, and says so"); - return g_failures; + expect(!none && none.error().code == os::errc::not_supported) << "a static program loads no library, and says so"; + return; } // A library every process of the system has, and a function of it. const std::string_view system = os::is_windows ? "kernel32.dll" : os::is_macos ? "/usr/lib/libSystem.B.dylib" : "libc.so.6"; const std::string_view function = os::is_windows ? "GetCurrentProcessId" : "getpid"; auto lib = os::library::open(std::filesystem::path(std::string(system))); - check(lib.has_value(), "a system library opens by name"); + expect(lib.has_value()) << "a system library opens by name"; if (lib) { - check(lib->symbol(function) != nullptr, "and defines its functions"); - check(lib->symbol("no_such_function_anywhere") == nullptr, "and not others"); + expect(lib->symbol(function) != nullptr) << "and defines its functions"; + expect(lib->symbol("no_such_function_anywhere") == nullptr) << "and not others"; // (x86_64 and aarch64 Windows have one calling convention.) - if constexpr (os::is_windows) check(lib->function(function)() != 0, "its function is called"); - else check(lib->function(function)() > 0, "its function is called"); + if constexpr (os::is_windows) expect(lib->function(function)() != 0) << "its function is called"; + else expect(lib->function(function)() > 0) << "its function is called"; } auto present = os::library::loaded(system); - check(present.has_value() && present->symbol(function) != nullptr, "a library in the process is found without loading it"); - check(!os::library::loaded("libnothing-loaded-here.so.0").has_value(), "and one that is not, is not"); - check(os::library::process_symbol(function) != nullptr, "the program's symbols resolve as the loader resolves them"); + expect(present.has_value() && present->symbol(function) != nullptr) << "a library in the process is found without loading it"; + expect(!os::library::loaded("libnothing-loaded-here.so.0").has_value()) << "and one that is not, is not"; + expect(os::library::process_symbol(function) != nullptr) << "the program's symbols resolve as the loader resolves them"; auto missing = os::library::open("libmcpplibs-os-no-such-library.so"); - check(!missing && missing.error().code == os::errc::not_found && !missing.error().message().empty(), "a missing library is an error"); - check(os::library::suffix() == (os::is_windows ? ".dll" : os::is_macos ? ".dylib" : ".so"), "the system's suffix"); + expect(!missing && missing.error().code == os::errc::not_found && !missing.error().message().empty()) << "a missing library is an error"; + expect(os::library::suffix() == (os::is_windows ? ".dll" : os::is_macos ? ".dylib" : ".so")) << "the system's suffix"; // Moving a handle keeps the library open once. if (lib) { os::library::handle moved = std::move(*lib); - check(moved.valid() && !lib->valid(), "a handle moves"); + expect(moved.valid() && !lib->valid()) << "a handle moves"; } - return g_failures; -} +}}; diff --git a/tests/linux_test.cpp b/tests/linux_test.cpp index 128b563..241e5a1 100644 --- a/tests/linux_test.cpp +++ b/tests/linux_test.cpp @@ -1,29 +1,27 @@ // mcpplibs.os.linux: the system-call translator a program runs under, from // what it reads (the facts), and for this program. -#include "check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; namespace os = mcpplibs::os; +using namespace mcpplibs::mtest; -int main() { +UTest linux_tests{"linux", [] { if constexpr (os::is_linux) { using facts = os::linux::translator_facts; auto is = [](const facts& f) { return os::linux::classify_translator(f).value_or(""); }; - check(is(facts{ "TracerPid:\t812\n", "proot\n", {} }) == "proot", "a program traced by proot is under PRoot"); - check(is(facts{ "TracerPid:\t812\n", "proot-static", {} }) == "proot", "whatever proot* is called"); - check(is(facts{ "TracerPid:\t812\n", "gdb\n", {} }).empty(), "a debugger is not a translator"); - check(is(facts{ "TracerPid:\t812\n", "", { "PATH=/bin", "PROOT_TMP_DIR=/tmp" } }) == "proot", - "the PROOT_ variables decide when the tracer cannot be read"); - check(is(facts{ "", "", { "PROOT_TMP_DIR=/tmp" } }) == "proot", "and when the status cannot be read"); - check(is(facts{ "", "", { "PATH=/bin", "NOT_PROOT_X=1" } }).empty(), "and name nothing without them"); - check(is(facts{ "Name:\tx\nTracerPid:\t0\n", "", { "PROOT_NO_SECCOMP=1" } }).empty(), - "a PROOT_ variable in a program nothing traces means nothing"); - check(is(facts{ "TracerPid:\tnot-a-number\n", "proot", {} }).empty(), "a status that names no tracer names none"); + expect(is(facts{ "TracerPid:\t812\n", "proot\n", {} }) == "proot") << "a program traced by proot is under PRoot"; + expect(is(facts{ "TracerPid:\t812\n", "proot-static", {} }) == "proot") << "whatever proot* is called"; + expect(is(facts{ "TracerPid:\t812\n", "gdb\n", {} }).empty()) << "a debugger is not a translator"; + expect(is(facts{ "TracerPid:\t812\n", "", { "PATH=/bin", "PROOT_TMP_DIR=/tmp" } }) == "proot") << "the PROOT_ variables decide when the tracer cannot be read"; + expect(is(facts{ "", "", { "PROOT_TMP_DIR=/tmp" } }) == "proot") << "and when the status cannot be read"; + expect(is(facts{ "", "", { "PATH=/bin", "NOT_PROOT_X=1" } }).empty()) << "and name nothing without them"; + expect(is(facts{ "Name:\tx\nTracerPid:\t0\n", "", { "PROOT_NO_SECCOMP=1" } }).empty()) << "a PROOT_ variable in a program nothing traces means nothing"; + expect(is(facts{ "TracerPid:\tnot-a-number\n", "proot", {} }).empty()) << "a status that names no tracer names none"; const auto here = os::linux::translator(); - check(!here || *here == "proot", "this program's translator is PRoot or none"); + expect(!here || *here == "proot") << "this program's translator is PRoot or none"; if (const auto expected = os::env::get("OS_EXPECT_TRANSLATOR")) - check(here.value_or("") == *expected, "this program is under the translator its environment says"); + expect(here.value_or("") == *expected) << "this program is under the translator its environment says"; } - return g_failures; -} +}}; diff --git a/tests/platform_test.cpp b/tests/platform_test.cpp index 7cfc429..c156dfb 100644 --- a/tests/platform_test.cpp +++ b/tests/platform_test.cpp @@ -2,11 +2,12 @@ // E29). Built for a system the host is a constant expression; built as an ok // image it is known when the image is loaded. The native rows also build // checks/constants, where the compile-time facts are static assertions. -#include "check.hpp" import std; import mcpplibs.os; +import mcpplibs.mtest; namespace os = mcpplibs::os; +using namespace mcpplibs::mtest; static_assert(os::is_linux + os::is_macos + os::is_windows + os::is_openkal + os::is_emscripten == 1); static_assert(os::is_posix == (os::is_linux || os::is_macos)); @@ -14,40 +15,38 @@ static_assert(os::name(os::ARCH) == "x86_64" || os::name(os::ARCH) == "aarch64" || os::name(os::ARCH) == "wasm32"); static_assert(os::is_emscripten == (os::name(os::ARCH) == "wasm32")); -int main() { +UTest platform_tests{"platform", [] { const auto host = os::host(); - check(host != os::platform::openkal, "the host is a system, whatever the target"); - if constexpr (!os::is_openkal) check(host == os::PLATFORM, "built for a system, the host is that system"); - else check(os::env::loader().has_value(), "an image is told the program that runs it"); - check(os::conventions().exe_suffix == (host == os::platform::windows ? ".exe" : ""), - "the host's conventions are the host's"); + expect(host != os::platform::openkal) << "the host is a system, whatever the target"; + if constexpr (!os::is_openkal) expect(host == os::PLATFORM) << "built for a system, the host is that system"; + else expect(os::env::loader().has_value()) << "an image is told the program that runs it"; + expect(os::conventions().exe_suffix == (host == os::platform::windows ? ".exe" : "")) << "the host's conventions are the host's"; const auto system = os::env::system(); - check(system && *system == os::name(host), "openkal names the system the program runs upon"); - check(!os::machine::arch().empty(), "the machine's architecture is named"); + expect(system && *system == os::name(host)) << "openkal names the system the program runs upon"; + expect(!os::machine::arch().empty()) << "the machine's architecture is named"; // Linux translates no program to another processor's instructions: the // machine is named as the processor this program runs on, whichever C // library answers the program's other questions. if constexpr (os::is_linux) - check(os::machine::arch() == os::name(os::ARCH), "Linux: the machine is the processor the program runs on"); + expect(os::machine::arch() == os::name(os::ARCH)) << "Linux: the machine is the processor the program runs on"; // A wasm module is told nothing of the machine beneath its host but the // number of processors, and it runs the instructions it was built for. if constexpr (os::is_emscripten) { - check(os::machine::arch() == "wasm32", "Emscripten: the machine is the module's"); - check(!os::machine::memory() && !os::machine::memory_available(), "Emscripten: the machine's memory is not known"); + expect(os::machine::arch() == "wasm32") << "Emscripten: the machine is the module's"; + expect(!os::machine::memory() && !os::machine::memory_available()) << "Emscripten: the machine's memory is not known"; } if constexpr (!os::is_openkal && !os::is_emscripten) - check(os::machine::memory().value_or(0) > 0, "the machine's memory is known"); + expect(os::machine::memory().value_or(0) > 0) << "the machine's memory is known"; const auto a = os::random::hex(16), b = os::random::hex(16); - check(a && a->size() == 32 && b && *a != *b, "the system's source gives bytes that differ from call to call"); + expect(a && a->size() == 32 && b && *a != *b) << "the system's source gives bytes that differ from call to call"; { const auto p = os::machine::processors(); - check(p.logical >= 1 && p.physical >= 1 && p.physical <= p.logical, "processors: logical and physical counted"); + expect(p.logical >= 1 && p.physical >= 1 && p.physical <= p.logical) << "processors: logical and physical counted"; if (!os::is_openkal && !os::is_emscripten) - check(os::machine::memory_available().value_or(0) > 0, "and the memory available now"); + expect(os::machine::memory_available().value_or(0) > 0) << "and the memory available now"; } if constexpr (os::is_windows) { - check(os::windows::code_page() != 0, "Windows: the ANSI code page is known"); + expect(os::windows::code_page() != 0) << "Windows: the ANSI code page is known"; (void)os::windows::long_paths_enabled(); } - return g_failures; -} +}}; diff --git a/tests/process_test.cpp b/tests/process_test.cpp index 9663578..7b9c853 100644 --- a/tests/process_test.cpp +++ b/tests/process_test.cpp @@ -1,23 +1,23 @@ // mcpplibs.os.process: this test starts copies of itself. -#include "check.hpp" +#include // `stderr': the C library's own stream, which a capture must also hold import std; import mcpplibs.os; +import mcpplibs.mtest; namespace os = mcpplibs::os; +using namespace mcpplibs::mtest; -// What starts this program again with an errand. +// What starts this program again with an errand: the copy is the child below. os::process::spawn_options copy_of_self(std::string errand) { - const auto again = os::env::self_command(); + const std::vector args{ std::move(errand) }; + const auto again = child_command("errand", args); os::process::spawn_options o; - if (again.empty()) return o; o.program = again.front(); o.args.assign(again.begin() + 1, again.end()); - o.args.push_back("--child"); - o.args.push_back(std::move(errand)); return o; } -int child(std::string_view errand) { +int run_errand(std::string_view errand) { if (errand == "say") { (void)os::stdio::write_output("said"); return 0; @@ -180,40 +180,35 @@ int child(std::string_view errand) { return 99; } -int main(int argc, char** argv) { - if (argc > 2 && std::string_view(argv[1]) == "--child") return child(argv[2]); +// A copy of this program started with an errand does it, and runs no case. +child errand_child{"errand", [](std::vector args) { return args.size() == 1 ? run_errand(args[0]) : 98; }}; + +UTest process_tests{"process", [] { const auto self = os::env::image(); - check(self.has_value(), "this program's image is named"); - if (!self) return g_failures; + expect(self.has_value()) << "this program's image is named"; + if (!self) return; // What starts this program again: its image, or in an ok image the loader // and the image. const auto again = os::env::self_command(); - check(!again.empty(), "what starts this program again is known"); - auto copy = [&](std::string errand) { - os::process::spawn_options o; - o.program = again.front(); - o.args.assign(again.begin() + 1, again.end()); - o.args.push_back("--child"); - o.args.push_back(std::move(errand)); - return o; - }; + expect(!again.empty()) << "what starts this program again is known"; + auto copy = [](std::string errand) { return copy_of_self(std::move(errand)); }; { auto o = copy("echo"); auto r = os::process::run(o, "hello"); if (!r) std::printf(" (the start of %s failed: %s)\n", o.program.string().c_str(), r.error().message().c_str()); - check(r && r->out == "hello", "a started program reads its input and writes its output"); - check(r && r->err == "err", "and its error output is collected beside"); - check(r && r->status.code == 7 && !r->status.by_environment, "and its status is the one it returned"); + expect(r && r->out == "hello") << "a started program reads its input and writes its output"; + expect(r && r->err == "err") << "and its error output is collected beside"; + expect(r && r->status.code == 7 && !r->status.by_environment) << "and its status is the one it returned"; } { auto o = copy("env"); o.env = { { "OS_TEST_VALUE", std::string("set") } }; auto r = os::process::run(o); - check(r && r->out == "set", "an environment override reaches the program"); + expect(r && r->out == "set") << "an environment override reaches the program"; o.env = { { "OS_TEST_VALUE", std::nullopt } }; r = os::process::run(o); - check(r && r->out == "", "and a removal removes"); + expect(r && r->out == "") << "and a removal removes"; } { const auto dir = std::filesystem::temp_directory_path() / "mcpplibs-os-cwd-probe"; @@ -221,7 +216,7 @@ int main(int argc, char** argv) { auto o = copy("cwd"); o.cwd = dir; auto r = os::process::run(o); - check(r && r->out == "mcpplibs-os-cwd-probe", "a program runs in the directory it is given"); + expect(r && r->out == "mcpplibs-os-cwd-probe") << "a program runs in the directory it is given"; } { auto o = copy("sleep"); @@ -229,16 +224,16 @@ int main(int argc, char** argv) { o.err = os::process::stdio::null; o.own_unit = true; auto p = os::process::process::spawn(o); - check(p.has_value(), "a program is started in a unit of its own"); + expect(p.has_value()) << "a program is started in a unit of its own"; if (p) { - check(p->id() != 0, "and has an identifier"); + expect(p->id() != 0) << "and has an identifier"; auto d = os::process::describe(p->id()); - check(d && !d->ended, "which another program can ask about"); + expect(d && !d->ended) << "which another program can ask about"; auto w = p->wait_for(std::chrono::milliseconds(50)); - check(w && !w->has_value(), "a wait with a bound expires while it runs"); + expect(w && !w->has_value()) << "a wait with a bound expires while it runs"; p->kill(); auto st = p->wait(); - check(st && !st->success(), "and the unit, ended, does not report success"); + expect(st && !st->success()) << "and the unit, ended, does not report success"; } } { @@ -246,9 +241,8 @@ int main(int argc, char** argv) { os::process::bounds limits; limits.out_limit = 10000; auto r = os::process::run(copy("flood"), limits); - check(r && r->status.success() && r->out.size() == 10000 && r->out_truncated && !r->timed_out, - "a bounded run keeps an output to its limit and says it cut it"); - check(r && r->err == "tail" && !r->err_truncated, "and keeps the other whole"); + expect(r && r->status.success() && r->out.size() == 10000 && r->out_truncated && !r->timed_out) << "a bounded run keeps an output to its limit and says it cut it"; + expect(r && r->err == "tail" && !r->err_truncated) << "and keeps the other whole"; } { // 0.2.1: past its hard bound a program is ended, and the run says so. @@ -258,8 +252,8 @@ int main(int argc, char** argv) { const auto began = std::chrono::steady_clock::now(); auto r = os::process::run(copy("sleep"), limits); const auto took = std::chrono::steady_clock::now() - began; - check(r && r->timed_out && !r->status.success(), "a bounded run ends a program past its hard bound"); - check(took < std::chrono::seconds(15), "and does not wait for it to end by itself"); + expect(r && r->timed_out && !r->status.success()) << "a bounded run ends a program past its hard bound"; + expect(took < std::chrono::seconds(15)) << "and does not wait for it to end by itself"; } { // 0.2.1: a program that ends while what it left holds its output open. @@ -268,26 +262,24 @@ int main(int argc, char** argv) { os::process::bounds limits; limits.drain = std::chrono::milliseconds(300); auto r = os::process::run(o, limits); - check(r && r->status.success() && r->held_open, "a bounded run says an output was held open, and ends the unit"); + expect(r && r->status.success() && r->held_open) << "a bounded run says an output was held open, and ends the unit"; } if (os::host() == os::platform::linux && !os::is_openkal) { // 0.2.1: the threads of a running program, each with its time. const auto me = os::process::self(); const auto threads = me ? os::process::threads(me->id) : std::vector{}; - check(!threads.empty() && std::ranges::any_of(threads, [&](const auto& t) { return t.id == me->id; }), - "the threads of this program are listed, its first among them"); + expect(!threads.empty() && std::ranges::any_of(threads, [&](const auto& t) { return t.id == me->id; })) << "the threads of this program are listed, its first among them"; } { // 0.2.1: `NAME=value' entries as a started program's environment. const std::vector entries{ "OS_TEST_VALUE=from-entries", "=C:", "NOEQUALS", "EMPTY=" }; const auto pairs = os::env::assignments(entries); - check(pairs.size() == 2 && pairs[0].first == "OS_TEST_VALUE" && pairs[0].second == "from-entries" - && pairs[1].first == "EMPTY" && pairs[1].second == "", - "entries become overrides; one without a name before its `=' is left out"); + expect(pairs.size() == 2 && pairs[0].first == "OS_TEST_VALUE" && pairs[0].second == "from-entries" + && pairs[1].first == "EMPTY" && pairs[1].second == "") << "entries become overrides; one without a name before its `=' is left out"; auto o = copy("env"); o.env = pairs; auto r = os::process::run(o); - check(r && r->out == "from-entries", "and reach the program"); + expect(r && r->out == "from-entries") << "and reach the program"; } { // 0.2.8: the directories a started program receives (openkal 0.23), @@ -314,18 +306,17 @@ int main(int argc, char** argv) { if (!ro && ro.error().code == os::errc::not_supported) { std::printf(" (this system grants no directories, or cannot hold one read-only: %s)\n", ro.error().message().c_str()); } else { - check(ro && ro->status.success() && ro->out == "refused", "a program granted a directory read-only creates nothing in it"); + expect(ro && ro->status.success() && ro->out == "refused") << "a program granted a directory read-only creates nothing in it"; if (ro && ro->out != "refused") std::printf(" it was given: %s\n", ro->err.c_str()); - check(!std::filesystem::exists(dir / "created.txt"), "and nothing is there"); + expect(!std::filesystem::exists(dir / "created.txt")) << "and nothing is there"; auto rw = granted(false); - check(rw && rw->status.success() && rw->out == "created", "and granted it to write, creates"); + expect(rw && rw->status.success() && rw->out == "created") << "and granted it to write, creates"; } std::filesystem::remove(dir / "created.txt"); } { auto r = os::process::run(copy("relay")); - check(r && r->status.success() && r->out.empty() && r->err == "said", - "a program started with its output on the error output writes there"); + expect(r && r->status.success() && r->out.empty() && r->err == "said") << "a program started with its output on the error output writes there"; } { auto o = copy("leave"); @@ -333,16 +324,16 @@ int main(int argc, char** argv) { o.err = os::process::stdio::null; o.own_unit = true; auto p = os::process::process::spawn(o); - check(p.has_value(), "a program that leaves another behind is started in a unit"); + expect(p.has_value()) << "a program that leaves another behind is started in a unit"; if (p) { auto st = p->wait(); - check(st && st->success(), "and ends"); + expect(st && st->success()) << "and ends"; auto held = p->read_output_for(std::chrono::milliseconds(300)); - check(held && !held->has_value(), "while what it left holds its output open"); + expect(held && !held->has_value()) << "while what it left holds its output open"; p->end_unit(); auto end = p->read_output_for(std::chrono::seconds(20)); - check(end && end->has_value() && (*end)->empty(), "and ending the rest of its unit closes it"); - check(p->reads_bounded(), "and every bounded read here was bounded"); + expect(end && end->has_value() && (*end)->empty()) << "and ending the rest of its unit closes it"; + expect(p->reads_bounded()) << "and every bounded read here was bounded"; } } if (os::interrupt::available()) { @@ -350,13 +341,13 @@ int main(int argc, char** argv) { auto p = os::process::process::spawn(o); if (p) { auto first = p->read_output(); - check(first && first->starts_with("armed"), "a started program takes requests to end over"); + expect(first && first->starts_with("armed")) << "a started program takes requests to end over"; p->terminate(); auto st = p->wait(); if constexpr (os::is_windows) { - check(st.has_value(), "a program asked to end is awaited"); + expect(st.has_value()) << "a program asked to end is awaited"; } else { - check(st && st->by_environment, "and ends as the request would have ended it"); + expect(st && st->by_environment) << "and ends as the request would have ended it"; } } } @@ -370,25 +361,24 @@ int main(int argc, char** argv) { for (;;) { auto c = p->read_output(); if (!c || c->empty()) break; all += *c; } (void)p->wait(); } - check(all == "123", "one pipe carries a program's output and error output in the order written"); + expect(all == "123") << "one pipe carries a program's output and error output in the order written"; } { auto o = copy("become"); auto r = os::process::run(o); - check(r && r->out == "became" && r->status.code == 7, - "a program that becomes another ends with that program's status and output"); + expect(r && r->out == "became" && r->status.code == 7) << "a program that becomes another ends with that program's status and output"; } if constexpr (os::is_posix) { // A file that is no image is refused as not a program, before this // program is replaced. const auto file = std::filesystem::temp_directory_path() / "os-not-a-program"; { std::ofstream(file) << "this is not an executable format\n"; } - check(os::fs::set_executable(file, true).has_value(), "the file is made executable"); + expect(os::fs::set_executable(file, true).has_value()) << "the file is made executable"; os::process::spawn_options o; o.program = file; o.in = o.out = o.err = os::process::stdio::inherit; auto r = os::process::exec(o); - check(!r && r.error().code == os::errc::not_program, "a file that is no image is not a program to become"); + expect(!r && r.error().code == os::errc::not_program) << "a file that is no image is not a program to become"; std::filesystem::remove(file); } if (!os::is_openkal) { @@ -411,21 +401,19 @@ int main(int argc, char** argv) { const bool held = st && st->code == 0 && out == "ready\nlate"; if (!held) std::printf(" (the copy ended with %d and wrote '%s')\n", st ? st->code : -1, out.c_str()); - check(held, "a zero bound asks without waiting, a bounded read of input expires, then reads what arrives"); + expect(held) << "a zero bound asks without waiting, a bounded read of input expires, then reads what arrives"; } auto d = os::process::run(copy("divert")); - check(d && d->out == "document" && d->err == "narration", - "output sent to the error stream for a while, and the document to the output as it was"); + expect(d && d->out == "document" && d->err == "narration") << "output sent to the error stream for a while, and the document to the output as it was"; auto c = os::process::run(copy("capture")); const bool kept = c && c->out == "[kal|libc|said]" && c->err == "after"; const bool refused = c && c->out == "unsupported" && os::is_openkal; if (!kept && !refused && c) std::printf(" (the copy wrote '%s' and '%s')\n", c->out.c_str(), c->err.c_str()); - check(kept || refused, "a standard stream kept while it is captured, with what a started program wrote, then put back"); + expect(kept || refused) << "a standard stream kept while it is captured, with what a started program wrote, then put back"; // What a program used: its own, and its waited-for children's together. auto mine = os::process::resources(os::process::of::self); - check(mine ? mine->peak_memory.value_or(0) > 0 : os::is_openkal || os::is_emscripten, - "this program's largest resident set is known"); + expect(mine ? mine->peak_memory.value_or(0) > 0 : os::is_openkal || os::is_emscripten) << "this program's largest resident set is known"; auto u = os::process::run(copy("use")); auto theirs = os::process::resources(os::process::of::children); const bool counted = theirs && theirs->peak_memory.value_or(0) >= (60u << 20) @@ -435,7 +423,7 @@ int main(int argc, char** argv) { std::printf(" (children: peak %llu, user %lld ns)\n", static_cast(theirs->peak_memory.value_or(0)), static_cast(theirs->user.count())); - check(u && (counted || untold), "a waited-for child's processor time and memory are accounted to its parent"); + expect(u && (counted || untold)) << "a waited-for child's processor time and memory are accounted to its parent"; } { // The command interpreter given a command with quotation marks, joined @@ -446,34 +434,30 @@ int main(int argc, char** argv) { o.program = "cmd.exe"; o.args = { "/d", "/s", "/c", "\"echo \"a b\" && echo c\"" }; auto r = os::process::run(o); - check(r && r->out.find("\"a b\"") != std::string::npos && r->out.find('c') != std::string::npos, - "the command interpreter runs a command with quotation marks as written"); + expect(r && r->out.find("\"a b\"") != std::string::npos && r->out.find('c') != std::string::npos) << "the command interpreter runs a command with quotation marks as written"; } else { o.program = again.front(); auto r = os::process::run(o); - check(!r && r.error().code == os::errc::not_supported, - "a joined command line is refused where a program receives a vector"); + expect(!r && r.error().code == os::errc::not_supported) << "a joined command line is refused where a program receives a vector"; } } if (!os::is_openkal) { auto r = os::process::run(copy("end-closed")); if (os::host() == os::platform::windows) - check(r && r->status.code == 5, "Windows has no ending for a closed reader, and the program goes on"); + expect(r && r->status.code == 5) << "Windows has no ending for a closed reader, and the program goes on"; else - check(r && r->status.by_environment && r->status.shell_code() == 141, - "a program ends as a closed reader ends it: 141 to a shell"); + expect(r && r->status.by_environment && r->status.shell_code() == 141) << "a program ends as a closed reader ends it: 141 to a shell"; } { auto me = os::process::self(); - check(me && me->id != 0, "this program has an identifier"); - check(me && os::process::alive(*me), "and is alive"); + expect(me && me->id != 0) << "this program has an identifier"; + expect(me && os::process::alive(*me)) << "and is alive"; } // The program the system starts: this one, or in an ok image its loader. const auto started = os::is_openkal ? os::env::loader() : self; if (started) { const auto whole = os::fs::absolute(*started); - check(os::env::find_executable(whole.filename().string(), whole.parent_path().string()).has_value(), - "a program is found by name in a list of directories"); + expect(os::env::find_executable(whole.filename().string(), whole.parent_path().string()).has_value()) << "a program is found by name in a list of directories"; } { // npm's directory holds `npm.cmd' and an extension-less `npm', the @@ -484,12 +468,10 @@ int main(int argc, char** argv) { (void)os::fs::write(dir / "tool.cmd", "@echo off\n"); const auto found = os::env::find_executable("tool", os::fs::absolute(dir).string()); const bool windows = os::host() == os::platform::windows; - check(found && found->filename() == (windows ? "tool.cmd" : "tool"), - "a bare name finds what the system starts: on Windows by an extension, never the bare file"); + expect(found && found->filename() == (windows ? "tool.cmd" : "tool")) << "a bare name finds what the system starts: on Windows by an extension, never the bare file"; const auto named = os::env::find_executable("tool.cmd", os::fs::absolute(dir).string()); - check(named && named->filename() == "tool.cmd", "and a name with its extension is taken as written"); + expect(named && named->filename() == "tool.cmd") << "and a name with its extension is taken as written"; std::error_code ec; std::filesystem::remove_all(dir, ec); } - return g_failures; -} +}}; diff --git a/tests/unit_test.cpp b/tests/unit_test.cpp index 776c17a..fff37c3 100644 --- a/tests/unit_test.cpp +++ b/tests/unit_test.cpp @@ -1,14 +1,15 @@ // Declared units and a retained observer over the existing directory IPC. -#include "check.hpp" import std; import mcpplibs.os.process; import mcpplibs.os.ipc; import mcpplibs.os.env; +import mcpplibs.mtest; namespace process = mcpplibs::os::process; namespace ipc = mcpplibs::os::ipc; using clock_type = std::chrono::steady_clock; using namespace std::chrono_literals; +using namespace mcpplibs::mtest; static std::optional line(ipc::connection& c, std::chrono::milliseconds bound) { const auto until = clock_type::now() + bound; @@ -27,32 +28,38 @@ static std::optional line(ipc::connection& c, std::chrono::millisec return {}; } -int main(int argc, char** argv) { - const auto command = mcpplibs::os::env::self_command(); - if (command.empty()) return 41; - auto self = [&](std::vector args) { - process::spawn_options options; - options.program = std::filesystem::u8path(command.front()); - options.args.assign(command.begin() + 1, command.end()); - options.args.insert(options.args.end(), args.begin(), args.end()); - return options; - }; - if (argc > 2 && std::string_view(argv[1]) == "writer") { - std::ofstream out(argv[2]); out << "declared-unit writer\n"; return out ? 0 : 40; - } - if (argc > 7 && std::string_view(argv[1]) == "helper") { +// This program again as the child `as', given `args'. +static process::spawn_options copy_of_self(std::string_view as, const std::vector& args) { + const auto command = child_command(as, args); + process::spawn_options options; + options.program = std::filesystem::u8path(command.front()); + options.args.assign(command.begin() + 1, command.end()); + return options; +} + +// The copy a helper starts in its declared unit: it writes its output and ends. +child writer_child{"writer", [](std::vector args) { + if (args.size() != 1) return 40; + std::ofstream out(args[0]); out << "declared-unit writer\n"; return out ? 0 : 40; +}}; + +// The copy that registers a declared unit, starts a writer in it and retires it: +// the directory and name of the registration, the output, the mode, and the +// program it continues. +child helper_child{"helper", [](std::vector args) -> int { + if (args.size() != 6) return 30; auto unit = process::owned_unit::create(); if (!unit) return 30; - const std::filesystem::path output(argv[4]); - const process::program continuation{std::stoull(argv[6]),std::stoull(argv[7])}; - if (std::string_view(argv[5]) != "none") { - auto c = ipc::connection::connect(argv[2],argv[3]); + const std::filesystem::path output(args[2]); + const process::program continuation{std::stoull(args[4]),std::stoull(args[5])}; + if (args[3] != "none") { + auto c = ipc::connection::connect(args[0],args[1]); if (!c || !c->write(unit->reference() + "\n")) return 31; - const auto ack = line(*c, std::string_view(argv[5]) == "timeout" ? 50ms : 2s); + const auto ack = line(*c, args[3] == "timeout" ? 50ms : 2s); if (!ack || *ack != "ACK") return 31; } { - auto options = self({"writer", output.string()}); + auto options = copy_of_self("writer", {output.string()}); options.out = process::stdio::null; options.err = process::stdio::null; options.declared_unit = &*unit; @@ -73,15 +80,16 @@ int main(int argc, char** argv) { std::this_thread::sleep_for(1ms); } if (auto again = unit->retire(continuation); !again || *again != process::unit_state::empty) return 36; - auto forbidden_options = self({"writer", output.string()+".forbidden"}); + auto forbidden_options = copy_of_self("writer", {output.string()+".forbidden"}); forbidden_options.declared_unit = &*unit; auto forbidden = process::process::spawn(forbidden_options); if (forbidden || forbidden.error().code != mcpplibs::os::errc::closed) return 37; return 0; - } +}}; +UTest unit_tests{"unit", [] { const auto identity = process::self(); - if (!identity) return 1; + expect(fatal(identity.has_value())) << "this program names itself"; const auto root = std::filesystem::temp_directory_path() / std::format("os-unit-{}",identity->id); std::filesystem::create_directories(root); struct Remove { std::filesystem::path path; ~Remove() { std::error_code ec; std::filesystem::remove_all(path,ec); } } remove{root}; @@ -95,7 +103,7 @@ int main(int argc, char** argv) { const auto started = clock_type::now(); if (mode != "none") { auto listening = ipc::listener::listen(root,name); - if (!listening) { check(false,"a unit registration listener is opened"); return; } + if (!listening) { expect(false) << "a unit registration listener is opened"; return; } listener.emplace(std::move(*listening)); server = std::jthread([&] { auto accepted = listener->accept_for(2s); @@ -111,7 +119,7 @@ int main(int argc, char** argv) { } int exit = -1; { - auto options = self({"helper", root.string(), name, output.string(), std::string(mode), + auto options = copy_of_self("helper", {root.string(), name, output.string(), std::string(mode), std::to_string(identity->id), std::to_string(identity->started_ns)}); options.in = process::stdio::null; options.out = process::stdio::null; @@ -120,14 +128,14 @@ int main(int argc, char** argv) { if (helper) { auto status = helper->wait(); if (status) exit=status->shell_code(); } } if (server.joinable()) server.join(); - check(server_ok,"the original unit is retained before acknowledgement"); + expect(server_ok) << "the original unit is retained before acknowledgement"; const bool rejected = mode == "closed" || mode == "timeout"; - check(exit == (rejected ? 31 : 0),"a compiler starts only after successful registration"); - check(std::filesystem::exists(output) != rejected,"a refused acknowledgement produces no output"); - check(!std::filesystem::exists(output.string()+".forbidden"),"retirement prevents further admission"); + expect(exit == (rejected ? 31 : 0)) << "a compiler starts only after successful registration"; + expect(std::filesystem::exists(output) != rejected) << "a refused acknowledgement produces no output"; + expect(!std::filesystem::exists(output.string()+".forbidden")) << "retirement prevents further admission"; if (observer) { auto state = observer->observe(); - check(state && *state == process::unit_state::empty,"the original unit is empty after helper completion"); + expect(state && *state == process::unit_state::empty) << "the original unit is empty after helper completion"; } const auto elapsed = std::chrono::duration_cast(clock_type::now()-started).count(); std::printf("{\"unit_start_mode\":\"%.*s\",\"elapsed_ns\":%lld}\n",int(mode.size()),mode.data(),static_cast(elapsed)); @@ -138,13 +146,12 @@ int main(int argc, char** argv) { const auto name=std::format("idle-{}",repeat); const auto started=clock_type::now(); auto listener=ipc::listener::listen(root,name); - check(bool(listener),"a no-op listener opens"); + expect(bool(listener)) << "a no-op listener opens"; if (!listener) continue; std::jthread server([&] { auto peer=listener->accept_for(2s); }); - { auto peer=ipc::connection::connect(root,name); check(bool(peer),"closure wakes the listener without a polling delay"); } + { auto peer=ipc::connection::connect(root,name); expect(bool(peer)) << "closure wakes the listener without a polling delay"; } server.join(); listener->close(); const auto elapsed=std::chrono::duration_cast(clock_type::now()-started).count(); std::printf("{\"unit_listener_noop_ns\":%lld}\n",static_cast(elapsed)); } - return g_failures; -} +}}; diff --git a/tests/watch_test.cpp b/tests/watch_test.cpp new file mode 100644 index 0000000..7f0f482 --- /dev/null +++ b/tests/watch_test.cpp @@ -0,0 +1,141 @@ +// mcpplibs.os.fs::watcher upon a scratch directory: what changed, each once, +// and nothing when nothing did; a save by rename; names made and removed; a +// directory's entries; a wait that ends when a change comes. +import std; +import mcpplibs.os; +import mcpplibs.mtest; + +namespace os = mcpplibs::os; +namespace stdfs = std::filesystem; +using namespace mcpplibs::mtest; +using namespace std::chrono_literals; + +namespace { + +stdfs::path scratch(std::string_view what) { + // Emscripten names no program (no `openkal.process'): random bytes there. + std::string tag; + if constexpr (os::is_emscripten) tag = os::random::hex(8).value_or("0"); + else tag = std::to_string(os::process::self()->id); + const auto dir = stdfs::temp_directory_path() / std::format("mcpplibs-os-watch-{}-{}", what, tag); + stdfs::remove_all(dir); + stdfs::create_directories(dir); + return dir; +} + +bool names(const std::vector& got, std::initializer_list want) { + return got.size() == want.size() && std::ranges::all_of(want, [&](const auto& w) { + return std::ranges::find(got, w) != got.end(); + }); +} + +std::string shown(const std::vector& got) { + std::string out; + for (const auto& p : got) out += std::format("[{}]", p.generic_string()); + return out; +} + +// A wait long enough for any system's notice, and for a look every poll interval. +constexpr auto kSettle = 2s; + +} // namespace + +UTest watch_tests{"watch", [] { + "a file written is told once, and nothing when nothing changed"_test = [] { + const auto dir = scratch("file"); + const auto file = dir / "a.txt"; + expect(fatal(os::fs::write(file, "one").has_value())); + auto w = os::fs::watcher::open(); + expect(fatal(w.has_value())); + expect(w->add(file).has_value()); + expect(w->changed().empty()) << "nothing changed yet"; + expect(os::fs::write(file, "two!").has_value()); + const auto got = w->changed(kSettle); + expect(names(got, {file})) << shown(got); + expect(w->changed().empty()) << "told once"; + stdfs::remove_all(dir); + }; + + "a file saved by a rename over it is told by its name"_test = [] { + const auto dir = scratch("rename"); + const auto file = dir / "view.ts"; + expect(fatal(os::fs::write(file, "export const a = 1;").has_value())); + auto w = os::fs::watcher::open(); + expect(fatal(w.has_value())); + expect(w->add(file).has_value()); + expect(os::fs::write_atomic(file, "export const a = 2;").has_value()); + auto got = w->changed(kSettle); + expect(names(got, {file})) << shown(got); + // And again, the name now naming another node. + expect(os::fs::write_atomic(file, "export const a = 3;").has_value()); + got = w->changed(kSettle); + expect(names(got, {file})) << shown(got); + stdfs::remove_all(dir); + }; + + "a name is told when it is made and when it is removed"_test = [] { + const auto dir = scratch("made"); + const auto file = dir / "later" / "theme.json"; + auto w = os::fs::watcher::open(); + expect(fatal(w.has_value())); + expect(w->add(file).has_value()) << "a name that is not there, in a directory that is not either"; + expect(w->changed().empty()); + stdfs::create_directories(file.parent_path()); + expect(os::fs::write(file, "{}").has_value()); + auto got = w->changed(kSettle); + expect(names(got, {file})) << shown(got); + stdfs::remove(file); + got = w->changed(kSettle); + expect(names(got, {file})) << shown(got); + stdfs::remove_all(dir); + }; + + "a directory is told of its entries, and another file's change tells nothing"_test = [] { + const auto dir = scratch("dir"); + const auto watched = dir / "components"; + stdfs::create_directories(watched); + const auto other = dir / "other.txt"; + expect(fatal(os::fs::write(other, "x").has_value())); + auto w = os::fs::watcher::open(); + expect(fatal(w.has_value())); + expect(w->add(watched).has_value()); + expect(os::fs::write(other, "changed").has_value()); + auto got = w->changed(300ms); + expect(got.empty()) << "a file beside the directory is not one of its entries: " << shown(got); + expect(os::fs::write(watched / "button.ts", "x").has_value()); + got = w->changed(kSettle); + expect(names(got, {watched})) << shown(got); + stdfs::remove_all(dir); + }; + + // A thread writes while the case waits: not a case on Emscripten, which + // starts no thread without -pthread. + if constexpr (!os::is_emscripten) "a wait ends when the change comes"_test = [] { + const auto dir = scratch("wait"); + const auto file = dir / "app.ts"; + expect(fatal(os::fs::write(file, "1").has_value())); + auto w = os::fs::watcher::open(); + expect(fatal(w.has_value())); + expect(w->add(file).has_value()); + std::jthread writer([&] { + std::this_thread::sleep_for(100ms); + (void)os::fs::write(file, "22"); + }); + const auto begun = std::chrono::steady_clock::now(); + const auto got = w->changed(5s); + const auto took = std::chrono::steady_clock::now() - begun; + expect(names(got, {file})) << shown(got); + expect(took < 2s) << std::chrono::duration_cast(took).count() << " ms"; + stdfs::remove_all(dir); + }; + + "a system with notice is said so, and gives a handle where it has one"_test = [] { + auto w = os::fs::watcher::open(); + expect(fatal(w.has_value())); + const bool noticed = os::host() == os::platform::linux || os::host() == os::platform::macos + || os::host() == os::platform::windows; + if constexpr (os::is_emscripten) expect(!w->notified()); + else if (noticed && !os::is_openkal) expect(w->notified()); + if (w->notified() && os::host() != os::platform::windows) expect(w->native_handle().has_value()); + }; +}};