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());
+ };
+}};