6 Commits

Author SHA1 Message Date
patchzyy d4c53dad4b Use a deterministic clock for input expression tests 2026-09-07 23:17:14 +02:00
patchzyy e05e15bce5 Implement Linux executable directory lookup 2026-09-07 23:14:51 +02:00
patchzyy 9066cb3560 Run the shared C++ runtime test suite through CTest 2026-09-07 23:12:18 +02:00
patchzyy 05d6a0b1ab Report NAND move destination conflicts correctly 2026-09-07 23:03:41 +02:00
patchzyy afb8f49866 Keep NAND moves internal and prevent destination overwrite races 2026-09-07 22:50:13 +02:00
patchzyy 58bfd32022 Fix NAND file moves across filesystems 2026-09-07 22:33:52 +02:00
10 changed files with 440 additions and 89 deletions
+19
View File
@@ -46,3 +46,22 @@ jobs:
- name: Test - name: Test
run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal
cpp:
name: C++ runtime tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest, macos-14]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Configure
run: cmake -S runtime/tests -B build/tests -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build/tests --config Release --parallel
- name: Test
run: ctest --test-dir build/tests -C Release --output-on-failure --no-tests=error
+1 -73
View File
@@ -269,80 +269,8 @@ target_include_directories(mkw_platform PUBLIC "${CMAKE_CURRENT_LIST_DIR}/includ
target_compile_features(mkw_platform PUBLIC cxx_std_17) target_compile_features(mkw_platform PUBLIC cxx_std_17)
set_target_properties(mkw_platform PROPERTIES UNITY_BUILD OFF) set_target_properties(mkw_platform PROPERTIES UNITY_BUILD OFF)
# Keep these independent from Aurora's BUILD_TESTING option: they validate the
# project's host-platform contracts, not Aurora's third-party test suite.
enable_testing() enable_testing()
add_executable(mkw_platform_paths_tests "${CMAKE_CURRENT_LIST_DIR}/tests/platform_paths_tests.cpp") add_subdirectory(tests)
target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17)
add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests)
add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp")
target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17)
add_test(NAME mkw_nand_save_tests COMMAND mkw_nand_save_tests)
add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp")
find_package(Threads REQUIRED)
target_link_libraries(mkw_nand_settings_tests PRIVATE Threads::Threads)
target_include_directories(mkw_nand_settings_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_nand_settings_tests PRIVATE cxx_std_17)
add_test(NAME mkw_nand_settings_tests COMMAND mkw_nand_settings_tests)
add_executable(mkw_sc_serial_tests "${CMAKE_CURRENT_LIST_DIR}/tests/sc_serial_tests.cpp")
target_include_directories(mkw_sc_serial_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_sc_serial_tests PRIVATE cxx_std_17)
add_test(NAME mkw_sc_serial_tests COMMAND mkw_sc_serial_tests)
# The input expression engine is self-contained, so it can be exercised without
# linking the runtime or SDL.
add_executable(mkw_input_expr_tests
"${CMAKE_CURRENT_LIST_DIR}/tests/test_expr.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/input_expr.cpp")
target_include_directories(mkw_input_expr_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_input_expr_tests PRIVATE cxx_std_17)
add_test(NAME mkw_input_expr_tests COMMAND mkw_input_expr_tests)
# HostContext deliberately keeps the platform-specific context primitive out
# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future
# refactors cannot silently remove its headers, implementation, or link edge.
if(MKW_PLATFORM_LINUX)
add_executable(mkw_linux_host_context_tests
"${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp")
target_include_directories(mkw_linux_host_context_tests PRIVATE
"${CMAKE_CURRENT_LIST_DIR}/include"
"${CMAKE_CURRENT_LIST_DIR}/third_party/libco")
target_compile_features(mkw_linux_host_context_tests PRIVATE cxx_std_17)
target_link_libraries(mkw_linux_host_context_tests PRIVATE mkw::libco)
add_test(NAME mkw_linux_host_context_tests COMMAND mkw_linux_host_context_tests)
endif()
if(MKW_PLATFORM_MACOS)
# Exercise the Apple Silicon context ABI and the public host-memory
# contracts separately from translated products.
enable_language(ASM)
add_executable(mkw_macos_context_abi_tests
"${CMAKE_CURRENT_LIST_DIR}/tests/macos_context_abi_tests.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S")
target_compile_features(mkw_macos_context_abi_tests PRIVATE cxx_std_17)
add_test(NAME mkw_macos_context_abi_tests COMMAND mkw_macos_context_abi_tests)
add_executable(mkw_macos_host_context_tests
"${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S")
target_include_directories(mkw_macos_host_context_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_macos_host_context_tests PRIVATE cxx_std_17)
add_test(NAME mkw_macos_host_context_tests COMMAND mkw_macos_host_context_tests)
add_executable(mkw_macos_guest_flat_memory_tests
"${CMAKE_CURRENT_LIST_DIR}/tests/macos_guest_flat_memory_tests.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/guest_flat_memory_macos.cpp")
target_include_directories(mkw_macos_guest_flat_memory_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_macos_guest_flat_memory_tests PRIVATE cxx_std_17)
add_test(NAME mkw_macos_guest_flat_memory_tests COMMAND mkw_macos_guest_flat_memory_tests)
endif()
# The translator emits the complete, content-addressed source graph. Consuming # The translator emits the complete, content-addressed source graph. Consuming
# this one manifest keeps configure independent of the 28k generated function # this one manifest keeps configure independent of the 28k generated function
+6 -1
View File
@@ -3,6 +3,7 @@
// Shared state and helpers live in nand_internal.h. // Shared state and helpers live in nand_internal.h.
#include "nand_internal.h" #include "nand_internal.h"
#include "nand_file_ops.h"
// ============================================================================ // ============================================================================
// Local helpers // Local helpers
@@ -378,11 +379,15 @@ extern "C" int32_t NANDMove_HLE(uint32_t srcPathPtr, uint32_t dstPathPtr) {
} }
std::error_code ec; std::error_code ec;
std::filesystem::rename(srcHost, dstHost, ec); NandMove(srcHost, dstHost, ec);
if (!ec) { if (!ec) {
return NAND_RESULT_OK; return NAND_RESULT_OK;
} }
if (ec == std::errc::file_exists) {
return NAND_RESULT_EXISTS;
}
LogNandError("NANDMove", "FAILED error=%d message='%s'", ec.value(), ec.message().c_str()); LogNandError("NANDMove", "FAILED error=%d message='%s'", ec.value(), ec.message().c_str());
return NAND_RESULT_UNKNOWN; return NAND_RESULT_UNKNOWN;
} }
+115
View File
@@ -0,0 +1,115 @@
#include "nand_file_ops.h"
#include <atomic>
#include <cerrno>
#include <chrono>
#include <filesystem>
#include <string>
#include <system_error>
#ifdef _WIN32
#include <windows.h>
#elif defined(__linux__)
#include <fcntl.h>
#include <linux/fs.h>
#include <sys/syscall.h>
#include <unistd.h>
#elif defined(__APPLE__)
#include <stdio.h>
#endif
namespace {
// Unlike std::filesystem::rename on POSIX, this cannot overwrite a destination
// created by another writer between checking it and publishing the move.
void RenameNoReplace(const std::filesystem::path& source,
const std::filesystem::path& destination, std::error_code& ec) {
#ifdef _WIN32
if (MoveFileExW(source.c_str(), destination.c_str(), 0)) ec.clear();
else ec = std::error_code(GetLastError(), std::system_category());
#else
#ifdef __linux__
const auto result = syscall(SYS_renameat2, AT_FDCWD, source.c_str(),
AT_FDCWD, destination.c_str(), RENAME_NOREPLACE);
#else
const auto result = renamex_np(source.c_str(), destination.c_str(), RENAME_EXCL);
#endif
if (result == 0) {
ec.clear();
return;
}
ec = std::error_code(errno, std::generic_category());
if (ec != std::errc::function_not_supported && ec != std::errc::invalid_argument &&
ec != std::errc::operation_not_supported) return;
// Older filesystems may lack exclusive rename. Linking also publishes
// without replacement; never fall back to an overwriting rename.
std::filesystem::create_hard_link(source, destination, ec);
if (!ec) std::filesystem::remove(source, ec);
#endif
}
struct StagedMove {
std::filesystem::path directory;
std::filesystem::path file;
~StagedMove() {
std::error_code ignored;
std::filesystem::remove(file, ignored);
std::filesystem::remove(directory, ignored);
}
};
} // namespace
// NANDMove does not replace an existing entry. Riivolution save redirects can
// put the destination on a different filesystem from the guest's /tmp files.
void NandMove(const std::filesystem::path& source, const std::filesystem::path& destination,
std::error_code& ec) {
namespace fs = std::filesystem;
const auto status = fs::symlink_status(destination, ec);
if (ec && ec != std::errc::no_such_file_or_directory) return;
ec.clear();
if (fs::exists(status)) {
ec = std::make_error_code(std::errc::file_exists);
return;
}
RenameNoReplace(source, destination, ec);
if (ec != std::errc::cross_device_link) return;
// Do not turn a directory move into a partially completed recursive copy,
// or follow a symlink and delete the link after copying its target.
const auto sourceStatus = fs::symlink_status(source, ec);
if (ec) return;
if (!fs::is_regular_file(sourceStatus)) {
ec = std::make_error_code(std::errc::cross_device_link);
return;
}
static std::atomic<unsigned long long> sequence{0};
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
fs::path stagingDirectory;
bool created = false;
for (int attempt = 0; attempt < 64; ++attempt) {
stagingDirectory = destination.parent_path() /
(".nand-move-" + std::to_string(stamp) + "-" + std::to_string(sequence++));
created = fs::create_directory(stagingDirectory, ec);
if (created) break;
if (ec && ec != std::errc::file_exists) return;
}
if (!created) {
ec = std::make_error_code(std::errc::file_exists);
return;
}
const StagedMove staging{stagingDirectory, stagingDirectory / "data"};
const auto& staged = staging.file;
fs::copy_file(source, staged, fs::copy_options::none, ec);
if (!ec) {
// Publication is a same-filesystem rename: readers never see a partial
// copy. Keep the source until the complete destination is in place.
RenameNoReplace(staged, destination, ec);
if (!ec) fs::remove(source, ec);
}
// A failed source removal leaves both complete copies and reports failure.
// Staging cleanup preserves ec, including on exceptions.
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <filesystem>
#include <system_error>
// Move without replacing an existing entry. Cross-filesystem regular files are
// staged at the destination before removing the source. On failure the source
// remains; a failed source removal can leave both complete copies.
// The caller must keep the source stable for the duration of the move; NAND HLE
// calls run synchronously on the cooperative guest thread without yielding.
void NandMove(const std::filesystem::path& source, const std::filesystem::path& destination,
std::error_code& ec);
+19 -6
View File
@@ -9,11 +9,24 @@
#include <unordered_map> #include <unordered_map>
namespace InputExpr { namespace InputExpr {
#ifdef MKW_INPUT_EXPR_TEST_CLOCK
// Only the standalone test target supplies this clock; runtime builds use the
// steady clock directly, with no mutable override or extra runtime state.
std::chrono::steady_clock::time_point TestClockNow();
#endif
namespace { namespace {
using Clock = std::chrono::steady_clock; using Clock = std::chrono::steady_clock;
using FSec = std::chrono::duration<double>; using FSec = std::chrono::duration<double>;
static Clock::time_point Now() {
#ifdef MKW_INPUT_EXPR_TEST_CLOCK
return TestClockNow();
#else
return Clock::now();
#endif
}
enum class Kind { enum class Kind {
Literal, Input, Not, Add, Sub, Mul, Div, And, Or, Xor, Literal, Input, Not, Add, Sub, Mul, Div, And, Or, Xor,
Greater, Less, Equal, Greater, Less, Equal,
@@ -56,7 +69,7 @@ struct Node {
mutable bool state = false; mutable bool state = false;
mutable unsigned taps = 0; mutable unsigned taps = 0;
mutable double value = 0.0; mutable double value = 0.0;
mutable Clock::time_point mark = Clock::now(); mutable Clock::time_point mark = Now();
mutable bool marked = false; mutable bool marked = false;
}; };
@@ -400,7 +413,7 @@ double Eval(const Node& node, const InputSource& source) {
return std::copysign(std::max(0.0, std::abs(v) - dz) / (1.0 - dz), v); return std::copysign(std::max(0.0, std::abs(v) - dz) / (1.0 - dz), v);
} }
case Kind::FnTimer: { case Kind::FnTimer: {
const auto now = Clock::now(); const auto now = Now();
if (!node.marked) { if (!node.marked) {
node.mark = now; node.mark = now;
node.marked = true; node.marked = true;
@@ -431,7 +444,7 @@ double Eval(const Node& node, const InputSource& source) {
return node.state ? 1.0 : 0.0; return node.state ? 1.0 : 0.0;
} }
case Kind::FnHold: { case Kind::FnHold: {
const auto now = Clock::now(); const auto now = Now();
if (!node.marked) { if (!node.marked) {
node.mark = now; node.mark = now;
node.marked = true; node.marked = true;
@@ -448,7 +461,7 @@ double Eval(const Node& node, const InputSource& source) {
return node.state ? 1.0 : 0.0; return node.state ? 1.0 : 0.0;
} }
case Kind::FnTap: { case Kind::FnTap: {
const auto now = Clock::now(); const auto now = Now();
if (!node.marked) { if (!node.marked) {
node.mark = now; node.mark = now;
node.marked = true; node.marked = true;
@@ -480,7 +493,7 @@ double Eval(const Node& node, const InputSource& source) {
return desired == node.taps ? 1.0 : 0.0; return desired == node.taps ? 1.0 : 0.0;
} }
case Kind::FnPulse: { case Kind::FnPulse: {
const auto now = Clock::now(); const auto now = Now();
const double input = Arg(node, 0, source); const double input = Arg(node, 0, source);
if (input < kConditionThreshold) { if (input < kConditionThreshold) {
node.released = true; node.released = true;
@@ -502,7 +515,7 @@ double Eval(const Node& node, const InputSource& source) {
return node.state ? 1.0 : 0.0; return node.state ? 1.0 : 0.0;
} }
case Kind::FnSmooth: { case Kind::FnSmooth: {
const auto now = Clock::now(); const auto now = Now();
if (!node.marked) { if (!node.marked) {
node.mark = now; node.mark = now;
node.marked = true; node.marked = true;
+7
View File
@@ -46,6 +46,13 @@ std::optional<std::filesystem::path> ExecutableDirectory() noexcept {
std::error_code ec; std::error_code ec;
const auto resolved = std::filesystem::weakly_canonical(path, ec); const auto resolved = std::filesystem::weakly_canonical(path, ec);
return (ec ? std::filesystem::path(path) : resolved).parent_path(); return (ec ? std::filesystem::path(path) : resolved).parent_path();
#elif defined(__linux__)
std::error_code ec;
const auto executable = std::filesystem::read_symlink("/proc/self/exe", ec);
if (ec) {
return std::nullopt;
}
return executable.parent_path();
#else #else
return std::nullopt; return std::nullopt;
#endif #endif
+64
View File
@@ -0,0 +1,64 @@
cmake_minimum_required(VERSION 3.16)
# Share the same test registrations with the full runtime build, while allowing
# CI and developers to run them without translated game files or Aurora.
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
project(WiiCompiledRuntimeTests LANGUAGES C CXX)
enable_testing()
endif()
get_filename_component(runtime_root "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
find_package(Threads REQUIRED)
function(mkw_add_test name)
add_executable(${name} ${ARGN})
target_include_directories(${name} PRIVATE "${runtime_root}/include")
target_compile_features(${name} PRIVATE cxx_std_17)
target_link_libraries(${name} PRIVATE Threads::Threads)
add_test(NAME ${name} COMMAND ${name})
endfunction()
if(NOT TARGET mkw_platform)
add_library(mkw_platform STATIC "${runtime_root}/src/platform/host_platform.cpp")
target_include_directories(mkw_platform PUBLIC "${runtime_root}/include")
target_compile_features(mkw_platform PUBLIC cxx_std_17)
endif()
if(WIN32)
target_link_libraries(mkw_platform PUBLIC shell32 ole32)
endif()
mkw_add_test(mkw_platform_paths_tests platform_paths_tests.cpp)
target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform)
mkw_add_test(mkw_nand_save_tests nand_save_tests.cpp)
mkw_add_test(mkw_nand_move_tests nand_move_tests.cpp
"${runtime_root}/src/hle/storage/nand_file_ops.cpp")
target_include_directories(mkw_nand_move_tests PRIVATE "${runtime_root}/src/hle/storage")
mkw_add_test(mkw_nand_settings_tests nand_settings_tests.cpp)
mkw_add_test(mkw_sc_serial_tests sc_serial_tests.cpp)
mkw_add_test(mkw_input_expr_tests test_expr.cpp "${runtime_root}/src/input_expr.cpp")
target_compile_definitions(mkw_input_expr_tests PRIVATE MKW_INPUT_EXPR_TEST_CLOCK)
if(WIN32)
mkw_add_test(mkw_windows_host_context_tests host_context_tests.cpp
"${runtime_root}/src/host_context.cpp")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
if(NOT TARGET mkw::libco)
add_library(mkw_libco STATIC "${runtime_root}/third_party/libco/libco.c")
add_library(mkw::libco ALIAS mkw_libco)
target_include_directories(mkw_libco PUBLIC "${runtime_root}/third_party/libco")
endif()
mkw_add_test(mkw_linux_host_context_tests host_context_tests.cpp
"${runtime_root}/src/host_context.cpp")
target_link_libraries(mkw_linux_host_context_tests PRIVATE mkw::libco)
elseif(APPLE)
if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|ARM64|aarch64)$")
message(FATAL_ERROR "The macOS runtime tests require Apple Silicon")
endif()
enable_language(ASM)
set(context_asm "${runtime_root}/src/platform/macos/co_switch.S")
mkw_add_test(mkw_macos_context_abi_tests macos_context_abi_tests.cpp "${context_asm}")
mkw_add_test(mkw_macos_host_context_tests host_context_tests.cpp
"${runtime_root}/src/host_context.cpp" "${context_asm}")
mkw_add_test(mkw_macos_guest_flat_memory_tests macos_guest_flat_memory_tests.cpp
"${runtime_root}/src/guest_flat_memory_macos.cpp")
endif()
+181
View File
@@ -0,0 +1,181 @@
#include "nand_file_ops.h"
#include <atomic>
#include <chrono>
#include <fstream>
#include <functional>
#include <iterator>
#include <string>
#include <thread>
#include <iostream>
#include <stdexcept>
#ifdef __linux__
#include <csignal>
#include <sys/resource.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
namespace fs = std::filesystem;
static void Require(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
static void Write(const fs::path& path, const std::string& data) {
std::ofstream output(path, std::ios::binary);
output << data;
output.close();
Require(bool(output), "Fixture write failed");
}
static std::string Read(const fs::path& path) {
std::ifstream input(path, std::ios::binary);
Require(bool(input), "Fixture read failed");
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
// Competing moves must have exactly one winner, retain the losing source, and
// publish the winner's complete contents. Exercise both direct and staged moves.
static void CheckCompetingMoves(const fs::path& sourceRoot, const fs::path& destinationRoot) {
const auto left = sourceRoot / "race-left";
const auto right = sourceRoot / "race-right";
const auto target = destinationRoot / "race-target";
const std::string leftBytes(65536, 'L'), rightBytes(65536, 'R');
for (int attempt = 0; attempt < 128; ++attempt) {
Write(left, leftBytes);
Write(right, rightBytes);
std::atomic<int> ready{0};
std::error_code leftError, rightError;
auto move = [&](const fs::path& source, std::error_code& ec) {
++ready;
while (ready.load() != 2) std::this_thread::yield();
NandMove(source, target, ec);
};
std::thread first(move, std::cref(left), std::ref(leftError));
std::thread second(move, std::cref(right), std::ref(rightError));
first.join();
second.join();
Require(bool(leftError) != bool(rightError), "Competing moves must have exactly one winner");
const bool leftWon = !leftError;
Require((leftWon ? rightError : leftError) == std::errc::file_exists,
"Losing move must report an existing destination");
Require(Read(target) == (leftWon ? leftBytes : rightBytes), "Winner's contents were overwritten");
Require(!fs::exists(leftWon ? left : right), "Winning source was not removed");
Require(Read(leftWon ? right : left) == (leftWon ? rightBytes : leftBytes),
"Losing source must remain intact");
fs::remove(leftWon ? right : left);
fs::remove(target);
}
}
int main() {
const auto name = "wiicomp-nand-move-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
const auto root = fs::temp_directory_path() / name;
fs::path other;
try {
fs::create_directory(root);
const auto source = root / "banner.bin";
const auto destination = root / "moved.bin";
std::error_code ec;
Write(source, "banner");
NandMove(source, destination, ec);
Require(!ec && !fs::exists(source) && Read(destination) == "banner", "Same-device move failed");
CheckCompetingMoves(root, root);
Write(source, "keep source");
NandMove(source, destination, ec);
Require(ec == std::errc::file_exists && Read(source) == "keep source" && Read(destination) == "banner",
"Existing destination must not be overwritten");
NandMove(root / "missing", root / "absent", ec);
Require(bool(ec) && !fs::exists(root / "absent"), "Missing source must fail");
NandMove(source, root / "missing-parent/file", ec);
Require(bool(ec) && Read(source) == "keep source", "Missing parent must preserve source");
fs::create_directory(root / "directory");
Write(root / "directory/child", "child");
NandMove(root / "directory", root / "renamed-directory", ec);
Require(!ec && Read(root / "renamed-directory/child") == "child", "Same-device directory move regressed");
#ifndef _WIN32
fs::create_symlink(root / "missing", root / "same-link");
NandMove(root / "same-link", root / "moved-link", ec);
Require(!ec && fs::is_symlink(fs::symlink_status(root / "moved-link")) &&
!fs::is_symlink(fs::symlink_status(root / "same-link")), "Same-device symlink move regressed");
#endif
#ifdef __linux__
// /dev/shm is a separate tmpfs on ordinary Linux systems, including CI
// and WSL. Fail rather than silently passing without exercising EXDEV.
other = fs::path("/dev/shm") / name;
fs::create_directory(other);
struct stat left{}, right{};
Require(::stat(root.c_str(), &left) == 0 && ::stat(other.c_str(), &right) == 0 && left.st_dev != right.st_dev,
"Cross-device test requires /tmp and /dev/shm on separate filesystems");
CheckCompetingMoves(root, other);
Require(fs::is_empty(other), "Competing moves left staging files behind");
const auto target = other / "banner.bin";
const std::string bytes = std::string(8192, '\0') + "banner payload";
Write(source, bytes);
fs::rename(source, target, ec);
Require(ec == std::errc::cross_device_link, "Fixture must reproduce the original EXDEV failure");
NandMove(source, target, ec);
Require(!ec && !fs::exists(source) && Read(target) == bytes, "Cross-device move must preserve every byte");
Write(source, "do not overwrite");
NandMove(source, target, ec);
Require(ec == std::errc::file_exists && Read(source) == "do not overwrite" && Read(target) == bytes,
"Cross-device move must preserve an existing destination");
fs::remove(target);
fs::create_symlink(other / "missing", target);
NandMove(source, target, ec);
Require(ec == std::errc::file_exists && fs::is_symlink(target) && Read(source) == "do not overwrite",
"Dangling destination symlink must not be replaced");
fs::remove(target);
NandMove(root / "renamed-directory", other / "directory", ec);
Require(ec == std::errc::cross_device_link && Read(root / "renamed-directory/child") == "child" &&
!fs::exists(other / "directory"), "Unsupported directory move must leave source intact");
fs::create_symlink(source, root / "link");
NandMove(root / "link", other / "link", ec);
Require(ec == std::errc::cross_device_link && fs::is_symlink(root / "link") && !fs::exists(other / "link"),
"Cross-device source symlinks must not be dereferenced");
// Force a real write failure after a partial copy without filling disk.
Write(source, bytes);
struct rlimit saved{}, limited{};
Require(getrlimit(RLIMIT_FSIZE, &saved) == 0, "Cannot read file-size limit");
limited = saved;
limited.rlim_cur = 1024;
const auto oldHandler = std::signal(SIGXFSZ, SIG_IGN);
Require(setrlimit(RLIMIT_FSIZE, &limited) == 0, "Cannot set file-size limit");
NandMove(source, target, ec);
const auto copyError = ec;
const auto restored = setrlimit(RLIMIT_FSIZE, &saved);
std::signal(SIGXFSZ, oldHandler);
Require(restored == 0, "Cannot restore file-size limit");
Require(bool(copyError) && Read(source) == bytes && !fs::exists(target) && fs::is_empty(other),
"Failed copy must retain source and remove partial staging files");
Require(geteuid() != 0, "Run permission tests as an unprivileged user");
fs::permissions(root, fs::perms::owner_read | fs::perms::owner_exec);
NandMove(source, target, ec);
fs::permissions(root, fs::perms::owner_all);
Require(bool(ec) && Read(source) == bytes && Read(target) == bytes,
"Failed source deletion must leave both complete copies");
Require(std::distance(fs::directory_iterator(other), fs::directory_iterator{}) == 1,
"Move must clean up staging directory");
fs::remove_all(other);
#endif
fs::remove_all(root);
std::cout << "NAND move tests passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
std::error_code ignored;
fs::permissions(root, fs::perms::owner_all, ignored);
fs::remove_all(root, ignored);
if (!other.empty()) fs::remove_all(other, ignored);
return 1;
}
}
+16 -9
View File
@@ -8,7 +8,14 @@
#include <cstdio> #include <cstdio>
#include <map> #include <map>
#include <string> #include <string>
#include <thread> #ifndef MKW_INPUT_EXPR_TEST_CLOCK
#error "Build this test through CMake so the deterministic clock is enabled"
#endif
static std::chrono::steady_clock::time_point g_now{};
namespace InputExpr {
std::chrono::steady_clock::time_point TestClockNow() { return g_now; }
}
static int g_failures = 0; static int g_failures = 0;
static std::map<std::string, double> g_inputs; static std::map<std::string, double> g_inputs;
@@ -41,7 +48,7 @@ static bool Pressed(const InputExpr::Expression& e) {
return e.Evaluate(Source()) > InputExpr::kConditionThreshold; return e.Evaluate(Source()) > InputExpr::kConditionThreshold;
} }
static void Sleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } static void AdvanceTime(int ms) { g_now += std::chrono::milliseconds(ms); }
int main() { int main() {
std::printf("Dolphin expression engine\n"); std::printf("Dolphin expression engine\n");
@@ -82,7 +89,7 @@ int main() {
auto hold = Compile("hold(`H`, 0.05)"); auto hold = Compile("hold(`H`, 0.05)");
g_inputs["H"] = 1.0; g_inputs["H"] = 1.0;
Check(!Pressed(hold), "hold not satisfied immediately"); Check(!Pressed(hold), "hold not satisfied immediately");
Sleep(70); AdvanceTime(70);
Check(Pressed(hold), "hold satisfied after the interval"); Check(Pressed(hold), "hold satisfied after the interval");
g_inputs["H"] = 0.0; g_inputs["H"] = 0.0;
Check(!Pressed(hold), "hold clears on release"); Check(!Pressed(hold), "hold clears on release");
@@ -93,7 +100,7 @@ int main() {
pulse.Evaluate(Source()); pulse.Evaluate(Source());
g_inputs["P"] = 1.0; g_inputs["P"] = 1.0;
Check(Pressed(pulse), "pulse fires on rising edge"); Check(Pressed(pulse), "pulse fires on rising edge");
Sleep(80); AdvanceTime(80);
Check(!Pressed(pulse), "pulse expires"); Check(!Pressed(pulse), "pulse expires");
// The timing-window idiom seen in shared Dolphin configs. // The timing-window idiom seen in shared Dolphin configs.
@@ -102,9 +109,9 @@ int main() {
window.Evaluate(Source()); window.Evaluate(Source());
g_inputs["W"] = 1.0; g_inputs["W"] = 1.0;
Check(!Pressed(window), "window closed before its start"); Check(!Pressed(window), "window closed before its start");
Sleep(90); AdvanceTime(90);
Check(Pressed(window), "window open between the two pulses"); Check(Pressed(window), "window open between the two pulses");
Sleep(90); AdvanceTime(90);
Check(!Pressed(window), "window closed after its end"); Check(!Pressed(window), "window closed after its end");
// timer ramps 0..1 and wraps, so a threshold turns it into a square wave. // timer ramps 0..1 and wraps, so a threshold turns it into a square wave.
@@ -114,7 +121,7 @@ int main() {
int low = 0; int low = 0;
for (int i = 0; i < 40; ++i) { for (int i = 0; i < 40; ++i) {
(Pressed(timer) ? high : low)++; (Pressed(timer) ? high : low)++;
Sleep(5); AdvanceTime(5);
} }
Check(high > 5 && low > 5, "timer alternates high and low"); Check(high > 5 && low > 5, "timer alternates high and low");
@@ -130,7 +137,7 @@ int main() {
high = low = 0; high = low = 0;
for (int i = 0; i < 60; ++i) { for (int i = 0; i < 60; ++i) {
(Pressed(dolphinLine) ? high : low)++; (Pressed(dolphinLine) ? high : low)++;
Sleep(2); AdvanceTime(2);
} }
Check(high > 5 && low > 5, "LB alternates via timer(0.01)"); Check(high > 5 && low > 5, "LB alternates via timer(0.01)");
@@ -192,7 +199,7 @@ int main() {
auto sm = Compile("smooth(`A`, 0)"); auto sm = Compile("smooth(`A`, 0)");
g_inputs["A"] = 1.0; g_inputs["A"] = 1.0;
sm.Evaluate(Source()); sm.Evaluate(Source());
Sleep(5); AdvanceTime(5);
Check(std::isfinite(sm.Evaluate(Source())), "smooth with a zero rate stays finite"); Check(std::isfinite(sm.Evaluate(Source())), "smooth with a zero rate stays finite");
} }