From 3bd8d6a9ac8ab45be5cc00a2d27e03b6543bb320 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 06:17:18 +0000 Subject: [PATCH 1/6] Add AGENTS.md with cloud-specific development instructions Co-authored-by: Albert Slepak --- AGENTS.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1047014 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# AGENTS.md + +## Cloud-specific instructions + +### Overview + +Stellux 3.0 is a bare-metal hobby OS kernel written in freestanding C++20 and assembly, targeting **x86_64** and **AArch64**. There is no web server, database, or container infrastructure. The "application" is the kernel itself, tested by booting in QEMU and inspecting serial output. + +### Prerequisites (system packages) + +System dependencies are installed via `make deps` (wraps `sudo apt install`). The Limine UEFI bootloader binaries are fetched via `make limine` (downloads from GitHub). Both are one-time setup steps already handled by the update script. + +### Build & Run commands + +See `Makefile` help (`make help`) and `.cursor/rules/build.md` for the full command reference. Key commands: + +- **Build**: `make kernel ARCH=x86_64` / `make kernel ARCH=aarch64` +- **Disk image**: `make image ARCH=x86_64` / `make image ARCH=aarch64` +- **Run with display**: `make run ARCH=x86_64` +- **Run headless** (for cloud/CI): `make run-headless ARCH=x86_64` +- **Lint**: `bash scripts/dynpriv-lint.sh` +- **Toolchain check**: `make toolchain-check` + +### Gotchas for cloud environments + +- **Headless mode required**: Use `make run-headless ARCH=` since there is no display server. QEMU uses `-nographic` and serial output goes to stdout. +- **OVMF_VARS.fd is mutable**: The x86_64 QEMU target requires a writable copy of the OVMF VARS file at `build/OVMF_VARS.fd`. This is auto-created by `make run*` targets, but if `build/` was cleaned you need to rebuild via `make image ARCH=x86_64` first. +- **AArch64 QEMU is slower**: AArch64 runs under TCG emulation (no KVM for cross-arch), so QEMU boot takes ~20-40s. Use `timeout 45` when scripting. +- **No automated test suite**: There is no unit test framework. Verification is done by booting the kernel in QEMU and checking serial output for `[INFO] Stellux 3.0 booting...` through `Initialization complete! Halting...`. +- **Both architectures must build and pass**: A feature is not complete until both `ARCH=x86_64` and `ARCH=aarch64` build without errors and boot successfully. +- **Linter**: `scripts/dynpriv-lint.sh` checks dynamic privilege annotation consistency (header vs source `__PRIVILEGED_CODE` markers and docstrings). Run it before committing changes to kernel code. From c1e0e00a315235db478afee56c2283f261599299 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 06:22:15 +0000 Subject: [PATCH 2/6] Add onboarding section to AGENTS.md referencing gather-context workflow Co-authored-by: Albert Slepak --- AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1047014..8541245 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,16 @@ # AGENTS.md +## Onboarding + +Before starting any task, new agents should perform the equivalent of the `/gather-context` command (see `.cursor/commands/gather-context.md`). In short: + +1. **Read project rules** (in order): `.cursor/rules/philosophy.md`, `architecture.md`, `style.md`, `low-level.md`, `error-handling.md`, `build.md`, `cpp-constraints.md` +2. **Explore directory structure**: `kernel/common/` (arch-independent interfaces), `kernel/arch/{x86_64,aarch64}/` (implementations), `kernel/boot/`, `kernel/mm/`, `kernel/trap/`, `kernel/sched/`, `kernel/syscall/` +3. **Read key source files**: a common header + both arch implementations, `start.S` and `arch_init` for both architectures, `Makefile` and `kernel/Makefile` +4. **Check current state**: `git log --oneline -20` to see recent development focus, and scan for TODOs/FIXMEs + +This context is essential — the project has strict dual-architecture requirements, a freestanding C++20 subset (no STL), and specific coding conventions that are easy to violate without reading the rules first. + ## Cloud-specific instructions ### Overview From d5efa892c97e670acb81f5276563ee3fd06fa309 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 07:10:10 +0000 Subject: [PATCH 3/6] Add in-kernel unit test framework scaffolding Co-authored-by: Albert Slepak --- .gitignore | 4 + Makefile | 52 ++- config.mk | 27 ++ kernel/Makefile | 16 + kernel/arch/aarch64/linker.ld | 15 + kernel/arch/x86_64/linker.ld | 15 + kernel/boot/boot.cpp | 30 ++ kernel/test/framework/test_framework.h | 219 +++++++++++ kernel/test/framework/test_registry.h | 37 ++ kernel/test/framework/test_runner.cpp | 517 +++++++++++++++++++++++++ kernel/test/framework/test_runner.h | 67 ++++ kernel/test/framework/test_utils.h | 26 ++ scripts/run-unit-tests.py | 168 ++++++++ 13 files changed, 1192 insertions(+), 1 deletion(-) create mode 100644 kernel/test/framework/test_framework.h create mode 100644 kernel/test/framework/test_registry.h create mode 100644 kernel/test/framework/test_runner.cpp create mode 100644 kernel/test/framework/test_runner.h create mode 100644 kernel/test/framework/test_utils.h create mode 100755 scripts/run-unit-tests.py diff --git a/.gitignore b/.gitignore index 90cee18..afd848f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build/ # Generated images images/*.img images/*.iso +images/unit-tests-*/ # Downloaded Limine binaries (reproducible via make limine) boot/limine/*.EFI @@ -18,6 +19,9 @@ boot/limine/limine *.swp *~ +# Python cache +scripts/__pycache__/ + # Clangd .cache/ compile_commands.json diff --git a/Makefile b/Makefile index 2269576..55e5f98 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,14 @@ GDB_PORT := 4554 # QEMU settings QEMU_MEMORY := 4G +# Unit test defaults (also defined in config.mk for kernel builds) +UNIT_TEST ?= 0 +UNIT_TEST_FILTER ?= +UNIT_TEST_FAIL_FAST ?= 0 +UNIT_TEST_REPEAT ?= 1 +UNIT_TEST_SEED ?= 0xC0FFEE +UNIT_TEST_TIMEOUT ?= 120 + # Verbosity (V=1 for verbose) ifeq ($(V),1) Q := @@ -55,6 +63,12 @@ export V export DEBUG export RELEASE export BUILD_DIR +export UNIT_TEST +export UNIT_TEST_FILTER +export UNIT_TEST_FAIL_FAST +export UNIT_TEST_REPEAT +export UNIT_TEST_SEED +export UNIT_TEST_TIMEOUT # ============================================================================ # Supported Architectures @@ -67,7 +81,7 @@ SUPPORTED_ARCHS := x86_64 aarch64 # ============================================================================ # Targets that require ARCH -ARCH_REQUIRED_TARGETS := kernel image run +ARCH_REQUIRED_TARGETS := kernel image run run-headless test # Check if current target requires ARCH CURRENT_GOALS := $(MAKECMDGOALS) @@ -100,6 +114,7 @@ endif # ============================================================================ .PHONY: all kernel image run run-headless clean \ + test test-all \ image-x86_64 image-aarch64 \ run-qemu-x86_64 run-qemu-aarch64 \ run-qemu-x86_64-headless run-qemu-aarch64-headless \ @@ -181,6 +196,33 @@ else ifeq ($(ARCH),aarch64) $(Q)$(MAKE) run-qemu-aarch64-headless endif +# ============================================================================ +# Unit Test Execution +# ============================================================================ + +test: check-lld + python3 scripts/run-unit-tests.py \ + --arch $(ARCH) \ + --filter "$(UNIT_TEST_FILTER)" \ + --fail-fast $(UNIT_TEST_FAIL_FAST) \ + --repeat $(UNIT_TEST_REPEAT) \ + --seed $(UNIT_TEST_SEED) \ + --timeout $(UNIT_TEST_TIMEOUT) + +test-all: + $(Q)$(MAKE) test ARCH=x86_64 \ + UNIT_TEST_FILTER="$(UNIT_TEST_FILTER)" \ + UNIT_TEST_FAIL_FAST=$(UNIT_TEST_FAIL_FAST) \ + UNIT_TEST_REPEAT=$(UNIT_TEST_REPEAT) \ + UNIT_TEST_SEED=$(UNIT_TEST_SEED) \ + UNIT_TEST_TIMEOUT=$(UNIT_TEST_TIMEOUT) + $(Q)$(MAKE) test ARCH=aarch64 \ + UNIT_TEST_FILTER="$(UNIT_TEST_FILTER)" \ + UNIT_TEST_FAIL_FAST=$(UNIT_TEST_FAIL_FAST) \ + UNIT_TEST_REPEAT=$(UNIT_TEST_REPEAT) \ + UNIT_TEST_SEED=$(UNIT_TEST_SEED) \ + UNIT_TEST_TIMEOUT=$(UNIT_TEST_TIMEOUT) + run-qemu-x86_64: $(IMAGE_DIR)/stellux-x86_64.img $(BUILD_DIR)/OVMF_VARS.fd @echo "" @echo "Serial output below. QEMU monitor: Ctrl+A C | Exit: Ctrl+A X" @@ -488,6 +530,8 @@ help: @echo " make image-aarch64 Build AArch64 disk image (shortcut)" @echo " make run ARCH= Build + run in QEMU (with display)" @echo " make run-headless ARCH= Build + run headless (for SSH)" + @echo " make test ARCH= Build + run in-kernel unit tests" + @echo " make test-all Run unit tests for both architectures" @echo " make usb ARCH= Build + print USB instructions" @echo "" @echo "Debugging (run QEMU target in one terminal, connect-gdb in another):" @@ -502,12 +546,18 @@ help: @echo " V=1 Verbose output (show commands)" @echo " DEBUG=1 Debug build (default)" @echo " RELEASE=1 Release build (-O2)" + @echo " UNIT_TEST_FILTER= Test filter (suite prefix or suite.case)" + @echo " UNIT_TEST_FAIL_FAST=1 Stop after first failing test" + @echo " UNIT_TEST_REPEAT= Repeat each matching test case" + @echo " UNIT_TEST_SEED= Deterministic test seed" + @echo " UNIT_TEST_TIMEOUT= Host timeout for make test" @echo "" @echo "Architectures: $(SUPPORTED_ARCHS)" @echo "" @echo "Examples:" @echo " make kernel ARCH=x86_64" @echo " make run ARCH=aarch64" + @echo " make test ARCH=x86_64" @echo " make kernel ARCH=x86_64 RELEASE=1 V=1" @echo "" @echo "Other:" diff --git a/config.mk b/config.mk index 72f1e5e..97ad61b 100644 --- a/config.mk +++ b/config.mk @@ -63,3 +63,30 @@ MAX_CPUS ?= 64 # Log level (0=debug, 1=info, 2=warn, 3=error, 4=fatal, 5=none) LOG_LEVEL ?= 0 + +# ============================================================================ +# Unit Test Configuration +# ============================================================================ + +# Build with in-kernel unit test framework enabled. +# 0 = disabled (default), 1 = enabled. +UNIT_TEST ?= 0 + +# Optional test filter: +# "" => run all tests +# "suite_prefix" => run suites matching prefix +# "suite_name.case_name" => run one test case +UNIT_TEST_FILTER ?= + +# Stop execution immediately after the first failing test case. +# 0 = continue running all tests (default), 1 = fail-fast. +UNIT_TEST_FAIL_FAST ?= 0 + +# Repeat each matching test case this many times (>= 1). +UNIT_TEST_REPEAT ?= 1 + +# Seed for deterministic per-test pseudo-random generation. +UNIT_TEST_SEED ?= 0xC0FFEE + +# Host-side unit test timeout (seconds) for QEMU runner. +UNIT_TEST_TIMEOUT ?= 120 diff --git a/kernel/Makefile b/kernel/Makefile index 4c2fbc6..2b8778d 100644 --- a/kernel/Makefile +++ b/kernel/Makefile @@ -65,6 +65,12 @@ CXX_SOURCES += $(shell find percpu -name '*.cpp' 2>/dev/null | sort) CXX_SOURCES += $(shell find syscall -name '*.cpp' 2>/dev/null | sort) CXX_SOURCES += $(shell find arch/$(ARCH) -name '*.cpp' 2>/dev/null | sort) +ifeq ($(UNIT_TEST),1) +CXX_SOURCES += $(shell find test/framework -name '*.cpp' 2>/dev/null | sort) +CXX_SOURCES += $(shell find test/common -name '*.cpp' 2>/dev/null | sort) +CXX_SOURCES += $(shell find test/arch/$(ARCH) -name '*.cpp' 2>/dev/null | sort) +endif + # Find all assembly sources (arch-specific only typically) ASM_SOURCES := $(shell find arch/$(ARCH) -name '*.S' 2>/dev/null | sort) @@ -109,6 +115,14 @@ CXXFLAGS_LOG := -DLOG_LEVEL=$(LOG_LEVEL) # Configuration defines (from config.mk) CXXFLAGS_CONFIG := -DMAX_CPUS=$(MAX_CPUS) +# Unit test config flags (used by framework and boot flow) +CXXFLAGS_TEST := \ + -DUNIT_TEST=$(UNIT_TEST) \ + -DUNIT_TEST_FAIL_FAST=$(UNIT_TEST_FAIL_FAST) \ + -DUNIT_TEST_REPEAT=$(UNIT_TEST_REPEAT) \ + -DUNIT_TEST_SEED=$(UNIT_TEST_SEED) \ + -DUNIT_TEST_FILTER=\"$(UNIT_TEST_FILTER)\" + # Include paths # Note: -Iarch/$(ARCH) allows arch-specific headers to be included with paths # like #include "hw/barrier.h" - the build system picks the right arch directory @@ -124,6 +138,7 @@ CXXFLAGS := \ $(CXXFLAGS_MODE) \ $(CXXFLAGS_LOG) \ $(CXXFLAGS_CONFIG) \ + $(CXXFLAGS_TEST) \ $(CXXFLAGS_INCLUDES) \ $(CXXFLAGS_EXTRA) @@ -159,6 +174,7 @@ ifeq ($(V),1) @echo "CXXFLAGS=$(CXXFLAGS)" @echo "ASFLAGS=$(ASFLAGS)" @echo "LDFLAGS=$(LDFLAGS)" + @echo "UNIT_TEST=$(UNIT_TEST)" @echo "SOURCES=$(CXX_SOURCES) $(ASM_SOURCES)" @echo "" else diff --git a/kernel/arch/aarch64/linker.ld b/kernel/arch/aarch64/linker.ld index 482a833..915fef7 100644 --- a/kernel/arch/aarch64/linker.ld +++ b/kernel/arch/aarch64/linker.ld @@ -34,10 +34,25 @@ SECTIONS { .rodata : AT(ADDR(.rodata) - KERNEL_VADDR) { __rodata_start = .; *(.rodata .rodata.*) + + /* Unit test registration tables (kept/sorted for deterministic order) */ + . = ALIGN(8); + __stlx_test_suites_start = .; + KEEP(*(SORT_BY_NAME(.stlx_test_suites))) + KEEP(*(SORT_BY_NAME(.stlx_test_suites.*))) + __stlx_test_suites_end = .; + + . = ALIGN(8); + __stlx_test_cases_start = .; + KEEP(*(SORT_BY_NAME(.stlx_test_cases))) + KEEP(*(SORT_BY_NAME(.stlx_test_cases.*))) + __stlx_test_cases_end = .; + /* Privileged rodata */ . = ALIGN(4096); __priv_rodata_start = .; *(.priv.rodata .priv.rodata.*) + . = ALIGN(4096); __rodata_end = .; } :rodata diff --git a/kernel/arch/x86_64/linker.ld b/kernel/arch/x86_64/linker.ld index 90b0689..98f0cbd 100644 --- a/kernel/arch/x86_64/linker.ld +++ b/kernel/arch/x86_64/linker.ld @@ -34,10 +34,25 @@ SECTIONS { .rodata : AT(ADDR(.rodata) - KERNEL_VADDR) { __rodata_start = .; *(.rodata .rodata.*) + + /* Unit test registration tables (kept/sorted for deterministic order) */ + . = ALIGN(8); + __stlx_test_suites_start = .; + KEEP(*(SORT_BY_NAME(.stlx_test_suites))) + KEEP(*(SORT_BY_NAME(.stlx_test_suites.*))) + __stlx_test_suites_end = .; + + . = ALIGN(8); + __stlx_test_cases_start = .; + KEEP(*(SORT_BY_NAME(.stlx_test_cases))) + KEEP(*(SORT_BY_NAME(.stlx_test_cases.*))) + __stlx_test_cases_end = .; + /* Privileged rodata */ . = ALIGN(4096); __priv_rodata_start = .; *(.priv.rodata .priv.rodata.*) + . = ALIGN(4096); __rodata_end = .; } :rodata diff --git a/kernel/boot/boot.cpp b/kernel/boot/boot.cpp index 0c1cb1f..c186fce 100644 --- a/kernel/boot/boot.cpp +++ b/kernel/boot/boot.cpp @@ -4,12 +4,19 @@ #include "hw/cpu.h" #include "arch/arch_init.h" #include "mm/mm.h" +#if UNIT_TEST +#include "test/framework/test_runner.h" +#endif /** * @brief Kernel entry point called by bootloader. * @note Privilege: **required** */ extern "C" __PRIVILEGED_CODE void stlx_init() { +#if UNIT_TEST + bool tests_ok = true; +#endif + if (boot_services::init() != boot_services::OK) { cpu::halt(); } @@ -24,10 +31,33 @@ extern "C" __PRIVILEGED_CODE void stlx_init() { log::info("Stellux 3.0 booting..."); +#if UNIT_TEST + if (test::run_phase(test::phase::early) != test::OK) { + tests_ok = false; + } +#endif + if (mm::init() != mm::OK) { log::fatal("mm::init failed"); } +#if UNIT_TEST + if (tests_ok && test::run_phase(test::phase::post_mm) != test::OK) { + tests_ok = false; + } + + test::print_summary(); + if (tests_ok && test::all_passed()) { + log::info("[TEST_RESULT] PASS"); + } else { + log::error("[TEST_RESULT] FAIL"); + } + + while (true) { + cpu::halt(); + } +#endif + log::debug("Initialization complete! Halting..."); while (true) { cpu::halt(); diff --git a/kernel/test/framework/test_framework.h b/kernel/test/framework/test_framework.h new file mode 100644 index 0000000..f39149b --- /dev/null +++ b/kernel/test/framework/test_framework.h @@ -0,0 +1,219 @@ +#ifndef STELLUX_TEST_FRAMEWORK_TEST_FRAMEWORK_H +#define STELLUX_TEST_FRAMEWORK_TEST_FRAMEWORK_H + +#include "test_runner.h" +#include "common/types.h" + +namespace test { + +inline bool cstr_equal(const char* a, const char* b) { + if (a == b) { + return true; + } + if (a == nullptr || b == nullptr) { + return false; + } + + while (*a && *b) { + if (*a != *b) { + return false; + } + ++a; + ++b; + } + return *a == '\0' && *b == '\0'; +} + +template +inline uint64_t to_u64(const T& value) { + if constexpr (__is_pointer(T)) { + return reinterpret_cast(value); + } else { + return static_cast(value); + } +} + +inline bool expect_true( + context& ctx, + bool cond, + const char* expr, + const char* file, + uint32_t line +) { + if (cond) { + return true; + } + fail_check(ctx, file, line, expr); + return false; +} + +inline bool expect_false( + context& ctx, + bool cond, + const char* expr, + const char* file, + uint32_t line +) { + if (!cond) { + return true; + } + fail_message(ctx, file, line, expr); + return false; +} + +template +inline bool expect_eq( + context& ctx, + const L& lhs, + const R& rhs, + const char* lhs_expr, + const char* rhs_expr, + const char* file, + uint32_t line +) { + if (lhs == rhs) { + return true; + } + fail_check_values(ctx, file, line, lhs_expr, rhs_expr, to_u64(lhs), to_u64(rhs)); + return false; +} + +template +inline bool expect_ne( + context& ctx, + const L& lhs, + const R& rhs, + const char* lhs_expr, + const char* rhs_expr, + const char* file, + uint32_t line +) { + if (lhs != rhs) { + return true; + } + fail_message(ctx, file, line, "expected values to differ"); + fail_check_values(ctx, file, line, lhs_expr, rhs_expr, to_u64(lhs), to_u64(rhs)); + return false; +} + +inline bool expect_streq( + context& ctx, + const char* lhs, + const char* rhs, + const char* lhs_expr, + const char* rhs_expr, + const char* file, + uint32_t line +) { + if (cstr_equal(lhs, rhs)) { + return true; + } + fail_check_strings(ctx, file, line, lhs_expr, rhs_expr, lhs, rhs); + return false; +} + +} // namespace test + +#define STLX_TEST_SUITE_EX(suite_ident, suite_phase, before_hook, after_hook) \ + namespace { \ + __attribute__((used, section(".stlx_test_suites." #suite_ident))) \ + const ::test::suite_desc stlx_test_suite_##suite_ident = { \ + .abi_version = ::test::ABI_VERSION, \ + .name = #suite_ident, \ + .run_phase = (suite_phase), \ + .before_each = (before_hook), \ + .after_each = (after_hook), \ + }; \ + } + +#define STLX_TEST_SUITE(suite_ident, suite_phase) \ + STLX_TEST_SUITE_EX(suite_ident, suite_phase, nullptr, nullptr) + +#define STLX_TEST(suite_ident, case_ident) \ + static __PRIVILEGED_CODE void stlx_test_case_##suite_ident##_##case_ident(::test::context& ctx); \ + namespace { \ + __attribute__((used, section(".stlx_test_cases." #suite_ident "." #case_ident))) \ + const ::test::case_desc stlx_test_case_desc_##suite_ident##_##case_ident = { \ + .abi_version = ::test::ABI_VERSION, \ + .suite = &stlx_test_suite_##suite_ident, \ + .name = #case_ident, \ + .body = stlx_test_case_##suite_ident##_##case_ident, \ + }; \ + } \ + static __PRIVILEGED_CODE void stlx_test_case_##suite_ident##_##case_ident(::test::context& ctx) + +#define STLX_EXPECT_TRUE(ctx, expr) \ + ::test::expect_true((ctx), static_cast(expr), #expr, __FILE__, static_cast(__LINE__)) + +#define STLX_EXPECT_FALSE(ctx, expr) \ + ::test::expect_false((ctx), static_cast(expr), #expr, __FILE__, static_cast(__LINE__)) + +#define STLX_EXPECT_EQ(ctx, lhs, rhs) \ + ::test::expect_eq((ctx), (lhs), (rhs), #lhs, #rhs, __FILE__, static_cast(__LINE__)) + +#define STLX_EXPECT_NE(ctx, lhs, rhs) \ + ::test::expect_ne((ctx), (lhs), (rhs), #lhs, #rhs, __FILE__, static_cast(__LINE__)) + +#define STLX_EXPECT_NULL(ctx, value) \ + STLX_EXPECT_EQ((ctx), (value), nullptr) + +#define STLX_EXPECT_NOT_NULL(ctx, value) \ + STLX_EXPECT_NE((ctx), (value), nullptr) + +#define STLX_EXPECT_STREQ(ctx, lhs, rhs) \ + ::test::expect_streq((ctx), (lhs), (rhs), #lhs, #rhs, __FILE__, static_cast(__LINE__)) + +#define STLX_ASSERT_TRUE(ctx, expr) \ + do { \ + if (!STLX_EXPECT_TRUE((ctx), (expr))) { \ + ::test::abort_case((ctx)); \ + return; \ + } \ + } while (0) + +#define STLX_ASSERT_FALSE(ctx, expr) \ + do { \ + if (!STLX_EXPECT_FALSE((ctx), (expr))) { \ + ::test::abort_case((ctx)); \ + return; \ + } \ + } while (0) + +#define STLX_ASSERT_EQ(ctx, lhs, rhs) \ + do { \ + if (!STLX_EXPECT_EQ((ctx), (lhs), (rhs))) { \ + ::test::abort_case((ctx)); \ + return; \ + } \ + } while (0) + +#define STLX_ASSERT_NE(ctx, lhs, rhs) \ + do { \ + if (!STLX_EXPECT_NE((ctx), (lhs), (rhs))) { \ + ::test::abort_case((ctx)); \ + return; \ + } \ + } while (0) + +#define STLX_ASSERT_STREQ(ctx, lhs, rhs) \ + do { \ + if (!STLX_EXPECT_STREQ((ctx), (lhs), (rhs))) { \ + ::test::abort_case((ctx)); \ + return; \ + } \ + } while (0) + +#define STLX_FAIL(ctx, message) \ + do { \ + ::test::fail_message((ctx), __FILE__, static_cast(__LINE__), (message)); \ + ::test::abort_case((ctx)); \ + return; \ + } while (0) + +#define STLX_SKIP(ctx, reason) \ + do { \ + ::test::skip_case((ctx), (reason)); \ + return; \ + } while (0) + +#endif // STELLUX_TEST_FRAMEWORK_TEST_FRAMEWORK_H diff --git a/kernel/test/framework/test_registry.h b/kernel/test/framework/test_registry.h new file mode 100644 index 0000000..55c080f --- /dev/null +++ b/kernel/test/framework/test_registry.h @@ -0,0 +1,37 @@ +#ifndef STELLUX_TEST_FRAMEWORK_TEST_REGISTRY_H +#define STELLUX_TEST_FRAMEWORK_TEST_REGISTRY_H + +#include "common/types.h" + +namespace test { + +constexpr uint32_t ABI_VERSION = 1; + +enum class phase : uint8_t { + early = 0, + post_mm = 1, +}; + +struct context; + +using test_body_fn = void (*)(context& ctx); +using suite_hook_fn = void (*)(context& ctx); + +struct suite_desc { + uint32_t abi_version; + const char* name; + phase run_phase; + suite_hook_fn before_each; + suite_hook_fn after_each; +}; + +struct case_desc { + uint32_t abi_version; + const suite_desc* suite; + const char* name; + test_body_fn body; +}; + +} // namespace test + +#endif // STELLUX_TEST_FRAMEWORK_TEST_REGISTRY_H diff --git a/kernel/test/framework/test_runner.cpp b/kernel/test/framework/test_runner.cpp new file mode 100644 index 0000000..fc53f98 --- /dev/null +++ b/kernel/test/framework/test_runner.cpp @@ -0,0 +1,517 @@ +#include "test/framework/test_runner.h" +#include "common/logging.h" + +#ifndef UNIT_TEST_FILTER +#define UNIT_TEST_FILTER "" +#endif + +#ifndef UNIT_TEST_FAIL_FAST +#define UNIT_TEST_FAIL_FAST 0 +#endif + +#ifndef UNIT_TEST_REPEAT +#define UNIT_TEST_REPEAT 1 +#endif + +#ifndef UNIT_TEST_SEED +#define UNIT_TEST_SEED 0xC0FFEE +#endif + +extern "C" { +extern const test::suite_desc __stlx_test_suites_start[]; +extern const test::suite_desc __stlx_test_suites_end[]; +extern const test::case_desc __stlx_test_cases_start[]; +extern const test::case_desc __stlx_test_cases_end[]; +} + +namespace test { + +static summary g_summary = {}; +static bool g_registry_checked = false; +static bool g_registry_valid = false; +static bool g_any_failure = false; + +static const char* k_filter = UNIT_TEST_FILTER; +static constexpr bool k_fail_fast = UNIT_TEST_FAIL_FAST != 0; +static constexpr uint64_t k_seed = static_cast(UNIT_TEST_SEED); + +static uint32_t repeat_count() { + uint32_t repeat = static_cast(UNIT_TEST_REPEAT); + if (repeat == 0) { + repeat = 1; + } + return repeat; +} + +static size_t suite_count() { + uintptr_t start = reinterpret_cast(__stlx_test_suites_start); + uintptr_t end = reinterpret_cast(__stlx_test_suites_end); + if (end <= start) { + return 0; + } + return (end - start) / sizeof(suite_desc); +} + +static size_t case_count() { + uintptr_t start = reinterpret_cast(__stlx_test_cases_start); + uintptr_t end = reinterpret_cast(__stlx_test_cases_end); + if (end <= start) { + return 0; + } + return (end - start) / sizeof(case_desc); +} + +static bool str_empty(const char* s) { + return s == nullptr || s[0] == '\0'; +} + +static bool str_eq(const char* a, const char* b) { + if (a == b) { + return true; + } + if (a == nullptr || b == nullptr) { + return false; + } + while (*a && *b) { + if (*a != *b) { + return false; + } + ++a; + ++b; + } + return *a == '\0' && *b == '\0'; +} + +static bool str_eq_n(const char* a, const char* b, size_t n) { + if (a == nullptr || b == nullptr) { + return false; + } + for (size_t i = 0; i < n; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; +} + +static bool str_starts_with(const char* str, const char* prefix) { + if (str == nullptr || prefix == nullptr) { + return false; + } + while (*prefix) { + if (*str == '\0' || *str != *prefix) { + return false; + } + ++str; + ++prefix; + } + return true; +} + +static const char* find_char(const char* s, char c) { + if (s == nullptr) { + return nullptr; + } + while (*s) { + if (*s == c) { + return s; + } + ++s; + } + return nullptr; +} + +static bool suite_known(const suite_desc* suite) { + size_t suites = suite_count(); + for (size_t i = 0; i < suites; i++) { + if (&__stlx_test_suites_start[i] == suite) { + return true; + } + } + return false; +} + +static bool filter_match(const char* suite_name, const char* case_name) { + if (str_empty(k_filter)) { + return true; + } + + const char* dot = find_char(k_filter, '.'); + if (dot == nullptr) { + return str_starts_with(suite_name, k_filter); + } + + size_t suite_len = static_cast(dot - k_filter); + if (!str_eq_n(suite_name, k_filter, suite_len) || suite_name[suite_len] != '\0') { + return false; + } + + const char* case_filter = dot + 1; + if (case_filter[0] == '\0') { + return true; + } + return str_eq(case_name, case_filter); +} + +static const char* phase_name(phase p) { + switch (p) { + case phase::early: return "early"; + case phase::post_mm: return "post_mm"; + default: return "unknown"; + } +} + +static uint64_t hash_cstr(const char* s, uint64_t seed) { + uint64_t h = seed ^ 0xcbf29ce484222325ULL; + if (s == nullptr) { + return h; + } + while (*s) { + h ^= static_cast(*s); + h *= 0x100000001b3ULL; + ++s; + } + return h; +} + +static uint64_t derive_seed(const char* suite_name, const char* case_name, uint32_t iteration) { + uint64_t h = hash_cstr(suite_name, k_seed); + h = hash_cstr(case_name, h); + h ^= static_cast(iteration) * 0x9e3779b97f4a7c15ULL; + if (h == 0) { + h = 0x9e3779b97f4a7c15ULL; + } + return h; +} + +static int32_t validate_registry() { + size_t suites = suite_count(); + size_t cases = case_count(); + + for (size_t i = 0; i < suites; i++) { + const suite_desc& suite = __stlx_test_suites_start[i]; + + if (suite.abi_version != ABI_VERSION) { + log::error("test registry: suite '%s' ABI mismatch (%u != %u)", + suite.name ? suite.name : "(null)", + suite.abi_version, + ABI_VERSION); + return ERR_INVALID_REGISTRY; + } + if (str_empty(suite.name)) { + log::error("test registry: suite[%lu] has empty name", i); + return ERR_INVALID_REGISTRY; + } + } + + for (size_t i = 0; i < suites; i++) { + const suite_desc& a = __stlx_test_suites_start[i]; + for (size_t j = i + 1; j < suites; j++) { + const suite_desc& b = __stlx_test_suites_start[j]; + if (str_eq(a.name, b.name)) { + log::error("test registry: duplicate suite name '%s'", a.name); + return ERR_INVALID_REGISTRY; + } + } + } + + for (size_t i = 0; i < cases; i++) { + const case_desc& test_case = __stlx_test_cases_start[i]; + + if (test_case.abi_version != ABI_VERSION) { + log::error("test registry: case '%s' ABI mismatch (%u != %u)", + test_case.name ? test_case.name : "(null)", + test_case.abi_version, + ABI_VERSION); + return ERR_INVALID_REGISTRY; + } + if (!suite_known(test_case.suite)) { + log::error("test registry: case '%s' references unknown suite", + test_case.name ? test_case.name : "(null)"); + return ERR_INVALID_REGISTRY; + } + if (str_empty(test_case.name)) { + log::error("test registry: case[%lu] has empty name", i); + return ERR_INVALID_REGISTRY; + } + if (test_case.body == nullptr) { + log::error("test registry: case '%s' has null body", test_case.name); + return ERR_INVALID_REGISTRY; + } + } + + for (size_t i = 0; i < cases; i++) { + const case_desc& a = __stlx_test_cases_start[i]; + for (size_t j = i + 1; j < cases; j++) { + const case_desc& b = __stlx_test_cases_start[j]; + if (a.suite == b.suite && str_eq(a.name, b.name)) { + log::error("test registry: duplicate case name '%s.%s'", + a.suite ? a.suite->name : "(null)", + a.name); + return ERR_INVALID_REGISTRY; + } + } + } + + if (cases == 0) { + log::warn("test registry: no test cases registered"); + } + + return OK; +} + +static bool suite_has_matching_cases(const suite_desc* suite, phase run_phase) { + size_t cases = case_count(); + for (size_t i = 0; i < cases; i++) { + const case_desc& test_case = __stlx_test_cases_start[i]; + if (test_case.suite != suite) { + continue; + } + if (suite->run_phase != run_phase) { + continue; + } + if (!filter_match(suite->name, test_case.name)) { + continue; + } + return true; + } + return false; +} + +static void maybe_validate_registry() { + if (g_registry_checked) { + return; + } + g_registry_valid = (validate_registry() == OK); + g_registry_checked = true; +} + +int32_t run_phase(phase run_phase) { + maybe_validate_registry(); + if (!g_registry_valid) { + return ERR_INVALID_REGISTRY; + } + + size_t suites = suite_count(); + size_t cases = case_count(); + uint32_t repeats = repeat_count(); + + uint32_t phase_suite_start = g_summary.suites_executed; + uint32_t phase_case_start = g_summary.cases_executed; + uint32_t phase_pass_start = g_summary.cases_passed; + uint32_t phase_fail_start = g_summary.cases_failed; + uint32_t phase_skip_start = g_summary.cases_skipped; + + log::info("[TEST_PHASE_BEGIN] %s", phase_name(run_phase)); + + for (size_t si = 0; si < suites; si++) { + const suite_desc& suite = __stlx_test_suites_start[si]; + if (suite.run_phase != run_phase) { + continue; + } + if (!suite_has_matching_cases(&suite, run_phase)) { + continue; + } + + g_summary.suites_executed++; + log::info("[TEST_SUITE_BEGIN] %s", suite.name); + + for (size_t ci = 0; ci < cases; ci++) { + const case_desc& test_case = __stlx_test_cases_start[ci]; + if (test_case.suite != &suite) { + continue; + } + if (!filter_match(suite.name, test_case.name)) { + continue; + } + + for (uint32_t iter = 0; iter < repeats; iter++) { + context ctx = { + .suite_name = suite.name, + .case_name = test_case.name, + .seed = derive_seed(suite.name, test_case.name, iter), + .prng_state = derive_seed(suite.name, test_case.name, iter), + .iteration = iter, + .expectation_failures = 0, + .aborted = false, + .skipped = false, + .skip_reason = nullptr, + }; + + if (suite.before_each) { + suite.before_each(ctx); + } + + test_case.body(ctx); + + if (suite.after_each) { + suite.after_each(ctx); + } + + g_summary.cases_executed++; + g_summary.expectation_failures += ctx.expectation_failures; + + if (ctx.skipped) { + g_summary.cases_skipped++; + log::warn("[TEST_CASE_SKIP] %s.%s[%u] reason=%s", + suite.name, + test_case.name, + iter, + ctx.skip_reason ? ctx.skip_reason : "(no reason)"); + continue; + } + + if (ctx.aborted || ctx.expectation_failures > 0) { + g_summary.cases_failed++; + g_any_failure = true; + log::error("[TEST_CASE_FAIL] %s.%s[%u] expectations=%u aborted=%u seed=0x%lx", + suite.name, + test_case.name, + iter, + ctx.expectation_failures, + ctx.aborted ? 1 : 0, + ctx.seed); + + if (k_fail_fast) { + log::error("[TEST_PHASE_ABORT] %s fail-fast triggered", phase_name(run_phase)); + log::info("[TEST_PHASE_END] %s suites=%u cases=%u passed=%u failed=%u skipped=%u", + phase_name(run_phase), + g_summary.suites_executed - phase_suite_start, + g_summary.cases_executed - phase_case_start, + g_summary.cases_passed - phase_pass_start, + g_summary.cases_failed - phase_fail_start, + g_summary.cases_skipped - phase_skip_start); + return ERR_FAILED; + } + } else { + g_summary.cases_passed++; + log::info("[TEST_CASE_PASS] %s.%s[%u]", + suite.name, + test_case.name, + iter); + } + } + } + } + + log::info("[TEST_PHASE_END] %s suites=%u cases=%u passed=%u failed=%u skipped=%u", + phase_name(run_phase), + g_summary.suites_executed - phase_suite_start, + g_summary.cases_executed - phase_case_start, + g_summary.cases_passed - phase_pass_start, + g_summary.cases_failed - phase_fail_start, + g_summary.cases_skipped - phase_skip_start); + + return OK; +} + +void print_summary() { + maybe_validate_registry(); + log::info("[TEST_SUMMARY] suites=%u cases=%u passed=%u failed=%u skipped=%u expectations=%u", + g_summary.suites_executed, + g_summary.cases_executed, + g_summary.cases_passed, + g_summary.cases_failed, + g_summary.cases_skipped, + g_summary.expectation_failures); +} + +bool all_passed() { + maybe_validate_registry(); + return g_registry_valid && !g_any_failure && g_summary.cases_failed == 0; +} + +void fail_check(context& ctx, const char* file, uint32_t line, const char* check) { + ctx.expectation_failures++; + log::error("[TEST_EXPECT] %s.%s[%u] %s:%u check failed: %s", + ctx.suite_name, + ctx.case_name, + ctx.iteration, + file, + line, + check); +} + +void fail_check_values( + context& ctx, + const char* file, + uint32_t line, + const char* lhs_expr, + const char* rhs_expr, + uint64_t lhs, + uint64_t rhs +) { + ctx.expectation_failures++; + log::error("[TEST_EXPECT] %s.%s[%u] %s:%u expected %s == %s (lhs=0x%lx rhs=0x%lx)", + ctx.suite_name, + ctx.case_name, + ctx.iteration, + file, + line, + lhs_expr, + rhs_expr, + lhs, + rhs); +} + +void fail_check_strings( + context& ctx, + const char* file, + uint32_t line, + const char* lhs_expr, + const char* rhs_expr, + const char* lhs, + const char* rhs +) { + ctx.expectation_failures++; + log::error("[TEST_EXPECT] %s.%s[%u] %s:%u expected %s == %s (lhs=\"%s\" rhs=\"%s\")", + ctx.suite_name, + ctx.case_name, + ctx.iteration, + file, + line, + lhs_expr, + rhs_expr, + lhs ? lhs : "(null)", + rhs ? rhs : "(null)"); +} + +void fail_message(context& ctx, const char* file, uint32_t line, const char* message) { + ctx.expectation_failures++; + log::error("[TEST_EXPECT] %s.%s[%u] %s:%u %s", + ctx.suite_name, + ctx.case_name, + ctx.iteration, + file, + line, + message ? message : "(null)"); +} + +void abort_case(context& ctx) { + ctx.aborted = true; +} + +void skip_case(context& ctx, const char* reason) { + ctx.skipped = true; + ctx.skip_reason = reason; +} + +uint64_t random_next_u64(context& ctx) { + uint64_t x = ctx.prng_state; + if (x == 0) { + x = 0x9e3779b97f4a7c15ULL; + } + + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + + ctx.prng_state = x; + return x * 0x2545f4914f6cdd1dULL; +} + +uint64_t context_seed(const context& ctx) { + return ctx.seed; +} + +} // namespace test diff --git a/kernel/test/framework/test_runner.h b/kernel/test/framework/test_runner.h new file mode 100644 index 0000000..4575503 --- /dev/null +++ b/kernel/test/framework/test_runner.h @@ -0,0 +1,67 @@ +#ifndef STELLUX_TEST_FRAMEWORK_TEST_RUNNER_H +#define STELLUX_TEST_FRAMEWORK_TEST_RUNNER_H + +#include "test_registry.h" +#include "common/types.h" + +namespace test { + +constexpr int32_t OK = 0; +constexpr int32_t ERR_FAILED = -1; +constexpr int32_t ERR_INVALID_REGISTRY = -2; + +struct context { + const char* suite_name; + const char* case_name; + uint64_t seed; + uint64_t prng_state; + uint32_t iteration; + uint32_t expectation_failures; + bool aborted; + bool skipped; + const char* skip_reason; +}; + +struct summary { + uint32_t suites_executed; + uint32_t cases_executed; + uint32_t cases_passed; + uint32_t cases_failed; + uint32_t cases_skipped; + uint32_t expectation_failures; +}; + +int32_t run_phase(phase run_phase); +void print_summary(); +bool all_passed(); + +void fail_check(context& ctx, const char* file, uint32_t line, const char* check); +void fail_check_values( + context& ctx, + const char* file, + uint32_t line, + const char* lhs_expr, + const char* rhs_expr, + uint64_t lhs, + uint64_t rhs +); +void fail_check_strings( + context& ctx, + const char* file, + uint32_t line, + const char* lhs_expr, + const char* rhs_expr, + const char* lhs, + const char* rhs +); +void fail_message(context& ctx, const char* file, uint32_t line, const char* message); + +void abort_case(context& ctx); +void skip_case(context& ctx, const char* reason); + +uint64_t random_next_u64(context& ctx); +uint64_t context_seed(const context& ctx); + +} // namespace test + +#endif // STELLUX_TEST_FRAMEWORK_TEST_RUNNER_H diff --git a/kernel/test/framework/test_utils.h b/kernel/test/framework/test_utils.h new file mode 100644 index 0000000..a7f4912 --- /dev/null +++ b/kernel/test/framework/test_utils.h @@ -0,0 +1,26 @@ +#ifndef STELLUX_TEST_FRAMEWORK_TEST_UTILS_H +#define STELLUX_TEST_FRAMEWORK_TEST_UTILS_H + +#include "test_runner.h" + +namespace test { + +inline uint64_t rand_u64(context& ctx) { + return random_next_u64(ctx); +} + +inline uint32_t rand_u32(context& ctx) { + return static_cast(random_next_u64(ctx) & 0xFFFFFFFFU); +} + +inline uint64_t rand_range(context& ctx, uint64_t min, uint64_t max) { + if (max <= min) { + return min; + } + uint64_t span = max - min + 1; + return min + (random_next_u64(ctx) % span); +} + +} // namespace test + +#endif // STELLUX_TEST_FRAMEWORK_TEST_UTILS_H diff --git a/scripts/run-unit-tests.py b/scripts/run-unit-tests.py new file mode 100755 index 0000000..f9faac8 --- /dev/null +++ b/scripts/run-unit-tests.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +Run Stellux in-kernel unit tests in QEMU and parse serial output. +""" + +from __future__ import annotations + +import argparse +import os +import select +import signal +import subprocess +import sys +import time +from typing import Optional + + +PASS_MARKER = "[TEST_RESULT] PASS" +FAIL_MARKER = "[TEST_RESULT] FAIL" + + +def terminate_process_group(proc: subprocess.Popen[str], grace_seconds: float = 3.0) -> None: + if proc.poll() is not None: + return + + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + return + + deadline = time.monotonic() + grace_seconds + while time.monotonic() < deadline: + if proc.poll() is not None: + return + time.sleep(0.05) + + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def build_command(args: argparse.Namespace) -> list[str]: + build_dir = f"build/unit-tests-{args.arch}" + image_dir = f"images/unit-tests-{args.arch}" + + return [ + "make", + "run-headless", + f"ARCH={args.arch}", + f"BUILD_DIR={build_dir}", + f"IMAGE_DIR={image_dir}", + "UNIT_TEST=1", + f"UNIT_TEST_FILTER={args.filter}", + f"UNIT_TEST_FAIL_FAST={args.fail_fast}", + f"UNIT_TEST_REPEAT={args.repeat}", + f"UNIT_TEST_SEED={args.seed}", + ] + + +def run(args: argparse.Namespace) -> int: + cmd = build_command(args) + print(f"[HOST_TEST] command: {' '.join(cmd)}", flush=True) + print(f"[HOST_TEST] timeout: {args.timeout}s", flush=True) + + proc = subprocess.Popen( + cmd, + cwd=args.workspace, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=False, + bufsize=0, + preexec_fn=os.setsid, + ) + + assert proc.stdout is not None + stdout_fd = proc.stdout.fileno() + + start = time.monotonic() + result: Optional[bool] = None + collected: list[str] = [] + tail = "" + + while True: + if time.monotonic() - start > args.timeout: + print("[HOST_TEST] timeout exceeded", flush=True) + terminate_process_group(proc) + result = False + break + + ready, _, _ = select.select([stdout_fd], [], [], 0.2) + if ready: + chunk = os.read(stdout_fd, 4096) + if chunk: + text = chunk.decode("utf-8", errors="replace") + collected.append(text) + print(text, end="", flush=True) + + tail = (tail + text)[-8192:] + if PASS_MARKER in tail: + print("[HOST_TEST] PASS marker detected", flush=True) + terminate_process_group(proc) + result = True + break + if FAIL_MARKER in tail: + print("[HOST_TEST] FAIL marker detected", flush=True) + terminate_process_group(proc) + result = False + break + continue + + if proc.poll() is not None: + break + + if result is None: + rc = proc.wait(timeout=5) + if rc != 0: + print(f"[HOST_TEST] process exited with rc={rc}", flush=True) + result = False + else: + print("[HOST_TEST] process exited without PASS/FAIL marker", flush=True) + result = False + else: + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + terminate_process_group(proc) + + if args.log_file: + os.makedirs(os.path.dirname(args.log_file), exist_ok=True) + with open(args.log_file, "w", encoding="utf-8") as f: + f.writelines(collected) + print(f"[HOST_TEST] wrote log: {args.log_file}", flush=True) + + if result: + print("[HOST_TEST] unit tests passed", flush=True) + return 0 + + print("[HOST_TEST] unit tests failed", flush=True) + return 1 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Stellux in-kernel unit tests") + parser.add_argument("--arch", choices=("x86_64", "aarch64"), required=True) + parser.add_argument("--filter", default="", help="Suite prefix or suite.case filter") + parser.add_argument("--fail-fast", type=int, default=0, choices=(0, 1)) + parser.add_argument("--repeat", type=int, default=1) + parser.add_argument("--seed", default="0xC0FFEE") + parser.add_argument("--timeout", type=int, default=120) + parser.add_argument("--workspace", default=".") + parser.add_argument("--log-file", default="") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.repeat < 1: + print("[HOST_TEST] repeat must be >= 1", flush=True) + return 2 + if args.timeout < 1: + print("[HOST_TEST] timeout must be >= 1", flush=True) + return 2 + return run(args) + + +if __name__ == "__main__": + sys.exit(main()) From 0cfa7929bf2d5da4ad7a55fb72637ce77e929acc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 07:16:27 +0000 Subject: [PATCH 4/6] Add core rb_tree, string, and logging unit tests Co-authored-by: Albert Slepak --- kernel/common/string.cpp | 12 ++ kernel/test/common/logging_tests.cpp | 85 ++++++++++++ kernel/test/common/rb_tree_tests.cpp | 178 +++++++++++++++++++++++++ kernel/test/common/string_tests.cpp | 61 +++++++++ kernel/test/framework/test_framework.h | 20 +++ 5 files changed, 356 insertions(+) create mode 100644 kernel/test/common/logging_tests.cpp create mode 100644 kernel/test/common/rb_tree_tests.cpp create mode 100644 kernel/test/common/string_tests.cpp diff --git a/kernel/common/string.cpp b/kernel/common/string.cpp index 264986d..1425aa4 100644 --- a/kernel/common/string.cpp +++ b/kernel/common/string.cpp @@ -39,3 +39,15 @@ int memcmp(const void* s1, const void* s2, size_t n) { } } // namespace string + +extern "C" void* memcpy(void* dest, const void* src, size_t n) { + return string::memcpy(dest, src, n); +} + +extern "C" void* memset(void* dest, int c, size_t n) { + return string::memset(dest, c, n); +} + +extern "C" int memcmp(const void* s1, const void* s2, size_t n) { + return string::memcmp(s1, s2, n); +} diff --git a/kernel/test/common/logging_tests.cpp b/kernel/test/common/logging_tests.cpp new file mode 100644 index 0000000..b16e9b1 --- /dev/null +++ b/kernel/test/common/logging_tests.cpp @@ -0,0 +1,85 @@ +#include "test/framework/test_framework.h" +#include "common/logging.h" + +namespace { + +constexpr size_t CAPTURE_CAPACITY = 4096; +char g_capture[CAPTURE_CAPACITY] = {}; +size_t g_capture_len = 0; + +void capture_reset() { + for (size_t i = 0; i < CAPTURE_CAPACITY; i++) { + g_capture[i] = '\0'; + } + g_capture_len = 0; +} + +void capture_write(const char* data, size_t len) { + for (size_t i = 0; i < len; i++) { + if (g_capture_len + 1 >= CAPTURE_CAPACITY) { + break; + } + g_capture[g_capture_len++] = data[i]; + } + g_capture[g_capture_len] = '\0'; +} + +bool capture_contains(const char* needle) { + if (needle == nullptr || needle[0] == '\0') { + return true; + } + + for (size_t i = 0; g_capture[i] != '\0'; i++) { + size_t j = 0; + while (needle[j] != '\0' && g_capture[i + j] != '\0' && g_capture[i + j] == needle[j]) { + j++; + } + if (needle[j] == '\0') { + return true; + } + } + return false; +} + +const log::backend g_capture_backend = { + .write = capture_write +}; + +__PRIVILEGED_CODE void logging_before_each(test::context&) { + capture_reset(); + log::set_backend(&g_capture_backend); +} + +__PRIVILEGED_CODE void logging_after_each(test::context&) { + log::set_backend(nullptr); +} + +} // namespace + +STLX_TEST_SUITE_EX(core_logging, test::phase::early, logging_before_each, logging_after_each); + +STLX_TEST(core_logging, info_prefix_and_integer_format) { + log::info("value=%u", 42U); + STLX_ASSERT_TRUE(ctx, capture_contains("[INFO] value=42\r\n")); +} + +STLX_TEST(core_logging, debug_hex_width_format) { + log::debug("hex=%08x", 0x1a2b); + STLX_ASSERT_TRUE(ctx, capture_contains("[DEBUG] hex=00001a2b\r\n")); +} + +STLX_TEST(core_logging, string_precision_and_null_string) { + log::info("s=%.3s null=%s", "abcdef", static_cast(nullptr)); + STLX_ASSERT_TRUE(ctx, capture_contains("s=abc null=(null)\r\n")); +} + +STLX_TEST(core_logging, percent_escape_is_rendered_once) { + log::warn("progress=100%%"); + STLX_ASSERT_TRUE(ctx, capture_contains("[WARN] progress=100%\r\n")); +} + +STLX_TEST(core_logging, pointer_format_has_0x_prefix) { + int local = 0; + log::info("ptr=%p", &local); + STLX_ASSERT_TRUE(ctx, capture_contains("[INFO] ptr=0x")); +} diff --git a/kernel/test/common/rb_tree_tests.cpp b/kernel/test/common/rb_tree_tests.cpp new file mode 100644 index 0000000..3bea27c --- /dev/null +++ b/kernel/test/common/rb_tree_tests.cpp @@ -0,0 +1,178 @@ +#include "test/framework/test_framework.h" +#include "test/framework/test_utils.h" +#include "common/rb_tree.h" + +namespace { + +struct rb_item { + uint64_t key; + rbt::node link; +}; + +struct rb_item_cmp { + bool operator()(const rb_item& a, const rb_item& b) const { + return a.key < b.key; + } +}; + +using rb_tree = rbt::tree; + +rb_item make_probe(uint64_t key) { + return rb_item{.key = key, .link = {}}; +} + +size_t count_present(const bool* present, size_t n) { + size_t count = 0; + for (size_t i = 0; i < n; i++) { + if (present[i]) { + count++; + } + } + return count; +} + +} // namespace + +STLX_TEST_SUITE(core_rb_tree, test::phase::early); + +STLX_TEST(core_rb_tree, insert_find_and_iteration_order) { + rb_tree tree; + rb_item items[] = { + {.key = 7, .link = {}}, + {.key = 3, .link = {}}, + {.key = 9, .link = {}}, + {.key = 1, .link = {}}, + {.key = 8, .link = {}}, + {.key = 2, .link = {}}, + {.key = 5, .link = {}}, + {.key = 6, .link = {}}, + {.key = 4, .link = {}}, + }; + + for (size_t i = 0; i < sizeof(items) / sizeof(items[0]); i++) { + STLX_ASSERT_TRUE(ctx, tree.insert(&items[i])); + STLX_ASSERT_TRUE(ctx, tree.validate()); + } + + STLX_ASSERT_EQ(ctx, tree.size(), static_cast(9)); + + for (uint64_t key = 1; key <= 9; key++) { + rb_item probe = make_probe(key); + rb_item* found = tree.find(probe); + STLX_ASSERT_NOT_NULL(ctx, found); + STLX_ASSERT_EQ(ctx, found->key, key); + } + + uint64_t expected = 1; + for (auto it = tree.begin(); it != tree.end(); ++it) { + STLX_ASSERT_EQ(ctx, it->key, expected); + expected++; + } + STLX_ASSERT_EQ(ctx, expected, static_cast(10)); + STLX_ASSERT_TRUE(ctx, tree.validate()); +} + +STLX_TEST(core_rb_tree, duplicate_keys_are_rejected) { + rb_tree tree; + + rb_item a{.key = 42, .link = {}}; + rb_item b{.key = 42, .link = {}}; + + STLX_ASSERT_TRUE(ctx, tree.insert(&a)); + STLX_ASSERT_FALSE(ctx, tree.insert(&b)); + STLX_ASSERT_EQ(ctx, tree.size(), static_cast(1)); + STLX_ASSERT_TRUE(ctx, tree.validate()); +} + +STLX_TEST(core_rb_tree, remove_and_bounds_queries) { + rb_tree tree; + rb_item items[16] = {}; + + for (size_t i = 0; i < 16; i++) { + items[i].key = i; + STLX_ASSERT_TRUE(ctx, tree.insert(&items[i])); + } + STLX_ASSERT_TRUE(ctx, tree.validate()); + + // Remove odd keys. + for (size_t i = 1; i < 16; i += 2) { + tree.remove(items[i]); + STLX_ASSERT_TRUE(ctx, tree.validate()); + } + + STLX_ASSERT_EQ(ctx, tree.size(), static_cast(8)); + + for (uint64_t key = 0; key < 16; key++) { + rb_item probe = make_probe(key); + rb_item* found = tree.find(probe); + if ((key & 1) == 0) { + STLX_ASSERT_NOT_NULL(ctx, found); + STLX_ASSERT_EQ(ctx, found->key, key); + } else { + STLX_ASSERT_NULL(ctx, found); + } + } + + rb_item probe7 = make_probe(7); + rb_item* lb7 = tree.lower_bound(probe7); + STLX_ASSERT_NOT_NULL(ctx, lb7); + STLX_ASSERT_EQ(ctx, lb7->key, static_cast(8)); + + rb_item probe8 = make_probe(8); + rb_item* ub8 = tree.upper_bound(probe8); + STLX_ASSERT_NOT_NULL(ctx, ub8); + STLX_ASSERT_EQ(ctx, ub8->key, static_cast(10)); + + rb_item* at6 = tree.find(make_probe(6)); + STLX_ASSERT_NOT_NULL(ctx, at6); + rb_item* next = tree.next(*at6); + rb_item* prev = tree.prev(*at6); + STLX_ASSERT_NOT_NULL(ctx, next); + STLX_ASSERT_NOT_NULL(ctx, prev); + STLX_ASSERT_EQ(ctx, next->key, static_cast(8)); + STLX_ASSERT_EQ(ctx, prev->key, static_cast(4)); +} + +STLX_TEST(core_rb_tree, randomized_insert_and_find_preserve_invariants) { + constexpr size_t KEY_SPACE = 64; + constexpr size_t OPS = 1000; + + rb_tree tree; + rb_item pool[KEY_SPACE] = {}; + bool present[KEY_SPACE] = {}; + + for (size_t i = 0; i < KEY_SPACE; i++) { + pool[i].key = i; + present[i] = false; + } + + for (size_t op = 0; op < OPS; op++) { + uint32_t which = test::rand_u32(ctx) % 2; + uint32_t key = test::rand_u32(ctx) % KEY_SPACE; + + if (which == 0) { + if (present[key]) { + // Intrusive nodes already linked in the tree must not be reinserted. + rb_item probe = make_probe(key); + rb_item* found = tree.find(probe); + STLX_EXPECT_NOT_NULL(ctx, found); + } else { + bool inserted = tree.insert(&pool[key]); + STLX_EXPECT_TRUE(ctx, inserted); + present[key] = true; + } + } else { + rb_item probe = make_probe(key); + rb_item* found = tree.find(probe); + if (present[key]) { + STLX_EXPECT_NOT_NULL(ctx, found); + } else { + STLX_EXPECT_NULL(ctx, found); + } + } + + STLX_ASSERT_TRUE(ctx, tree.validate()); + } + + STLX_ASSERT_EQ(ctx, tree.size(), count_present(present, KEY_SPACE)); +} diff --git a/kernel/test/common/string_tests.cpp b/kernel/test/common/string_tests.cpp new file mode 100644 index 0000000..9b5f71a --- /dev/null +++ b/kernel/test/common/string_tests.cpp @@ -0,0 +1,61 @@ +#include "test/framework/test_framework.h" +#include "common/string.h" + +STLX_TEST_SUITE(core_string, test::phase::early); + +STLX_TEST(core_string, strlen_handles_basic_inputs) { + STLX_ASSERT_EQ(ctx, string::strlen(""), static_cast(0)); + STLX_ASSERT_EQ(ctx, string::strlen("a"), static_cast(1)); + STLX_ASSERT_EQ(ctx, string::strlen("hello"), static_cast(5)); + STLX_ASSERT_EQ(ctx, string::strlen("stellux 3.0"), static_cast(11)); +} + +STLX_TEST(core_string, memset_fills_expected_bytes) { + uint8_t buf[32] = {}; + + string::memset(buf, 0xA5, sizeof(buf)); + for (size_t i = 0; i < sizeof(buf); i++) { + STLX_ASSERT_EQ(ctx, buf[i], static_cast(0xA5)); + } + + string::memset(buf, 0x00, sizeof(buf)); + for (size_t i = 0; i < sizeof(buf); i++) { + STLX_ASSERT_EQ(ctx, buf[i], static_cast(0x00)); + } +} + +STLX_TEST(core_string, memcpy_copies_and_memcmp_matches) { + uint8_t src[16] = { + 0x10, 0x11, 0x12, 0x13, + 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1A, 0x1B, + 0x1C, 0x1D, 0x1E, 0x1F + }; + uint8_t dst[16] = {}; + + string::memcpy(dst, src, sizeof(src)); + STLX_ASSERT_EQ(ctx, string::memcmp(dst, src, sizeof(src)), static_cast(0)); + + dst[7] = 0x99; + STLX_ASSERT_NE(ctx, string::memcmp(dst, src, sizeof(src)), static_cast(0)); +} + +STLX_TEST(core_string, memcpy_with_zero_length_is_noop) { + uint8_t src[8] = {1, 2, 3, 4, 5, 6, 7, 8}; + uint8_t dst[8] = {9, 9, 9, 9, 9, 9, 9, 9}; + + string::memcpy(dst, src, 0); + for (size_t i = 0; i < sizeof(dst); i++) { + STLX_ASSERT_EQ(ctx, dst[i], static_cast(9)); + } +} + +STLX_TEST(core_string, memcmp_lexicographic_ordering) { + const char* a = "abc"; + const char* b = "abd"; + const char* c = "abc"; + + STLX_ASSERT_EQ(ctx, string::memcmp(a, c, 3), static_cast(0)); + STLX_ASSERT_TRUE(ctx, string::memcmp(a, b, 3) < 0); + STLX_ASSERT_TRUE(ctx, string::memcmp(b, a, 3) > 0); +} diff --git a/kernel/test/framework/test_framework.h b/kernel/test/framework/test_framework.h index f39149b..3a596d8 100644 --- a/kernel/test/framework/test_framework.h +++ b/kernel/test/framework/test_framework.h @@ -33,6 +33,10 @@ inline uint64_t to_u64(const T& value) { } } +inline uint64_t to_u64(decltype(nullptr)) { + return 0; +} + inline bool expect_true( context& ctx, bool cond, @@ -195,6 +199,22 @@ inline bool expect_streq( } \ } while (0) +#define STLX_ASSERT_NULL(ctx, value) \ + do { \ + if (!STLX_EXPECT_NULL((ctx), (value))) { \ + ::test::abort_case((ctx)); \ + return; \ + } \ + } while (0) + +#define STLX_ASSERT_NOT_NULL(ctx, value) \ + do { \ + if (!STLX_EXPECT_NOT_NULL((ctx), (value))) { \ + ::test::abort_case((ctx)); \ + return; \ + } \ + } while (0) + #define STLX_ASSERT_STREQ(ctx, lhs, rhs) \ do { \ if (!STLX_EXPECT_STREQ((ctx), (lhs), (rhs))) { \ From f1a83ad573185582d9a13d32d9c9a56d3fb4cfe1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 07:21:24 +0000 Subject: [PATCH 5/6] Add PMM paging KVA and VMM unit test suites Co-authored-by: Albert Slepak --- kernel/test/common/kva_tests.cpp | 143 +++++++++++++++++++++++++++ kernel/test/common/paging_tests.cpp | 97 ++++++++++++++++++ kernel/test/common/pmm_tests.cpp | 45 +++++++++ kernel/test/common/vmm_tests.cpp | 146 ++++++++++++++++++++++++++++ scripts/run-unit-tests.py | 20 +++- 5 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 kernel/test/common/kva_tests.cpp create mode 100644 kernel/test/common/paging_tests.cpp create mode 100644 kernel/test/common/pmm_tests.cpp create mode 100644 kernel/test/common/vmm_tests.cpp diff --git a/kernel/test/common/kva_tests.cpp b/kernel/test/common/kva_tests.cpp new file mode 100644 index 0000000..9726ab2 --- /dev/null +++ b/kernel/test/common/kva_tests.cpp @@ -0,0 +1,143 @@ +#include "test/framework/test_framework.h" +#include "mm/kva.h" +#include "mm/pmm.h" + +STLX_TEST_SUITE(mm_kva, test::phase::post_mm); + +STLX_TEST(mm_kva, alloc_query_free_roundtrip_with_guards) { + kva::allocation alloc = {}; + kva::allocation query = {}; + bool allocated = false; + + if (!STLX_EXPECT_EQ(ctx, + kva::alloc(2 * pmm::PAGE_SIZE, + pmm::PAGE_SIZE, + 1, + 1, + kva::placement::low, + kva::tag::boot, + 0, + alloc), + kva::OK)) { + goto cleanup; + } + allocated = true; + + STLX_EXPECT_EQ(ctx, alloc.size, static_cast(2 * pmm::PAGE_SIZE)); + STLX_EXPECT_EQ(ctx, alloc.base, alloc.reserved_base + pmm::PAGE_SIZE); + STLX_EXPECT_EQ(ctx, alloc.guard_pre, static_cast(1)); + STLX_EXPECT_EQ(ctx, alloc.guard_post, static_cast(1)); + + STLX_EXPECT_EQ(ctx, kva::query(alloc.base, query), kva::OK); + STLX_EXPECT_EQ(ctx, query.base, alloc.base); + STLX_EXPECT_EQ(ctx, query.alloc_tag, kva::tag::boot); + + // Query from inside pre-guard must still resolve to this allocation. + STLX_EXPECT_EQ(ctx, kva::query(alloc.reserved_base, query), kva::OK); + STLX_EXPECT_EQ(ctx, query.base, alloc.base); + + STLX_EXPECT_EQ(ctx, kva::free(alloc.base), kva::OK); + allocated = false; + + STLX_EXPECT_EQ(ctx, kva::query(alloc.base, query), kva::ERR_NOT_FOUND); + +cleanup: + if (allocated) { + kva::free(alloc.base); + } +} + +STLX_TEST(mm_kva, invalid_alignment_is_rejected) { + kva::allocation alloc = {}; + STLX_ASSERT_EQ(ctx, + kva::alloc(pmm::PAGE_SIZE, + 123, + 0, + 0, + kva::placement::low, + kva::tag::generic, + 0, + alloc), + kva::ERR_ALIGNMENT); +} + +STLX_TEST(mm_kva, reserve_then_free_range) { + kva::allocation alloc = {}; + kva::allocation query = {}; + bool reserved = false; + + if (!STLX_EXPECT_EQ(ctx, + kva::alloc(pmm::PAGE_SIZE, + pmm::PAGE_SIZE, + 0, + 0, + kva::placement::low, + kva::tag::generic, + 0, + alloc), + kva::OK)) { + goto cleanup; + } + + if (!STLX_EXPECT_EQ(ctx, kva::free(alloc.base), kva::OK)) { + goto cleanup; + } + + if (!STLX_EXPECT_EQ(ctx, kva::reserve(alloc.base, pmm::PAGE_SIZE, kva::tag::boot), kva::OK)) { + goto cleanup; + } + reserved = true; + + STLX_EXPECT_EQ(ctx, kva::query(alloc.base, query), kva::OK); + STLX_EXPECT_EQ(ctx, query.alloc_tag, kva::tag::boot); + +cleanup: + if (reserved) { + kva::free(alloc.base); + } +} + +STLX_TEST(mm_kva, high_placement_returns_higher_addresses_than_low) { + kva::allocation low = {}; + kva::allocation high = {}; + bool low_alloc = false; + bool high_alloc = false; + + if (!STLX_EXPECT_EQ(ctx, + kva::alloc(pmm::PAGE_SIZE, + pmm::PAGE_SIZE, + 0, + 0, + kva::placement::low, + kva::tag::generic, + 0, + low), + kva::OK)) { + goto cleanup; + } + low_alloc = true; + + if (!STLX_EXPECT_EQ(ctx, + kva::alloc(pmm::PAGE_SIZE, + pmm::PAGE_SIZE, + 0, + 0, + kva::placement::high, + kva::tag::generic, + 0, + high), + kva::OK)) { + goto cleanup; + } + high_alloc = true; + + STLX_EXPECT_TRUE(ctx, high.base > low.base); + +cleanup: + if (high_alloc) { + kva::free(high.base); + } + if (low_alloc) { + kva::free(low.base); + } +} diff --git a/kernel/test/common/paging_tests.cpp b/kernel/test/common/paging_tests.cpp new file mode 100644 index 0000000..06b71ba --- /dev/null +++ b/kernel/test/common/paging_tests.cpp @@ -0,0 +1,97 @@ +#include "test/framework/test_framework.h" +#include "mm/paging.h" +#include "mm/kva.h" +#include "mm/pmm.h" + +namespace { + +int32_t alloc_test_va(kva::allocation& out) { + return kva::alloc( + pmm::PAGE_SIZE, + pmm::PAGE_SIZE, + 0, + 0, + kva::placement::low, + kva::tag::boot, + 0, + out + ); +} + +} // namespace + +STLX_TEST_SUITE(mm_paging, test::phase::post_mm); + +STLX_TEST(mm_paging, map_get_unmap_roundtrip) { + kva::allocation kva_alloc = {}; + pmm::phys_addr_t phys = 0; + + pmm::phys_addr_t root = paging::get_kernel_pt_root(); + + STLX_ASSERT_EQ(ctx, alloc_test_va(kva_alloc), kva::OK); + + phys = pmm::alloc_page(); + STLX_ASSERT_NE(ctx, phys, static_cast(0)); + + STLX_ASSERT_EQ(ctx, paging::map_page(kva_alloc.base, phys, paging::PAGE_KERNEL_RW, root), paging::OK); + + STLX_ASSERT_TRUE(ctx, paging::is_mapped(kva_alloc.base, root)); + STLX_ASSERT_EQ(ctx, paging::get_physical(kva_alloc.base, root), phys); + + paging::page_flags_t flags = paging::get_page_flags(kva_alloc.base, root); + STLX_ASSERT_TRUE(ctx, (flags & paging::PAGE_READ) != 0); + STLX_ASSERT_TRUE(ctx, (flags & paging::PAGE_WRITE) != 0); + + STLX_ASSERT_EQ(ctx, paging::unmap_page(kva_alloc.base, root), paging::OK); + + STLX_ASSERT_FALSE(ctx, paging::is_mapped(kva_alloc.base, root)); + STLX_ASSERT_EQ(ctx, paging::unmap_page(kva_alloc.base, root), paging::OK); // idempotent + + STLX_ASSERT_EQ(ctx, pmm::free_page(phys), pmm::OK); + STLX_ASSERT_EQ(ctx, kva::free(kva_alloc.base), kva::OK); +} + +STLX_TEST(mm_paging, set_page_flags_updates_permissions) { + kva::allocation kva_alloc = {}; + pmm::phys_addr_t phys = 0; + + pmm::phys_addr_t root = paging::get_kernel_pt_root(); + + STLX_ASSERT_EQ(ctx, alloc_test_va(kva_alloc), kva::OK); + + phys = pmm::alloc_page(); + STLX_ASSERT_NE(ctx, phys, static_cast(0)); + + STLX_ASSERT_EQ(ctx, paging::map_page(kva_alloc.base, phys, paging::PAGE_KERNEL_RW, root), paging::OK); + + STLX_ASSERT_EQ(ctx, paging::set_page_flags(kva_alloc.base, paging::PAGE_KERNEL_RO, root), paging::OK); + + paging::page_flags_t flags = paging::get_page_flags(kva_alloc.base, root); + STLX_ASSERT_TRUE(ctx, (flags & paging::PAGE_READ) != 0); + STLX_ASSERT_FALSE(ctx, (flags & paging::PAGE_WRITE) != 0); + + STLX_ASSERT_EQ(ctx, paging::unmap_page(kva_alloc.base, root), paging::OK); + STLX_ASSERT_EQ(ctx, pmm::free_page(phys), pmm::OK); + STLX_ASSERT_EQ(ctx, kva::free(kva_alloc.base), kva::OK); +} + +STLX_TEST(mm_paging, unaligned_arguments_are_rejected) { + kva::allocation kva_alloc = {}; + pmm::phys_addr_t phys = 0; + + pmm::phys_addr_t root = paging::get_kernel_pt_root(); + + STLX_ASSERT_EQ(ctx, alloc_test_va(kva_alloc), kva::OK); + + phys = pmm::alloc_page(); + STLX_ASSERT_NE(ctx, phys, static_cast(0)); + + STLX_ASSERT_EQ(ctx, + paging::map_page(kva_alloc.base + 1, phys, paging::PAGE_KERNEL_RW, root), + paging::ERR_ALIGNMENT); + STLX_ASSERT_EQ(ctx, + paging::map_page(kva_alloc.base, phys + 1, paging::PAGE_KERNEL_RW, root), + paging::ERR_ALIGNMENT); + STLX_ASSERT_EQ(ctx, pmm::free_page(phys), pmm::OK); + STLX_ASSERT_EQ(ctx, kva::free(kva_alloc.base), kva::OK); +} diff --git a/kernel/test/common/pmm_tests.cpp b/kernel/test/common/pmm_tests.cpp new file mode 100644 index 0000000..3e52d9c --- /dev/null +++ b/kernel/test/common/pmm_tests.cpp @@ -0,0 +1,45 @@ +#include "test/framework/test_framework.h" +#include "mm/pmm.h" + +STLX_TEST_SUITE(mm_pmm, test::phase::post_mm); + +STLX_TEST(mm_pmm, alloc_free_single_page_preserves_free_count) { + uint64_t before = pmm::free_page_count(); + pmm::phys_addr_t page = pmm::alloc_page(); + + STLX_ASSERT_NE(ctx, page, static_cast(0)); + STLX_ASSERT_EQ(ctx, pmm::free_page_count(), before - 1); + + STLX_ASSERT_EQ(ctx, pmm::free_page(page), pmm::OK); + STLX_ASSERT_EQ(ctx, pmm::free_page_count(), before); +} + +STLX_TEST(mm_pmm, alloc_free_contiguous_block_honors_alignment) { + constexpr uint8_t ORDER = 4; // 16 pages + + pmm::phys_addr_t block = pmm::alloc_pages(ORDER, pmm::ZONE_ANY); + STLX_ASSERT_NE(ctx, block, static_cast(0)); + STLX_ASSERT_EQ(ctx, block & (pmm::order_to_bytes(ORDER) - 1), static_cast(0)); + STLX_ASSERT_EQ(ctx, pmm::free_pages(block, ORDER), pmm::OK); +} + +STLX_TEST(mm_pmm, invalid_unaligned_free_is_rejected) { + STLX_ASSERT_EQ(ctx, pmm::free_pages(123, 0), pmm::ERR_INVALID_ADDR); +} + +STLX_TEST(mm_pmm, dma32_allocation_stays_below_4gb) { + constexpr uint64_t DMA32_LIMIT = 0x100000000ULL; + + pmm::phys_addr_t page = pmm::alloc_page(pmm::ZONE_DMA32); + STLX_ASSERT_NE(ctx, page, static_cast(0)); + STLX_ASSERT_TRUE(ctx, page < DMA32_LIMIT); + STLX_ASSERT_EQ(ctx, pmm::free_page(page), pmm::OK); +} + +STLX_TEST(mm_pmm, double_free_is_detected) { + pmm::phys_addr_t page = pmm::alloc_page(); + STLX_ASSERT_NE(ctx, page, static_cast(0)); + + STLX_ASSERT_EQ(ctx, pmm::free_page(page), pmm::OK); + STLX_ASSERT_EQ(ctx, pmm::free_page(page), pmm::ERR_DOUBLE_FREE); +} diff --git a/kernel/test/common/vmm_tests.cpp b/kernel/test/common/vmm_tests.cpp new file mode 100644 index 0000000..99acf03 --- /dev/null +++ b/kernel/test/common/vmm_tests.cpp @@ -0,0 +1,146 @@ +#include "test/framework/test_framework.h" +#include "mm/vmm.h" +#include "mm/paging.h" +#include "mm/pmm.h" + +STLX_TEST_SUITE(mm_vmm, test::phase::post_mm); + +STLX_TEST(mm_vmm, alloc_and_free_non_contiguous_pages) { + uintptr_t addr = 0; + uint8_t* bytes = nullptr; + bool allocated = false; + + pmm::phys_addr_t root = paging::get_kernel_pt_root(); + + if (!STLX_EXPECT_EQ(ctx, + vmm::alloc(2, paging::PAGE_KERNEL_RW, vmm::ALLOC_ZERO, kva::tag::generic, addr), + vmm::OK)) { + goto cleanup; + } + allocated = true; + + STLX_EXPECT_TRUE(ctx, addr != 0); + STLX_EXPECT_TRUE(ctx, paging::is_mapped(addr, root)); + STLX_EXPECT_TRUE(ctx, paging::is_mapped(addr + pmm::PAGE_SIZE, root)); + + bytes = reinterpret_cast(addr); + for (size_t i = 0; i < 64; i++) { + STLX_EXPECT_EQ(ctx, bytes[i], static_cast(0)); + } + + bytes[0] = 0xA5; + STLX_EXPECT_EQ(ctx, bytes[0], static_cast(0xA5)); + + STLX_EXPECT_EQ(ctx, vmm::free(addr), vmm::OK); + allocated = false; + STLX_EXPECT_FALSE(ctx, paging::is_mapped(addr, root)); + +cleanup: + if (allocated) { + vmm::free(addr); + } +} + +STLX_TEST(mm_vmm, alloc_contiguous_reports_physical_base) { + uintptr_t addr = 0; + pmm::phys_addr_t phys = 0; + bool allocated = false; + + pmm::phys_addr_t root = paging::get_kernel_pt_root(); + + if (!STLX_EXPECT_EQ(ctx, + vmm::alloc_contiguous(4, + pmm::ZONE_ANY, + paging::PAGE_KERNEL_RW, + 0, + kva::tag::generic, + addr, + phys), + vmm::OK)) { + goto cleanup; + } + allocated = true; + + STLX_EXPECT_NE(ctx, addr, static_cast(0)); + STLX_EXPECT_NE(ctx, phys, static_cast(0)); + STLX_EXPECT_EQ(ctx, paging::get_physical(addr, root), phys); + + STLX_EXPECT_EQ(ctx, vmm::free(addr), vmm::OK); + allocated = false; + STLX_EXPECT_FALSE(ctx, paging::is_mapped(addr, root)); + +cleanup: + if (allocated) { + vmm::free(addr); + } +} + +STLX_TEST(mm_vmm, alloc_stack_creates_unmapped_guard_region) { + uintptr_t base = 0; + uintptr_t top = 0; + bool allocated = false; + + pmm::phys_addr_t root = paging::get_kernel_pt_root(); + + if (!STLX_EXPECT_EQ(ctx, + vmm::alloc_stack(2, 1, kva::tag::privileged_stack, base, top), + vmm::OK)) { + goto cleanup; + } + allocated = true; + + STLX_EXPECT_EQ(ctx, top - base, static_cast(2 * pmm::PAGE_SIZE)); + STLX_EXPECT_TRUE(ctx, paging::is_mapped(base, root)); + STLX_EXPECT_TRUE(ctx, paging::is_mapped(base + pmm::PAGE_SIZE, root)); + STLX_EXPECT_FALSE(ctx, paging::is_mapped(base - pmm::PAGE_SIZE, root)); + + STLX_EXPECT_EQ(ctx, vmm::free(base), vmm::OK); + allocated = false; + +cleanup: + if (allocated) { + vmm::free(base); + } +} + +STLX_TEST(mm_vmm, map_phys_unmap_keeps_physical_page_owned_by_caller) { + pmm::phys_addr_t phys = 0; + uintptr_t map_base = 0; + uintptr_t map_va = 0; + bool mapped = false; + bool phys_allocated = false; + + pmm::phys_addr_t root = paging::get_kernel_pt_root(); + + phys = pmm::alloc_page(); + if (!STLX_EXPECT_NE(ctx, phys, static_cast(0))) { + goto cleanup; + } + phys_allocated = true; + + *reinterpret_cast(paging::phys_to_virt(phys)) = 0x5A; + + if (!STLX_EXPECT_EQ(ctx, + vmm::map_phys(phys, pmm::PAGE_SIZE, paging::PAGE_KERNEL_RW, map_base, map_va), + vmm::OK)) { + goto cleanup; + } + mapped = true; + + STLX_EXPECT_EQ(ctx, *reinterpret_cast(map_va), static_cast(0x5A)); + + STLX_EXPECT_EQ(ctx, vmm::free(map_va), vmm::OK); + mapped = false; + STLX_EXPECT_FALSE(ctx, paging::is_mapped(map_base, root)); + + STLX_EXPECT_EQ(ctx, pmm::free_page(phys), pmm::OK); + phys_allocated = false; + +cleanup: + if (mapped) { + vmm::free(map_va); + } + if (phys_allocated) { + pmm::free_page(phys); + } +} diff --git a/scripts/run-unit-tests.py b/scripts/run-unit-tests.py index f9faac8..39e5dc8 100755 --- a/scripts/run-unit-tests.py +++ b/scripts/run-unit-tests.py @@ -40,9 +40,25 @@ def terminate_process_group(proc: subprocess.Popen[str], grace_seconds: float = pass +def sanitize_tag(value: str, fallback: str = "all") -> str: + cleaned = "".join(ch if ch.isalnum() else "_" for ch in value) + cleaned = cleaned.strip("_") + if not cleaned: + cleaned = fallback + return cleaned[:48] + + def build_command(args: argparse.Namespace) -> list[str]: - build_dir = f"build/unit-tests-{args.arch}" - image_dir = f"images/unit-tests-{args.arch}" + filter_tag = sanitize_tag(args.filter, fallback="all") + seed_tag = sanitize_tag(str(args.seed), fallback="seed") + build_dir = ( + f"build/unit-tests-{args.arch}-" + f"{filter_tag}-ff{args.fail_fast}-r{args.repeat}-s{seed_tag}" + ) + image_dir = ( + f"images/unit-tests-{args.arch}-" + f"{filter_tag}-ff{args.fail_fast}-r{args.repeat}-s{seed_tag}" + ) return [ "make", From 8ed0218e50ce0f2b9bc84bde400197fff55eba98 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Feb 2026 07:23:42 +0000 Subject: [PATCH 6/6] Add unit test CI workflow and testing docs Co-authored-by: Albert Slepak --- .github/workflows/kernel-unit-tests.yml | 65 ++++++++++++++++ kernel/test/README.md | 99 +++++++++++++++++++++++++ kernel/test/framework/test_framework.h | 4 +- 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/kernel-unit-tests.yml create mode 100644 kernel/test/README.md diff --git a/.github/workflows/kernel-unit-tests.yml b/.github/workflows/kernel-unit-tests.yml new file mode 100644 index 0000000..70d6915 --- /dev/null +++ b/.github/workflows/kernel-unit-tests.yml @@ -0,0 +1,65 @@ +name: Kernel Unit Tests + +on: + push: + branches: + - "**" + pull_request: + +jobs: + dynpriv-lint: + name: dynpriv lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run dynamic privilege lint + run: bash scripts/dynpriv-lint.sh + + unit-tests: + name: unit-tests (${{ matrix.arch }}) + runs-on: ubuntu-latest + needs: dynpriv-lint + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + arch: [x86_64, aarch64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install build and QEMU dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang lld llvm \ + qemu-system-x86 qemu-system-arm \ + ovmf qemu-efi-aarch64 \ + mtools gdisk xorriso \ + gdb-multiarch + + - name: Fetch Limine EFI binaries + run: make limine + + - name: Run unit tests + shell: bash + run: | + set -euo pipefail + if [[ "${{ matrix.arch }}" == "aarch64" ]]; then + timeout_sec=240 + else + timeout_sec=180 + fi + + make test ARCH=${{ matrix.arch }} UNIT_TEST_TIMEOUT=${timeout_sec} \ + | tee unit-tests-${{ matrix.arch }}.log + + - name: Upload test log + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-tests-${{ matrix.arch }}-log + path: unit-tests-${{ matrix.arch }}.log diff --git a/kernel/test/README.md b/kernel/test/README.md new file mode 100644 index 0000000..7dec6a9 --- /dev/null +++ b/kernel/test/README.md @@ -0,0 +1,99 @@ +# Stellux Kernel Unit Tests + +This directory contains the in-kernel unit testing framework and suites for Stellux 3.0. + +## Design goals + +- Freestanding-friendly (no STL, no exceptions, no runtime constructors) +- Deterministic execution and machine-readable results +- Easy test registration and easy test authoring +- Dual-architecture compatible (x86_64 + aarch64) + +## Layout + +- `framework/` + - `test_framework.h` — registration + assertion macros + - `test_runner.h/.cpp` — execution engine + - `test_registry.h` — descriptor types and phases + - `test_utils.h` — deterministic PRNG helpers for tests +- `common/` + - core + memory-layer suites + +## Running tests + +From the repository root: + +```bash +# Run all suites for one architecture +make test ARCH=x86_64 +make test ARCH=aarch64 + +# Run both architectures +make test-all + +# Filter by suite prefix +make test ARCH=x86_64 UNIT_TEST_FILTER=core_ +make test ARCH=x86_64 UNIT_TEST_FILTER=mm_ + +# Filter one case +make test ARCH=x86_64 UNIT_TEST_FILTER=core_string.strlen_handles_basic_inputs +``` + +Useful options: + +- `UNIT_TEST_FAIL_FAST=1` +- `UNIT_TEST_REPEAT=` +- `UNIT_TEST_SEED=` +- `UNIT_TEST_TIMEOUT=` + +## Writing tests + +### 1) Declare a suite + +```cpp +STLX_TEST_SUITE(core_string, test::phase::early); +``` + +or with per-case hooks: + +```cpp +STLX_TEST_SUITE_EX(my_suite, test::phase::post_mm, before_each, after_each); +``` + +### 2) Add cases + +```cpp +STLX_TEST(core_string, strlen_handles_basic_inputs) { + STLX_ASSERT_EQ(ctx, string::strlen("abc"), static_cast(3)); +} +``` + +### 3) Assertions + +- `STLX_EXPECT_*` records failure and continues +- `STLX_ASSERT_*` records failure and aborts current case +- `STLX_SKIP(ctx, "reason")` marks case skipped + +Common checks: + +- `STLX_EXPECT_TRUE/FALSE` +- `STLX_EXPECT_EQ/NE` +- `STLX_EXPECT_NULL/NOT_NULL` +- `STLX_EXPECT_STREQ` + +## Phase guidance + +- `test::phase::early`: + - tests that do not require PMM/paging/KVA/VMM initialization +- `test::phase::post_mm`: + - tests that require memory subsystems to be active + +## CI output markers + +The runner emits: + +- `[TEST_SUMMARY] ...` +- `[TEST_RESULT] PASS` +- `[TEST_RESULT] FAIL` + +The host script (`scripts/run-unit-tests.py`) parses these markers and returns appropriate exit codes for CI. diff --git a/kernel/test/framework/test_framework.h b/kernel/test/framework/test_framework.h index 3a596d8..a87979d 100644 --- a/kernel/test/framework/test_framework.h +++ b/kernel/test/framework/test_framework.h @@ -134,7 +134,7 @@ inline bool expect_streq( STLX_TEST_SUITE_EX(suite_ident, suite_phase, nullptr, nullptr) #define STLX_TEST(suite_ident, case_ident) \ - static __PRIVILEGED_CODE void stlx_test_case_##suite_ident##_##case_ident(::test::context& ctx); \ + static void stlx_test_case_##suite_ident##_##case_ident(::test::context& ctx); \ namespace { \ __attribute__((used, section(".stlx_test_cases." #suite_ident "." #case_ident))) \ const ::test::case_desc stlx_test_case_desc_##suite_ident##_##case_ident = { \ @@ -144,7 +144,7 @@ inline bool expect_streq( .body = stlx_test_case_##suite_ident##_##case_ident, \ }; \ } \ - static __PRIVILEGED_CODE void stlx_test_case_##suite_ident##_##case_ident(::test::context& ctx) + static void stlx_test_case_##suite_ident##_##case_ident(::test::context& ctx) #define STLX_EXPECT_TRUE(ctx, expr) \ ::test::expect_true((ctx), static_cast(expr), #expr, __FILE__, static_cast(__LINE__))