diff --git a/.github/actions/mtest/action.yml b/.github/actions/mtest/action.yml new file mode 100644 index 00000000..d9f07da1 --- /dev/null +++ b/.github/actions/mtest/action.yml @@ -0,0 +1,70 @@ +name: mtest +description: > + The mtest engine the conformance suite reports to: the release .github/versions.env pins (MTEST_VERSION), an + xlings package (xim:mtest) that the index verifies, else the release bundle checked against MTEST_SHA256; + MTEST names its launcher. A job that has no xlings yet gets the pinned one first (XLINGS_VERSION). +runs: + using: composite + steps: + - name: xlings, where the job has none + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + command -v xlings > /dev/null 2>&1 && exit 0 + curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 \ + https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh \ + | bash -s "$XLINGS_VERSION" + echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" + - name: xlings, where the job has none (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + if (Get-Command xlings -ErrorAction SilentlyContinue) { exit 0 } + irm https://d2learn.org/xlings-install.ps1.txt | iex + "$env:USERPROFILE\.xlings\subos\current\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + . .github/versions.env # MTEST_VERSION, MTEST_SHA256: no workflow states the pin + # xlings by the configured mirror (GLOBAL), then by CN (GitCode first), each with a refreshed index + # (a cached index can predate the release, and xlings keeps it when an anonymous refresh fails); else + # the release bundle itself, checked against MTEST_SHA256: the web address, then the API's, which + # answered 200 while the web one answered 504 (GitHub's 2026-11-09 incident). + launcher="" + for mirror in GLOBAL CN; do + xlings config --mirror "$mirror" > /dev/null 2>&1 || true + xlings update > /dev/null 2>&1 || true + if xlings install "mtest@$MTEST_VERSION" -y -g; then launcher=$(command -v mtest); break; fi + done + # The package's own launcher, not the xvm shim: on Windows the shim runs the package's alias through + # cmd, which reads `&` in an argument (`--tag "on:win32-x64&release"`) as a second command. + exe=''; [ "$RUNNER_OS" != Windows ] || exe='.exe' + own="${XLINGS_HOME:-$HOME/.xlings}/data/xpkgs/xim-x-mtest/$MTEST_VERSION/bin/mtest$exe" + if [ -n "$launcher" ] && [ -f "$own" ]; then launcher="$own"; fi + xlings config --mirror GLOBAL > /dev/null 2>&1 || true + if [ -z "$launcher" ]; then + echo "::warning::xlings could not install mtest $MTEST_VERSION; taking the release itself" + archive="mtest-$MTEST_VERSION-openkal-bundle-all.tar.gz" + temp="$RUNNER_TEMP"; python=python3 + if [ "$RUNNER_OS" = Windows ]; then temp=$(cygpath -u "$RUNNER_TEMP"); python=python; fi + if ! curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o "$temp/$archive" \ + "https://github.com/mcpp-community/mtest/releases/download/v$MTEST_VERSION/$archive"; then + id=$(curl -fsSL --retry 3 --retry-all-errors -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/mcpp-community/mtest/releases/tags/v$MTEST_VERSION" \ + | "$python" -c 'import json,sys; print(next(str(a["id"]) for a in json.load(sys.stdin)["assets"] if a["name"] == sys.argv[1]))' "$archive") + curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/octet-stream" -o "$temp/$archive" \ + "https://api.github.com/repos/mcpp-community/mtest/releases/assets/$id" + fi + actual=$("$python" -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$temp/$archive") + [ "$actual" = "$MTEST_SHA256" ] || { echo "::error::$archive: sha256 $actual, the workflow pins $MTEST_SHA256"; exit 1; } + mkdir -p "$temp/mtest" && tar xzf "$temp/$archive" -C "$temp/mtest" + launcher="$temp/mtest/mtest-$MTEST_VERSION-openkal-bundle-all/bin/mtest" + [ "$RUNNER_OS" = Windows ] && launcher="$launcher.exe" + fi + "$launcher" --version + "$launcher" --version | grep -qF "$MTEST_VERSION" || { echo "::error::the mtest at $launcher is not $MTEST_VERSION"; exit 1; } + echo "MTEST=$launcher" >> "$GITHUB_ENV" diff --git a/.github/actions/test-report/action.yml b/.github/actions/test-report/action.yml new file mode 100644 index 00000000..5dd66d2b --- /dev/null +++ b/.github/actions/test-report/action.yml @@ -0,0 +1,47 @@ +name: test-report +description: > + The runs `mcpp test` kept in the mtest store's form: taken into one store of + the job, written as one Markdown report of this workflow run's runs into the + job's summary, and kept as an artifact for the report over every job of the + workflow run (ci.yml `report`). +inputs: + name: + description: the artifact's name, unique in the workflow run + required: true + stores: + description: the stores `mcpp test` kept in this job, relative to the workspace, separated by spaces + required: false + default: tests/target/mtest +runs: + using: composite + steps: + - uses: ./.github/actions/mtest + - shell: bash + env: + STORES: ${{ inputs.stores }} + run: | + set -euo pipefail + # The POSIX spelling of the runner's directories for this shell; the + # artifact's path below is the runner's own. + temp="$(cygpath -u "$RUNNER_TEMP" 2> /dev/null || printf '%s' "$RUNNER_TEMP")" + workspace="$(cygpath -u "$GITHUB_WORKSPACE" 2> /dev/null || printf '%s' "$GITHUB_WORKSPACE")" + job="$temp/mtest-job" + mkdir -p "$job" + taken=0 + for store in $STORES; do + [ -f "$workspace/$store/index.ndjson" ] || continue + (cd "$job" && "$MTEST" store import --from "$workspace/$store") + taken=1 + done + if [ "$taken" = 0 ]; then echo "mcpp test kept no run here"; exit 0; fi + (cd "$job" && "$MTEST" report --format md --group "$GITHUB_RUN_ID") >> "$GITHUB_STEP_SUMMARY" \ + || echo "::warning::no run of this workflow run in the store" + echo "MTEST_JOB_STORE=$RUNNER_TEMP/mtest-job/.mtest" >> "$GITHUB_ENV" + - uses: actions/upload-artifact@v4 + if: env.MTEST_JOB_STORE != '' + with: + name: ${{ inputs.name }} + path: ${{ env.MTEST_JOB_STORE }} + include-hidden-files: true + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/versions.env b/.github/versions.env new file mode 100644 index 00000000..f670c407 --- /dev/null +++ b/.github/versions.env @@ -0,0 +1,5 @@ +# The engines CI pins, read by the composite actions (.github/actions/mtest). +# mtest: an xlings package (xim:mtest) whose sha256 the index states; the release +# bundle's sha256 checks the fallback download. +MTEST_VERSION=0.5.2 +MTEST_SHA256=be443f8c546b3f4628dcd3f1eb6f45b4d698a7f805232f966349faf8250863c6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 939c0b2d..3dc0c319 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,13 @@ env: # Windows program built here links no Win32 library mimalloc's own layer names. # The image matrix uses the corrected platform group selected by this engine. # MCPP_SOURCE_REF supplies its candidate until the release is installable. - MCPP_VERSION: 2026.11.5.1 + # 0.20.0: 2026.11.9.1, for the tests on the mtest library (a test file of + # mtest's objects has no `main' from 2026.11.8.1, and `mcpp test' keeps its + # runs in mtest's store from 2026.11.9.1). + MCPP_VERSION: 2026.11.9.1 XLINGS_VERSION: v2026.10.26.1 + # okrun is built from openkal-image, whose openkal-run states its own floor. + OKRUN_MCPP_VERSION: 2026.11.9.1 XLINGS_NON_INTERACTIVE: '1' jobs: @@ -131,6 +136,23 @@ jobs: cd examples/import-std && mcpp run 2>&1 | tee out.log grep -q 'import std above openkal: 2 4 7' out.log + # THE RUNTIME'S TESTS, ON THE mtest LIBRARY (tests/): examples/cxx's + # observations as one case, a second name for a file through + # std::filesystem, and a program found by the system's conventions. A + # package beside the runtime, as the examples are; `mcpp test' keeps the + # run in mtest's store (tests/target/mtest). + - name: The runtime's tests + run: | + set -euo pipefail + cd tests && mcpp test 2>&1 | tee tests.log + grep -q 'test result ok' tests.log + + # The runs `mcpp test` kept, reported and kept for the report job. + - uses: ./.github/actions/test-report + if: always() + with: + name: mtest-${{ github.job }}-${{ strategy.job-index }} + # WHAT A FRONT END DOES WITH THE RUNTIME, TIMED AGAINST THE SYSTEM'S OWN # (0.17.0; the design's L2). bench/ built above this runtime, and with # this machine's C++ library by the same compiler family, Clang: the @@ -648,6 +670,51 @@ jobs: grep -q 'unwound: true' out.log grep -q 'import std over openkal: ok' out.log + # THE RUNTIME'S TESTS ON THIS HOST (tests/, on the mtest library). On + # Windows this is where a program named without a separator is looked + # for by that system's conventions: `hostname' through a semicolon + # PATH and PATHEXT (openkal-musl 0.29.0). + - name: The runtime's tests on this host + run: | + set -euo pipefail + cd tests && rm -rf target + mcpp test --target '${{ matrix.native }}' 2>&1 | tee tests.log + grep -q 'test result ok' tests.log + + # The runs `mcpp test` kept, reported and kept for the report job. + - uses: ./.github/actions/test-report + if: always() + with: + name: mtest-${{ github.job }}-${{ strategy.job-index }} + + # EVERY JOB'S RUNS AS ONE REPORT (`mtest ci report`, the workflow run's group). + report: + name: tests (report) + needs: [runtime, host-dimension] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/mtest + - uses: actions/download-artifact@v4 + with: + pattern: mtest-* + path: ${{ runner.temp }}/stores + - name: Every job's runs, reported together + shell: bash + run: | + set -euo pipefail + found=0 + for store in "$RUNNER_TEMP"/stores/*/; do + [ -f "$store/index.ndjson" ] || continue + "$MTEST" store import --from "$store" + found=1 + done + if [ "$found" = 0 ]; then echo "no job kept a run" >> "$GITHUB_STEP_SUMMARY"; exit 0; fi + "$MTEST" ci report --group "$GITHUB_RUN_ID" --out "$RUNNER_TEMP/tests.md" + cat "$RUNNER_TEMP/tests.md" >> "$GITHUB_STEP_SUMMARY" + # ── AN OK IMAGE (`-openkal`, openkal SPEC clause 12) ───────────────── # # libc++, libc++abi, libunwind and the builtins inside an image, above @@ -743,7 +810,11 @@ jobs: "$env:USERPROFILE\.xlings\subos\current\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: okrun, built here run: | - xlings install mcpp -y -g + # PINNED, AND TO THE FLOOR openkal-run STATES. Unpinned, this step + # took whatever the runner's index offered (2026.10.24.1), and + # openkal-run, which states mcpp >= 2026.11.9.1, refused it. + xlings update + xlings install "mcpp@$OKRUN_MCPP_VERSION" -y -g mcpp self config --mirror GLOBAL mcpp toolchain install llvm 23.1.3 mcpp toolchain default llvm@23.1.3 diff --git a/README.md b/README.md index 01214a49..d482637d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ C library. ```toml [dependencies] -openkal-llvm-runtime = "0.19.2" +openkal-llvm-runtime = "0.20.0" ``` > **Engine floor (mcpp 2026.11.2.1):** 0.17.0 is built over openkal-musl 0.26.0, @@ -328,6 +328,16 @@ openkal-musl 0.27.3 selects openkal-linux 0.24.1 on Linux and openkal-ld 0.4.3 for an image. Linux confinement requires Landlock ABI 3 to enforce read-only grants against raw file truncation. The runtime implementation is unchanged. +## OpenKal 0.25 dependency group + +Runtime 0.20.0 is built over openkal-musl `^0.29.0` and the OpenKal 0.25 +implementations beneath it (Linux 0.26, macOS 0.23, Windows 0.21, OpenSBI 0.9). +The C++ runtime surface and the allocator selection are unchanged. Through the C +library, `std::filesystem::create_hard_link` gives a file a second name +(`link`, over `kal_fs_hard_link`), and a program named without a separator is +looked for by the system's conventions: on Windows PATH is split on `;` and the +`PATHEXT` suffixes are tried. + ## OpenKal 0.24 dependency group Runtime 0.19.2 keeps the volume where a name is rebuilt part by part. `X:` is a diff --git a/mcpp.toml b/mcpp.toml index 3d8d864a..134a1712 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "openkal-llvm-runtime" -version = "0.19.2" +version = "0.20.0" description = "LLVM's C++ runtime libraries — libc++, libc++abi and libunwind — configured for openkal-musl rather than for a host C library." license = "Apache-2.0" authors = ["mcpplibs"] @@ -230,10 +230,10 @@ cflags = ["-DDISABLE_AARCH64_FMV=1"] # (measured on OpenSBI: every request for an arena refused, ENOMEM). A # target's edge replaces a general one, so each states every feature. [target.'cfg(not(os = "none"))'.dependencies] -openkal-musl = { version = "^0.28.0", default-features = false, features = ["posix", "malloc-mimalloc"] } +openkal-musl = { version = "^0.29.0", default-features = false, features = ["posix", "malloc-mimalloc"] } [target.'cfg(os = "none")'.dependencies] -openkal-musl = { version = "^0.28.0", default-features = false, features = ["posix", "malloc-mallocng"] } +openkal-musl = { version = "^0.29.0", default-features = false, features = ["posix", "malloc-mallocng"] } [build] diff --git a/tests/cxx.cpp b/tests/cxx.cpp new file mode 100644 index 00000000..cd7322ed --- /dev/null +++ b/tests/cxx.cpp @@ -0,0 +1,362 @@ +// The C++ runtime above openkal-musl, examined by its behaviour: the +// observations of examples/cxx as one mtest case (the example itself stays the +// program the cross-builds carry to the other systems). +// What a C++ program above openkal reaches for, and the one observation that +// settles whether the runtime is really there: an exception thrown across a +// frame and caught. +// +// AND THE PARTS OF THE STANDARD LIBRARY WHOSE AVAILABILITY IS A STATEMENT +// ABOUT THE ENVIRONMENT BENEATH. `__config_site` declares +// `_LIBCPP_HAS_FILESYSTEM 1` and `_LIBCPP_HAS_RANDOM_DEVICE 1` for a hosted +// row; those declarations are this package's claim about openkal-musl, and a +// claim that drifts from what the port provides does not fail to build and does +// not fail to link --- it produces a program that takes a path the environment +// cannot support and reports nothing. The same shape as `_LIBCPP_HAS_TERMINAL`, +// which is what the workflow's reconciliation step exists because of. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Capability query for the symlink block below. `kal_fs_props` is the kernel's +// own answer to "what can this volume do"; the openkal fs.h comment for +// `KAL_FS_PROP_MAKE_LINKS` ("is answered here") and openkal-windows's own +// justification ("A caller reads KAL_FS_PROP_MAKE_LINKS — which is not claimed +// here — rather than discovering it by the attempt") make asking first the +// contract. The test below now obeys it. +#include + +import mcpplibs.mtest; + +using namespace mcpplibs::mtest; + +// THREE NAMES A PROGRAM ABOVE THIS STACK MAY USE, ASSERTED BY COMPILING. +// +// musl's INTERNAL header overlay defines `hidden`, `weak` and `weak_alias` as +// macros that mean something only to musl's own sources, and openkal-musl used +// to publish the path it is built from --- so a program above it could not +// declare any of the three (mcpplibs/openkal-musl#13). This is the C++ half of +// that criterion, and it belongs here rather than there: the report came from a +// C++23 workspace, and `hidden` in C++ was given C LINKAGE rather than emptied, +// which is a different failure from the C one and would not be caught by it. +static int hidden = 7; +static int weak = 11; +struct weak_alias { int value; }; + +// THE thread_local PROBE'S SUBJECT, at file scope because a local struct's +// destructor cannot reach a lambda capture. See the criterion in `main`. +static std::atomic tls_dtor_ran{0}; +struct TlsProbe { int v; ~TlsProbe() { tls_dtor_ran.store(v); } }; + +static int depth_three(int n) { if (n > 2) throw std::runtime_error("thrown"); return n; } +static int depth_two(int n) { return depth_three(n) + 1; } +static int depth_one(int n) { return depth_two(n) + 1; } + +UTest cxx_test{"cxx", [] { + auto check = [](bool ok, const char* what) { expect(ok) << what; }; + + std::vector v{5, 3, 9, 1}; + std::sort(v.begin(), v.end()); + check(v[0] == 1 && v[3] == 9, "a vector is sorted"); + + std::string s = "openkal"; + s += "-llvm-runtime"; + check(s.size() == 20 && s.find("llvm") == 8, "a string is built and searched"); + + check(depth_one(1) == 3, "a value returns through three frames"); + + bool caught = false; + try { depth_one(5); } catch (const std::runtime_error& e) { caught = std::string(e.what()) == "thrown"; } + check(caught, "an exception is thrown across three frames and caught"); + + // The unwinder ran; that it ran correctly is what this observes. A frame + // whose destructor did not run would leave this false. + struct sentinel { bool* p; ~sentinel() { *p = true; } }; + bool destroyed = false; + try { sentinel g{&destroyed}; throw 1; } catch (int) {} + check(destroyed, "a destructor runs while the stack is unwound"); + + check(hidden + weak == 18 && weak_alias{3}.value == 3, + "hidden, weak and weak_alias are the program's own identifiers"); + + // --- std::thread, which is the C++ face of openkal.task ----------------- + // + // A THREAD THAT STARTS IS NOT A THREAD THAT IS JOINED. libc++ keeps the + // pthread_t it was given, and musl declared that type `unsigned long` for + // C++ --- thirty-two bits on Windows --- so the join read through half a + // pointer and ended the program. Fixed in openkal-musl 0.13.2. + { + int written = 0; + std::thread worker([&] { written = 42; }); + const bool joinable = worker.joinable(); + worker.join(); + check(joinable && written == 42 && !worker.joinable(), "a thread is started and joined"); + } + + // AND A DETACHED THREAD ENDS WITHOUT ENDING THE PROGRAM. musl released a + // detached thread's mapping from a 256-byte stack every exiting thread + // shares, and openkal-musl's path for the calls that end the thread overran + // it into the context table; on macOS the program stopped as the thread + // ended. Fixed in openkal-musl 0.13.3. + { + static std::atomic ran{false}; + std::thread([] { ran = true; }).detach(); + for (int i = 0; i < 400 && !ran; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); + // Running is reported before ending; the pause lets the thread end. + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + check(ran, "a detached thread runs and ends"); + } + + // --- the allocator, past the size musl maps on its own -------------------- + // + // A STRING THAT GROWS PAST A MAPPING KEEPS WHAT IT HOLDS. musl obtains an + // allocation of 131,052 bytes or more as a mapping of its own and uses it to + // the end of its last page; openkal-musl's mapping was only the length asked + // for, and on Windows the rest of the page was the next heap block, so a + // string grown past 196,607 bytes ended the program. Fixed in openkal-musl + // 0.13.5. + { + std::string grown; + for (std::size_t i = 0; i < (std::size_t{1} << 22); ++i) grown.push_back(static_cast('a' + i % 26)); + std::vector copies(4, grown); + bool same = grown.size() == (std::size_t{1} << 22); + for (const auto& copy : copies) same = same && copy == grown; + for (std::size_t i = 0; same && i < grown.size(); i += 4093) same = grown[i] == static_cast('a' + i % 26); + check(same, "a string grows past the allocator's own mappings"); + } + + // --- names beneath a volume (openkal-llvm-runtime#64) ------------------- + // + // The C library names a file on a system with volumes `D:/...` and takes + // `X:/` as a root. The same name is absolute to std::filesystem, and the + // working directory is absolute on every system. + { + namespace fs = std::filesystem; + const fs::path v = "D:/a/b.txt"; + check(v.is_absolute() && v.root_name() == "D:" && v.root_directory() == "/", "`D:/a/b.txt' has a root name and a root directory"); + check(v.relative_path() == "a/b.txt" && v.parent_path() == "D:/a" && v.filename() == "b.txt", "and decomposes beneath it"); + std::vector forward, backward; + for (const auto& e : v) forward.push_back(e.string()); + for (auto it = v.end(); it != v.begin();) backward.insert(backward.begin(), (--it)->string()); + check(forward == std::vector{"D:", "/", "a", "b.txt"} && backward == forward, "its elements are the same read either way"); + check(!fs::path("D:").is_absolute() && fs::path("D:").root_name().empty(), "`D:' without a separator is an ordinary name"); + check(!fs::path("D:x").is_absolute(), "`D:x' is relative"); + check((fs::path("x") / "D:/y") == "D:/y" && fs::path("x").append(std::string("D:/y")) == "D:/y", "appending an absolute volume name replaces the path"); + check(fs::path("D:/a/b").lexically_relative("D:/a") == "b" && fs::path("/a/b").is_absolute(), "a name relative to a volume, and `/' unchanged"); + std::error_code cwd_ec; + const fs::path cwd = fs::current_path(cwd_ec); + check(!cwd_ec && cwd.is_absolute(), "the working directory is absolute"); + check(fs::absolute(cwd, cwd_ec) == cwd && !cwd_ec, "absolute() of it is itself"); + const fs::path can = fs::canonical(cwd, cwd_ec); + check(!cwd_ec && can.is_absolute(), "canonical() of it succeeds"); + // 0.19.2: a builder that appends element by element keeps the volume. + check((fs::path("D:/a") / "/b") == "D:/b" && (fs::path("D:/a") / fs::path("/b")) == "D:/b" + && fs::path("D:/a").append(std::string("/b")) == "D:/b", "a rooted name appended beneath a volume keeps it"); + check(fs::path("D:/a/./b/../c").lexically_normal() == "D:/a/c" && fs::path("D:/").lexically_normal() == "D:/", + "lexically_normal() keeps the volume"); + check((fs::path("/a") / "/b") == "/b" && fs::path("/a/../b").lexically_normal() == "/b", "and `/' is unchanged"); + const fs::path missing = cwd / "openkal-no-such-name" / "." / "x"; + const fs::path weak = fs::weakly_canonical(missing, cwd_ec); + check(!cwd_ec && weak.is_absolute() && weak.root_name() == cwd.root_name(), "weakly_canonical() keeps the working directory's volume"); + } + + // --- std::filesystem, which is the C++ face of openkal.fs --------------- + + namespace fs = std::filesystem; + const fs::path dir = "cxx-probe.d"; + std::error_code ec; + + fs::remove_all(dir, ec); + check(fs::create_directory(dir, ec) && !ec, "a directory is created"); + + { + std::FILE* f = std::fopen((dir / "a.txt").c_str(), "w"); + check(f != nullptr, "a file is created inside it"); + if (f) { std::fputs("0123456789", f); std::fclose(f); } + } + + check(fs::exists(dir / "a.txt", ec) && !ec, "the file is found by name"); + check(fs::file_size(dir / "a.txt", ec) == 10 && !ec, "its size is reported"); + + int entries = 0; + for (const auto& e : fs::directory_iterator(dir, ec)) { (void)e; ++entries; } + check(entries == 1 && !ec, "the directory enumerates exactly what is in it"); + + // AND A FILE IS COPIED, OR THE KERNEL SAYS IT CANNOT BE. + // + // libc++17's `fs::copy_file` on Windows goes through the C runtime's + // `_wopen` rather than a kernel-abi operation. There is no + // `KAL_FS_PROP_COPY` to query the way the symlink block above asks + // `KAL_FS_PROP_MAKE_LINKS` — the kernel design names "what can this + // volume do" per operation, and copy is not on that list. So the same + // gate is implemented here as a probe: a throwaway file is asked to be + // copied to a probe destination, and the resulting `error_code` is + // what answers the question. If the probe returns success, the real + // copy + size assertions run. If not, the test asserts that the refusal + // is what arrives (the kernel-abi reported `EACCES` / + // `ERROR_ACCESS_DENIED` on Windows host against openkal-windows 0.8.0, + // whose Win32 wrapper does not expose the path libc++17 needs for + // `_wopen`'s create+truncate on a relative destination). Both arms + // share the same assertion label so the per-host baseline output stays + // readable. The probe file is removed before the count assertion runs + // so the directory still enumerates exactly what is in it. + { + std::error_code probe_ec; + fs::copy_file(dir / "a.txt", dir / "_copy_probe.txt", probe_ec); + if (!probe_ec) { + fs::remove(dir / "_copy_probe.txt", probe_ec); probe_ec.clear(); + check(fs::copy_file(dir / "a.txt", dir / "b.txt", ec) && !ec + && fs::file_size(dir / "b.txt", ec) == 10 && !ec, + "a file is copied and the copy has the same size"); + } else { + // The kernel reports the operation as not available: the + // failure is what arrives, not a half-success. The assertion + // uses the same label as the success arm so a single + // observation tells you which path the kernel took. + ec.clear(); + fs::copy_file(dir / "a.txt", dir / "b.txt", ec); + check(static_cast(ec), + "a file is copied and the copy has the same size"); + } + } + + // AND THE OPERATION openkal HAS NO ATOM FOR, CHECKED AS A REFUSAL. + // + // `kal_node_info` carries a boolean `writable` and not a mode word, so + // openkal-musl refuses `chmod` rather than succeeding and reporting + // something else afterwards --- and a refusal that arrives as a + // `std::error_code` is what a C++ caller can act upon. + // + // THIS IS THE HALF THAT WOULD BE OMITTED. A probe checking only that the + // supported operations work would pass just as well for a port that + // silently accepted it, which is the outcome the report + // (openkal-linux#13) described as "expected 0600, got 0777". + ec.clear(); + fs::permissions(dir / "a.txt", fs::perms::owner_read, ec); + check(static_cast(ec), "changing permission bits is refused, not ignored"); + + // AND THE ONE THAT WAS A REFUSAL AND IS NOW AN OPERATION. + // + // This block read `create_symlink ... check(ec)` --- a link was refused, + // and the refusal was the assertion. openkal 0.9 added `kal_fs_link_create` + // and `kal_fs_link_read`, openkal-musl 0.7 answers `symlinkat` and + // `readlinkat` with them, and the refusal stopped arriving. + // + // A TEST THAT ASSERTS A LIMITATION BECOMES FALSE WHEN THE LIMITATION IS + // LIFTED, AND IT FAILS RATHER THAN GOING QUIET. That is the good case and + // it is why the assertion was written this way round: had it merely + // tolerated both answers, the arrival of the operation would have been + // invisible here, and this file is the only place in the ecosystem where a + // C++ standard library exercises it. + // + // THE TEST IS NOW GATED ON THE KERNEL'S OWN ANSWER. `kal_fs_props` is the + // kernel-side capability query; the openkal fs.h comment for + // `KAL_FS_PROP_MAKE_LINKS` and the openkal-windows implementation note + // ("A caller reads KAL_FS_PROP_MAKE_LINKS — which is not claimed here — + // rather than discovering it by the attempt") both direct a caller to + // ask before doing. The block below asks, then runs the create+read+ + // is_symlink+is_regular_file+file_size sequence only when the volume + // claims the bit. Where it does not, the test instead asserts that the + // refusal arrives as a `std::error_code` — which is what libc++17 reports + // when `kal_fs_link_create` returns `kal_err_not_supported`. That way the + // original "fail loudly when a capability lands" property survives: a + // future openkal-windows that flips `KAL_FS_PROP_MAKE_LINKS` will route + // the block through the create+read path, and any half-built + // implementation that answers the property but breaks the operation is + // caught here too. + // THE TARGET IS `a.txt' AND NOT `dir / "a.txt"'. A link's content is + // resolved relative to the directory HOLDING THE LINK, not to the working + // directory --- so the second spelling, which looks more careful, produces + // `cxx-probe.d/cxx-probe.d/a.txt' and a dangling link. It was written that + // way here first, and the three assertions below failed against a port that + // was answering correctly. + { + kal_dir cwd{}; kal_uintptr l = 0; + kal_fs_preopen(0, &cwd, nullptr, 0, &l); + const kal_uintptr props = kal_fs_props(cwd); + if (props & KAL_FS_PROP_MAKE_LINKS) { + ec.clear(); + fs::create_symlink("a.txt", dir / "link", ec); + check(!ec, "a symbolic link is created"); + check(fs::read_symlink(dir / "link", ec) == "a.txt" && !ec, + "and reading it gives back the name it was made from"); + + // The distinction the link exists to make: an enquiry that resolves + // and one that does not answer about different nodes. A port that + // conflated them reported every link as the file it points at, + // which is what made a tree containing one uncopyable. + check(fs::is_symlink(fs::symlink_status(dir / "link", ec)) && !ec, + "an enquiry that does not resolve reports the link itself"); + check(fs::is_regular_file(fs::status(dir / "link", ec)) && !ec, + "and one that resolves reports the file it names"); + check(fs::file_size(dir / "link", ec) == 10 && !ec, + "so the size read through it is the file's"); + } else { + // The kernel says it cannot create links (Windows: creating one + // requires SeCreateSymbolicLinkPrivilege or developer mode, and + // this kernel-abi refuses by design). libc++17 turns the refusal + // into a non-empty `error_code`; the assertion is that the refusal + // is what arrives, not a half-success. + ec.clear(); + fs::create_symlink("a.txt", dir / "link", ec); + check(static_cast(ec), + "make_links is not claimed; the refusal is what arrives"); + } + } + + // AND THE TREE IS STILL WALKABLE. `remove_all` recurses, and a directory + // holding a link is the case where resolving during the walk removes the + // wrong node or loops. + fs::remove_all(dir, ec); + check(!fs::exists(dir, ec), "the directory and its contents are removed"); + + // --- std::random_device, which is the C++ face of openkal.random -------- + + { + std::random_device rd; + const unsigned a = rd(), b = rd(), c = rd(); + // THE CRITERION IS THAT THEY DIFFER, NOT THAT ANY ONE OF THEM IS + // ANYTHING. A source stuck at a constant satisfies "a number was + // produced" and is exactly what a port that forgot to fill the buffer + // would produce. + check(!(a == b && b == c), "three draws from the entropy source differ"); + } + + // --- thread_local destructors, where no other C runtime supplies them --- + + // TWO CHECKS, AND THE SECOND IS THE ONE THAT WAS SILENTLY FALSE. + // + // `__cxa_thread_atexit` is exported by upstream libc++abi on Linux and + // Fuchsia only, because everywhere else another runtime already defines it + // --- on an ordinary MinGW target, `libmingw32.a`. openkal replaces the C + // library and its runtime together, so both sides assumed the other would + // and the link failed by name on `x86_64-windows-gnu`. + // + // Exporting it was not enough. The fallback kept its list of pending + // destructors in a `__thread` variable, and this runtime is built with + // `-femulated-tls` for PE: emutls releases the thread's block behind a + // pthread key of its own, and the key destructor that walks the list runs + // after that, reading a FRESH ZEROED block at a different address. The + // link then succeeded, the program ran, and nothing was destroyed. + // + // So a check that only asserted construction --- or only asserted that the + // program linked --- would have passed through both defects. The list now + // lives in the key's own value, which no other key's teardown can reach. + { + std::atomic seen{0}; + std::thread t([&]{ thread_local TlsProbe p{7}; seen.store(p.v); }); + t.join(); + check(seen.load() == 7, + "a thread_local is constructed in a spawned thread"); + check(tls_dtor_ran.load() == 7, + "and its destructor runs when that thread ends"); + } + +}}; diff --git a/tests/hard_link.cpp b/tests/hard_link.cpp new file mode 100644 index 00000000..b89b0fcf --- /dev/null +++ b/tests/hard_link.cpp @@ -0,0 +1,35 @@ +// A second name for a file through the C++ library: std::filesystem's +// create_hard_link and equivalent reach `link' and `stat' of openkal-musl +// 0.29.0, over kal_fs_hard_link (openkal 0.25). The count of a file's names is +// not asserted: openkal's node information carries no such count, and +// hard_link_count answers 1. +import std; +import mcpplibs.mtest; + +using namespace mcpplibs::mtest; + +UTest hard_link_test{"hard_link", [] { + namespace fs = std::filesystem; + const fs::path a = "okr-link-a.tmp", b = "okr-link-b.tmp"; + std::error_code ec; + fs::remove(a, ec); fs::remove(b, ec); + { std::ofstream(a) << "linked"; } + ec.clear(); + fs::create_hard_link(a, b, ec); + if (ec == std::errc::not_supported || ec == std::errc::operation_not_supported) + skip("this volume keeps one name per file"); + expect(!ec) << "a file is given a second name (" << ec.message() << ")"; + expect(fs::equivalent(a, b, ec) && !ec) << "and the two names are one file"; + std::string text; + { std::ifstream in(b); std::getline(in, text); } + expect(eq(text, std::string("linked"))) << "the second name reads as the first"; + ec.clear(); + fs::create_hard_link(a, b, ec); + expect(ec == std::errc::file_exists) << "a second name that is taken is refused"; + fs::remove(a, ec); + text.clear(); + { std::ifstream in(b); std::getline(in, text); } + expect(!fs::exists(a) && eq(text, std::string("linked"))) + << "removing one name leaves the file under the other"; + fs::remove(b, ec); +}}; diff --git a/tests/mcpp.toml b/tests/mcpp.toml new file mode 100644 index 00000000..e0e3caa4 --- /dev/null +++ b/tests/mcpp.toml @@ -0,0 +1,20 @@ +# The runtime's tests, on the mtest library: a C++ program above this runtime, +# above openkal-musl, above the openkal implementation of the system the tests +# run on. A package of its own beside the runtime, as the examples are, because +# the runtime's own build is the one that makes libc++ (its flags, its dialect) +# and a program that imports std is a consumer of what that build makes. +[package] +name = "openkal-llvm-runtime-tests" +version = "0.1.0" + +[dependencies] +openkal-llvm-runtime = { path = ".." } + +[dev-dependencies] +mcpplibs.mtest = "0.5.2" + +[test] +discover = ["*.cpp"] + +[toolchain] +default = "llvm@22.1.8" diff --git a/tests/program_search.cpp b/tests/program_search.cpp new file mode 100644 index 00000000..1677a685 --- /dev/null +++ b/tests/program_search.cpp @@ -0,0 +1,89 @@ +// A program named without a separator is looked for by the conventions of the +// system the program runs on (openkal-musl 0.29.0; openkal clause 11 entry 30, +// `kal_env_system'). On Windows the PATH is split on `;', a name that carries +// a suffix is tried as given and one without it only with each PATHEXT suffix, +// so `hostname' is found as `hostname.exe' in System32; elsewhere the colon of +// POSIX is unchanged. +#include +#include +#include +#include +#include +#include +#include + +import std; +import mcpplibs.mtest; + +using namespace mcpplibs::mtest; + +extern "C" char** environ; + +namespace { + +bool windows_conventions() { + char name[16] = {}; + const kal_intptr n = kal_env_system(name, sizeof name); + return n == 7 && std::memcmp(name, "windows", 7) == 0; +} + +// Starts `file' through posix_spawnp with PATH (and PATHEXT, where given) set +// as stated, and answers its exit status, or -1 when it was not started. +int start(const char* file, const std::string& path, const char* pathext) { + const char* old_path = std::getenv("PATH"); + const std::string kept_path = old_path ? old_path : ""; + const char* old_ext = std::getenv("PATHEXT"); + const std::string kept_ext = old_ext ? old_ext : ""; + setenv("PATH", path.c_str(), 1); + if (pathext) setenv("PATHEXT", pathext, 1); + char* argv[] = { const_cast(file), nullptr }; + pid_t pid = -1; + const int e = posix_spawnp(&pid, file, nullptr, nullptr, argv, environ); + if (old_path) setenv("PATH", kept_path.c_str(), 1); else unsetenv("PATH"); + if (pathext) { if (old_ext) setenv("PATHEXT", kept_ext.c_str(), 1); else unsetenv("PATHEXT"); } + if (e != 0) return -1; + int status = -1; + if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status)) return -1; + return WEXITSTATUS(status); +} + +} // namespace + +UTest program_search_test{"program_search", [] { + if (windows_conventions()) { + const char* root = std::getenv("SystemRoot"); + if (!root) root = std::getenv("SYSTEMROOT"); + if (!root) skip("the system names no SystemRoot"); + const std::string system32 = std::string(root) + "\\System32"; + // An entry that does not exist first, so that the search has to go on. + const std::string path = std::string("C:\\okr-absent-dir;") + system32; + expect(eq(start("hostname", path, ".COM;.EXE;.BAT;.CMD"), 0)) + << "`hostname' is found as hostname.exe through a semicolon-separated PATH and PATHEXT"; + expect(eq(start("hostname", path, nullptr), 0)) + << "and through the default suffixes where PATHEXT is not set"; + expect(eq(start("hostname.exe", path, ".COM;.EXE"), 0)) + << "a name that carries its suffix is found as given"; + expect(eq(start("okr-no-such-program", path, ".EXE"), -1)) + << "a name found nowhere is not started"; + // A name without a suffix is tried only with the suffixes, as Windows' + // command interpreter searches: a file `hostname' that is not a + // program, in an earlier entry, is passed over for System32's + // hostname.exe rather than ending the search. + namespace fs = std::filesystem; + std::error_code ec; + const fs::path first = fs::current_path(ec) / "okr-first-entry"; + fs::create_directories(first, ec); + { std::ofstream(first / "hostname") << "not a program\n"; } + const std::string shadowed = first.string() + ";" + system32; + expect(eq(start("hostname", shadowed, ".COM;.EXE"), 0)) + << "a suffixless file in an earlier entry does not stop the search for hostname.exe"; + fs::remove_all(first, ec); + } else { + if (access("/usr/bin/true", X_OK) != 0 && access("/bin/true", X_OK) != 0) + skip("this system has no `true' to start"); + expect(eq(start("true", "/okr-absent-dir:/usr/bin:/bin", nullptr), 0)) + << "`true' is found past an entry that does not exist, through a colon-separated PATH"; + expect(eq(start("okr-no-such-program", "/okr-absent-dir:/usr/bin:/bin", nullptr), -1)) + << "a name found nowhere is not started"; + } +}}; diff --git a/tools/branch-graph.sh b/tools/branch-graph.sh index 888abb9b..45e2e1fe 100755 --- a/tools/branch-graph.sh +++ b/tools/branch-graph.sh @@ -122,3 +122,30 @@ if grep -nE '^openkal[a-z-]* = ("|\{ version)' "${manifests[@]}"; then fi echo "the whole stack names working trees; nothing in it names a version" + +# THE TESTS' PACKAGE NAMES THE SAME TREES, AND FROM ITS ROOT. tests/ reaches +# the test library, which reaches mcpplibs.os, which states the specification +# and the system's implementation by version; the trees above are named by the +# C library, which is not the root, and an engine refuses a package requested +# both by path from inside the graph and by version ("Pick one"). Declared by +# the tests' own manifest, the root's declaration is the one used. +if [ -f tests/mcpp.toml ]; then + { + echo "" + echo "# Written by tools/branch-graph.sh: the stack's working trees." + echo "[dependencies.mcpplibs]" + echo "openkal = { path = \"../.spec\" }" + for backend in $(grep -oE '^openkal-[a-z]+ = \{ path = "\.\./\.openkal-[a-z]+"' .musl/mcpp.toml | cut -d' ' -f1); do + case "$backend" in + openkal-linux) cond='os = "linux"' ;; + openkal-macos) cond='os = "macos"' ;; + openkal-windows) cond='windows' ;; + *) continue ;; + esac + echo "" + echo "[target.'cfg($cond)'.dependencies.mcpplibs]" + echo "$backend = { path = \"../.$backend\" }" + done + } >> tests/mcpp.toml + echo "the tests' package names the same trees" +fi