summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/module.mk30
-rw-r--r--test/selftest.cc83
-rw-r--r--test/test_atomic.cc102
-rw-r--r--test/test_dcache.cc232
-rw-r--r--test/test_driver_model.cc209
-rw-r--r--test/test_exec.cc447
-rw-r--r--test/test_fdt.cc50
-rw-r--r--test/test_klog.cc175
-rw-r--r--test/test_mutex.cc183
-rw-r--r--test/test_namei.cc173
-rw-r--r--test/test_page_cache.cc180
-rw-r--r--test/test_pid.cc106
-rw-r--r--test/test_pipe_devfs.cc420
-rw-r--r--test/test_pmm.cc283
-rw-r--r--test/test_random.cc49
-rw-r--r--test/test_rbtree.cc119
-rw-r--r--test/test_sched.cc257
-rw-r--r--test/test_signal.cc193
-rw-r--r--test/test_slab.cc255
-rw-r--r--test/test_smp.cc136
-rw-r--r--test/test_spinlock.cc135
-rw-r--r--test/test_sys_fs_extra.cc238
-rw-r--r--test/test_sys_mm.cc178
-rw-r--r--test/test_sys_process.cc93
-rw-r--r--test/test_trap.cc197
-rw-r--r--test/test_uaccess.cc97
-rw-r--r--test/test_vfs_io.cc345
-rw-r--r--test/test_vma.cc282
-rw-r--r--test/test_vmm.cc58
-rw-r--r--test/test_waitqueue.cc91
30 files changed, 5396 insertions, 0 deletions
diff --git a/test/module.mk b/test/module.mk
new file mode 100644
index 0000000..47a693c
--- /dev/null
+++ b/test/module.mk
@@ -0,0 +1,30 @@
+# test/module.mk
+SRCS_CC += test/selftest.cc
+SRCS_CC += test/test_pmm.cc
+SRCS_CC += test/test_slab.cc
+SRCS_CC += test/test_spinlock.cc
+SRCS_CC += test/test_mutex.cc
+SRCS_CC += test/test_driver_model.cc
+SRCS_CC += test/test_rbtree.cc
+SRCS_CC += test/test_vmm.cc
+SRCS_CC += test/test_vma.cc
+SRCS_CC += test/test_atomic.cc
+SRCS_CC += test/test_fdt.cc
+SRCS_CC += test/test_trap.cc
+SRCS_CC += test/test_sched.cc
+SRCS_CC += test/test_klog.cc
+SRCS_CC += test/test_random.cc
+SRCS_CC += test/test_waitqueue.cc
+SRCS_CC += test/test_uaccess.cc
+SRCS_CC += test/test_sys_mm.cc
+SRCS_CC += test/test_smp.cc
+SRCS_CC += test/test_dcache.cc
+SRCS_CC += test/test_namei.cc
+SRCS_CC += test/test_vfs_io.cc
+SRCS_CC += test/test_exec.cc
+SRCS_CC += test/test_pipe_devfs.cc
+SRCS_CC += test/test_sys_process.cc
+SRCS_CC += test/test_signal.cc
+SRCS_CC += test/test_pid.cc
+SRCS_CC += test/test_sys_fs_extra.cc
+SRCS_CC += test/test_page_cache.cc
diff --git a/test/selftest.cc b/test/selftest.cc
new file mode 100644
index 0000000..e1bb2b4
--- /dev/null
+++ b/test/selftest.cc
@@ -0,0 +1,83 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * Boot Self-Test Runner
+ *
+ * Entry point that invokes all registered self-test suites.
+ * Called from kernel_main() after all subsystem initialization.
+ */
+
+#include <lume/selftest.h>
+
+/* Individual test suite entry points */
+void selftest_pmm();
+void selftest_slab();
+void selftest_spinlock();
+void selftest_mutex();
+void selftest_driver_model();
+void selftest_rbtree();
+void selftest_vmm();
+void selftest_fdt();
+void selftest_vma();
+void selftest_atomic();
+void selftest_trap();
+void selftest_sched();
+void selftest_klog();
+void selftest_random();
+void selftest_waitqueue();
+void selftest_uaccess();
+void selftest_sys_mm();
+void selftest_dcache();
+void selftest_namei();
+void selftest_vfs_io();
+void selftest_exec();
+void selftest_devfs();
+void selftest_pipe();
+void selftest_fcntl();
+void selftest_sys_process();
+void selftest_signal();
+void selftest_pid();
+void selftest_sys_fs_extra();
+void selftest_page_cache();
+
+void selftest_run_all()
+{
+ kprintf("\n========================================\n");
+ kprintf(" Boot Self-Test Suite\n");
+ kprintf("========================================\n");
+
+ selftest_spinlock();
+ selftest_mutex();
+ selftest_driver_model();
+ selftest_pmm();
+ selftest_slab();
+ selftest_rbtree();
+ selftest_vmm();
+ selftest_vma();
+ selftest_atomic();
+ selftest_fdt();
+ selftest_trap();
+ selftest_sched();
+ selftest_klog();
+ selftest_random();
+ selftest_waitqueue();
+ selftest_uaccess();
+ selftest_sys_mm();
+ selftest_dcache();
+ selftest_namei();
+ selftest_vfs_io();
+ selftest_devfs();
+ selftest_pipe();
+ selftest_fcntl();
+ selftest_sys_process();
+ selftest_signal();
+ selftest_pid();
+ selftest_sys_fs_extra();
+ selftest_page_cache();
+ selftest_exec();
+
+ kprintf("========================================\n");
+ kprintf(" All self-tests PASSED\n");
+ kprintf("========================================\n\n");
+}
diff --git a/test/test_atomic.cc b/test/test_atomic.cc
new file mode 100644
index 0000000..9851e7b
--- /dev/null
+++ b/test/test_atomic.cc
@@ -0,0 +1,102 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * Boot Self-Test: lume::atomic<T>
+ *
+ * Tests:
+ * 1. Default construction (zero-initialized)
+ * 2. Explicit construction
+ * 3. store / load round-trip with different memory orders
+ * 4. exchange returns old value
+ * 5. fetch_add / fetch_sub arithmetic
+ * 6. compare_exchange success and failure paths
+ */
+
+#include <lume/selftest.h>
+#include <lume/atomic.h>
+
+void selftest_atomic() {
+ st_begin("atomic: default construction is zero");
+ {
+ lume::atomic<uint32> a;
+ ST_ASSERT_EQ(a.load(), 0U);
+ }
+ st_pass();
+
+ st_begin("atomic: explicit construction");
+ {
+ lume::atomic<uint64> a(42);
+ ST_ASSERT_EQ(a.load(), 42ULL);
+ }
+ st_pass();
+
+ st_begin("atomic: store/load round-trip");
+ {
+ lume::atomic<uint32> a;
+ a.store(0xDEAD, __ATOMIC_RELEASE);
+ ST_ASSERT_EQ(a.load(__ATOMIC_ACQUIRE), 0xDEADU);
+
+ a.store(0, __ATOMIC_SEQ_CST);
+ ST_ASSERT_EQ(a.load(__ATOMIC_SEQ_CST), 0U);
+ }
+ st_pass();
+
+ st_begin("atomic: exchange returns old value");
+ {
+ lume::atomic<uint32> a(10);
+ uint32 old = a.exchange(20);
+ ST_ASSERT_EQ(old, 10U);
+ ST_ASSERT_EQ(a.load(), 20U);
+ }
+ st_pass();
+
+ st_begin("atomic: fetch_add / fetch_sub");
+ {
+ lume::atomic<uint32> a(100);
+
+ uint32 old = a.fetch_add(5);
+ ST_ASSERT_EQ(old, 100U);
+ ST_ASSERT_EQ(a.load(), 105U);
+
+ old = a.fetch_sub(10);
+ ST_ASSERT_EQ(old, 105U);
+ ST_ASSERT_EQ(a.load(), 95U);
+
+ // Underflow wraps (unsigned)
+ lume::atomic<uint32> b(0);
+ old = b.fetch_sub(1);
+ ST_ASSERT_EQ(old, 0U);
+ ST_ASSERT_EQ(b.load(), 0xFFFFFFFFU);
+ }
+ st_pass();
+
+ st_begin("atomic: compare_exchange success and failure");
+ {
+ lume::atomic<uint32> a(42);
+
+ // Success path: expected matches, swap happens
+ uint32 expected = 42;
+ bool ok = a.compare_exchange(expected, 99);
+ ST_ASSERT(ok);
+ ST_ASSERT_EQ(a.load(), 99U);
+ ST_ASSERT_EQ(expected, 42U); // expected unchanged on success
+
+ // Failure path: expected doesn't match, expected gets updated
+ expected = 0; // wrong
+ ok = a.compare_exchange(expected, 200);
+ ST_ASSERT(!ok);
+ ST_ASSERT_EQ(a.load(), 99U); // value unchanged
+ ST_ASSERT_EQ(expected, 99U); // expected updated to actual value
+ }
+ st_pass();
+
+ st_begin("atomic: 64-bit fetch_add correctness");
+ {
+ lume::atomic<uint64> a(0xFFFFFFFF00000000ULL);
+ uint64 old = a.fetch_add(1);
+ ST_ASSERT_EQ(old, 0xFFFFFFFF00000000ULL);
+ ST_ASSERT_EQ(a.load(), 0xFFFFFFFF00000001ULL);
+ }
+ st_pass();
+}
diff --git a/test/test_dcache.cc b/test/test_dcache.cc
new file mode 100644
index 0000000..9e0eec5
--- /dev/null
+++ b/test/test_dcache.cc
@@ -0,0 +1,232 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_dcache.cc — Self-tests for the dentry cache layer
+ */
+
+#include <lume/selftest.h>
+#include <lume/dcache.h>
+#include <lume/fs.h>
+#include <lume/mount.h>
+#include <lume/errno.h>
+#include <lume/slab.h>
+
+/* Root dentry/mount from VFS init */
+extern dentry* g_root_dentry;
+extern vfsmount* g_root_mnt;
+
+void selftest_dcache()
+{
+ /* ---- d_alloc: basic allocation under root ---- */
+ st_begin("dcache: d_alloc basic");
+ {
+ qstr name{};
+ name.name = "test_child";
+ name.len = 10;
+ name.hash = full_name_hash(name.name, name.len);
+
+ dentry* child = d_alloc(g_root_dentry, &name);
+ ST_ASSERT(child != nullptr);
+ ST_ASSERT_EQ(child->d_name.len, 10u);
+ ST_ASSERT(child->d_parent == g_root_dentry);
+ ST_ASSERT(child->d_inode == nullptr); /* not instantiated yet */
+ ST_ASSERT(child->d_sb == g_root_dentry->d_sb);
+
+ /* Name was copied into inline buffer */
+ bool match = true;
+ for (uint32 i = 0; i < name.len; i++) {
+ if (child->d_name.name[i] != name.name[i]) { match = false; break; }
+ }
+ ST_ASSERT(match);
+
+ d_drop(child);
+ dput(child);
+ }
+ st_pass();
+
+ /* ---- d_lookup: find existing child ---- */
+ st_begin("dcache: d_lookup finds child");
+ {
+ qstr name{};
+ name.name = "lookup_me";
+ name.len = 9;
+ name.hash = full_name_hash(name.name, name.len);
+
+ dentry* child = d_alloc(g_root_dentry, &name);
+ ST_ASSERT(child != nullptr);
+
+ /* d_lookup should find it */
+ dentry* found = d_lookup(g_root_dentry, &name);
+ ST_ASSERT(found != nullptr);
+ ST_ASSERT(found == child);
+ dput(found); /* drop d_lookup ref */
+
+ d_drop(child);
+ dput(child);
+ }
+ st_pass();
+
+ /* ---- d_lookup: miss returns nullptr ---- */
+ st_begin("dcache: d_lookup miss returns nullptr");
+ {
+ qstr name{};
+ name.name = "nonexistent_xyz";
+ name.len = 15;
+ name.hash = full_name_hash(name.name, name.len);
+
+ dentry* found = d_lookup(g_root_dentry, &name);
+ ST_ASSERT(found == nullptr);
+ }
+ st_pass();
+
+ /* ---- d_instantiate: bind inode to dentry ---- */
+ st_begin("dcache: d_instantiate binds inode");
+ {
+ qstr name{};
+ name.name = "has_inode";
+ name.len = 9;
+ name.hash = full_name_hash(name.name, name.len);
+
+ dentry* child = d_alloc(g_root_dentry, &name);
+ ST_ASSERT(child != nullptr);
+ ST_ASSERT(child->d_inode == nullptr);
+
+ /* Create a dummy inode */
+ inode* ip = new_inode(g_root_dentry->d_sb);
+ ST_ASSERT(ip != nullptr);
+ ip->i_mode = S_IFREG | 0644;
+
+ d_instantiate(child, ip);
+ ST_ASSERT(child->d_inode == ip);
+ ST_ASSERT(child->d_flags & DCACHE_REGULAR_TYPE);
+
+ d_drop(child);
+ dput(child);
+ }
+ st_pass();
+
+ /* ---- dget/dput: refcount ---- */
+ st_begin("dcache: dget/dput refcount");
+ {
+ qstr name{};
+ name.name = "reftest";
+ name.len = 7;
+ name.hash = full_name_hash(name.name, name.len);
+
+ dentry* child = d_alloc(g_root_dentry, &name);
+ ST_ASSERT(child != nullptr);
+ /* d_alloc sets refcount = 1 */
+ int rc1 = child->d_lockref.load();
+ ST_ASSERT(rc1 >= 1);
+
+ dget(child);
+ int rc2 = child->d_lockref.load();
+ ST_ASSERT_EQ(rc2, rc1 + 1);
+
+ dput(child);
+ int rc3 = child->d_lockref.load();
+ ST_ASSERT_EQ(rc3, rc1);
+
+ d_drop(child);
+ dput(child);
+ }
+ st_pass();
+
+ /* ---- d_drop: removes from parent children list ---- */
+ st_begin("dcache: d_drop removes from parent");
+ {
+ qstr name{};
+ name.name = "dropme";
+ name.len = 6;
+ name.hash = full_name_hash(name.name, name.len);
+
+ dentry* child = d_alloc(g_root_dentry, &name);
+ ST_ASSERT(child != nullptr);
+
+ /* Should be findable */
+ dentry* f1 = d_lookup(g_root_dentry, &name);
+ ST_ASSERT(f1 != nullptr);
+ dput(f1);
+
+ /* Drop it */
+ d_drop(child);
+
+ /* Should no longer be findable */
+ dentry* f2 = d_lookup(g_root_dentry, &name);
+ ST_ASSERT(f2 == nullptr);
+
+ dput(child);
+ }
+ st_pass();
+
+ /* ---- d_path: root returns "/" ---- */
+ st_begin("dcache: d_path root returns /");
+ {
+ path p{};
+ p.de = g_root_dentry;
+ p.mnt = g_root_mnt;
+
+ char buf[64];
+ char* result = d_path(&p, buf, sizeof(buf));
+ ST_ASSERT(result != nullptr);
+ ST_ASSERT(result[0] == '/');
+ ST_ASSERT(result[1] == '\0');
+ }
+ st_pass();
+
+ /* ---- d_path: child path ---- */
+ st_begin("dcache: d_path child returns /name");
+ {
+ qstr name{};
+ name.name = "pathtest";
+ name.len = 8;
+ name.hash = full_name_hash(name.name, name.len);
+
+ dentry* child = d_alloc(g_root_dentry, &name);
+ ST_ASSERT(child != nullptr);
+
+ path p{};
+ p.de = child;
+ p.mnt = g_root_mnt;
+
+ char buf[64];
+ char* result = d_path(&p, buf, sizeof(buf));
+ ST_ASSERT(result != nullptr);
+
+ /* Should be "/pathtest" */
+ const char* expected = "/pathtest";
+ bool eq = true;
+ for (int i = 0; expected[i]; i++) {
+ if (result[i] != expected[i]) { eq = false; break; }
+ }
+ ST_ASSERT(eq);
+
+ d_drop(child);
+ dput(child);
+ }
+ st_pass();
+
+ /* ---- d_alloc_anon: root-like dentry ---- */
+ st_begin("dcache: d_alloc_anon");
+ {
+ dentry* anon = d_alloc_anon(g_root_dentry->d_sb);
+ ST_ASSERT(anon != nullptr);
+ ST_ASSERT(anon->d_parent == anon); /* root: parent is self */
+ ST_ASSERT(anon->d_sb == g_root_dentry->d_sb);
+ dput(anon);
+ }
+ st_pass();
+
+ /* ---- full_name_hash: deterministic ---- */
+ st_begin("dcache: full_name_hash deterministic");
+ {
+ uint32 h1 = full_name_hash("hello", 5);
+ uint32 h2 = full_name_hash("hello", 5);
+ ST_ASSERT_EQ(h1, h2);
+
+ uint32 h3 = full_name_hash("world", 5);
+ ST_ASSERT_NE(h1, h3);
+ }
+ st_pass();
+}
diff --git a/test/test_driver_model.cc b/test/test_driver_model.cc
new file mode 100644
index 0000000..8f105b3
--- /dev/null
+++ b/test/test_driver_model.cc
@@ -0,0 +1,209 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_driver_model — exercises kref, kobject, and the bus/device/driver
+ * core. Uses a dummy bus + dummy driver + dummy device so the test is
+ * self-contained and doesn't depend on FDT/platform_device.
+ */
+
+#include <lume/selftest.h>
+#include <lume/kref.h>
+#include <lume/kobject.h>
+#include <lume/device.h>
+
+/* ------------------------------------------------------------------ */
+/* Test 1: kref get/put + release fires exactly once */
+/* ------------------------------------------------------------------ */
+
+namespace {
+
+struct krefable {
+ kref ref;
+ int release_count;
+};
+
+void krefable_release(kref* r)
+{
+ auto* k = container_of(r, krefable, ref);
+ k->release_count++;
+}
+
+void test_kref()
+{
+ st_begin("kref: init/get/put");
+
+ krefable k = {};
+ kref_init(&k.ref);
+
+ ST_ASSERT_EQ(kref_read(&k.ref), 1);
+
+ kref_get(&k.ref);
+ kref_get(&k.ref);
+ ST_ASSERT_EQ(kref_read(&k.ref), 3);
+
+ ST_ASSERT_EQ(kref_put(&k.ref, krefable_release), 0);
+ ST_ASSERT_EQ(kref_put(&k.ref, krefable_release), 0);
+ ST_ASSERT_EQ(k.release_count, 0);
+
+ ST_ASSERT_EQ(kref_put(&k.ref, krefable_release), 1);
+ ST_ASSERT_EQ(k.release_count, 1);
+
+ st_pass();
+}
+
+/* ------------------------------------------------------------------ */
+/* Test 2: kobject parent/child + release on final put */
+/* ------------------------------------------------------------------ */
+
+int g_child_released = 0;
+int g_parent_released = 0;
+
+void child_release(kobject*) { g_child_released++; }
+void parent_release(kobject*) { g_parent_released++; }
+
+const kobj_type child_ktype = { .release = child_release };
+const kobj_type parent_ktype = { .release = parent_release };
+
+void test_kobject_hierarchy()
+{
+ st_begin("kobject: parent/child + ref counting");
+
+ g_child_released = 0;
+ g_parent_released = 0;
+
+ kobject parent;
+ kobject_init(&parent, &parent_ktype);
+
+ kobject child;
+ kobject_init(&child, &child_ktype);
+ kobject_add(&child, &parent, "child");
+
+ /* Adding child took a ref on parent. */
+ ST_ASSERT_EQ(kref_read(&parent.refcount), 2);
+ ST_ASSERT(child.parent == &parent);
+
+ /* Drop child's "added" reference — release should NOT fire because
+ * we (the test) still hold the original kobject_init ref. */
+ kobject_del(&child);
+ ST_ASSERT(child.parent == nullptr);
+ ST_ASSERT_EQ(kref_read(&parent.refcount), 1);
+ ST_ASSERT_EQ(g_child_released, 0);
+ ST_ASSERT_EQ(g_parent_released, 0);
+
+ /* Drop final refs. */
+ kobject_put(&child);
+ ST_ASSERT_EQ(g_child_released, 1);
+
+ kobject_put(&parent);
+ ST_ASSERT_EQ(g_parent_released, 1);
+
+ st_pass();
+}
+
+/* ------------------------------------------------------------------ */
+/* Test 3: bus / device / driver bind via match + probe */
+/* ------------------------------------------------------------------ */
+
+struct dummy_dev { device dev; int probed; };
+struct dummy_drv { device_driver drv; const char* want; };
+
+int g_probes = 0;
+
+int dummy_match(device* dev, device_driver* drv)
+{
+ auto* dd = container_of(drv, dummy_drv, drv);
+ return (dev->init_name != nullptr &&
+ dd->want != nullptr &&
+ __builtin_strcmp(dev->init_name, dd->want) == 0) ? 1 : 0;
+}
+
+int dummy_probe(device* dev)
+{
+ auto* dd = container_of(dev, dummy_dev, dev);
+ dd->probed++;
+ g_probes++;
+ return 0;
+}
+
+bus_type dummy_bus = {
+ .name = "test_dummy",
+ .match = dummy_match,
+ .probe = nullptr, /* fall through to drv->probe */
+ .devices = {},
+ .drivers = {},
+ .lock = {},
+ .registered = false,
+};
+
+void test_bus_bind()
+{
+ st_begin("driver model: bus / driver / device bind");
+
+ g_probes = 0;
+ ST_ASSERT_EQ(bus_register(&dummy_bus), 0);
+
+ /* Register driver before any matching device exists. */
+ dummy_drv drv_a = {};
+ drv_a.drv.name = "drv_a";
+ drv_a.drv.bus = &dummy_bus;
+ drv_a.drv.probe = dummy_probe;
+ drv_a.want = "compat_a";
+ ST_ASSERT_EQ(driver_register(&drv_a.drv), 0);
+
+ /* Register a device that should bind to drv_a immediately. */
+ dummy_dev dev_a = {};
+ dev_a.dev.bus = &dummy_bus;
+ dev_a.dev.init_name = "compat_a";
+ ST_ASSERT_EQ(device_register(&dev_a.dev), 0);
+ ST_ASSERT_EQ(dev_a.probed, 1);
+ ST_ASSERT(dev_a.dev.driver == &drv_a.drv);
+
+ /* Register a device first, then a driver — should also bind. */
+ dummy_dev dev_b = {};
+ dev_b.dev.bus = &dummy_bus;
+ dev_b.dev.init_name = "compat_b";
+ ST_ASSERT_EQ(device_register(&dev_b.dev), 0);
+ ST_ASSERT_EQ(dev_b.probed, 0);
+
+ dummy_drv drv_b = {};
+ drv_b.drv.name = "drv_b";
+ drv_b.drv.bus = &dummy_bus;
+ drv_b.drv.probe = dummy_probe;
+ drv_b.want = "compat_b";
+ ST_ASSERT_EQ(driver_register(&drv_b.drv), 0);
+ ST_ASSERT_EQ(dev_b.probed, 1);
+ ST_ASSERT(dev_b.dev.driver == &drv_b.drv);
+
+ /* Non-matching device sits unbound. */
+ dummy_dev dev_c = {};
+ dev_c.dev.bus = &dummy_bus;
+ dev_c.dev.init_name = "compat_unknown";
+ ST_ASSERT_EQ(device_register(&dev_c.dev), 0);
+ ST_ASSERT_EQ(dev_c.probed, 0);
+ ST_ASSERT(dev_c.dev.driver == nullptr);
+
+ ST_ASSERT_EQ(g_probes, 2);
+
+ /* Cleanup. */
+ device_unregister(&dev_a.dev);
+ device_unregister(&dev_b.dev);
+ device_unregister(&dev_c.dev);
+ driver_unregister(&drv_a.drv);
+ driver_unregister(&drv_b.drv);
+
+ st_pass();
+}
+
+} // namespace
+
+/* ------------------------------------------------------------------ */
+/* Entry */
+/* ------------------------------------------------------------------ */
+
+void selftest_driver_model()
+{
+ test_kref();
+ test_kobject_hierarchy();
+ test_bus_bind();
+}
diff --git a/test/test_exec.cc b/test/test_exec.cc
new file mode 100644
index 0000000..bbfa3b6
--- /dev/null
+++ b/test/test_exec.cc
@@ -0,0 +1,447 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_exec.cc — Self-tests for execve / ELF loader
+ *
+ * Tests the ELF validation, binary loading, and user stack construction
+ * by creating synthetic ELF binaries in RamFS and invoking do_execve
+ * in a controlled environment.
+ *
+ * We cannot test the actual user-space transition (that requires
+ * sret to U-mode), but we CAN test everything up to and including
+ * the trapframe setup.
+ */
+
+#include <lume/selftest.h>
+#include <lume/elf.h>
+#include <lume/task.h>
+#include <lume/vmm.h>
+#include <lume/pmm.h>
+#include <lume/addr.h>
+#include <lume/errno.h>
+#include <lume/fdtable.h>
+#include <lume/fs.h>
+#include <lume/fs_struct.h>
+#include <lume/dcache.h>
+#include <lume/namei.h>
+#include <lume/file.h>
+#include <lume/mm_layout.h>
+#include <arch/trapframe.h>
+#include <arch/pmap.h>
+
+/* Extern: do_execve (kernel/exec.cc) */
+int do_execve(const char* pathname, const char* const* argv,
+ const char* const* envp);
+
+/* Extern: VFS file creation syscalls */
+int64 sys_openat(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_write(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_close(uint64, uint64, uint64, uint64, uint64, uint64);
+
+/* ================================================================== */
+/* Helper: build a minimal valid RISC-V ELF64 in a buffer */
+/* ================================================================== */
+
+struct TestElf {
+ Elf64_Ehdr ehdr;
+ Elf64_Phdr phdr;
+ uint8 code[16]; /* Tiny "program" — just a few NOPs */
+};
+
+/*
+ * Build a minimal static ELF64 for RISC-V.
+ * Single PT_LOAD segment mapping the tiny code at 0x10000.
+ * Entry point = 0x10000 + offsetof(code).
+ */
+static void build_minimal_elf(TestElf* elf)
+{
+ /* Zero everything */
+ for (uint64 i = 0; i < sizeof(*elf); i++)
+ reinterpret_cast<uint8*>(elf)[i] = 0;
+
+ /* ELF header */
+ Elf64_Ehdr* e = &elf->ehdr;
+ e->e_ident[EI_MAG0] = ELFMAG0;
+ e->e_ident[EI_MAG1] = ELFMAG1;
+ e->e_ident[EI_MAG2] = ELFMAG2;
+ e->e_ident[EI_MAG3] = ELFMAG3;
+ e->e_ident[EI_CLASS] = ELFCLASS64;
+ e->e_ident[EI_DATA] = ELFDATA2LSB;
+ e->e_ident[EI_VERSION] = 1;
+ e->e_type = ET_EXEC;
+ e->e_machine = EM_RISCV;
+ e->e_version = 1;
+ e->e_phoff = sizeof(Elf64_Ehdr);
+ e->e_phentsize = sizeof(Elf64_Phdr);
+ e->e_phnum = 1;
+ e->e_ehsize = sizeof(Elf64_Ehdr);
+
+ /* Code section offset within the file */
+ uint64 code_offset = sizeof(Elf64_Ehdr) + sizeof(Elf64_Phdr);
+
+ /* Entry point = vaddr of code */
+ uint64 load_vaddr = 0x10000;
+ e->e_entry = load_vaddr + code_offset;
+
+ /* Program header — single PT_LOAD covering entire file */
+ Elf64_Phdr* p = &elf->phdr;
+ p->p_type = PT_LOAD;
+ p->p_flags = PF_R | PF_X;
+ p->p_offset = 0;
+ p->p_vaddr = load_vaddr;
+ p->p_paddr = load_vaddr;
+ p->p_filesz = sizeof(TestElf);
+ p->p_memsz = sizeof(TestElf);
+ p->p_align = 0x1000;
+
+ /* Code: RISC-V "unimp" (all zeros = illegal instruction, but good enough for testing) */
+ /* We just need the loader to succeed; we won't actually execute this. */
+ elf->code[0] = 0x13; /* nop = addi x0, x0, 0 */
+ elf->code[4] = 0x13;
+ elf->code[8] = 0x73; /* ebreak */
+}
+
+/* ================================================================== */
+/* Helper: write a buffer to a file in RamFS */
+/* ================================================================== */
+
+static int create_test_file(const char* path, const void* data, uint64 size)
+{
+ /* Open with O_CREAT | O_WRONLY | O_TRUNC */
+ int64 fd = sys_openat(
+ static_cast<uint64>(-100), /* AT_FDCWD */
+ reinterpret_cast<uint64>(path),
+ O_CREAT | O_WRONLY | O_TRUNC,
+ 0755, 0, 0);
+ if (fd < 0) return static_cast<int>(fd);
+
+ int64 written = sys_write(
+ static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(data),
+ size, 0, 0, 0);
+
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+
+ if (written < 0) return static_cast<int>(written);
+ if (static_cast<uint64>(written) != size) return -EIO;
+ return 0;
+}
+
+/* ================================================================== */
+/* Helper: ensure current task has VFS context for file ops */
+/* ================================================================== */
+
+struct ExecTestCtx {
+ TaskControlBlock* curr;
+ VmSpace* old_mm;
+ arch::TrapFrame fake_tf;
+ arch::TrapFrame* old_tf;
+ uint64 saved_satp;
+};
+
+static void exec_test_setup(ExecTestCtx* ctx)
+{
+ ctx->curr = current_task();
+
+ /* Save kernel SATP so we can restore it in teardown */
+ asm volatile("csrr %0, satp" : "=r"(ctx->saved_satp));
+
+ /* Save and replace mm with a fresh one */
+ ctx->old_mm = ctx->curr->mm;
+ ctx->curr->mm = VmSpace::create();
+
+ /* Set up a fake trapframe so do_execve can modify it */
+ for (uint64 i = 0; i < sizeof(ctx->fake_tf); i++)
+ reinterpret_cast<uint8*>(&ctx->fake_tf)[i] = 0;
+ ctx->old_tf = ctx->curr->trapframe;
+ ctx->curr->trapframe = &ctx->fake_tf;
+}
+
+static void exec_test_teardown(ExecTestCtx* ctx)
+{
+ /* Restore trapframe */
+ ctx->curr->trapframe = ctx->old_tf;
+
+ /* Destroy the test mm (may have been replaced by do_execve) */
+ VmSpace* test_mm = ctx->curr->mm;
+ ctx->curr->mm = ctx->old_mm;
+ if (test_mm && test_mm != ctx->old_mm) {
+ /* If do_execve succeeded, it activated new_mm's page table.
+ * Switch back to the kernel page table before destroying. */
+ asm volatile("csrw satp, %0" :: "r"(ctx->saved_satp));
+ asm volatile("sfence.vma");
+ test_mm->put();
+ }
+}
+
+/* ================================================================== */
+/* Test cases */
+/* ================================================================== */
+
+void selftest_exec()
+{
+ /* ---- 1a. Non-existent path under missing directory ---- */
+ st_begin("exec: missing path returns error");
+ {
+ const char* argv[] = { "/no_such_dir/no_file", nullptr };
+ const char* envp[] = { nullptr };
+ int ret = do_execve("/no_such_dir/no_file", argv, envp);
+ ST_ASSERT(ret < 0); /* -ENOENT from filename_lookup */
+ }
+ st_pass();
+
+ /* ---- 1b. Directory is not executable ---- */
+ st_begin("exec: directory returns -EACCES");
+ {
+ /* "/" exists and is a directory */
+ const char* argv[] = { "/dev", nullptr };
+ const char* envp[] = { nullptr };
+ int ret = do_execve("/dev", argv, envp);
+ ST_ASSERT_EQ(ret, -EACCES);
+ }
+ st_pass();
+
+ /* ---- 2. Bad ELF magic ---- */
+ st_begin("exec: bad ELF magic returns -ENOEXEC");
+ {
+ uint8 garbage[128];
+ for (int i = 0; i < 128; i++) garbage[i] = 0xAA;
+ int err = create_test_file("/test_bad_magic", garbage, 128);
+ ST_ASSERT_EQ(err, 0);
+
+ ExecTestCtx ctx;
+ exec_test_setup(&ctx);
+
+ const char* argv[] = { "/test_bad_magic", nullptr };
+ const char* envp[] = { nullptr };
+ int ret = do_execve("/test_bad_magic", argv, envp);
+ ST_ASSERT_EQ(ret, -ENOEXEC);
+
+ exec_test_teardown(&ctx);
+ }
+ st_pass();
+
+ /* ---- 3. Wrong ELF class (32-bit) ---- */
+ st_begin("exec: 32-bit ELF returns -ENOEXEC");
+ {
+ TestElf elf;
+ build_minimal_elf(&elf);
+ elf.ehdr.e_ident[EI_CLASS] = 1; /* ELFCLASS32 */
+ int err = create_test_file("/test_elf32", &elf, sizeof(elf));
+ ST_ASSERT_EQ(err, 0);
+
+ ExecTestCtx ctx;
+ exec_test_setup(&ctx);
+
+ const char* argv[] = { "/test_elf32", nullptr };
+ const char* envp[] = { nullptr };
+ int ret = do_execve("/test_elf32", argv, envp);
+ ST_ASSERT_EQ(ret, -ENOEXEC);
+
+ exec_test_teardown(&ctx);
+ }
+ st_pass();
+
+ /* ---- 4. Wrong endianness (big-endian) ---- */
+ st_begin("exec: big-endian ELF returns -ENOEXEC");
+ {
+ TestElf elf;
+ build_minimal_elf(&elf);
+ elf.ehdr.e_ident[EI_DATA] = 2; /* ELFDATA2MSB */
+ int err = create_test_file("/test_be", &elf, sizeof(elf));
+ ST_ASSERT_EQ(err, 0);
+
+ ExecTestCtx ctx;
+ exec_test_setup(&ctx);
+
+ const char* argv[] = { "/test_be", nullptr };
+ const char* envp[] = { nullptr };
+ int ret = do_execve("/test_be", argv, envp);
+ ST_ASSERT_EQ(ret, -ENOEXEC);
+
+ exec_test_teardown(&ctx);
+ }
+ st_pass();
+
+ /* ---- 5. Wrong machine (x86_64 instead of RISC-V) ---- */
+ st_begin("exec: wrong machine type returns -ENOEXEC");
+ {
+ TestElf elf;
+ build_minimal_elf(&elf);
+ elf.ehdr.e_machine = 62; /* EM_X86_64 */
+ int err = create_test_file("/test_x86", &elf, sizeof(elf));
+ ST_ASSERT_EQ(err, 0);
+
+ ExecTestCtx ctx;
+ exec_test_setup(&ctx);
+
+ const char* argv[] = { "/test_x86", nullptr };
+ const char* envp[] = { nullptr };
+ int ret = do_execve("/test_x86", argv, envp);
+ ST_ASSERT_EQ(ret, -ENOEXEC);
+
+ exec_test_teardown(&ctx);
+ }
+ st_pass();
+
+ /* ---- 6. Truncated ELF (too small for header) ---- */
+ st_begin("exec: truncated ELF returns -ENOEXEC");
+ {
+ /* Valid magic but file is too short for full header */
+ uint8 tiny[8] = { 0x7f, 'E', 'L', 'F', 2, 1, 1, 0 };
+ int err = create_test_file("/test_tiny", tiny, sizeof(tiny));
+ ST_ASSERT_EQ(err, 0);
+
+ ExecTestCtx ctx;
+ exec_test_setup(&ctx);
+
+ const char* argv[] = { "/test_tiny", nullptr };
+ const char* envp[] = { nullptr };
+ int ret = do_execve("/test_tiny", argv, envp);
+ ST_ASSERT_EQ(ret, -ENOEXEC);
+
+ exec_test_teardown(&ctx);
+ }
+ st_pass();
+
+ /* ---- 7. Zero program headers ---- */
+ st_begin("exec: zero phnum returns -ENOEXEC");
+ {
+ TestElf elf;
+ build_minimal_elf(&elf);
+ elf.ehdr.e_phnum = 0;
+ int err = create_test_file("/test_nophdr", &elf, sizeof(elf));
+ ST_ASSERT_EQ(err, 0);
+
+ ExecTestCtx ctx;
+ exec_test_setup(&ctx);
+
+ const char* argv[] = { "/test_nophdr", nullptr };
+ const char* envp[] = { nullptr };
+ int ret = do_execve("/test_nophdr", argv, envp);
+ ST_ASSERT_EQ(ret, -ENOEXEC);
+
+ exec_test_teardown(&ctx);
+ }
+ st_pass();
+
+ /* ---- 8. Valid ELF: successful load and trapframe setup ---- */
+ st_begin("exec: valid ELF loads and sets up trapframe");
+ {
+ TestElf elf;
+ build_minimal_elf(&elf);
+ int err = create_test_file("/test_valid", &elf, sizeof(elf));
+ ST_ASSERT_EQ(err, 0);
+
+ ExecTestCtx ctx;
+ exec_test_setup(&ctx);
+
+ const char* argv[] = { "/test_valid", nullptr };
+ const char* envp[] = { "HOME=/", nullptr };
+ int ret = do_execve("/test_valid", argv, envp);
+ ST_ASSERT_EQ(ret, 0);
+
+ /* Verify trapframe was set up correctly */
+ arch::TrapFrame* tf = &ctx.fake_tf;
+
+ /* Entry point should match what we set in the ELF */
+ uint64 code_offset = sizeof(Elf64_Ehdr) + sizeof(Elf64_Phdr);
+ uint64 expected_entry = 0x10000 + code_offset;
+ ST_ASSERT_EQ(tf->epc, expected_entry);
+
+ /* Stack pointer should be in user space, below the stack top */
+ ST_ASSERT(tf->sp > 0);
+ ST_ASSERT(tf->sp < kUserStackTop);
+ ST_ASSERT(tf->sp >= kUserStackBase);
+
+ /* sstatus: SPP=0 (U-mode), SPIE=1 (interrupts enabled on sret) */
+ uint64 spp_bit = (1ULL << 8);
+ uint64 spie_bit = (1ULL << 5);
+ ST_ASSERT_EQ(tf->sstatus & spp_bit, 0ULL); /* Not S-mode */
+ ST_ASSERT_EQ(tf->sstatus & spie_bit, spie_bit); /* SPIE set */
+
+ /* General registers should be zeroed */
+ ST_ASSERT_EQ(tf->ra, 0ULL);
+ ST_ASSERT_EQ(tf->gp, 0ULL);
+ ST_ASSERT_EQ(tf->tp, 0ULL);
+ for (int i = 0; i < 7; i++)
+ ST_ASSERT_EQ(tf->t[i], 0ULL);
+ for (int i = 0; i < 12; i++)
+ ST_ASSERT_EQ(tf->s[i], 0ULL);
+ for (int i = 0; i < 8; i++)
+ ST_ASSERT_EQ(tf->a[i], 0ULL);
+
+ exec_test_teardown(&ctx);
+ }
+ st_pass();
+
+ /* ---- 9. Verify user stack layout (argc/argv) ---- */
+ st_begin("exec: user stack has correct argc/argv layout");
+ {
+ TestElf elf;
+ build_minimal_elf(&elf);
+ int err = create_test_file("/test_stack", &elf, sizeof(elf));
+ ST_ASSERT_EQ(err, 0);
+
+ ExecTestCtx ctx;
+ exec_test_setup(&ctx);
+
+ const char* argv[] = { "/test_stack", "arg1", nullptr };
+ const char* envp[] = { "A=B", nullptr };
+ int ret = do_execve("/test_stack", argv, envp);
+ ST_ASSERT_EQ(ret, 0);
+
+ /* The stack was set up in the new mm.
+ * Read argc from the top of the stack using pmap lookup. */
+ VmSpace* mm = ctx.curr->mm;
+ uint64 sp = ctx.fake_tf.sp;
+
+ /* Look up the physical page for sp */
+ uint64 pa = 0;
+ bool mapped = pmap::lookup(mm->root_pa, sp & ~(kPageSize - 1), &pa);
+ ST_ASSERT(mapped);
+
+ /* Read argc (first uint64 on stack) */
+ uint64 page_offset = sp & (kPageSize - 1);
+ uint8* kva = reinterpret_cast<uint8*>(pa_to_va(PhysAddr{pa}).raw);
+ uint64 argc = *reinterpret_cast<uint64*>(kva + page_offset);
+ ST_ASSERT_EQ(argc, 2ULL); /* "/test_stack" and "arg1" */
+
+ exec_test_teardown(&ctx);
+ }
+ st_pass();
+
+ /* ---- 10. Multiple consecutive execve replaces mm correctly ---- */
+ st_begin("exec: consecutive execve replaces address space");
+ {
+ TestElf elf;
+ build_minimal_elf(&elf);
+ int err = create_test_file("/test_reexec", &elf, sizeof(elf));
+ ST_ASSERT_EQ(err, 0);
+
+ ExecTestCtx ctx;
+ exec_test_setup(&ctx);
+
+ /* First exec */
+ const char* argv[] = { "/test_reexec", nullptr };
+ const char* envp[] = { nullptr };
+ int ret = do_execve("/test_reexec", argv, envp);
+ ST_ASSERT_EQ(ret, 0);
+
+ VmSpace* first_mm = ctx.curr->mm;
+ ST_ASSERT(first_mm != nullptr);
+
+ /* Second exec — should replace the first mm */
+ ret = do_execve("/test_reexec", argv, envp);
+ ST_ASSERT_EQ(ret, 0);
+
+ /* mm should be different (new address space) */
+ VmSpace* second_mm = ctx.curr->mm;
+ ST_ASSERT(second_mm != nullptr);
+ ST_ASSERT_NE(first_mm, second_mm);
+
+ exec_test_teardown(&ctx);
+ }
+ st_pass();
+}
diff --git a/test/test_fdt.cc b/test/test_fdt.cc
new file mode 100644
index 0000000..cf09029
--- /dev/null
+++ b/test/test_fdt.cc
@@ -0,0 +1,50 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * Boot Self-Test: FDT (Flattened Device Tree)
+ */
+
+#include <lume/selftest.h>
+#include <lume/fdt.h>
+
+// Manually declare what we need to avoid header conflicts with toolchain libc
+extern "C" int strcmp(const char *s1, const char *s2);
+
+void selftest_fdt() {
+ st_begin("fdt: tree root access");
+ {
+ DeviceNode* root = fdt_get_node_by_path("/");
+ ST_ASSERT(root != nullptr);
+ ST_ASSERT_EQ(strcmp(root->get_name(), ""), 0);
+ }
+ st_pass();
+
+ st_begin("fdt: find existing compatible node (UART)");
+ {
+ // QEMU virt RISC-V platform always has a ns16550a UART
+ DeviceNode* uart = fdt_find_compatible("ns16550a");
+ ST_ASSERT(uart != nullptr);
+
+ // Use get_prop_string which is convenience helper
+ const char* compat = uart->get_prop_string("compatible");
+ ST_ASSERT(compat != nullptr);
+
+ // Check raw property access
+ const FdtProperty* reg = uart->get_prop("reg");
+ ST_ASSERT(reg != nullptr);
+ ST_ASSERT(reg->value != nullptr);
+ ST_ASSERT(reg->len > 0);
+ }
+ st_pass();
+
+ st_begin("fdt: non-existent node/compatible queries");
+ {
+ DeviceNode* missing1 = fdt_get_node_by_path("/definitely_does_not_exist_node_12345");
+ ST_ASSERT(missing1 == nullptr);
+
+ DeviceNode* missing2 = fdt_find_compatible("invalid_compatible_string_xyz");
+ ST_ASSERT(missing2 == nullptr);
+ }
+ st_pass();
+}
diff --git a/test/test_klog.cc b/test/test_klog.cc
new file mode 100644
index 0000000..896e7ed
--- /dev/null
+++ b/test/test_klog.cc
@@ -0,0 +1,175 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * Boot Self-Test: klog
+ *
+ * Tests:
+ * 1. read vs read_consume semantics
+ * 2. subsystem mask filtering
+ * 3. ratelimit bookkeeping
+ * 4. flush advances console tail when console filtering is active
+ * 5. seq counter increments on writes
+ */
+
+#include <lume/selftest.h>
+#include <lume/klog.h>
+#include <string.h>
+
+static void drain_klog()
+{
+ static char buf[512];
+ while (true) {
+ int n = klog_read_consume(buf, sizeof(buf));
+ if (n <= 0) {
+ break;
+ }
+ }
+}
+
+static bool test_read_peek_and_consume()
+{
+ drain_klog();
+
+ klog_ex(KLOG_INFO, KLOG_SUBSYS_TEST, "klog_test_a");
+ klog_ex(KLOG_INFO, KLOG_SUBSYS_TEST, "klog_test_b");
+
+ KlogStats s0;
+ klog_stats(&s0);
+
+ char buf[1024];
+ int n = klog_read(buf, sizeof(buf) - 1);
+ if (n <= 0) {
+ return false;
+ }
+ buf[n] = '\0';
+ if (!strstr(buf, "klog_test_a") || !strstr(buf, "klog_test_b")) {
+ return false;
+ }
+
+ KlogStats s1;
+ klog_stats(&s1);
+ if (s1.tail != s0.tail) {
+ return false;
+ }
+
+ int n2 = klog_read_consume(buf, sizeof(buf) - 1);
+ if (n2 <= 0) {
+ return false;
+ }
+ buf[n2] = '\0';
+ if (!strstr(buf, "klog_test_a") || !strstr(buf, "klog_test_b")) {
+ return false;
+ }
+
+ KlogStats s2;
+ klog_stats(&s2);
+ return s2.tail != s0.tail;
+}
+
+static bool test_subsys_mask()
+{
+ drain_klog();
+
+ const uint64 all_mask = (1ULL << KLOG_SUBSYS_MAX) - 1;
+ klog_set_subsys_mask(1ULL << KLOG_SUBSYS_TEST);
+
+ klog_ex(KLOG_INFO, KLOG_SUBSYS_TEST, "klog_subsys_ok");
+ klog_ex(KLOG_INFO, KLOG_SUBSYS_GENERIC, "klog_subsys_drop");
+
+ char buf[1024];
+ int n = klog_read_consume(buf, sizeof(buf) - 1);
+ klog_set_subsys_mask(all_mask);
+
+ if (n <= 0) {
+ return false;
+ }
+ buf[n] = '\0';
+ return strstr(buf, "klog_subsys_ok") && !strstr(buf, "klog_subsys_drop");
+}
+
+static bool test_ratelimit()
+{
+ KlogRateLimit rl = {};
+ rl.interval = 100;
+ rl.burst = 2;
+
+ bool a = klog_ratelimit(&rl, 10);
+ bool b = klog_ratelimit(&rl, 20);
+ bool c = klog_ratelimit(&rl, 30);
+
+ if (!a || !b || c) {
+ return false;
+ }
+ if (rl.printed != 2 || rl.suppressed != 1) {
+ return false;
+ }
+
+ bool d = klog_ratelimit(&rl, 200);
+ if (!d) {
+ return false;
+ }
+ return rl.printed == 1 && rl.suppressed == 0;
+}
+
+static bool test_flush_advances_console_tail()
+{
+ drain_klog();
+
+ klog_set_level(KLOG_ERR);
+ klog_ex(KLOG_INFO, KLOG_SUBSYS_TEST, "klog_flush_msg");
+
+ KlogStats s1;
+ klog_stats(&s1);
+ if (s1.console_tail == s1.head) {
+ klog_set_level(static_cast<KlogLevel>(KLOG_DEFAULT_LEVEL));
+ return false;
+ }
+
+ klog_flush();
+
+ KlogStats s2;
+ klog_stats(&s2);
+
+ klog_set_level(static_cast<KlogLevel>(KLOG_DEFAULT_LEVEL));
+ return s2.console_tail == s2.head;
+}
+
+static bool test_seq_increments()
+{
+ drain_klog();
+
+ KlogStats s0;
+ klog_stats(&s0);
+
+ klog_ex(KLOG_INFO, KLOG_SUBSYS_TEST, "klog_seq_1");
+ klog_ex(KLOG_INFO, KLOG_SUBSYS_TEST, "klog_seq_2");
+
+ KlogStats s1;
+ klog_stats(&s1);
+
+ return s1.seq >= s0.seq + 2;
+}
+
+void selftest_klog()
+{
+ st_begin("klog: read vs read_consume");
+ ST_ASSERT(test_read_peek_and_consume());
+ st_pass();
+
+ st_begin("klog: subsys mask filters");
+ ST_ASSERT(test_subsys_mask());
+ st_pass();
+
+ st_begin("klog: ratelimit bookkeeping");
+ ST_ASSERT(test_ratelimit());
+ st_pass();
+
+ st_begin("klog: flush advances console tail");
+ ST_ASSERT(test_flush_advances_console_tail());
+ st_pass();
+
+ st_begin("klog: seq increments on write");
+ ST_ASSERT(test_seq_increments());
+ st_pass();
+}
diff --git a/test/test_mutex.cc b/test/test_mutex.cc
new file mode 100644
index 0000000..e86c984
--- /dev/null
+++ b/test/test_mutex.cc
@@ -0,0 +1,183 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_mutex — self-tests for the Mutex sleeping lock.
+ *
+ * Coverage:
+ * - basic lock/unlock single-thread
+ * - try_lock when free and when held
+ * - mutual exclusion across kthreads (no lost increments)
+ * - FIFO wake order (A acquires, B/C queue; release lets B in then C)
+ */
+
+#include <lume/selftest.h>
+#include <lume/mutex.h>
+#include <lume/task.h>
+#include <lume/sched.h>
+#include <lume/kprintf.h>
+
+extern TaskControlBlock* kthread_create(const char* name,
+ void (*entry)(void*), void* arg,
+ uint8 priority);
+extern void task_exit(int code);
+
+/* ------------------------------------------------------------------ */
+/* Test 1: basic lock/unlock */
+/* ------------------------------------------------------------------ */
+
+static void test_basic()
+{
+ st_begin("mutex: basic lock/unlock");
+
+ Mutex m;
+ m.init("test_basic");
+
+ ST_ASSERT(!m.is_locked());
+ m.lock();
+ ST_ASSERT(m.is_locked());
+ ST_ASSERT(m.is_owned_by_current());
+ m.unlock();
+ ST_ASSERT(!m.is_locked());
+
+ st_pass();
+}
+
+/* ------------------------------------------------------------------ */
+/* Test 2: try_lock */
+/* ------------------------------------------------------------------ */
+
+static void test_trylock()
+{
+ st_begin("mutex: try_lock");
+
+ Mutex m;
+ m.init("test_trylock");
+
+ ST_ASSERT(m.try_lock() == true);
+ ST_ASSERT(m.is_locked());
+
+ /* A second try_lock from the same task: since owner == current,
+ * we still return false (try_lock is non-recursive). */
+ ST_ASSERT(m.try_lock() == false);
+
+ m.unlock();
+ ST_ASSERT(!m.is_locked());
+ ST_ASSERT(m.try_lock() == true);
+ m.unlock();
+
+ st_pass();
+}
+
+/* ------------------------------------------------------------------ */
+/* Test 3: mutual exclusion across kthreads */
+/* ------------------------------------------------------------------ */
+
+static Mutex g_mut;
+static volatile int32 g_counter = 0;
+static volatile int32 g_done = 0;
+static constexpr int32 kIters = 200;
+
+static void mutex_worker(void*)
+{
+ for (int i = 0; i < kIters; i++) {
+ g_mut.lock();
+ int32 v = g_counter;
+ /* Yield inside the critical section to expose missing mutex.
+ * If the mutex were broken we'd see lost increments. */
+ sched_yield();
+ g_counter = v + 1;
+ g_mut.unlock();
+ }
+ __sync_fetch_and_add(&g_done, 1);
+ task_exit(0);
+}
+
+static void test_mutual_exclusion()
+{
+ st_begin("mutex: mutual exclusion (2 workers)");
+
+ g_mut.init("test_mutex_mx");
+ g_counter = 0;
+ g_done = 0;
+
+ kthread_create("mut_w1", mutex_worker, nullptr, 0);
+ kthread_create("mut_w2", mutex_worker, nullptr, 0);
+
+ /* Spin-yield until both workers finish. */
+ while (g_done < 2) {
+ sched_yield();
+ }
+
+ ST_ASSERT_EQ(g_counter, 2 * kIters);
+ ST_ASSERT(!g_mut.is_locked());
+
+ st_pass();
+}
+
+/* ------------------------------------------------------------------ */
+/* Test 4: FIFO wake order */
+/* ------------------------------------------------------------------ */
+
+static Mutex g_fifo_mut;
+static volatile int32 g_fifo_seq = 0; // monotonically increases
+static volatile int32 g_fifo_order[3]; // order each worker observed
+static volatile int32 g_fifo_done = 0;
+
+static void fifo_worker(void* arg)
+{
+ int slot = static_cast<int>(reinterpret_cast<uint64>(arg));
+ g_fifo_mut.lock();
+ g_fifo_order[slot] = __sync_fetch_and_add(&g_fifo_seq, 1);
+ g_fifo_mut.unlock();
+ __sync_fetch_and_add(&g_fifo_done, 1);
+ task_exit(0);
+}
+
+static void test_fifo_order()
+{
+ st_begin("mutex: FIFO wake order (3 waiters)");
+
+ g_fifo_mut.init("test_mutex_fifo");
+ g_fifo_seq = 0;
+ g_fifo_done = 0;
+ g_fifo_order[0] = g_fifo_order[1] = g_fifo_order[2] = -1;
+
+ /* Main grabs the lock first so workers will queue up behind. */
+ g_fifo_mut.lock();
+
+ /* Spawn workers in deterministic order. After each create() yield
+ * once so the new thread reaches mutex.lock() and enqueues itself
+ * before we spawn the next one. */
+ kthread_create("fifo_A", fifo_worker, reinterpret_cast<void*>(0), 0);
+ sched_yield();
+ kthread_create("fifo_B", fifo_worker, reinterpret_cast<void*>(1), 0);
+ sched_yield();
+ kthread_create("fifo_C", fifo_worker, reinterpret_cast<void*>(2), 0);
+ sched_yield();
+
+ /* Release: A should fire next, then B, then C. */
+ g_fifo_mut.unlock();
+
+ while (g_fifo_done < 3) {
+ sched_yield();
+ }
+
+ ST_ASSERT_EQ(g_fifo_order[0], 0);
+ ST_ASSERT_EQ(g_fifo_order[1], 1);
+ ST_ASSERT_EQ(g_fifo_order[2], 2);
+
+ st_pass();
+}
+
+/* ------------------------------------------------------------------ */
+/* Entry */
+/* ------------------------------------------------------------------ */
+
+void selftest_mutex()
+{
+ test_basic();
+ test_trylock();
+ test_mutual_exclusion();
+ test_fifo_order();
+}
diff --git a/test/test_namei.cc b/test/test_namei.cc
new file mode 100644
index 0000000..8e4ecb0
--- /dev/null
+++ b/test/test_namei.cc
@@ -0,0 +1,173 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_namei.cc — Self-tests for pathname resolution (namei.cc)
+ */
+
+#include <lume/selftest.h>
+#include <lume/namei.h>
+#include <lume/dcache.h>
+#include <lume/fs.h>
+#include <lume/file.h>
+#include <lume/mount.h>
+#include <lume/task.h>
+#include <lume/errno.h>
+
+extern dentry* g_root_dentry;
+extern vfsmount* g_root_mnt;
+
+/* sys_mkdirat from kernel/sys_fs.cc */
+int64 sys_mkdirat(uint64, uint64, uint64, uint64, uint64, uint64);
+
+void selftest_namei()
+{
+ /* ---- lookup root "/" ---- */
+ st_begin("namei: lookup root /");
+ {
+ path p{};
+ int err = filename_lookup(AT_FDCWD, "/", 0, &p);
+ ST_ASSERT_EQ(err, 0);
+ ST_ASSERT(p.de == g_root_dentry);
+ ST_ASSERT(p.de->d_inode != nullptr);
+ ST_ASSERT(S_ISDIR(p.de->d_inode->i_mode));
+ }
+ st_pass();
+
+ /* ---- lookup /dev (exists from boot) ---- */
+ st_begin("namei: lookup /dev");
+ {
+ path p{};
+ int err = filename_lookup(AT_FDCWD, "/dev", 0, &p);
+ ST_ASSERT_EQ(err, 0);
+ ST_ASSERT(p.de != nullptr);
+ ST_ASSERT(p.de->d_inode != nullptr);
+ ST_ASSERT(S_ISDIR(p.de->d_inode->i_mode));
+ }
+ st_pass();
+
+ /* ---- lookup nonexistent path returns -ENOENT ---- */
+ st_begin("namei: lookup nonexistent returns -ENOENT");
+ {
+ path p{};
+ int err = filename_lookup(AT_FDCWD, "/this_does_not_exist", 0, &p);
+ ST_ASSERT_EQ(err, -ENOENT);
+ }
+ st_pass();
+
+ /* ---- lookup /dev/console (chardev) ---- */
+ st_begin("namei: lookup /dev/console");
+ {
+ path p{};
+ int err = filename_lookup(AT_FDCWD, "/dev/console", 0, &p);
+ ST_ASSERT_EQ(err, 0);
+ ST_ASSERT(p.de->d_inode != nullptr);
+ ST_ASSERT(S_ISCHR(p.de->d_inode->i_mode));
+ }
+ st_pass();
+
+ /* ---- lookup with LOOKUP_DIRECTORY on non-dir returns -ENOTDIR ---- */
+ st_begin("namei: LOOKUP_DIRECTORY on file returns -ENOTDIR");
+ {
+ path p{};
+ int err = filename_lookup(AT_FDCWD, "/dev/console", LOOKUP_DIRECTORY, &p);
+ ST_ASSERT_EQ(err, -ENOTDIR);
+ }
+ st_pass();
+
+ /* ---- lookup with LOOKUP_DIRECTORY on dir succeeds ---- */
+ st_begin("namei: LOOKUP_DIRECTORY on dir succeeds");
+ {
+ path p{};
+ int err = filename_lookup(AT_FDCWD, "/dev", LOOKUP_DIRECTORY, &p);
+ ST_ASSERT_EQ(err, 0);
+ ST_ASSERT(S_ISDIR(p.de->d_inode->i_mode));
+ }
+ st_pass();
+
+ /* ---- kern_path shortcut ---- */
+ st_begin("namei: kern_path works like filename_lookup");
+ {
+ path p{};
+ int err = kern_path("/dev", 0, &p);
+ ST_ASSERT_EQ(err, 0);
+ ST_ASSERT(p.de->d_inode != nullptr);
+ }
+ st_pass();
+
+ /* ---- dot path: /dev/. resolves to /dev ---- */
+ st_begin("namei: /dev/. resolves to /dev");
+ {
+ path p1{}, p2{};
+ int e1 = filename_lookup(AT_FDCWD, "/dev", 0, &p1);
+ int e2 = filename_lookup(AT_FDCWD, "/dev/.", 0, &p2);
+ ST_ASSERT_EQ(e1, 0);
+ ST_ASSERT_EQ(e2, 0);
+ ST_ASSERT(p1.de == p2.de);
+ }
+ st_pass();
+
+ /* ---- dotdot path: /dev/.. resolves to root ---- */
+ st_begin("namei: /dev/.. resolves to root");
+ {
+ path p{};
+ int err = filename_lookup(AT_FDCWD, "/dev/..", 0, &p);
+ ST_ASSERT_EQ(err, 0);
+ ST_ASSERT(p.de == g_root_dentry);
+ }
+ st_pass();
+
+ /* ---- lookup into non-dir returns -ENOTDIR ---- */
+ st_begin("namei: path through non-dir returns -ENOTDIR");
+ {
+ path p{};
+ int err = filename_lookup(AT_FDCWD, "/dev/console/child", 0, &p);
+ ST_ASSERT_EQ(err, -ENOTDIR);
+ }
+ st_pass();
+
+ /* ---- null/empty paths ---- */
+ st_begin("namei: null name returns -EINVAL");
+ {
+ path p{};
+ int err = filename_lookup(AT_FDCWD, nullptr, 0, &p);
+ ST_ASSERT_EQ(err, -EINVAL);
+ }
+ st_pass();
+
+ /* ---- LOOKUP_PARENT: returns parent dentry ---- */
+ st_begin("namei: LOOKUP_PARENT returns parent");
+ {
+ path p{};
+ int err = filename_lookup(AT_FDCWD, "/dev/console", LOOKUP_PARENT, &p);
+ ST_ASSERT_EQ(err, 0);
+ /* Should return /dev, not /dev/console */
+ path dev_path{};
+ int e2 = filename_lookup(AT_FDCWD, "/dev", 0, &dev_path);
+ ST_ASSERT_EQ(e2, 0);
+ ST_ASSERT(p.de == dev_path.de);
+ }
+ st_pass();
+
+ /* ---- mkdir + lookup round-trip ---- */
+ st_begin("namei: mkdir then lookup succeeds");
+ {
+ /* Create /test_namei_dir via sys_mkdirat */
+ int64 ret = sys_mkdirat(
+ static_cast<uint64>(-100),
+ reinterpret_cast<uint64>("/test_namei_dir"),
+ 0755, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)0);
+
+ /* Now lookup should find it */
+ path p{};
+ int err = filename_lookup(AT_FDCWD, "/test_namei_dir", 0, &p);
+ ST_ASSERT_EQ(err, 0);
+ ST_ASSERT(p.de->d_inode != nullptr);
+ ST_ASSERT(S_ISDIR(p.de->d_inode->i_mode));
+ }
+ st_pass();
+
+ /* NOTE: trailing slash "/dev/" not tested — Phase 1 namei
+ * doesn't handle empty trailing components. */
+}
diff --git a/test/test_page_cache.cc b/test/test_page_cache.cc
new file mode 100644
index 0000000..ee0e0e2
--- /dev/null
+++ b/test/test_page_cache.cc
@@ -0,0 +1,180 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_page_cache.cc — Self-tests for the page cache hash table
+ *
+ * Tests: insert, get, remove, duplicate insert, invalidate,
+ * get_or_alloc, null args.
+ */
+
+#include <lume/selftest.h>
+#include <lume/page_cache.h>
+#include <lume/page.h>
+#include <lume/pmm.h>
+#include <lume/errno.h>
+
+void selftest_page_cache()
+{
+ /* Use sentinel pointers as fake "mapping" identifiers.
+ * These are never dereferenced — only used as hash keys. */
+ int mapping_a_tag = 0;
+ int mapping_b_tag = 0;
+ void* mapping_a = &mapping_a_tag;
+ void* mapping_b = &mapping_b_tag;
+
+ /* ---- insert and get ---- */
+ st_begin("page_cache: insert then get returns same page");
+ {
+ Page* page = pmm_alloc_page();
+ ST_ASSERT(page != nullptr);
+
+ int ret = page_cache_insert(mapping_a, 0, page);
+ ST_ASSERT_EQ(ret, 0);
+
+ Page* got = page_cache_get(mapping_a, 0);
+ ST_ASSERT_EQ(got, page);
+
+ /* put the reference from get */
+ page_cache_put(got);
+
+ /* Clean up */
+ page_cache_remove(mapping_a, 0);
+ pmm_free_page(page);
+ }
+ st_pass();
+
+ /* ---- get nonexistent returns nullptr ---- */
+ st_begin("page_cache: get nonexistent returns nullptr");
+ {
+ Page* got = page_cache_get(mapping_a, 99999);
+ ST_ASSERT(got == nullptr);
+ }
+ st_pass();
+
+ /* ---- duplicate insert returns -EEXIST ---- */
+ st_begin("page_cache: duplicate insert returns -EEXIST");
+ {
+ Page* page = pmm_alloc_page();
+ ST_ASSERT(page != nullptr);
+
+ int ret1 = page_cache_insert(mapping_a, 1, page);
+ ST_ASSERT_EQ(ret1, 0);
+
+ Page* page2 = pmm_alloc_page();
+ ST_ASSERT(page2 != nullptr);
+
+ int ret2 = page_cache_insert(mapping_a, 1, page2);
+ ST_ASSERT_EQ(ret2, -EEXIST);
+
+ /* Clean up */
+ page_cache_remove(mapping_a, 1);
+ pmm_free_page(page);
+ pmm_free_page(page2);
+ }
+ st_pass();
+
+ /* ---- remove returns page ---- */
+ st_begin("page_cache: remove returns page and deletes entry");
+ {
+ Page* page = pmm_alloc_page();
+ ST_ASSERT(page != nullptr);
+
+ page_cache_insert(mapping_a, 2, page);
+
+ Page* removed = page_cache_remove(mapping_a, 2);
+ ST_ASSERT_EQ(removed, page);
+
+ /* Should be gone now */
+ Page* got = page_cache_get(mapping_a, 2);
+ ST_ASSERT(got == nullptr);
+
+ pmm_free_page(page);
+ }
+ st_pass();
+
+ /* ---- remove nonexistent returns nullptr ---- */
+ st_begin("page_cache: remove nonexistent returns nullptr");
+ {
+ Page* got = page_cache_remove(mapping_a, 77777);
+ ST_ASSERT(got == nullptr);
+ }
+ st_pass();
+
+ /* ---- different mappings are independent ---- */
+ st_begin("page_cache: different mappings are independent");
+ {
+ Page* pa = pmm_alloc_page();
+ Page* pb = pmm_alloc_page();
+ ST_ASSERT(pa != nullptr);
+ ST_ASSERT(pb != nullptr);
+
+ page_cache_insert(mapping_a, 5, pa);
+ page_cache_insert(mapping_b, 5, pb);
+
+ Page* got_a = page_cache_get(mapping_a, 5);
+ Page* got_b = page_cache_get(mapping_b, 5);
+ ST_ASSERT_EQ(got_a, pa);
+ ST_ASSERT_EQ(got_b, pb);
+ ST_ASSERT_NE(got_a, got_b);
+
+ page_cache_put(got_a);
+ page_cache_put(got_b);
+ page_cache_remove(mapping_a, 5);
+ page_cache_remove(mapping_b, 5);
+ pmm_free_page(pa);
+ pmm_free_page(pb);
+ }
+ st_pass();
+
+ /* ---- invalidate removes all entries for a mapping ---- */
+ st_begin("page_cache: invalidate removes all entries for mapping");
+ {
+ Page* pages[4];
+ for (int i = 0; i < 4; i++) {
+ pages[i] = pmm_alloc_page();
+ ST_ASSERT(pages[i] != nullptr);
+ page_cache_insert(mapping_a, 10 + i, pages[i]);
+ }
+
+ /* Also insert one for mapping_b */
+ Page* pb = pmm_alloc_page();
+ page_cache_insert(mapping_b, 10, pb);
+
+ /* Invalidate mapping_a */
+ page_cache_invalidate(mapping_a);
+
+ /* All mapping_a entries should be gone */
+ for (int i = 0; i < 4; i++) {
+ Page* got = page_cache_get(mapping_a, 10 + i);
+ ST_ASSERT(got == nullptr);
+ pmm_free_page(pages[i]);
+ }
+
+ /* mapping_b should still be there */
+ Page* got_b = page_cache_get(mapping_b, 10);
+ ST_ASSERT_EQ(got_b, pb);
+ page_cache_put(got_b);
+ page_cache_remove(mapping_b, 10);
+ pmm_free_page(pb);
+ }
+ st_pass();
+
+ /* ---- insert null mapping returns -EINVAL ---- */
+ st_begin("page_cache: insert null mapping returns -EINVAL");
+ {
+ Page* page = pmm_alloc_page();
+ int ret = page_cache_insert(nullptr, 0, page);
+ ST_ASSERT_EQ(ret, -EINVAL);
+ pmm_free_page(page);
+ }
+ st_pass();
+
+ /* ---- insert null page returns -EINVAL ---- */
+ st_begin("page_cache: insert null page returns -EINVAL");
+ {
+ int ret = page_cache_insert(mapping_a, 0, nullptr);
+ ST_ASSERT_EQ(ret, -EINVAL);
+ }
+ st_pass();
+}
diff --git a/test/test_pid.cc b/test/test_pid.cc
new file mode 100644
index 0000000..e79eb1e
--- /dev/null
+++ b/test/test_pid.cc
@@ -0,0 +1,106 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_pid.cc — Self-tests for PID allocator
+ *
+ * Tests: alloc, free, re-use, exhaustion guard, reserved PIDs.
+ */
+
+#include <lume/selftest.h>
+#include <lume/pid.h>
+
+void selftest_pid()
+{
+ /* Create a local PidAllocator for isolated testing */
+ PidAllocator pa;
+ pa.init();
+
+ /* ---- PID 0 is reserved (idle/swapper) ---- */
+ st_begin("pid: PID 0 is reserved");
+ {
+ /* First alloc should return 1 (PID 0 reserved for idle) */
+ int32 first = pa.alloc();
+ ST_ASSERT(first >= 1);
+ }
+ st_pass();
+
+ /* ---- Sequential allocation returns unique PIDs ---- */
+ st_begin("pid: sequential allocation returns unique PIDs");
+ {
+ PidAllocator pa2;
+ pa2.init();
+
+ int32 p1 = pa2.alloc();
+ int32 p2 = pa2.alloc();
+ int32 p3 = pa2.alloc();
+ ST_ASSERT(p1 >= 1);
+ ST_ASSERT(p2 >= 1);
+ ST_ASSERT(p3 >= 1);
+ ST_ASSERT_NE(p1, p2);
+ ST_ASSERT_NE(p2, p3);
+ ST_ASSERT_NE(p1, p3);
+ }
+ st_pass();
+
+ /* ---- Freed PID can be reused ---- */
+ st_begin("pid: freed PID can be reused");
+ {
+ PidAllocator pa3;
+ pa3.init();
+
+ /* Allocate all PIDs from 2..N */
+ constexpr int kAllocCount = 60;
+ int32 pids[kAllocCount];
+ for (int i = 0; i < kAllocCount; i++) {
+ pids[i] = pa3.alloc();
+ ST_ASSERT(pids[i] >= 1);
+ }
+
+ /* Free one in the middle */
+ int32 freed_pid = pids[kAllocCount / 2];
+ pa3.free(freed_pid);
+
+ /* Allocate again — should eventually get the freed PID */
+ bool found = false;
+ for (int i = 0; i < kAllocCount; i++) {
+ int32 p = pa3.alloc();
+ if (p == freed_pid) { found = true; break; }
+ pa3.free(p);
+ }
+ ST_ASSERT(found);
+ }
+ st_pass();
+
+ /* ---- free invalid PID is safe ---- */
+ st_begin("pid: free invalid PID is no-op");
+ {
+ PidAllocator pa4;
+ pa4.init();
+
+ /* These should not crash */
+ pa4.free(-1);
+ pa4.free(kMaxPid);
+ pa4.free(kMaxPid + 100);
+
+ /* Allocator should still work — PID 0 still reserved */
+ int32 p = pa4.alloc();
+ ST_ASSERT(p >= 1);
+ }
+ st_pass();
+
+ /* ---- Monotonic allocation before wrap ---- */
+ st_begin("pid: monotonic allocation");
+ {
+ PidAllocator pa5;
+ pa5.init();
+
+ int32 prev = pa5.alloc();
+ for (int i = 0; i < 10; i++) {
+ int32 next = pa5.alloc();
+ ST_ASSERT(next > prev);
+ prev = next;
+ }
+ }
+ st_pass();
+}
diff --git a/test/test_pipe_devfs.cc b/test/test_pipe_devfs.cc
new file mode 100644
index 0000000..ed375ed
--- /dev/null
+++ b/test/test_pipe_devfs.cc
@@ -0,0 +1,420 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_pipe_devfs.cc — Self-tests for pipe, /dev/null, /dev/zero, fcntl
+ */
+
+#include <lume/selftest.h>
+#include <lume/errno.h>
+#include <lume/file.h>
+#include <lume/fs.h>
+#include <lume/fs_types.h>
+#include <lume/task.h>
+#include <lume/fdtable.h>
+#include <lume/pipe.h>
+
+/* Syscall entry points */
+int64 sys_openat(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_close(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_read(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_write(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_pipe2(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_fcntl(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_fstat_wrapper(uint64, uint64, uint64, uint64, uint64, uint64);
+
+/* ================================================================== */
+/* Helpers */
+/* ================================================================== */
+
+static int64 open_dev(const char* path, int flags)
+{
+ return sys_openat(
+ static_cast<uint64>(-100), /* AT_FDCWD */
+ reinterpret_cast<uint64>(path),
+ static_cast<uint64>(flags),
+ 0, 0, 0);
+}
+
+static int64 do_close(int64 fd)
+{
+ return sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+}
+
+static int64 do_read(int64 fd, void* buf, uint64 count)
+{
+ return sys_read(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(buf), count, 0, 0, 0);
+}
+
+static int64 do_write(int64 fd, const void* buf, uint64 count)
+{
+ return sys_write(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(buf), count, 0, 0, 0);
+}
+
+/* fcntl command constants — must match sys_fs.cc */
+inline constexpr int F_DUPFD = 0;
+inline constexpr int F_GETFD = 1;
+inline constexpr int F_SETFD = 2;
+inline constexpr int F_GETFL = 3;
+inline constexpr int F_SETFL = 4;
+inline constexpr int F_DUPFD_CLOEXEC = 1030;
+inline constexpr int FD_CLOEXEC = 1;
+
+/* ================================================================== */
+/* /dev/null tests */
+/* ================================================================== */
+
+void selftest_devfs()
+{
+ /* ---- /dev/null: read returns EOF ---- */
+ st_begin("devfs: /dev/null read returns 0 (EOF)");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ char buf[16];
+ int64 n = do_read(fd, buf, sizeof(buf));
+ ST_ASSERT_EQ(n, (int64)0);
+ do_close(fd);
+ }
+ st_pass();
+
+ /* ---- /dev/null: write discards data ---- */
+ st_begin("devfs: /dev/null write discards data");
+ {
+ int64 fd = open_dev("/dev/null", O_WRONLY);
+ ST_ASSERT(fd >= 0);
+
+ const char* msg = "discarded";
+ int64 n = do_write(fd, msg, 9);
+ ST_ASSERT_EQ(n, (int64)9);
+ do_close(fd);
+ }
+ st_pass();
+
+ /* ---- /dev/null: fstat reports S_IFCHR ---- */
+ st_begin("devfs: /dev/null fstat is S_IFCHR");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ kstat st{};
+ int64 ret = sys_fstat_wrapper(
+ static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(&st), 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)0);
+ ST_ASSERT(S_ISCHR(st.st_mode));
+ do_close(fd);
+ }
+ st_pass();
+
+ /* ---- /dev/zero: read returns zeros ---- */
+ st_begin("devfs: /dev/zero read returns zeros");
+ {
+ int64 fd = open_dev("/dev/zero", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ char buf[32];
+ /* Pre-fill with non-zero */
+ for (int i = 0; i < 32; i++) buf[i] = 0xFF;
+
+ int64 n = do_read(fd, buf, 32);
+ ST_ASSERT_EQ(n, (int64)32);
+
+ bool all_zero = true;
+ for (int i = 0; i < 32; i++) {
+ if (buf[i] != 0) { all_zero = false; break; }
+ }
+ ST_ASSERT(all_zero);
+ do_close(fd);
+ }
+ st_pass();
+
+ /* ---- /dev/zero: write discards data ---- */
+ st_begin("devfs: /dev/zero write discards data");
+ {
+ int64 fd = open_dev("/dev/zero", O_WRONLY);
+ ST_ASSERT(fd >= 0);
+
+ char buf[16] = {1, 2, 3};
+ int64 n = do_write(fd, buf, 16);
+ ST_ASSERT_EQ(n, (int64)16);
+ do_close(fd);
+ }
+ st_pass();
+}
+
+/* ================================================================== */
+/* Pipe tests */
+/* ================================================================== */
+
+void selftest_pipe()
+{
+ /* ---- pipe2: basic creation ---- */
+ st_begin("pipe: pipe2 creates two valid fds");
+ {
+ int fds[2] = {-1, -1};
+ int64 ret = sys_pipe2(
+ reinterpret_cast<uint64>(fds), 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)0);
+ ST_ASSERT(fds[0] >= 0);
+ ST_ASSERT(fds[1] >= 0);
+ ST_ASSERT_NE(fds[0], fds[1]);
+
+ do_close(fds[0]);
+ do_close(fds[1]);
+ }
+ st_pass();
+
+ /* ---- pipe: write then read ---- */
+ st_begin("pipe: write then read returns correct data");
+ {
+ int fds[2];
+ sys_pipe2(reinterpret_cast<uint64>(fds), 0, 0, 0, 0, 0);
+
+ const char* msg = "pipe_test";
+ int64 w = do_write(fds[1], msg, 9);
+ ST_ASSERT_EQ(w, (int64)9);
+
+ char buf[16] = {};
+ int64 r = do_read(fds[0], buf, 16);
+ ST_ASSERT_EQ(r, (int64)9);
+
+ bool match = true;
+ for (int i = 0; i < 9; i++) {
+ if (buf[i] != msg[i]) { match = false; break; }
+ }
+ ST_ASSERT(match);
+
+ do_close(fds[0]);
+ do_close(fds[1]);
+ }
+ st_pass();
+
+ /* ---- pipe: read from closed write end returns EOF ---- */
+ st_begin("pipe: read after writer close returns EOF");
+ {
+ int fds[2];
+ sys_pipe2(reinterpret_cast<uint64>(fds), 0, 0, 0, 0, 0);
+
+ /* Write some data, then close write end */
+ const char* msg = "eof";
+ do_write(fds[1], msg, 3);
+ do_close(fds[1]);
+
+ /* Read the data */
+ char buf[16];
+ int64 r1 = do_read(fds[0], buf, 16);
+ ST_ASSERT_EQ(r1, (int64)3);
+
+ /* Next read should return 0 (EOF) */
+ int64 r2 = do_read(fds[0], buf, 16);
+ ST_ASSERT_EQ(r2, (int64)0);
+
+ do_close(fds[0]);
+ }
+ st_pass();
+
+ /* ---- pipe: write to closed read end returns -EPIPE ---- */
+ st_begin("pipe: write after reader close returns -EPIPE");
+ {
+ int fds[2];
+ sys_pipe2(reinterpret_cast<uint64>(fds), 0, 0, 0, 0, 0);
+
+ /* Close read end */
+ do_close(fds[0]);
+
+ /* Write should fail */
+ const char* msg = "broken";
+ int64 w = do_write(fds[1], msg, 6);
+ ST_ASSERT_EQ(w, (int64)-EPIPE);
+
+ do_close(fds[1]);
+ }
+ st_pass();
+
+ /* ---- pipe: multiple writes accumulate ---- */
+ st_begin("pipe: multiple writes accumulate in buffer");
+ {
+ int fds[2];
+ sys_pipe2(reinterpret_cast<uint64>(fds), 0, 0, 0, 0, 0);
+
+ do_write(fds[1], "AAA", 3);
+ do_write(fds[1], "BBB", 3);
+
+ char buf[16] = {};
+ int64 r = do_read(fds[0], buf, 16);
+ ST_ASSERT_EQ(r, (int64)6);
+ ST_ASSERT_EQ(buf[0], 'A');
+ ST_ASSERT_EQ(buf[3], 'B');
+
+ do_close(fds[0]);
+ do_close(fds[1]);
+ }
+ st_pass();
+
+ /* ---- pipe: read partial ---- */
+ st_begin("pipe: partial read leaves remainder");
+ {
+ int fds[2];
+ sys_pipe2(reinterpret_cast<uint64>(fds), 0, 0, 0, 0, 0);
+
+ do_write(fds[1], "ABCDEF", 6);
+
+ char buf[3] = {};
+ int64 r1 = do_read(fds[0], buf, 3);
+ ST_ASSERT_EQ(r1, (int64)3);
+ ST_ASSERT_EQ(buf[0], 'A');
+ ST_ASSERT_EQ(buf[2], 'C');
+
+ int64 r2 = do_read(fds[0], buf, 3);
+ ST_ASSERT_EQ(r2, (int64)3);
+ ST_ASSERT_EQ(buf[0], 'D');
+ ST_ASSERT_EQ(buf[2], 'F');
+
+ do_close(fds[0]);
+ do_close(fds[1]);
+ }
+ st_pass();
+}
+
+/* ================================================================== */
+/* fcntl tests */
+/* ================================================================== */
+
+void selftest_fcntl()
+{
+ /* ---- fcntl F_DUPFD ---- */
+ st_begin("fcntl: F_DUPFD duplicates fd");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ int64 fd2 = sys_fcntl(static_cast<uint64>(fd), F_DUPFD, 0, 0, 0, 0);
+ ST_ASSERT(fd2 >= 0);
+ ST_ASSERT_NE(fd, fd2);
+
+ /* Both should be valid reads */
+ char buf[4];
+ int64 r = do_read(fd2, buf, 4);
+ ST_ASSERT_EQ(r, (int64)0); /* /dev/null → EOF */
+
+ do_close(fd);
+ do_close(fd2);
+ }
+ st_pass();
+
+ /* ---- fcntl F_DUPFD with min_fd ---- */
+ st_begin("fcntl: F_DUPFD respects min_fd");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ int64 fd2 = sys_fcntl(static_cast<uint64>(fd), F_DUPFD, 100, 0, 0, 0);
+ ST_ASSERT(fd2 >= 100);
+
+ do_close(fd);
+ do_close(fd2);
+ }
+ st_pass();
+
+ /* ---- fcntl F_GETFD / F_SETFD ---- */
+ st_begin("fcntl: F_GETFD/F_SETFD cloexec");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ /* Initially no CLOEXEC */
+ int64 flags = sys_fcntl(static_cast<uint64>(fd), F_GETFD, 0, 0, 0, 0);
+ ST_ASSERT_EQ(flags, (int64)0);
+
+ /* Set CLOEXEC */
+ int64 ret = sys_fcntl(static_cast<uint64>(fd), F_SETFD, FD_CLOEXEC, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)0);
+
+ /* Verify it was set */
+ flags = sys_fcntl(static_cast<uint64>(fd), F_GETFD, 0, 0, 0, 0);
+ ST_ASSERT_EQ(flags, (int64)FD_CLOEXEC);
+
+ /* Clear it */
+ sys_fcntl(static_cast<uint64>(fd), F_SETFD, 0, 0, 0, 0);
+ flags = sys_fcntl(static_cast<uint64>(fd), F_GETFD, 0, 0, 0, 0);
+ ST_ASSERT_EQ(flags, (int64)0);
+
+ do_close(fd);
+ }
+ st_pass();
+
+ /* ---- fcntl F_GETFL ---- */
+ st_begin("fcntl: F_GETFL returns open flags");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ int64 fl = sys_fcntl(static_cast<uint64>(fd), F_GETFL, 0, 0, 0, 0);
+ /* O_RDONLY == 0, so result should have no write bits */
+ ST_ASSERT_EQ(fl & O_WRONLY, (int64)0);
+
+ do_close(fd);
+ }
+ st_pass();
+
+ /* ---- fcntl F_SETFL O_NONBLOCK ---- */
+ st_begin("fcntl: F_SETFL toggles O_NONBLOCK");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ /* Set O_NONBLOCK */
+ sys_fcntl(static_cast<uint64>(fd), F_SETFL, O_NONBLOCK, 0, 0, 0);
+ int64 fl = sys_fcntl(static_cast<uint64>(fd), F_GETFL, 0, 0, 0, 0);
+ ST_ASSERT(fl & O_NONBLOCK);
+
+ /* Clear it */
+ sys_fcntl(static_cast<uint64>(fd), F_SETFL, 0, 0, 0, 0);
+ fl = sys_fcntl(static_cast<uint64>(fd), F_GETFL, 0, 0, 0, 0);
+ ST_ASSERT_EQ(fl & O_NONBLOCK, (int64)0);
+
+ do_close(fd);
+ }
+ st_pass();
+
+ /* ---- fcntl F_DUPFD_CLOEXEC ---- */
+ st_begin("fcntl: F_DUPFD_CLOEXEC sets cloexec");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ int64 fd2 = sys_fcntl(static_cast<uint64>(fd), F_DUPFD_CLOEXEC, 0, 0, 0, 0);
+ ST_ASSERT(fd2 >= 0);
+
+ int64 flags = sys_fcntl(static_cast<uint64>(fd2), F_GETFD, 0, 0, 0, 0);
+ ST_ASSERT_EQ(flags, (int64)FD_CLOEXEC);
+
+ do_close(fd);
+ do_close(fd2);
+ }
+ st_pass();
+
+ /* ---- fcntl bad fd ---- */
+ st_begin("fcntl: bad fd returns -EBADF");
+ {
+ int64 ret = sys_fcntl(999, F_GETFD, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-EBADF);
+ }
+ st_pass();
+
+ /* ---- fcntl unknown cmd ---- */
+ st_begin("fcntl: unknown cmd returns -EINVAL");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ int64 ret = sys_fcntl(static_cast<uint64>(fd), 9999, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-EINVAL);
+ do_close(fd);
+ }
+ st_pass();
+}
diff --git a/test/test_pmm.cc b/test/test_pmm.cc
new file mode 100644
index 0000000..3330866
--- /dev/null
+++ b/test/test_pmm.cc
@@ -0,0 +1,283 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * Boot Self-Test: Physical Memory Manager (PMM)
+ *
+ * Tests:
+ * 1. Single page alloc/free round-trip
+ * 2. Page address alignment (4KB)
+ * 3. No duplicate pages from consecutive allocs
+ * 4. Free then re-alloc returns the same page (PCP cache)
+ * 5. Multi-page (order > 0) alloc/free
+ * 6. Bulk alloc stress test (alloc many, free all)
+ * 7. Reference counting (incref/decref)
+ * 8. page_to_pa / pa_to_page round-trip
+ */
+
+#include <lume/selftest.h>
+#include <lume/pmm.h>
+#include <lume/addr.h>
+
+void selftest_pmm()
+{
+ st_begin("pmm: single page alloc/free");
+ {
+ Page* page = pmm_alloc_page();
+ ST_ASSERT(page != nullptr);
+ ST_ASSERT(page->type == PageType::Allocated);
+ ST_ASSERT(page->order == 0);
+
+ uint64 pa = page_to_pa(page).raw;
+ ST_ASSERT_EQ(pa % kPageSize, 0ULL); // 4KB aligned
+
+ uint64 va = page_to_va(page).raw;
+ ST_ASSERT_EQ(va, pa_to_va(phys_addr(pa)).raw);
+
+ pmm_free_page(page);
+ }
+ st_pass();
+
+ st_begin("pmm: no duplicate pages");
+ {
+ Page* p1 = pmm_alloc_page();
+ Page* p2 = pmm_alloc_page();
+ ST_ASSERT(p1 != nullptr);
+ ST_ASSERT(p2 != nullptr);
+ ST_ASSERT_NE(p1, p2);
+ ST_ASSERT_NE(page_to_pa(p1).raw, page_to_pa(p2).raw);
+ pmm_free_page(p2);
+ pmm_free_page(p1);
+ }
+ st_pass();
+
+ st_begin("pmm: free then re-alloc (PCP)");
+ {
+ Page* p1 = pmm_alloc_page();
+ uint64 pa1 = page_to_pa(p1).raw;
+ pmm_free_page(p1);
+
+ // PCP should return the most recently freed page
+ Page* p2 = pmm_alloc_page();
+ uint64 pa2 = page_to_pa(p2).raw;
+ ST_ASSERT_EQ(pa1, pa2);
+ pmm_free_page(p2);
+ }
+ st_pass();
+
+ st_begin("pmm: multi-page alloc (order 2 = 4 pages)");
+ {
+ Page* page = pmm_alloc_pages(2);
+ ST_ASSERT(page != nullptr);
+ ST_ASSERT(page->order == 2);
+
+ uint64 pa = page_to_pa(page).raw;
+ ST_ASSERT_EQ(pa % (kPageSize * 4), 0ULL); // Naturally aligned
+
+ pmm_free_pages(page, 2);
+ }
+ st_pass();
+
+ st_begin("pmm: bulk alloc/free stress (256 pages)");
+ {
+ constexpr int N = 256;
+ Page* pages[N];
+
+ // Allocate all
+ for (int i = 0; i < N; i++) {
+ pages[i] = pmm_alloc_page();
+ ST_ASSERT(pages[i] != nullptr);
+ }
+
+ // Verify no duplicates (spot check: consecutive pairs)
+ for (int i = 1; i < N; i++) {
+ ST_ASSERT_NE(pages[i], pages[i - 1]);
+ }
+
+ // Free all
+ for (int i = 0; i < N; i++) {
+ pmm_free_page(pages[i]);
+ }
+ }
+ st_pass();
+
+ st_begin("pmm: page_to_pa / pa_to_page round-trip");
+ {
+ Page* page = pmm_alloc_page();
+ ST_ASSERT(page != nullptr);
+ uint64 pa = page_to_pa(page).raw;
+ Page* page2 = pa_to_page(phys_addr(pa));
+ ST_ASSERT_EQ(page, page2);
+ pmm_free_page(page);
+ }
+ st_pass();
+
+ st_begin("pmm: refcount incref/decref");
+ {
+ Page* page = pmm_alloc_page();
+ ST_ASSERT(page != nullptr);
+ ST_ASSERT(page->refcount.load() == 1);
+
+ page_incref(page);
+ ST_ASSERT(page->refcount.load() == 2);
+
+ page_decref(page); // 2 -> 1, should NOT free
+ ST_ASSERT(page->refcount.load() == 1);
+ ST_ASSERT(page->type == PageType::Allocated);
+
+ page_decref(page); // 1 -> 0, should free to pool
+ // After decref to 0, page is returned to pool.
+ // We can't safely check page->type because it might be reused.
+ }
+ st_pass();
+ st_begin("pmm: pmm_alloc_pages natural alignment (order-3)");
+ {
+ Page* page = pmm_alloc_pages(3);
+ ST_ASSERT(page != nullptr);
+ uint64 pa = page_to_pa(page).raw;
+ ST_ASSERT_EQ(pa % (kPageSize * 8), 0ULL);
+ pmm_free_pages(page, 3);
+ }
+ st_pass();
+
+ st_begin("pmm: page_decref to 0 and reuse (order-0)");
+ {
+ Page* page = pmm_alloc_page();
+ uint64 pa = page_to_pa(page).raw;
+ page_decref(page); // drops 1 -> 0, freeing it
+
+ // Next alloc from PCP should give back the same page
+ Page* page2 = pmm_alloc_page();
+ ST_ASSERT_EQ(page_to_pa(page2).raw, pa);
+ pmm_free_page(page2);
+ }
+ st_pass();
+
+ st_begin("pmm: page_decref to 0 on multi-page blocks (order-3)");
+ {
+ Page* page = pmm_alloc_pages(3);
+ uint64 pa = page_to_pa(page).raw;
+ page_decref(page); // Should internally route to pmm_free_pages(page, 3)
+
+ // Re-allocate order-3 and see if we get it back (buddy coalescing was respected)
+ Page* page2 = pmm_alloc_pages(3);
+ ST_ASSERT_EQ(page_to_pa(page2).raw, pa);
+ pmm_free_pages(page2, 3);
+ }
+ st_pass();
+
+ st_begin("pmm: buddy split and block coalescing correctness");
+ {
+ // Force split: allocate order-1, free it, allocate two order-0
+ Page* page_ord1 = pmm_alloc_pages(1);
+ uint64 pa1 = page_to_pa(page_ord1).raw;
+ pmm_free_pages(page_ord1, 1);
+
+ Page* p0_a = pmm_alloc_page();
+ Page* p0_b = pmm_alloc_page();
+
+ // Free them to trigger coalesce
+ pmm_free_page(p0_a);
+ pmm_free_page(p0_b);
+
+ // Allocate order-1 again to verify successful coalescing
+ Page* page_ord1_again = pmm_alloc_pages(1);
+ ST_ASSERT_EQ(page_to_pa(page_ord1_again).raw, pa1);
+ pmm_free_pages(page_ord1_again, 1);
+ }
+ st_pass();
+
+ st_begin("pmm: mixed order cross allocation");
+ {
+ Page* p0 = pmm_alloc_page();
+ Page* p2 = pmm_alloc_pages(2);
+ Page* p0b = pmm_alloc_page();
+
+ ST_ASSERT(p0 != nullptr);
+ ST_ASSERT(p2 != nullptr);
+ ST_ASSERT(p0b != nullptr);
+ ST_ASSERT_NE(page_to_pa(p0).raw, page_to_pa(p2).raw);
+
+ pmm_free_page(p0);
+ pmm_free_pages(p2, 2);
+ pmm_free_page(p0b);
+ }
+ st_pass();
+
+ st_begin("pmm: PCP drain/refill boundaries");
+ {
+ // kPcpHighWatermark = 64. Allocating and freeing 65 blocks triggers drain.
+ Page* pages[65];
+ for (int i=0; i<65; i++) pages[i] = pmm_alloc_page();
+ for (int i=0; i<65; i++) pmm_free_page(pages[i]);
+
+ // The system shouldn't crash and PCP logic should handle this.
+ Page* verify_page = pmm_alloc_page();
+ ST_ASSERT(verify_page != nullptr);
+ pmm_free_page(verify_page);
+ }
+ st_pass();
+
+ st_begin("pmm: OOM exhaustion stability");
+ {
+ Page* head = nullptr;
+ // First allocate large chunks to be fast
+ while (true) {
+ Page* page = pmm_alloc_pages(5);
+ if (!page) break;
+ uint64* ptr = reinterpret_cast<uint64*>(page_to_va(page).raw);
+ *ptr = reinterpret_cast<uint64>(head);
+ // encode order in the second word
+ *(ptr + 1) = 5;
+ head = page;
+ }
+ // Then allocate single frames until absolute zero
+ while (true) {
+ Page* page = pmm_alloc_page();
+ if (!page) break;
+ uint64* ptr = reinterpret_cast<uint64*>(page_to_va(page).raw);
+ *ptr = reinterpret_cast<uint64>(head);
+ *(ptr + 1) = 0;
+ head = page;
+ }
+
+ // We hit nullptr. System is completely exhausted.
+ ST_ASSERT(pmm_alloc_page() == nullptr);
+
+ // Free everything
+ while (head) {
+ uint64* ptr = reinterpret_cast<uint64*>(page_to_va(head).raw);
+ Page* nxt = reinterpret_cast<Page*>(*ptr);
+ uint64 order = *(ptr + 1);
+ if (order == 5) pmm_free_pages(head, 5);
+ else pmm_free_page(head);
+ head = nxt;
+ }
+ }
+ st_pass();
+
+ st_begin("pmm: out of bounds order allocation gracefully fails");
+ {
+ // kMaxOrder is 11, so passing 11 or higher should safely return nullptr.
+ Page* page = pmm_alloc_pages(11);
+ ST_ASSERT(page == nullptr);
+ page = pmm_alloc_pages(255);
+ ST_ASSERT(page == nullptr);
+ }
+ st_pass();
+
+ st_begin("pmm: pmm_get_mem_base / pmm_get_mem_size sanity");
+ {
+ uint64 base = pmm_get_mem_base().raw;
+ uint64 size = pmm_get_mem_size();
+
+ // We no longer assert on QEMU specific memory address
+ // ST_ASSERT(base >= 0x80000000ULL);
+ ST_ASSERT(size > 0);
+ // Size must be page-aligned
+ ST_ASSERT_EQ(size % kPageSize, 0ULL);
+ // Base must be page-aligned
+ ST_ASSERT_EQ(base % kPageSize, 0ULL);
+ }
+ st_pass();
+}
diff --git a/test/test_random.cc b/test/test_random.cc
new file mode 100644
index 0000000..d546410
--- /dev/null
+++ b/test/test_random.cc
@@ -0,0 +1,49 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+#include <lume/selftest.h>
+#include <lume/random.h>
+#include <lume/kprintf.h>
+
+void selftest_random()
+{
+ /* Test 1: Basic 64-bit generation */
+ kprintf("[test] random: basic 64-bit generation ... ");
+ uint64 v1 = get_random_u64();
+ uint64 v2 = get_random_u64();
+ if (v1 == v2) {
+ kernel_panic("random: two consecutive generations were identical!");
+ }
+ kprintf("PASS\n");
+
+ /* Test 2: Basic 32-bit generation */
+ kprintf("[test] random: basic 32-bit generation ... ");
+ uint32 v3 = get_random_u32();
+ uint32 v4 = get_random_u32();
+ if (v3 == v4) {
+ kernel_panic("random: two consecutive 32-bit generations were identical!");
+ }
+ kprintf("PASS\n");
+
+ /* Test 3: Byte generation */
+ kprintf("[test] random: byte generation ... ");
+ uint8 buf1[16];
+ uint8 buf2[16];
+ get_random_bytes(buf1, sizeof(buf1));
+ get_random_bytes(buf2, sizeof(buf2));
+
+ bool all_zero1 = true;
+ bool all_zero2 = true;
+ bool same = true;
+
+ for (int i = 0; i < 16; i++) {
+ if (buf1[i] != 0) all_zero1 = false;
+ if (buf2[i] != 0) all_zero2 = false;
+ if (buf1[i] != buf2[i]) same = false;
+ }
+
+ if (all_zero1 || all_zero2 || same) {
+ kernel_panic("random: byte generation statistical failure!");
+ }
+ kprintf("PASS\n");
+}
diff --git a/test/test_rbtree.cc b/test/test_rbtree.cc
new file mode 100644
index 0000000..27f049a
--- /dev/null
+++ b/test/test_rbtree.cc
@@ -0,0 +1,119 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+#include <lume/selftest.h>
+#include <lume/rbtree.h>
+
+struct TestNode {
+ int key;
+ struct rb_node rb;
+};
+
+static bool node_less(struct rb_node *node, const struct rb_node *parent) {
+ TestNode *n = rb_entry(node, TestNode, rb);
+ const TestNode *p = reinterpret_cast<const TestNode*>(
+ reinterpret_cast<const char*>(parent) - __builtin_offsetof(TestNode, rb));
+ return n->key < p->key;
+}
+
+static int node_cmp(const void *key, const struct rb_node *node) {
+ int k = *static_cast<const int*>(key);
+ const TestNode *n = reinterpret_cast<const TestNode*>(
+ reinterpret_cast<const char*>(node) - __builtin_offsetof(TestNode, rb));
+ if (k < n->key) return -1;
+ if (k > n->key) return 1;
+ return 0;
+}
+
+void selftest_rbtree() {
+ st_begin("rbtree: basic insert, find, erase");
+ {
+ struct rb_root tree = RB_ROOT;
+ TestNode n1{10, {}}, n2{20, {}}, n3{5, {}};
+
+ rb_add(&n1.rb, &tree, node_less);
+ rb_add(&n2.rb, &tree, node_less);
+ rb_add(&n3.rb, &tree, node_less);
+
+ int search_key = 20;
+ struct rb_node *found = rb_find(&search_key, &tree, node_cmp);
+ ST_ASSERT(found == &n2.rb);
+
+ search_key = 99;
+ found = rb_find(&search_key, &tree, node_cmp);
+ ST_ASSERT(found == nullptr);
+
+ rb_erase(&n2.rb, &tree);
+ // Verify erased
+ search_key = 20;
+ found = rb_find(&search_key, &tree, node_cmp);
+ ST_ASSERT(found == nullptr);
+ }
+ st_pass();
+
+ st_begin("rbtree: ordered iteration");
+ {
+ struct rb_root tree = RB_ROOT;
+ TestNode nodes[5] = { {50, {}}, {10, {}}, {40, {}}, {20, {}}, {30, {}} };
+ for (int i=0; i<5; i++) {
+ rb_add(&nodes[i].rb, &tree, node_less);
+ }
+
+ int expected[] = {10, 20, 30, 40, 50};
+ int idx = 0;
+ struct rb_node *node = rb_first(&tree);
+ while (node) {
+ TestNode *n = rb_entry(node, TestNode, rb);
+ ST_ASSERT_EQ(n->key, expected[idx]);
+ idx++;
+ node = rb_next(node);
+ }
+ ST_ASSERT_EQ(idx, 5);
+ }
+ st_pass();
+
+ st_begin("rbtree: large scale insert/erase balance");
+ {
+ struct rb_root tree = RB_ROOT;
+ constexpr int N = 200;
+ static TestNode nodes[N]; // Static to avoid blowing up the BSP stack
+ for (int i=0; i<N; i++) {
+ nodes[i].key = (i * 17) % N; // Pseudo-random insertions
+ rb_add(&nodes[i].rb, &tree, node_less);
+ }
+
+ int c = 0;
+ for (struct rb_node *n = rb_first(&tree); n; n = rb_next(n)) c++;
+ ST_ASSERT_EQ(c, N);
+
+ for (int i=0; i<N; i+=2) {
+ rb_erase(&nodes[i].rb, &tree);
+ }
+ c = 0;
+ for (struct rb_node *n = rb_first(&tree); n; n = rb_next(n)) c++;
+ ST_ASSERT_EQ(c, N / 2);
+ }
+ st_pass();
+
+ st_begin("rbtree: empty tree operations");
+ {
+ struct rb_root tree = RB_ROOT;
+ ST_ASSERT(RB_EMPTY_ROOT(&tree));
+ ST_ASSERT(rb_first(&tree) == nullptr);
+ ST_ASSERT(rb_last(&tree) == nullptr);
+ }
+ st_pass();
+
+ st_begin("rbtree: rb_erase_init after effects");
+ {
+ struct rb_root tree = RB_ROOT;
+ TestNode n1{100, {}};
+ rb_add(&n1.rb, &tree, node_less);
+ ST_ASSERT(!RB_EMPTY_NODE(&n1.rb));
+
+ rb_erase_init(&n1.rb, &tree);
+ ST_ASSERT(RB_EMPTY_NODE(&n1.rb));
+ ST_ASSERT(RB_EMPTY_ROOT(&tree));
+ }
+ st_pass();
+}
diff --git a/test/test_sched.cc b/test/test_sched.cc
new file mode 100644
index 0000000..d90c30a
--- /dev/null
+++ b/test/test_sched.cc
@@ -0,0 +1,257 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * Boot self-tests for the Scheduler subsystem.
+ *
+ * Tests RunQueue bitmap operations, priority selection,
+ * round-robin ordering, current_task(), and time slice policy.
+ */
+
+#include <lume/types.h>
+#include <lume/task.h>
+#include <lume/sched.h>
+#include <lume/sched_class.h>
+#include <lume/config.h>
+#include <lume/selftest.h>
+#include <lume/pmm.h>
+#include <lume/addr.h>
+#include <lume/new.h>
+#include <kernel/sched_internal.h>
+
+/* ========================================================================
+ * Helper: create a minimal test TCB (no real kernel stack, just metadata)
+ * ======================================================================== */
+
+static TaskControlBlock g_test_tcbs[8];
+
+static TaskControlBlock* make_test_tcb(int index, const char* name,
+ uint8 priority, uint8 cpu)
+{
+ TaskControlBlock* t = &g_test_tcbs[index];
+ t->context = {};
+ t->wait_chld.init("test_wq");
+ t->name = name;
+ t->priority = priority;
+ t->cpu_id = cpu;
+ t->pid = 100 + index;
+ t->tgid = 100 + index;
+ t->state = TaskState::Runnable;
+ t->time_slice = priority_to_timeslice(priority);
+ t->run_link.init();
+ t->all_link.init();
+ t->children.init();
+ t->sibling.init();
+ t->mm = nullptr;
+ t->trapframe = nullptr;
+ t->kstack_pa = 0;
+ t->kstack_va = 0;
+ t->flags = TF_KTHREAD;
+ t->sched_class = &g_sched_class_o1;
+ t->sched_entity = &t->run_link;
+ return t;
+}
+
+/* ========================================================================
+ * Test cases
+ * ======================================================================== */
+
+static bool test_current_task()
+{
+ /* After sched_init, current_task() should be the BSP boot TCB */
+ TaskControlBlock* curr = current_task();
+ return curr != nullptr && curr->state == TaskState::Running;
+}
+
+static bool test_priority_to_timeslice()
+{
+ /* Higher priority (lower number) should get longer time slice */
+ int32 ts_high = priority_to_timeslice(0);
+ int32 ts_mid = priority_to_timeslice(15);
+ int32 ts_low = priority_to_timeslice(31);
+
+ return ts_high > ts_mid && ts_mid > ts_low && ts_low >= 1;
+}
+
+static bool test_o1_pick_next_priority()
+{
+ RunQueue rq;
+ rq.init(0);
+ g_sched_class_o1.init_rq(&rq);
+
+ TaskControlBlock* t_lo = make_test_tcb(0, "test_p5", 5, 0);
+ TaskControlBlock* t_hi = make_test_tcb(1, "test_p2", 2, 0);
+
+ g_sched_class_o1.enqueue_task(&rq, t_lo);
+ g_sched_class_o1.enqueue_task(&rq, t_hi);
+
+ TaskControlBlock* next = g_sched_class_o1.pick_next_task(&rq);
+ return next == t_hi;
+}
+
+static bool test_o1_fifo_order()
+{
+ RunQueue rq;
+ rq.init(0);
+ g_sched_class_o1.init_rq(&rq);
+
+ TaskControlBlock* t0 = make_test_tcb(0, "test_q0", 10, 0);
+ TaskControlBlock* t1 = make_test_tcb(1, "test_q1", 10, 0);
+ TaskControlBlock* t2 = make_test_tcb(2, "test_q2", 10, 0);
+
+ g_sched_class_o1.enqueue_task(&rq, t0);
+ g_sched_class_o1.enqueue_task(&rq, t1);
+ g_sched_class_o1.enqueue_task(&rq, t2);
+
+ TaskControlBlock* next = g_sched_class_o1.pick_next_task(&rq);
+ if (next != t0) return false;
+ g_sched_class_o1.dequeue_task(&rq, t0);
+
+ next = g_sched_class_o1.pick_next_task(&rq);
+ if (next != t1) return false;
+ g_sched_class_o1.dequeue_task(&rq, t1);
+
+ next = g_sched_class_o1.pick_next_task(&rq);
+ if (next != t2) return false;
+
+ return true;
+}
+
+static bool test_o1_nr_running()
+{
+ RunQueue rq;
+ rq.init(0);
+ g_sched_class_o1.init_rq(&rq);
+
+ TaskControlBlock* t0 = make_test_tcb(0, "test_n0", 8, 0);
+ TaskControlBlock* t1 = make_test_tcb(1, "test_n1", 9, 0);
+
+ if (rq.nr_running() != 0 || rq.o1().nr_running() != 0) return false;
+
+ g_sched_class_o1.enqueue_task(&rq, t0);
+ if (rq.nr_running() != 1 || rq.o1().nr_running() != 1) return false;
+
+ g_sched_class_o1.enqueue_task(&rq, t1);
+ if (rq.nr_running() != 2 || rq.o1().nr_running() != 2) return false;
+
+ g_sched_class_o1.dequeue_task(&rq, t0);
+ if (rq.nr_running() != 1 || rq.o1().nr_running() != 1) return false;
+
+ g_sched_class_o1.dequeue_task(&rq, t1);
+ return rq.nr_running() == 0 && rq.o1().nr_running() == 0;
+}
+
+static bool test_o1_pick_next_empty()
+{
+ RunQueue rq;
+ rq.init(0);
+ g_sched_class_o1.init_rq(&rq);
+
+ return g_sched_class_o1.pick_next_task(&rq) == nullptr;
+}
+
+static bool test_priority_to_timeslice_bounds()
+{
+ /* Boundary values */
+ int32 ts0 = priority_to_timeslice(0);
+ int32 ts31 = priority_to_timeslice(31);
+
+ return ts0 == 20 && ts31 >= 1;
+}
+
+static bool test_enqueue_dequeue()
+{
+ /* Create a test task and enqueue/dequeue it */
+ TaskControlBlock* t = make_test_tcb(0, "test_ed", 10, 0);
+
+ sched_enqueue(t);
+ if (t->state != TaskState::Runnable) return false;
+
+ sched_dequeue(t);
+ /* After dequeue, run_link should be self-pointing */
+ return t->run_link.next == &t->run_link;
+}
+
+static bool test_multiple_enqueue_dequeue()
+{
+ /* Enqueue 3 tasks at different priorities, dequeue all */
+ TaskControlBlock* t0 = make_test_tcb(0, "test_m0", 5, 0);
+ TaskControlBlock* t1 = make_test_tcb(1, "test_m1", 10, 0);
+ TaskControlBlock* t2 = make_test_tcb(2, "test_m2", 20, 0);
+
+ sched_enqueue(t0);
+ sched_enqueue(t1);
+ sched_enqueue(t2);
+
+ sched_dequeue(t0);
+ sched_dequeue(t1);
+ sched_dequeue(t2);
+
+ return t0->run_link.is_empty() &&
+ t1->run_link.is_empty() &&
+ t2->run_link.is_empty();
+}
+
+static bool test_current_task_name()
+{
+ TaskControlBlock* curr = current_task();
+ if (!curr || !curr->name) return false;
+
+ /* BSP boot TCB should be named "kernel_main" */
+ const char* expected = "kernel_main";
+ const char* actual = curr->name;
+ while (*expected && *actual) {
+ if (*expected != *actual) return false;
+ expected++;
+ actual++;
+ }
+ return *expected == *actual;
+}
+
+/* ========================================================================
+ * Test runner
+ * ======================================================================== */
+
+void selftest_sched()
+{
+ st_begin("sched: current_task is valid after init");
+ ST_ASSERT(test_current_task());
+ st_pass();
+
+ st_begin("sched: current_task name is kernel_main");
+ ST_ASSERT(test_current_task_name());
+ st_pass();
+
+ st_begin("sched: priority_to_timeslice ordering");
+ ST_ASSERT(test_priority_to_timeslice());
+ st_pass();
+
+ st_begin("sched: priority_to_timeslice bounds");
+ ST_ASSERT(test_priority_to_timeslice_bounds());
+ st_pass();
+
+ st_begin("sched: enqueue/dequeue round-trip");
+ ST_ASSERT(test_enqueue_dequeue());
+ st_pass();
+
+ st_begin("sched: multiple enqueue/dequeue");
+ ST_ASSERT(test_multiple_enqueue_dequeue());
+ st_pass();
+
+ st_begin("sched: o1 pick_next priority");
+ ST_ASSERT(test_o1_pick_next_priority());
+ st_pass();
+
+ st_begin("sched: o1 FIFO order");
+ ST_ASSERT(test_o1_fifo_order());
+ st_pass();
+
+ st_begin("sched: o1 nr_running accounting");
+ ST_ASSERT(test_o1_nr_running());
+ st_pass();
+
+ st_begin("sched: o1 pick_next empty");
+ ST_ASSERT(test_o1_pick_next_empty());
+ st_pass();
+}
+
diff --git a/test/test_signal.cc b/test/test_signal.cc
new file mode 100644
index 0000000..360b0e6
--- /dev/null
+++ b/test/test_signal.cc
@@ -0,0 +1,193 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_signal.cc — Self-tests for signal infrastructure
+ *
+ * Tests the kernel-internal signal APIs directly (not via syscalls,
+ * since syscalls use copy_to_user which rejects kernel-space buffers).
+ */
+
+#include <lume/selftest.h>
+#include <lume/signal.h>
+#include <lume/signal_types.h>
+#include <lume/signal_nr.h>
+#include <lume/task.h>
+#include <lume/errno.h>
+#include <lume/new.h>
+
+/* From signal.cc */
+extern int send_signal(int sig, kernel_siginfo* info, TaskControlBlock* task);
+
+/* Drain all queued sigqueue entries from a task's pending list */
+static void flush_pending(TaskControlBlock* task)
+{
+ task->pending.signal = sigset_t{};
+ list_node* head = &task->pending.list;
+ while (head->next != head) {
+ list_node* node = head->next;
+ head->next = node->next;
+ node->next->prev = head;
+ auto* q = list_entry<sigqueue, &sigqueue::list>(node);
+ delete q;
+ }
+}
+
+void selftest_signal()
+{
+ TaskControlBlock* curr = current_task();
+
+ /* Ensure sighand exists for this task */
+ if (!curr->sighand) {
+ curr->sighand = new sighand_struct;
+ curr->sighand->init();
+ }
+
+ /* Ensure pending list is initialized */
+ if (curr->pending.list.next == nullptr) {
+ curr->pending.list.next = &curr->pending.list;
+ curr->pending.list.prev = &curr->pending.list;
+ curr->pending.signal = sigset_t{};
+ }
+
+ /* ---- send_signal queues a signal ---- */
+ st_begin("signal: send_signal queues to pending");
+ {
+ curr->pending.signal = sigset_t{};
+
+ kernel_siginfo info{};
+ info.si_signo = SIGUSR1;
+ info.si_code = 0;
+
+ int ret = send_signal(SIGUSR1, &info, curr);
+ ST_ASSERT_EQ(ret, 0);
+ ST_ASSERT(curr->pending.signal.has(SIGUSR1));
+
+ flush_pending(curr);
+ }
+ st_pass();
+
+ /* ---- send_signal: invalid signal returns -EINVAL ---- */
+ st_begin("signal: send_signal invalid sig returns -EINVAL");
+ {
+ int ret = send_signal(0, nullptr, curr);
+ ST_ASSERT_EQ(ret, -EINVAL);
+
+ ret = send_signal(_NSIG + 1, nullptr, curr);
+ ST_ASSERT_EQ(ret, -EINVAL);
+ }
+ st_pass();
+
+ /* ---- send_signal: null task returns -ESRCH ---- */
+ st_begin("signal: send_signal null task returns -ESRCH");
+ {
+ int ret = send_signal(SIGUSR1, nullptr, nullptr);
+ ST_ASSERT_EQ(ret, -ESRCH);
+ }
+ st_pass();
+
+ /* ---- duplicate standard signal is coalesced ---- */
+ st_begin("signal: duplicate standard signal coalesced");
+ {
+ curr->pending.signal = sigset_t{};
+
+ send_signal(SIGUSR1, nullptr, curr);
+ ST_ASSERT(curr->pending.signal.has(SIGUSR1));
+
+ int ret = send_signal(SIGUSR1, nullptr, curr);
+ ST_ASSERT_EQ(ret, 0);
+
+ flush_pending(curr);
+ }
+ st_pass();
+
+ /* ---- sigset_t: add/del/has ---- */
+ st_begin("signal: sigset_t add/del/has");
+ {
+ sigset_t s{};
+ ST_ASSERT(!s.has(SIGUSR1));
+ s.add(SIGUSR1);
+ ST_ASSERT(s.has(SIGUSR1));
+ s.del(SIGUSR1);
+ ST_ASSERT(!s.has(SIGUSR1));
+ }
+ st_pass();
+
+ /* ---- sigset_t: bitwise operators ---- */
+ st_begin("signal: sigset_t bitwise OR and AND");
+ {
+ sigset_t a{};
+ a.add(SIGUSR1);
+ sigset_t b{};
+ b.add(SIGUSR2);
+
+ sigset_t c = a | b;
+ ST_ASSERT(c.has(SIGUSR1));
+ ST_ASSERT(c.has(SIGUSR2));
+
+ sigset_t d = c & a;
+ ST_ASSERT(d.has(SIGUSR1));
+ ST_ASSERT(!d.has(SIGUSR2));
+ }
+ st_pass();
+
+ /* ---- blocked mask: direct manipulation ---- */
+ st_begin("signal: blocked mask direct manipulation");
+ {
+ sigset_t saved = curr->blocked;
+ curr->blocked = sigset_t{};
+
+ curr->blocked.add(SIGUSR1);
+ curr->blocked.add(SIGUSR2);
+ ST_ASSERT(curr->blocked.has(SIGUSR1));
+ ST_ASSERT(curr->blocked.has(SIGUSR2));
+
+ curr->blocked.del(SIGUSR1);
+ ST_ASSERT(!curr->blocked.has(SIGUSR1));
+ ST_ASSERT(curr->blocked.has(SIGUSR2));
+
+ curr->blocked = saved;
+ }
+ st_pass();
+
+ /* ---- sighand action table ---- */
+ st_begin("signal: sighand action table defaults to SIG_DFL");
+ {
+ /* All handlers should default to SIG_DFL (0) */
+ kernel_sigaction* ka = &curr->sighand->action[SIGUSR1 - 1];
+ ST_ASSERT_EQ(ka->sa_handler, (uint64)SIG_DFL);
+
+ /* Set a handler and verify */
+ ka->sa_handler = 0x12345678ULL;
+ ST_ASSERT_EQ(ka->sa_handler, (uint64)0x12345678ULL);
+
+ /* Reset */
+ ka->sa_handler = SIG_DFL;
+ }
+ st_pass();
+
+ /* ---- sig_kernel_only rejects SIGKILL/SIGSTOP handlers ---- */
+ st_begin("signal: SIGKILL/SIGSTOP are kernel-only");
+ {
+ ST_ASSERT(sig_kernel_only(SIGKILL));
+ ST_ASSERT(sig_kernel_only(SIGSTOP));
+ ST_ASSERT(!sig_kernel_only(SIGUSR1));
+ ST_ASSERT(!sig_kernel_only(SIGINT));
+ }
+ st_pass();
+
+ /* ---- sig_default_action correctness ---- */
+ st_begin("signal: sig_default_action returns correct actions");
+ {
+ ST_ASSERT_EQ((int)sig_default_action(SIGCHLD), (int)SIG_ACT_IGN);
+ ST_ASSERT_EQ((int)sig_default_action(SIGKILL), (int)SIG_ACT_TERM);
+ ST_ASSERT_EQ((int)sig_default_action(SIGSTOP), (int)SIG_ACT_STOP);
+ ST_ASSERT_EQ((int)sig_default_action(SIGCONT), (int)SIG_ACT_CONT);
+ ST_ASSERT_EQ((int)sig_default_action(SIGSEGV), (int)SIG_ACT_CORE);
+ }
+ st_pass();
+
+ /* Clean up */
+ flush_pending(curr);
+ curr->blocked = sigset_t{};
+}
diff --git a/test/test_slab.cc b/test/test_slab.cc
new file mode 100644
index 0000000..8a448b4
--- /dev/null
+++ b/test/test_slab.cc
@@ -0,0 +1,255 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * Boot Self-Test: Slab Allocator (kmalloc/kfree)
+ *
+ * Tests:
+ * 1. Small allocation (8 bytes) and free
+ * 2. Multiple size classes
+ * 3. No aliasing between consecutive allocs
+ * 4. Alignment check (minimum 8-byte aligned)
+ * 5. Bulk alloc/free stress
+ * 6. operator new / operator delete
+ * 7. Large allocation (> 2048, falls back to PMM)
+ * 8. Zero-size and null-free edge cases
+ */
+
+#include <lume/selftest.h>
+#include <lume/new.h>
+#include <lume/pmm.h>
+#include <lume/addr.h>
+
+/* Forward declarations — defined in slab.cc */
+void* kmalloc(uint32 size);
+void kfree(void* ptr);
+
+void selftest_slab()
+{
+ st_begin("slab: small alloc (8 bytes)");
+ {
+ void* p = kmalloc(8);
+ ST_ASSERT(p != nullptr);
+ // Write and read back to verify the memory is usable
+ *static_cast<uint64*>(p) = 0xDEADBEEFCAFEBABEULL;
+ ST_ASSERT_EQ(*static_cast<uint64*>(p), 0xDEADBEEFCAFEBABEULL);
+ kfree(p);
+ }
+ st_pass();
+
+ st_begin("slab: multiple size classes");
+ {
+ uint32 sizes[] = {8, 16, 32, 64, 128, 256, 512, 1024, 2048};
+ void* ptrs[9];
+ for (int i = 0; i < 9; i++) {
+ ptrs[i] = kmalloc(sizes[i]);
+ ST_ASSERT(ptrs[i] != nullptr);
+ // Write a marker
+ *static_cast<uint8*>(ptrs[i]) = static_cast<uint8>(i);
+ }
+ // Verify markers (no cross-contamination)
+ for (int i = 0; i < 9; i++) {
+ ST_ASSERT_EQ(*static_cast<uint8*>(ptrs[i]), static_cast<uint8>(i));
+ }
+ for (int i = 0; i < 9; i++) {
+ kfree(ptrs[i]);
+ }
+ }
+ st_pass();
+
+ st_begin("slab: no aliasing");
+ {
+ void* a = kmalloc(32);
+ void* b = kmalloc(32);
+ ST_ASSERT(a != nullptr);
+ ST_ASSERT(b != nullptr);
+ ST_ASSERT_NE(a, b);
+ kfree(b);
+ kfree(a);
+ }
+ st_pass();
+
+ st_begin("slab: alignment (8-byte minimum)");
+ {
+ for (int i = 0; i < 16; i++) {
+ void* p = kmalloc(7); // odd size, should still be 8-aligned
+ ST_ASSERT(p != nullptr);
+ ST_ASSERT_EQ(reinterpret_cast<uint64>(p) % 8, 0ULL);
+ kfree(p);
+ }
+ }
+ st_pass();
+
+ st_begin("slab: bulk alloc/free stress (128 x 64B)");
+ {
+ constexpr int N = 128;
+ void* ptrs[N];
+ for (int i = 0; i < N; i++) {
+ ptrs[i] = kmalloc(64);
+ ST_ASSERT(ptrs[i] != nullptr);
+ }
+ // Free in reverse order
+ for (int i = N - 1; i >= 0; i--) {
+ kfree(ptrs[i]);
+ }
+ }
+ st_pass();
+
+ st_begin("slab: operator new/delete");
+ {
+ struct TestObj {
+ uint64 a;
+ uint64 b;
+ };
+ TestObj* obj = new TestObj{42, 99};
+ ST_ASSERT(obj != nullptr);
+ ST_ASSERT_EQ(obj->a, 42ULL);
+ ST_ASSERT_EQ(obj->b, 99ULL);
+ delete obj;
+ }
+ st_pass();
+
+ st_begin("slab: large alloc (4096 bytes, PMM fallback)");
+ {
+ void* p = kmalloc(4096);
+ ST_ASSERT(p != nullptr);
+ ST_ASSERT_EQ(reinterpret_cast<uint64>(p) % kPageSize, 0ULL);
+ kfree(p);
+ }
+ st_pass();
+
+ st_begin("slab: edge cases (zero size, null free)");
+ {
+ void* p = kmalloc(0);
+ ST_ASSERT(p == nullptr);
+ kfree(nullptr); // should not crash
+ }
+ st_pass();
+ st_begin("slab: LIFO freelist reallocation (same address)");
+ {
+ void* a = kmalloc(32);
+ kfree(a);
+ void* b = kmalloc(32);
+ ST_ASSERT_EQ(a, b);
+ kfree(b);
+ }
+ st_pass();
+
+ st_begin("slab: boundary conditions (2048 bytes size class)");
+ {
+ // 2048 bytes should still use slab (2 objects per page)
+ // We allocate 3 to guarantee at least two fall in the exact same slab page
+ void* p1 = kmalloc(2048);
+ void* p2 = kmalloc(2048);
+ void* p3 = kmalloc(2048);
+
+ auto diff = [](void* a, void* b) -> uint64 {
+ uint64 ua = reinterpret_cast<uint64>(a);
+ uint64 ub = reinterpret_cast<uint64>(b);
+ return ua > ub ? ua - ub : ub - ua;
+ };
+
+ // At least one adjacent pair MUST be exactly separated by 2048 bytes
+ bool found_2048 = (diff(p1, p2) == 2048ULL) || (diff(p2, p3) == 2048ULL);
+ ST_ASSERT(found_2048);
+
+ kfree(p1);
+ kfree(p2);
+ kfree(p3);
+ }
+ st_pass();
+
+ st_begin("slab: cross-page allocation (> 1 page equivalent objects)");
+ {
+ // Allocating more than one page worth of 32-byte objects (4096 / 32 = 128)
+ constexpr int NUM = 150;
+ void* ptrs[NUM];
+ for (int i=0; i<NUM; i++) {
+ ptrs[i] = kmalloc(32);
+ ST_ASSERT(ptrs[i] != nullptr);
+ }
+ for (int i=0; i<NUM; i++) {
+ kfree(ptrs[i]);
+ }
+ }
+ st_pass();
+
+ st_begin("slab: empty page reclamation (verifiable by page availability)");
+ {
+ // Allocate 128-byte objects until we hit the start of a completely new page.
+ void* base_ptr = nullptr;
+ void* drain[64];
+ int drain_count = 0;
+
+ while (true) {
+ void* p = kmalloc(128);
+ // Because 128 divides 4096 evenly, a new page starts at an exact multiple of kPageSize.
+ if (reinterpret_cast<uint64>(p) % kPageSize == 0) {
+ base_ptr = p;
+ break;
+ }
+ drain[drain_count++] = p;
+ }
+
+ // base_ptr is the very first object in a fresh slab page!
+ // The page holds exactly 32 objects (4096 / 128 = 32). We already hold 1 (base_ptr).
+ void* ptrs[32];
+ ptrs[0] = base_ptr;
+ for (int i = 1; i < 32; i++) {
+ ptrs[i] = kmalloc(128);
+ }
+
+ // At this point, we completely own all 32 objects of this slab page.
+ uint64 page_pa = va_to_pa(virt_addr(reinterpret_cast<uint64>(base_ptr))).raw;
+ Page* page = pa_to_page(phys_addr(page_pa));
+
+ ST_ASSERT(page->type == PageType::Slab);
+ ST_ASSERT(page->slab.obj_count == 32);
+
+ // Free all items in this isolated slab page
+ for (int i = 0; i < 32; i++) {
+ kfree(ptrs[i]);
+ }
+
+ // The page MUST have been returned to the PMM (either global Free or Per-CPU Cached).
+ ST_ASSERT(page->type == PageType::Free || page->type == PageType::PcpCached);
+
+ // Clean up the drain array
+ for (int i = 0; i < drain_count; i++) {
+ kfree(drain[i]);
+ }
+ }
+ st_pass();
+
+ st_begin("slab: continuous operator new/delete memory leak loop");
+ {
+ struct BigObj { uint8 data[512]; };
+ BigObj* ptr = nullptr;
+ // 10K allocations to ensure no memory leak causes OOM
+ for (int i = 0; i < 10000; i++) {
+ ptr = new BigObj;
+ ST_ASSERT(ptr != nullptr);
+ delete ptr;
+ }
+ }
+ st_pass();
+
+ st_begin("slab: allocation pressure (mixed sizes)");
+ {
+ constexpr int MAX_ITEMS = 100;
+ void* ptrs[MAX_ITEMS];
+ uint32 sizes[MAX_ITEMS];
+
+ for (int i = 0; i < MAX_ITEMS; i++) {
+ sizes[i] = (i * 17) % 2000 + 8; // Pseudo-random sizes between 8 and 2008
+ ptrs[i] = kmalloc(sizes[i]);
+ ST_ASSERT(ptrs[i] != nullptr);
+ ST_ASSERT(reinterpret_cast<uintptr>(ptrs[i]) % 8 == 0);
+ }
+
+ for (int i = 0; i < MAX_ITEMS; i++) {
+ kfree(ptrs[i]);
+ }
+ }
+ st_pass();
+}
diff --git a/test/test_smp.cc b/test/test_smp.cc
new file mode 100644
index 0000000..f934526
--- /dev/null
+++ b/test/test_smp.cc
@@ -0,0 +1,136 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_smp.cc — Post-SMP boot tests
+ *
+ * These tests run after all APs are online. They verify:
+ * 1. IPI delivery — BSP sends IPI to each AP, AP increments counter.
+ * 2. Task migration — a task moves from one runqueue to another.
+ * 3. TLB shootdown IPI — cross-CPU flush.
+ *
+ * Called from the init thread (PID 1) context, NOT from the pre-SMP
+ * self-test suite.
+ */
+
+#include <lume/selftest.h>
+#include <lume/smp.h>
+#include <lume/sched.h>
+#include <lume/atomic.h>
+#include <lume/config.h>
+#include <arch/cpu.h>
+
+/* Shared counter incremented by APs when they receive an IPI */
+static lume::atomic<uint32> g_ipi_ack_count{0};
+
+/* ------------------------------------------------------------------ */
+/* IPI delivery test */
+/* ------------------------------------------------------------------ */
+
+void selftest_ipi_delivery()
+{
+ st_begin("smp: IPI delivery to all APs");
+
+ /* Reset counter */
+ g_ipi_ack_count.store(0, __ATOMIC_RELAXED);
+
+ uint32 bsp = static_cast<uint32>(arch::cpu::id());
+ uint32 num_cpus = 4; /* TODO: derive from FDT */
+
+ /* Build mask of all APs */
+ uint64 ap_mask = 0;
+ uint32 expected = 0;
+ for (uint32 i = 0; i < num_cpus; i++) {
+ if (i == bsp) continue;
+ ap_mask |= (1ULL << i);
+ expected++;
+ }
+
+ /* Send reschedule IPI to all APs */
+ smp_send_ipi(ap_mask, IPI_RESCHEDULE);
+
+ /* Wait a short while for APs to process the interrupt.
+ * APs are in wfi (idle loop), so they'll wake immediately
+ * on interrupt delivery. We just need to give them time
+ * to run the handler. */
+ for (volatile int i = 0; i < 100000; i++) {
+ /* spin-wait */
+ }
+
+ /* We can't directly check that IPI was received because
+ * IPI_RESCHEDULE just sets need_resched — no ack.
+ * Instead verify no crash occurred and the IPI infrastructure
+ * didn't deadlock. This is a liveness test. */
+
+ st_pass();
+}
+
+/* ------------------------------------------------------------------ */
+/* IPI halt test (send to self, skip) */
+/* ------------------------------------------------------------------ */
+
+void selftest_ipi_self_skip()
+{
+ st_begin("smp: IPI reason bitmask accumulation");
+
+ uint32 cpu = static_cast<uint32>(arch::cpu::id());
+
+ /* Manually set multiple reasons atomically */
+ g_ipi_data[cpu].pending.store(0, __ATOMIC_RELAXED);
+ g_ipi_data[cpu].pending.fetch_or(IPI_RESCHEDULE, __ATOMIC_RELEASE);
+ g_ipi_data[cpu].pending.fetch_or(IPI_TLB_SHOOTDOWN, __ATOMIC_RELEASE);
+
+ uint32 val = g_ipi_data[cpu].pending.load(__ATOMIC_ACQUIRE);
+ ST_ASSERT_EQ(val, IPI_RESCHEDULE | IPI_TLB_SHOOTDOWN);
+
+ /* Clean up */
+ g_ipi_data[cpu].pending.store(0, __ATOMIC_RELAXED);
+
+ st_pass();
+}
+
+/* ------------------------------------------------------------------ */
+/* TLB shootdown IPI test */
+/* ------------------------------------------------------------------ */
+
+void selftest_tlb_shootdown()
+{
+ st_begin("smp: TLB shootdown IPI (cross-CPU flush)");
+
+ uint32 bsp = static_cast<uint32>(arch::cpu::id());
+ uint32 num_cpus = 4;
+
+ /* Build AP mask */
+ uint64 ap_mask = 0;
+ for (uint32 i = 0; i < num_cpus; i++) {
+ if (i == bsp) continue;
+ ap_mask |= (1ULL << i);
+ }
+
+ /* Set TLB range on each AP's IpiData */
+ for (uint32 i = 0; i < num_cpus; i++) {
+ if (i == bsp) continue;
+ g_ipi_data[i].tlb_start = 0;
+ g_ipi_data[i].tlb_end = 0; /* 0,0 = full flush */
+ }
+
+ /* Send TLB shootdown */
+ smp_send_ipi(ap_mask, IPI_TLB_SHOOTDOWN);
+
+ /* Wait for APs to process */
+ for (volatile int i = 0; i < 100000; i++) {}
+
+ /* If we got here without panic, TLB shootdown succeeded */
+ st_pass();
+}
+
+/* ------------------------------------------------------------------ */
+/* Public entry point */
+/* ------------------------------------------------------------------ */
+
+void selftest_smp()
+{
+ selftest_ipi_delivery();
+ selftest_ipi_self_skip();
+ selftest_tlb_shootdown();
+}
diff --git a/test/test_spinlock.cc b/test/test_spinlock.cc
new file mode 100644
index 0000000..be794a0
--- /dev/null
+++ b/test/test_spinlock.cc
@@ -0,0 +1,135 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * Boot Self-Test: Spinlock
+ *
+ * Tests:
+ * 1. Basic acquire/release
+ * 2. LockGuard RAII acquire/release
+ * 3. Nested locking (two different locks)
+ * 4. Interrupt state preservation across lock/unlock
+ * 5. is_held_by_current_cpu correctness
+ *
+ * Note: We cannot test multi-core contention in BSP-only self-test.
+ * That requires AP participation (future Phase 2 test).
+ */
+
+#include <lume/selftest.h>
+#include <kernel/sync/spinlock.h>
+#include <kernel/sync/cpu_state.h>
+#include <arch/cpu.h>
+
+void selftest_spinlock()
+{
+ st_begin("spinlock: basic acquire/release");
+ {
+ Spinlock lock;
+ lock.init("test_basic");
+
+ ST_ASSERT(!lock.is_held_by_current_cpu());
+
+ lock.acquire();
+ ST_ASSERT(lock.is_held_by_current_cpu());
+
+ lock.release();
+ ST_ASSERT(!lock.is_held_by_current_cpu());
+ }
+ st_pass();
+
+ st_begin("spinlock: LockGuard RAII");
+ {
+ Spinlock lock;
+ lock.init("test_guard");
+
+ {
+ LockGuard guard(lock);
+ ST_ASSERT(lock.is_held_by_current_cpu());
+ }
+ // After scope exit, lock should be released
+ ST_ASSERT(!lock.is_held_by_current_cpu());
+ }
+ st_pass();
+
+ st_begin("spinlock: nested locking (two locks)");
+ {
+ Spinlock lock_a, lock_b;
+ lock_a.init("test_nest_a");
+ lock_b.init("test_nest_b");
+
+ lock_a.acquire();
+ ST_ASSERT(lock_a.is_held_by_current_cpu());
+
+ lock_b.acquire();
+ ST_ASSERT(lock_b.is_held_by_current_cpu());
+
+ lock_b.release();
+ ST_ASSERT(!lock_b.is_held_by_current_cpu());
+ ST_ASSERT(lock_a.is_held_by_current_cpu()); // A still held
+
+ lock_a.release();
+ ST_ASSERT(!lock_a.is_held_by_current_cpu());
+ }
+ st_pass();
+
+ st_begin("spinlock: interrupt state preservation");
+ {
+ Spinlock lock;
+ lock.init("test_intr");
+
+ // Interrupts are currently off (BSP init period), but let's
+ // verify the lock doesn't corrupt the state.
+ bool before = arch::cpu::intr_enabled();
+
+ lock.acquire();
+ // Inside lock, interrupts must be disabled
+ ST_ASSERT(!arch::cpu::intr_enabled());
+ lock.release();
+
+ // After release, interrupt state should be restored
+ ST_ASSERT_EQ(arch::cpu::intr_enabled(), before);
+ }
+ st_pass();
+ st_begin("spinlock: deep nesting tracking (> 2)");
+ {
+ Spinlock l1, l2, l3;
+ l1.init("l1");
+ l2.init("l2");
+ l3.init("l3");
+
+ bool before = arch::cpu::intr_enabled();
+
+ l1.acquire();
+ l2.acquire();
+ l3.acquire();
+
+ ST_ASSERT(!arch::cpu::intr_enabled());
+
+ l3.release();
+ ST_ASSERT(!arch::cpu::intr_enabled());
+
+ l2.release();
+ ST_ASSERT(!arch::cpu::intr_enabled());
+
+ l1.release();
+ ST_ASSERT_EQ(arch::cpu::intr_enabled(), before);
+ }
+ st_pass();
+
+ st_begin("spinlock: interrupt leak detection logic (lock_depth metadata)");
+ {
+ Spinlock l;
+ l.init("l");
+
+ auto& state = sync::g_cpu_sync_states[arch::cpu::id()];
+ int initial_depth = state.lock_depth;
+
+ l.acquire();
+ // The lock depth should strictly increase
+ ST_ASSERT_EQ(state.lock_depth, initial_depth + 1);
+ l.release();
+
+ ST_ASSERT_EQ(state.lock_depth, initial_depth);
+ }
+ st_pass();
+}
diff --git a/test/test_sys_fs_extra.cc b/test/test_sys_fs_extra.cc
new file mode 100644
index 0000000..705ce63
--- /dev/null
+++ b/test/test_sys_fs_extra.cc
@@ -0,0 +1,238 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_sys_fs_extra.cc — Self-tests for additional filesystem syscalls
+ *
+ * Tests: dup3, chdir/getcwd, writev, ioctl (stub), unlinkat (stub).
+ * Complements test_vfs_io.cc which covers the core read/write/open/close.
+ */
+
+#include <lume/selftest.h>
+#include <lume/errno.h>
+#include <lume/file.h>
+#include <lume/fs.h>
+#include <lume/fs_types.h>
+#include <lume/task.h>
+
+/* Syscall entry points */
+int64 sys_openat(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_close(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_read(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_write(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_dup3(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_chdir(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_getcwd(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_writev(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_ioctl(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_unlinkat(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_mkdirat(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_lseek(uint64, uint64, uint64, uint64, uint64, uint64);
+
+/* ================================================================== */
+/* Helpers */
+/* ================================================================== */
+
+static int64 open_dev(const char* path, int flags)
+{
+ return sys_openat(
+ static_cast<uint64>(-100),
+ reinterpret_cast<uint64>(path),
+ static_cast<uint64>(flags), 0, 0, 0);
+}
+
+static int64 do_close(int64 fd)
+{
+ return sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+}
+
+static int64 do_read(int64 fd, void* buf, uint64 count)
+{
+ return sys_read(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(buf), count, 0, 0, 0);
+}
+
+
+/* iovec must match sys_fs.cc definition */
+struct test_iovec {
+ void* iov_base;
+ uint64 iov_len;
+};
+
+/* ================================================================== */
+/* Tests */
+/* ================================================================== */
+
+void selftest_sys_fs_extra()
+{
+ /* ---- dup3: duplicate fd to specific target ---- */
+ st_begin("sys_fs: dup3 duplicates to specific fd");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ /* dup3 to fd 50 */
+ int64 fd2 = sys_dup3(static_cast<uint64>(fd), 50, 0, 0, 0, 0);
+ ST_ASSERT_EQ(fd2, (int64)50);
+
+ /* Both should work */
+ char buf[4];
+ int64 r = do_read(fd2, buf, 4);
+ ST_ASSERT_EQ(r, (int64)0); /* /dev/null → EOF */
+
+ do_close(fd);
+ do_close(fd2);
+ }
+ st_pass();
+
+ /* ---- dup3: same fd returns -EINVAL ---- */
+ st_begin("sys_fs: dup3 same fd returns -EINVAL");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ int64 ret = sys_dup3(static_cast<uint64>(fd),
+ static_cast<uint64>(fd), 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-EINVAL);
+
+ do_close(fd);
+ }
+ st_pass();
+
+ /* ---- chdir to /dev, getcwd returns /dev ---- */
+ st_begin("sys_fs: chdir then getcwd returns new dir");
+ {
+ /* Save cwd */
+ char saved_cwd[64] = {};
+ sys_getcwd(reinterpret_cast<uint64>(saved_cwd), 64, 0, 0, 0, 0);
+
+ /* chdir to /dev */
+ int64 ret = sys_chdir(
+ reinterpret_cast<uint64>("/dev"), 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)0);
+
+ /* getcwd should return /dev */
+ char buf[64] = {};
+ int64 len = sys_getcwd(
+ reinterpret_cast<uint64>(buf), 64, 0, 0, 0, 0);
+ ST_ASSERT(len > 0);
+ ST_ASSERT_EQ(buf[0], '/');
+ ST_ASSERT_EQ(buf[1], 'd');
+ ST_ASSERT_EQ(buf[2], 'e');
+ ST_ASSERT_EQ(buf[3], 'v');
+
+ /* Restore cwd to / */
+ sys_chdir(reinterpret_cast<uint64>("/"), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- chdir to nonexistent returns -ENOENT ---- */
+ st_begin("sys_fs: chdir nonexistent returns -ENOENT");
+ {
+ int64 ret = sys_chdir(
+ reinterpret_cast<uint64>("/no_such_dir"), 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-ENOENT);
+ }
+ st_pass();
+
+ /* ---- chdir to file returns -ENOTDIR ---- */
+ st_begin("sys_fs: chdir to file returns -ENOTDIR");
+ {
+ int64 ret = sys_chdir(
+ reinterpret_cast<uint64>("/dev/null"), 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-ENOTDIR);
+ }
+ st_pass();
+
+ /* ---- writev: scatter write to file ---- */
+ st_begin("sys_fs: writev scatter write");
+ {
+ /* Create a file, write with writev, read back */
+ int64 fd = sys_openat(
+ static_cast<uint64>(-100),
+ reinterpret_cast<uint64>("/writev_test"),
+ O_CREAT | O_RDWR, 0644, 0, 0);
+ ST_ASSERT(fd >= 0);
+
+ char a[] = "AAA";
+ char b[] = "BBB";
+ test_iovec iov[2];
+ iov[0].iov_base = a;
+ iov[0].iov_len = 3;
+ iov[1].iov_base = b;
+ iov[1].iov_len = 3;
+
+ int64 w = sys_writev(
+ static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(iov), 2, 0, 0, 0);
+ ST_ASSERT_EQ(w, (int64)6);
+
+ /* Seek back to start and read */
+ sys_lseek(static_cast<uint64>(fd), 0, 0 /* SEEK_SET */, 0, 0, 0);
+
+ char buf[8] = {};
+ int64 r = do_read(fd, buf, 8);
+ ST_ASSERT_EQ(r, (int64)6);
+ ST_ASSERT_EQ(buf[0], 'A');
+ ST_ASSERT_EQ(buf[3], 'B');
+
+ do_close(fd);
+ }
+ st_pass();
+
+ /* ---- writev: bad fd returns -EBADF ---- */
+ st_begin("sys_fs: writev bad fd returns -EBADF");
+ {
+ test_iovec iov[1];
+ char dummy[] = "x";
+ iov[0].iov_base = dummy;
+ iov[0].iov_len = 1;
+
+ int64 ret = sys_writev(999,
+ reinterpret_cast<uint64>(iov), 1, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-EBADF);
+ }
+ st_pass();
+
+ /* ---- ioctl stub returns -ENOTTY ---- */
+ st_begin("sys_fs: ioctl stub returns -ENOTTY");
+ {
+ int64 fd = open_dev("/dev/null", O_RDONLY);
+ ST_ASSERT(fd >= 0);
+
+ int64 ret = sys_ioctl(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-ENOTTY);
+
+ do_close(fd);
+ }
+ st_pass();
+
+ /* ---- unlinkat stub returns -ENOSYS ---- */
+ st_begin("sys_fs: unlinkat stub returns -ENOSYS");
+ {
+ int64 ret = sys_unlinkat(
+ static_cast<uint64>(-100),
+ reinterpret_cast<uint64>("/some_file"),
+ 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-ENOSYS);
+ }
+ st_pass();
+
+ /* ---- getcwd: null buf returns -EINVAL ---- */
+ st_begin("sys_fs: getcwd null buf returns -EINVAL");
+ {
+ int64 ret = sys_getcwd(0, 64, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-EINVAL);
+ }
+ st_pass();
+
+ /* ---- getcwd: zero size returns -EINVAL ---- */
+ st_begin("sys_fs: getcwd zero size returns -EINVAL");
+ {
+ char buf[4];
+ int64 ret = sys_getcwd(
+ reinterpret_cast<uint64>(buf), 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-EINVAL);
+ }
+ st_pass();
+}
diff --git a/test/test_sys_mm.cc b/test/test_sys_mm.cc
new file mode 100644
index 0000000..2c1a7dc
--- /dev/null
+++ b/test/test_sys_mm.cc
@@ -0,0 +1,178 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+#include <lume/selftest.h>
+#include <lume/task.h>
+#include <lume/vmm.h>
+#include <lume/mman.h>
+#include <lume/errno.h>
+
+/* sys_mm.cc APIs */
+int64 sys_brk(uint64 new_brk, uint64, uint64, uint64, uint64, uint64);
+int64 sys_mmap(uint64 addr, uint64 length, uint64 prot,
+ uint64 flags, uint64 fd, uint64 offset);
+int64 sys_munmap(uint64 addr, uint64 length, uint64, uint64, uint64, uint64);
+int64 sys_mprotect(uint64 addr, uint64 len, uint64 prot,
+ uint64, uint64, uint64);
+
+void selftest_sys_mm()
+{
+ st_begin("sys_mm: mmap invalid arguments");
+ {
+ TaskControlBlock* curr = current_task();
+ VmSpace* old_mm = curr->mm;
+
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT(space != nullptr);
+ curr->mm = space;
+
+ ST_ASSERT_EQ(sys_mmap(0, 0, PROT_READ, MAP_PRIVATE | MAP_ANONYMOUS, 0, 0), -EINVAL);
+ ST_ASSERT_EQ(sys_mmap(0, 0x1000, PROT_READ, MAP_PRIVATE | MAP_ANONYMOUS, 0, 1), -EINVAL);
+ ST_ASSERT_EQ(sys_mmap(0, 0x1000, PROT_READ, 0, 0, 0), -EINVAL);
+ ST_ASSERT_EQ(sys_mmap(0, 0x1000, PROT_READ, MAP_PRIVATE | MAP_SHARED, 0, 0), -EINVAL);
+ ST_ASSERT_EQ(sys_mmap(0x123, 0x1000, PROT_READ, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, 0, 0), -EINVAL);
+
+ curr->mm = old_mm;
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("sys_mm: mmap/munmap/mprotect basic flow");
+ {
+ TaskControlBlock* curr = current_task();
+ VmSpace* old_mm = curr->mm;
+
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT(space != nullptr);
+ curr->mm = space;
+
+ int64 addr = sys_mmap(0, 0x3000, PROT_READ | PROT_WRITE,
+ MAP_PRIVATE | MAP_ANONYMOUS, 0, 0);
+ ST_ASSERT(addr > 0);
+
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, addr, VmFaultCause::Read), 0);
+
+ int64 mp = sys_mprotect(addr + kPageSize, kPageSize, PROT_READ, 0, 0, 0);
+ ST_ASSERT_EQ(mp, 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, addr + kPageSize, VmFaultCause::Write), -EFAULT);
+
+ int64 mu = sys_munmap(addr, 0x3000, 0, 0, 0, 0);
+ ST_ASSERT_EQ(mu, 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, addr, VmFaultCause::Read), -EFAULT);
+
+ curr->mm = old_mm;
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("sys_mm: MAP_FIXED replaces existing mapping");
+ {
+ TaskControlBlock* curr = current_task();
+ VmSpace* old_mm = curr->mm;
+
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT(space != nullptr);
+ curr->mm = space;
+
+ uint64 base = 0x600000;
+ ST_ASSERT_EQ(sys_mmap(base, 0x2000, PROT_READ,
+ MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, 0, 0),
+ static_cast<int64>(base));
+
+ ST_ASSERT_EQ(sys_mmap(base, 0x1000, PROT_READ | PROT_WRITE,
+ MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, 0, 0),
+ static_cast<int64>(base));
+
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, base, VmFaultCause::Read), 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, base, VmFaultCause::Write), 0);
+
+ curr->mm = old_mm;
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("sys_mm: mmap hint fallback on overlap");
+ {
+ TaskControlBlock* curr = current_task();
+ VmSpace* old_mm = curr->mm;
+
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT(space != nullptr);
+ curr->mm = space;
+
+ uint64 base = 0x700000;
+ ST_ASSERT_EQ(sys_mmap(base, 0x2000, PROT_READ,
+ MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, 0, 0),
+ static_cast<int64>(base));
+
+ int64 hint = sys_mmap(base, 0x1000, PROT_READ,
+ MAP_PRIVATE | MAP_ANONYMOUS, 0, 0);
+ ST_ASSERT(hint > 0);
+ ST_ASSERT(hint != static_cast<int64>(base));
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, hint, VmFaultCause::Read), 0);
+
+ curr->mm = old_mm;
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("sys_mm: brk growth and shrink");
+ {
+ TaskControlBlock* curr = current_task();
+ VmSpace* old_mm = curr->mm;
+
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT(space != nullptr);
+ curr->mm = space;
+
+ int64 brk0 = sys_brk(0x400000, 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(brk0, 0x400000);
+
+ int64 brk1 = sys_brk(0x402000, 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(brk1, 0x402000);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x400000, VmFaultCause::Read), 0);
+
+ int64 brk2 = sys_brk(0x400000, 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(brk2, 0x400000);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x401000, VmFaultCause::Read), -EFAULT);
+
+ curr->mm = old_mm;
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("sys_mm: brk invalid shrink below start");
+ {
+ TaskControlBlock* curr = current_task();
+ VmSpace* old_mm = curr->mm;
+
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT(space != nullptr);
+ curr->mm = space;
+
+ ST_ASSERT_EQ(sys_brk(0x500000, 0, 0, 0, 0, 0), 0x500000);
+ ST_ASSERT_EQ(sys_brk(0x4FF000, 0, 0, 0, 0, 0), -EINVAL);
+
+ curr->mm = old_mm;
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("sys_mm: mprotect invalid range");
+ {
+ TaskControlBlock* curr = current_task();
+ VmSpace* old_mm = curr->mm;
+
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT(space != nullptr);
+ curr->mm = space;
+
+ ST_ASSERT_EQ(sys_mmap(0, 0x1000, PROT_READ, MAP_PRIVATE | MAP_ANONYMOUS, 0, 0) > 0, true);
+ ST_ASSERT_EQ(sys_mprotect(0x123, 0x1000, PROT_READ, 0, 0, 0), -EINVAL);
+ ST_ASSERT_EQ(sys_mprotect(0x900000, 0x1000, PROT_READ, 0, 0, 0), -ENOMEM);
+
+ curr->mm = old_mm;
+ space->destroy();
+ }
+ st_pass();
+}
diff --git a/test/test_sys_process.cc b/test/test_sys_process.cc
new file mode 100644
index 0000000..686bed6
--- /dev/null
+++ b/test/test_sys_process.cc
@@ -0,0 +1,93 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_sys_process.cc — Self-tests for process-control syscalls
+ *
+ * Tests: getpid, gettid, getppid, getuid, getgid, set_tid_address,
+ * sched_yield, uname.
+ */
+
+#include <lume/selftest.h>
+#include <lume/errno.h>
+#include <lume/task.h>
+#include <lume/types.h>
+
+/* Syscall entry points */
+int64 sys_getpid(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_gettid(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_getppid(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_getuid(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_getgid(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_set_tid_address(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_sched_yield_wrapper(uint64, uint64, uint64, uint64, uint64, uint64);
+
+void selftest_sys_process()
+{
+ /* ---- getpid returns current tgid ---- */
+ st_begin("sys_process: getpid returns valid pid");
+ {
+ int64 pid = sys_getpid(0, 0, 0, 0, 0, 0);
+ /* Boot task pid is -1 but tgid may differ; just verify it returns */
+ TaskControlBlock* curr = current_task();
+ ST_ASSERT_EQ(pid, (int64)curr->tgid);
+ }
+ st_pass();
+
+ /* ---- gettid returns current pid (tid) ---- */
+ st_begin("sys_process: gettid returns current tid");
+ {
+ int64 tid = sys_gettid(0, 0, 0, 0, 0, 0);
+ TaskControlBlock* curr = current_task();
+ ST_ASSERT_EQ(tid, (int64)curr->pid);
+ }
+ st_pass();
+
+ /* ---- getppid returns parent tgid ---- */
+ st_begin("sys_process: getppid returns parent tgid");
+ {
+ int64 ppid = sys_getppid(0, 0, 0, 0, 0, 0);
+ TaskControlBlock* curr = current_task();
+ int64 expected = curr->parent ? (int64)curr->parent->tgid : 0;
+ ST_ASSERT_EQ(ppid, expected);
+ }
+ st_pass();
+
+ /* ---- getuid returns 0 (root) ---- */
+ st_begin("sys_process: getuid returns 0 (root)");
+ {
+ int64 uid = sys_getuid(0, 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(uid, (int64)0);
+ }
+ st_pass();
+
+ /* ---- getgid returns 0 (root) ---- */
+ st_begin("sys_process: getgid returns 0 (root)");
+ {
+ int64 gid = sys_getgid(0, 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(gid, (int64)0);
+ }
+ st_pass();
+
+ /* ---- set_tid_address stores pointer and returns tid ---- */
+ st_begin("sys_process: set_tid_address returns tid");
+ {
+ uint64 dummy_addr = 0x12345678ULL;
+ int64 tid = sys_set_tid_address(dummy_addr, 0, 0, 0, 0, 0);
+ TaskControlBlock* curr = current_task();
+ ST_ASSERT_EQ(tid, (int64)curr->pid);
+ ST_ASSERT_EQ(curr->clear_child_tid, dummy_addr);
+
+ /* Reset it to avoid side effects */
+ curr->clear_child_tid = 0;
+ }
+ st_pass();
+
+ /* ---- sched_yield returns 0 ---- */
+ st_begin("sys_process: sched_yield returns 0");
+ {
+ int64 ret = sys_sched_yield_wrapper(0, 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)0);
+ }
+}
+
diff --git a/test/test_trap.cc b/test/test_trap.cc
new file mode 100644
index 0000000..c91948a
--- /dev/null
+++ b/test/test_trap.cc
@@ -0,0 +1,197 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * Boot self-tests for the Trap/IRQ subsystem.
+ *
+ * Tests MD→MI translation, IrqManager register/dispatch/unregister,
+ * and stvec installation verification.
+ */
+
+#include <lume/types.h>
+#include <lume/selftest.h>
+#include <lume/trap.h>
+#include <lume/irq.h>
+#include <lume/errno.h>
+#include <lume/console.h>
+#include <arch/trap.h>
+
+TrapCause trap_translate_scause(uint64 scause);
+
+/* ========================================================================
+ * IrqManager test helpers
+ * ======================================================================== */
+
+static volatile uint32 g_test_irq_fired = 0;
+static volatile void* g_test_irq_arg = nullptr;
+
+static void test_irq_handler(uint32 irq, void* arg)
+{
+ g_test_irq_fired = irq;
+ g_test_irq_arg = arg;
+}
+
+/* ========================================================================
+ * Test cases
+ * ======================================================================== */
+
+static bool test_stvec_installed() {
+ uint64 vec = arch::trap::get_vector();
+ return vec != 0;
+}
+
+static bool test_scause_exception_translation() {
+ /* Verify all exception causes map correctly */
+ bool ok = true;
+ ok = ok && (trap_translate_scause(EXC_LOAD_PAGE_FAULT) == TrapCause::PageFaultLoad);
+ ok = ok && (trap_translate_scause(EXC_STORE_PAGE_FAULT) == TrapCause::PageFaultStore);
+ ok = ok && (trap_translate_scause(EXC_INST_PAGE_FAULT) == TrapCause::PageFaultExec);
+ ok = ok && (trap_translate_scause(EXC_ECALL_FROM_U) == TrapCause::Syscall);
+ ok = ok && (trap_translate_scause(EXC_ILLEGAL_INST) == TrapCause::IllegalInst);
+ ok = ok && (trap_translate_scause(EXC_BREAKPOINT) == TrapCause::Breakpoint);
+ ok = ok && (trap_translate_scause(EXC_LOAD_ACCESS_FAULT) == TrapCause::AccessFault);
+ ok = ok && (trap_translate_scause(EXC_INST_MISALIGNED) == TrapCause::AlignFault);
+ return ok;
+}
+
+static bool test_scause_interrupt_translation() {
+ bool ok = true;
+ ok = ok && (trap_translate_scause(SCAUSE_INTERRUPT | IRQ_S_TIMER) == TrapCause::IrqTimer);
+ ok = ok && (trap_translate_scause(SCAUSE_INTERRUPT | IRQ_S_SOFTWARE) == TrapCause::IrqSoftware);
+ ok = ok && (trap_translate_scause(SCAUSE_INTERRUPT | IRQ_S_EXTERNAL) == TrapCause::IrqExternal);
+ ok = ok && (trap_translate_scause(SCAUSE_INTERRUPT | 99) == TrapCause::Unknown);
+ return ok;
+}
+
+static bool test_scause_unknown() {
+ /* ecall from S-mode should map to Unknown (we only handle U-mode ecall) */
+ return trap_translate_scause(EXC_ECALL_FROM_S) == TrapCause::Unknown;
+}
+
+static bool test_irqmanager_register_dispatch() {
+ IrqManager mgr;
+ mgr.init();
+
+ g_test_irq_fired = 0;
+ g_test_irq_arg = nullptr;
+
+ int dummy_arg = 42;
+ int ret = mgr.register_handler(10, test_irq_handler, &dummy_arg);
+ if (ret != 0) return false;
+
+ /* Dispatch IRQ 10 — handler should be called */
+ mgr.dispatch(10);
+
+ if (g_test_irq_fired != 10) return false;
+ if (g_test_irq_arg != &dummy_arg) return false;
+
+ return true;
+}
+
+static bool test_irqmanager_unregister() {
+ IrqManager mgr;
+ mgr.init();
+
+ g_test_irq_fired = 0;
+
+ mgr.register_handler(5, test_irq_handler, nullptr);
+ mgr.unregister_handler(5);
+
+ /* Dispatch after unregister — handler should NOT be called */
+ mgr.dispatch(5);
+
+ return g_test_irq_fired == 0;
+}
+
+static bool test_irqmanager_double_register() {
+ IrqManager mgr;
+ mgr.init();
+
+ int ret1 = mgr.register_handler(7, test_irq_handler, nullptr);
+ int ret2 = mgr.register_handler(7, test_irq_handler, nullptr);
+
+ return ret1 == 0 && ret2 == -EBUSY;
+}
+
+static bool test_irqmanager_invalid_irq() {
+ IrqManager mgr;
+ mgr.init();
+
+ int ret = mgr.register_handler(999, test_irq_handler, nullptr);
+ return ret == -EINVAL;
+}
+
+static bool test_irqmanager_has_handler() {
+ IrqManager mgr;
+ mgr.init();
+
+ if (mgr.has_handler(10)) return false; /* not yet registered */
+
+ mgr.register_handler(10, test_irq_handler, nullptr);
+ if (!mgr.has_handler(10)) return false; /* now registered */
+
+ mgr.unregister_handler(10);
+ if (mgr.has_handler(10)) return false; /* unregistered */
+
+ return true;
+}
+
+static bool test_irqmanager_spurious_dispatch() {
+ IrqManager mgr;
+ mgr.init();
+
+ g_test_irq_fired = 0;
+
+ /* Dispatch an unregistered IRQ — should not crash or fire */
+ mgr.dispatch(0);
+ mgr.dispatch(63);
+
+ return g_test_irq_fired == 0;
+}
+
+/* ========================================================================
+ * Test runner
+ * ======================================================================== */
+
+void selftest_trap()
+{
+ st_begin("trap: stvec is installed");
+ ST_ASSERT(test_stvec_installed());
+ st_pass();
+
+ st_begin("trap: scause exception translation");
+ ST_ASSERT(test_scause_exception_translation());
+ st_pass();
+
+ st_begin("trap: scause interrupt translation");
+ ST_ASSERT(test_scause_interrupt_translation());
+ st_pass();
+
+ st_begin("trap: scause unknown (ecall from S)");
+ ST_ASSERT(test_scause_unknown());
+ st_pass();
+
+ st_begin("trap: IrqManager register/dispatch");
+ ST_ASSERT(test_irqmanager_register_dispatch());
+ st_pass();
+
+ st_begin("trap: IrqManager unregister");
+ ST_ASSERT(test_irqmanager_unregister());
+ st_pass();
+
+ st_begin("trap: IrqManager double register");
+ ST_ASSERT(test_irqmanager_double_register());
+ st_pass();
+
+ st_begin("trap: IrqManager invalid IRQ");
+ ST_ASSERT(test_irqmanager_invalid_irq());
+ st_pass();
+
+ st_begin("trap: IrqManager has_handler");
+ ST_ASSERT(test_irqmanager_has_handler());
+ st_pass();
+
+ st_begin("trap: IrqManager spurious dispatch");
+ ST_ASSERT(test_irqmanager_spurious_dispatch());
+ st_pass();
+}
diff --git a/test/test_uaccess.cc b/test/test_uaccess.cc
new file mode 100644
index 0000000..c5a8c6c
--- /dev/null
+++ b/test/test_uaccess.cc
@@ -0,0 +1,97 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+#include <lume/selftest.h>
+#include <lume/uaccess.h>
+#include <lume/errno.h>
+
+#include <lume/vmm.h>
+#include <lume/pmm.h>
+#include <lume/tlb.h>
+#include <lume/mm_layout.h>
+#include <arch/pmap.h>
+
+void selftest_uaccess()
+{
+ /* To test valid user addresses (< kKernelVaBase), we need a writable page mapped there.
+ * We cannot use VmSpace::create() because it does not yet inherit kernel mappings,
+ * so switching to it hangs. We also cannot map it with the U bit because S-mode
+ * would need the SUM bit enabled.
+ * So we manually allocate a physical page and map it into the CURRENT page table
+ * at 0x100000 without the U bit, just for this test.
+ */
+ uint64 mock_user_addr = 0x100000;
+
+ uint64 satp;
+ asm volatile("csrr %0, satp" : "=r"(satp));
+ uint64 root_pa = (satp & 0xFFFFFFFFFFFULL) << 12;
+
+ Page* page = pmm_alloc_page();
+ ST_ASSERT(page != nullptr);
+ uint64 paddr = page_to_pa(page).raw;
+
+ pmap::map(root_pa, mock_user_addr, paddr, pmap::PTE_R | pmap::PTE_W | pmap::PTE_V | pmap::PTE_A | pmap::PTE_D);
+
+ /* Flush TLB to see the new mapping */
+ tlb_shootdown_all();
+
+ st_begin("uaccess: copy_to_user valid range");
+ char kernel_buf[32] = "Hello User Space!";
+ int ret = copy_to_user(mock_user_addr, kernel_buf, 18);
+ ST_ASSERT_EQ(ret, 0);
+ st_pass();
+
+ st_begin("uaccess: copy_from_user valid range");
+ char read_buf[32] = {0};
+ /* We wrote "Hello User Space!" to mock_user_addr previously */
+ ret = copy_from_user(read_buf, mock_user_addr, 18);
+ ST_ASSERT_EQ(ret, 0);
+ ST_ASSERT(read_buf[0] == 'H');
+ ST_ASSERT(read_buf[1] == 'e');
+ ST_ASSERT(read_buf[6] == 'U');
+ st_pass();
+
+ st_begin("uaccess: strncpy_from_user valid range");
+ char str_buf[32] = {0};
+ int64 len = strncpy_from_user(str_buf, mock_user_addr, 32);
+ ST_ASSERT_EQ(len, 17); /* "Hello User Space!" is 17 chars long */
+ ST_ASSERT(str_buf[0] == 'H');
+ st_pass();
+
+ st_begin("uaccess: strncpy_from_user max_len truncation");
+ char trunc_buf[32] = {0};
+ len = strncpy_from_user(trunc_buf, mock_user_addr, 6);
+ ST_ASSERT_EQ(len, 5); /* Will copy 5 chars and NUL terminate */
+ ST_ASSERT(trunc_buf[4] == 'o');
+ ST_ASSERT(trunc_buf[5] == '\0');
+ st_pass();
+
+ st_begin("uaccess: copy_to_user invalid range (crosses boundary)");
+ ret = copy_to_user(kKernelVaBase - 10, kernel_buf, 20);
+ ST_ASSERT_EQ(ret, -EFAULT);
+ st_pass();
+
+ st_begin("uaccess: copy_to_user invalid range (entirely in kernel)");
+ ret = copy_to_user(kKernelVaBase + 0x1000, kernel_buf, 20);
+ ST_ASSERT_EQ(ret, -EFAULT);
+ st_pass();
+
+ st_begin("uaccess: copy_to_user invalid range (overflow)");
+ ret = copy_to_user(0xFFFFFFFFFFFFFFF0ULL, kernel_buf, 0x100);
+ ST_ASSERT_EQ(ret, -EFAULT);
+ st_pass();
+
+ st_begin("uaccess: copy_from_user invalid ranges");
+ ret = copy_from_user(read_buf, kKernelVaBase - 5, 10);
+ ST_ASSERT_EQ(ret, -EFAULT);
+
+ ret = copy_from_user(read_buf, kKernelVaBase, 10);
+ ST_ASSERT_EQ(ret, -EFAULT);
+ st_pass();
+
+ st_begin("uaccess: strncpy_from_user invalid range");
+ uint64 invalid_addr = kKernelVaBase + 0x1000;
+ len = strncpy_from_user(str_buf, invalid_addr, 10);
+ ST_ASSERT_EQ(len, -EFAULT);
+ st_pass();
+}
diff --git a/test/test_vfs_io.cc b/test/test_vfs_io.cc
new file mode 100644
index 0000000..3d3b125
--- /dev/null
+++ b/test/test_vfs_io.cc
@@ -0,0 +1,345 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * test_vfs_io.cc — Self-tests for VFS file I/O via syscalls
+ *
+ * Tests openat, read, write, close, lseek, dup, fstat, getdents64,
+ * getcwd, chdir, and mkdirat through the kernel syscall layer.
+ */
+
+#include <lume/selftest.h>
+#include <lume/errno.h>
+#include <lume/file.h>
+#include <lume/fs.h>
+#include <lume/fs_types.h>
+#include <lume/task.h>
+#include <lume/fdtable.h>
+
+/* Syscall entry points */
+int64 sys_openat(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_close(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_read(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_write(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_lseek(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_dup(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_dup3(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_fstat_wrapper(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_getcwd(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_chdir(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_mkdirat(uint64, uint64, uint64, uint64, uint64, uint64);
+int64 sys_getdents64(uint64, uint64, uint64, uint64, uint64, uint64);
+
+/* Helper: open a file, returns fd or negative error */
+static int64 open_file(const char* path, int flags, uint32 mode)
+{
+ return sys_openat(
+ static_cast<uint64>(-100), /* AT_FDCWD */
+ reinterpret_cast<uint64>(path),
+ static_cast<uint64>(flags),
+ static_cast<uint64>(mode), 0, 0);
+}
+
+void selftest_vfs_io()
+{
+ /* ---- openat O_CREAT creates a file ---- */
+ st_begin("vfs_io: openat O_CREAT creates file");
+ {
+ int64 fd = open_file("/vfs_test_file", O_CREAT | O_WRONLY, 0644);
+ ST_ASSERT(fd >= 0);
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- openat existing file ---- */
+ st_begin("vfs_io: openat existing file succeeds");
+ {
+ int64 fd = open_file("/vfs_test_file", O_RDONLY, 0);
+ ST_ASSERT(fd >= 0);
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- openat nonexistent without O_CREAT returns -ENOENT ---- */
+ st_begin("vfs_io: openat nonexistent returns -ENOENT");
+ {
+ int64 fd = open_file("/no_such_file_ever", O_RDONLY, 0);
+ ST_ASSERT_EQ(fd, (int64)-ENOENT);
+ }
+ st_pass();
+
+ /* ---- write then read ---- */
+ st_begin("vfs_io: write then read back data");
+ {
+ /* Write */
+ int64 fd = open_file("/vfs_rw_test", O_CREAT | O_WRONLY, 0644);
+ ST_ASSERT(fd >= 0);
+
+ const char* msg = "Hello VFS!";
+ int64 written = sys_write(
+ static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(msg),
+ 10, 0, 0, 0);
+ ST_ASSERT_EQ(written, (int64)10);
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+
+ /* Read */
+ fd = open_file("/vfs_rw_test", O_RDONLY, 0);
+ ST_ASSERT(fd >= 0);
+
+ char buf[32] = {};
+ int64 nread = sys_read(
+ static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(buf),
+ sizeof(buf), 0, 0, 0);
+ ST_ASSERT_EQ(nread, (int64)10);
+
+ bool match = true;
+ for (int i = 0; i < 10; i++) {
+ if (buf[i] != msg[i]) { match = false; break; }
+ }
+ ST_ASSERT(match);
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- read past EOF returns 0 ---- */
+ st_begin("vfs_io: read past EOF returns 0");
+ {
+ int64 fd = open_file("/vfs_rw_test", O_RDONLY, 0);
+ ST_ASSERT(fd >= 0);
+
+ char buf[64];
+ /* First read: 10 bytes */
+ int64 n1 = sys_read(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(buf), 64, 0, 0, 0);
+ ST_ASSERT_EQ(n1, (int64)10);
+
+ /* Second read: should be 0 (EOF) */
+ int64 n2 = sys_read(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(buf), 64, 0, 0, 0);
+ ST_ASSERT_EQ(n2, (int64)0);
+
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- O_TRUNC clears file content ---- */
+ st_begin("vfs_io: O_TRUNC clears file content");
+ {
+ int64 fd = open_file("/vfs_rw_test", O_WRONLY | O_TRUNC, 0);
+ ST_ASSERT(fd >= 0);
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+
+ fd = open_file("/vfs_rw_test", O_RDONLY, 0);
+ ST_ASSERT(fd >= 0);
+
+ char buf[16];
+ int64 n = sys_read(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(buf), 16, 0, 0, 0);
+ ST_ASSERT_EQ(n, (int64)0); /* truncated = empty */
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- close invalid fd returns -EBADF ---- */
+ st_begin("vfs_io: close bad fd returns -EBADF");
+ {
+ int64 ret = sys_close(999, 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-EBADF);
+ }
+ st_pass();
+
+ /* ---- read/write on bad fd returns -EBADF ---- */
+ st_begin("vfs_io: read/write bad fd returns -EBADF");
+ {
+ char buf[8];
+ int64 r = sys_read(999, reinterpret_cast<uint64>(buf), 8, 0, 0, 0);
+ ST_ASSERT_EQ(r, (int64)-EBADF);
+
+ int64 w = sys_write(999, reinterpret_cast<uint64>(buf), 8, 0, 0, 0);
+ ST_ASSERT_EQ(w, (int64)-EBADF);
+ }
+ st_pass();
+
+ /* ---- lseek SEEK_SET / SEEK_CUR / SEEK_END ---- */
+ st_begin("vfs_io: lseek SEEK_SET/CUR/END");
+ {
+ /* Write 20 bytes */
+ int64 fd = open_file("/vfs_lseek_test", O_CREAT | O_RDWR, 0644);
+ ST_ASSERT(fd >= 0);
+
+ char data[20];
+ for (int i = 0; i < 20; i++) data[i] = static_cast<char>('A' + i);
+ sys_write(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(data), 20, 0, 0, 0);
+
+ /* SEEK_SET to 5 */
+ int64 pos = sys_lseek(static_cast<uint64>(fd), 5, SEEK_SET, 0, 0, 0);
+ ST_ASSERT_EQ(pos, (int64)5);
+
+ /* Read 1 byte, should be 'F' */
+ char c = 0;
+ sys_read(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(&c), 1, 0, 0, 0);
+ ST_ASSERT_EQ(c, 'F');
+
+ /* SEEK_CUR +3 → pos 9 */
+ pos = sys_lseek(static_cast<uint64>(fd), 3, SEEK_CUR, 0, 0, 0);
+ ST_ASSERT_EQ(pos, (int64)9);
+
+ /* SEEK_END -1 → pos 19 */
+ pos = sys_lseek(static_cast<uint64>(fd), -1, SEEK_END, 0, 0, 0);
+ ST_ASSERT_EQ(pos, (int64)19);
+
+ sys_read(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(&c), 1, 0, 0, 0);
+ ST_ASSERT_EQ(c, 'T');
+
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- lseek invalid whence returns -EINVAL ---- */
+ st_begin("vfs_io: lseek invalid whence");
+ {
+ int64 fd = open_file("/vfs_lseek_test", O_RDONLY, 0);
+ ST_ASSERT(fd >= 0);
+ int64 ret = sys_lseek(static_cast<uint64>(fd), 0, 99, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)-EINVAL);
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- dup: duplicate fd shares file position ---- */
+ st_begin("vfs_io: dup shares file position");
+ {
+ int64 fd1 = open_file("/vfs_lseek_test", O_RDONLY, 0);
+ ST_ASSERT(fd1 >= 0);
+
+ int64 fd2 = sys_dup(static_cast<uint64>(fd1), 0, 0, 0, 0, 0);
+ ST_ASSERT(fd2 >= 0);
+ ST_ASSERT_NE(fd1, fd2);
+
+ /* Both should work for reading */
+ char c = 0;
+ int64 n = sys_read(static_cast<uint64>(fd2),
+ reinterpret_cast<uint64>(&c), 1, 0, 0, 0);
+ ST_ASSERT_EQ(n, (int64)1);
+
+ sys_close(static_cast<uint64>(fd1), 0, 0, 0, 0, 0);
+ sys_close(static_cast<uint64>(fd2), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- fstat: basic stat ---- */
+ st_begin("vfs_io: fstat returns valid kstat");
+ {
+ int64 fd = open_file("/vfs_lseek_test", O_RDONLY, 0);
+ ST_ASSERT(fd >= 0);
+
+ kstat st{};
+ int64 ret = sys_fstat_wrapper(
+ static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(&st), 0, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)0);
+ ST_ASSERT(S_ISREG(st.st_mode));
+ ST_ASSERT_EQ(st.st_size, (int64)20);
+
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- getcwd: returns "/" ---- */
+ st_begin("vfs_io: getcwd returns /");
+ {
+ char buf[64] = {};
+ int64 len = sys_getcwd(
+ reinterpret_cast<uint64>(buf), 64, 0, 0, 0, 0);
+ ST_ASSERT(len > 0);
+ ST_ASSERT_EQ(buf[0], '/');
+ }
+ st_pass();
+
+ /* ---- mkdirat: create directory ---- */
+ st_begin("vfs_io: mkdirat creates directory");
+ {
+ int64 ret = sys_mkdirat(
+ static_cast<uint64>(-100),
+ reinterpret_cast<uint64>("/vfs_io_dir"),
+ 0755, 0, 0, 0);
+ ST_ASSERT_EQ(ret, (int64)0);
+
+ /* Open it to confirm it's a directory */
+ int64 fd = open_file("/vfs_io_dir", O_RDONLY, 0);
+ ST_ASSERT(fd >= 0);
+
+ kstat st{};
+ sys_fstat_wrapper(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(&st), 0, 0, 0, 0);
+ ST_ASSERT(S_ISDIR(st.st_mode));
+
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- getdents64: list root directory ---- */
+ st_begin("vfs_io: getdents64 lists root children");
+ {
+ int64 fd = open_file("/", O_RDONLY, 0);
+ ST_ASSERT(fd >= 0);
+
+ char buf[512] = {};
+ int64 n = sys_getdents64(
+ static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(buf),
+ 512, 0, 0, 0);
+ ST_ASSERT(n > 0); /* At least "." and ".." */
+
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- write to O_RDONLY fd returns -EBADF ---- */
+ st_begin("vfs_io: write to O_RDONLY returns -EBADF");
+ {
+ int64 fd = open_file("/vfs_rw_test", O_RDONLY, 0);
+ ST_ASSERT(fd >= 0);
+
+ char buf[] = "x";
+ int64 w = sys_write(static_cast<uint64>(fd),
+ reinterpret_cast<uint64>(buf), 1, 0, 0, 0);
+ ST_ASSERT_EQ(w, (int64)-EBADF);
+ sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+
+ /* ---- double close returns -EBADF ---- */
+ st_begin("vfs_io: double close returns -EBADF");
+ {
+ int64 fd = open_file("/vfs_rw_test", O_RDONLY, 0);
+ ST_ASSERT(fd >= 0);
+
+ int64 r1 = sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(r1, (int64)0);
+
+ int64 r2 = sys_close(static_cast<uint64>(fd), 0, 0, 0, 0, 0);
+ ST_ASSERT_EQ(r2, (int64)-EBADF);
+ }
+ st_pass();
+
+ /* ---- fd reuse after close ---- */
+ st_begin("vfs_io: fd reuse after close");
+ {
+ int64 fd1 = open_file("/vfs_rw_test", O_RDONLY, 0);
+ ST_ASSERT(fd1 >= 0);
+ sys_close(static_cast<uint64>(fd1), 0, 0, 0, 0, 0);
+
+ int64 fd2 = open_file("/vfs_rw_test", O_RDONLY, 0);
+ ST_ASSERT(fd2 >= 0);
+ /* fd2 should reuse the slot of fd1 */
+ ST_ASSERT(fd2 <= fd1);
+ sys_close(static_cast<uint64>(fd2), 0, 0, 0, 0, 0);
+ }
+ st_pass();
+}
diff --git a/test/test_vma.cc b/test/test_vma.cc
new file mode 100644
index 0000000..5278914
--- /dev/null
+++ b/test/test_vma.cc
@@ -0,0 +1,282 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+#include <lume/selftest.h>
+#include <lume/vmm.h>
+#include <lume/addr.h>
+#include <lume/errno.h>
+#include <arch/pmap.h>
+
+void selftest_vma() {
+ st_begin("vma: create and destroy VmSpace");
+ {
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT(space != nullptr);
+ ST_ASSERT(space->root_pa != 0);
+
+ // Add a few regions to ensure destruction works
+ vmm_map_user(space, 0x1000, 0x1000, VM_READ);
+ vmm_map_user(space, 0x3000, 0x1000, VM_READ | VM_WRITE);
+
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: map user region and handle demand paging");
+ {
+ VmSpace* space = VmSpace::create();
+
+ // Map 8KB (2 pages) at 0x400000 using MI-layer flags
+ int ret = vmm_map_user(space, 0x400000, 0x2000, VM_READ | VM_WRITE);
+ ST_ASSERT_EQ(ret, 0);
+
+ // Fault in the first page
+ ret = vmm_handle_page_fault(space, 0x400500, VmFaultCause::Read);
+ ST_ASSERT_EQ(ret, 0);
+
+ // Verify mapping in hardware page table
+ uint64 pa = 0;
+ bool mapped = pmap::lookup(space->root_pa, 0x400000, &pa);
+ ST_ASSERT(mapped);
+ ST_ASSERT_NE(pa, 0ULL);
+
+ // Fault in the second page
+ ret = vmm_handle_page_fault(space, 0x401FFF, VmFaultCause::Read);
+ ST_ASSERT_EQ(ret, 0);
+
+ // Should reject fault outside VMA with -EFAULT
+ ret = vmm_handle_page_fault(space, 0x402000, VmFaultCause::Read);
+ ST_ASSERT_EQ(ret, -EFAULT);
+
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: unmap user region prevents page fault");
+ {
+ VmSpace* space = VmSpace::create();
+ vmm_map_user(space, 0x10000, 0x2000, VM_READ);
+
+ // Inside the region — should succeed
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x10500, VmFaultCause::Read), 0);
+
+ // Unmap exactly
+ vmm_unmap_user(space, 0x10000, 0x2000);
+
+ // Should fail now with -EFAULT
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x10500, VmFaultCause::Read), -EFAULT);
+
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: duplicate page fault on same page returns 0");
+ {
+ VmSpace* space = VmSpace::create();
+ vmm_map_user(space, 0x200000, 0x1000, VM_READ | VM_WRITE);
+
+ // First fault allocates the physical page
+ int ret = vmm_handle_page_fault(space, 0x200000, VmFaultCause::Read);
+ ST_ASSERT_EQ(ret, 0);
+
+ // Second fault on the same page should hit "already mapped" path
+ ret = vmm_handle_page_fault(space, 0x200100, VmFaultCause::Read);
+ ST_ASSERT_EQ(ret, 0);
+
+ // Verify both land on the same physical page
+ uint64 pa1 = 0, pa2 = 0;
+ pmap::lookup(space->root_pa, 0x200000, &pa1);
+ pmap::lookup(space->root_pa, 0x200100, &pa2);
+ // Same page, so PA for both addresses should be the same base page
+ ST_ASSERT_EQ(pa1, pa2);
+
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: vm_perm_to_pte flag translation correctness");
+ {
+ // Test each MI flag maps to correct hardware PTE bit
+ uint64 pte_r = pmap::vm_perm_to_pte(VM_READ);
+ ST_ASSERT(pte_r & pmap::PTE_R);
+ ST_ASSERT(!(pte_r & pmap::PTE_W));
+ ST_ASSERT(!(pte_r & pmap::PTE_X));
+ ST_ASSERT(!(pte_r & pmap::PTE_U));
+
+ uint64 pte_rw = pmap::vm_perm_to_pte(VM_READ | VM_WRITE);
+ ST_ASSERT(pte_rw & pmap::PTE_R);
+ ST_ASSERT(pte_rw & pmap::PTE_W);
+
+ uint64 pte_rx = pmap::vm_perm_to_pte(VM_READ | VM_EXEC);
+ ST_ASSERT(pte_rx & pmap::PTE_R);
+ ST_ASSERT(pte_rx & pmap::PTE_X);
+ ST_ASSERT(!(pte_rx & pmap::PTE_W));
+
+ uint64 pte_u = pmap::vm_perm_to_pte(VM_READ | VM_USER);
+ ST_ASSERT(pte_u & pmap::PTE_R);
+ ST_ASSERT(pte_u & pmap::PTE_U);
+
+ // All combined
+ uint64 pte_all = pmap::vm_perm_to_pte(VM_READ | VM_WRITE | VM_EXEC | VM_USER);
+ ST_ASSERT(pte_all & pmap::PTE_R);
+ ST_ASSERT(pte_all & pmap::PTE_W);
+ ST_ASSERT(pte_all & pmap::PTE_X);
+ ST_ASSERT(pte_all & pmap::PTE_U);
+ ST_ASSERT(pte_all & pmap::PTE_V); // Always valid
+ }
+ st_pass();
+
+ st_begin("vma: unmap subset only removes contained VMAs");
+ {
+ VmSpace* space = VmSpace::create();
+ // Map three separate regions
+ vmm_map_user(space, 0x50000, 0x1000, VM_READ);
+ vmm_map_user(space, 0x60000, 0x1000, VM_READ);
+ vmm_map_user(space, 0x70000, 0x1000, VM_READ);
+
+ // Unmap only the middle one (range covers 0x60000-0x61000)
+ vmm_unmap_user(space, 0x60000, 0x1000);
+
+ // First and third should still be reachable
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x50000, VmFaultCause::Read), 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x70000, VmFaultCause::Read), 0);
+
+ // Middle should fail
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x60000, VmFaultCause::Read), -EFAULT);
+
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: null space returns -EINVAL");
+ {
+ ST_ASSERT_EQ(vmm_map_user(nullptr, 0x1000, 0x1000, VM_READ), -EINVAL);
+ ST_ASSERT_EQ(vmm_unmap_user(nullptr, 0x1000, 0x1000), -EINVAL);
+ ST_ASSERT_EQ(vmm_handle_page_fault(nullptr, 0x1000, VmFaultCause::Read), -EINVAL);
+ }
+ st_pass();
+
+ st_begin("vma: clone and copy-on-write");
+ {
+ VmSpace* parent = VmSpace::create();
+ vmm_map_user(parent, 0x80000, 0x1000, VM_READ | VM_WRITE);
+
+ /* Trigger read fault in parent to allocate physical page */
+ ST_ASSERT_EQ(vmm_handle_page_fault(parent, 0x80000, VmFaultCause::Read), 0);
+ uint64 parent_pa_initial;
+ ST_ASSERT(pmap::lookup(parent->root_pa, 0x80000, &parent_pa_initial));
+
+ /* Clone the address space */
+ VmSpace* child = vmm_clone(parent);
+ ST_ASSERT(child != nullptr);
+
+ /* Verify child has the same physical page mapped but COW */
+ uint64 child_pa;
+ ST_ASSERT(pmap::lookup(child->root_pa, 0x80000, &child_pa));
+ ST_ASSERT_EQ(child_pa, parent_pa_initial);
+ ST_ASSERT(pmap::is_cow(child->root_pa, 0x80000));
+ ST_ASSERT(pmap::is_cow(parent->root_pa, 0x80000)); /* Parent also marked COW */
+
+ /* Trigger Store Page Fault (cause=15) in child */
+ ST_ASSERT_EQ(vmm_handle_page_fault(child, 0x80000, VmFaultCause::Write), 0);
+
+ /* Child should now have a new physical page, no longer COW */
+ uint64 child_pa_new;
+ ST_ASSERT(pmap::lookup(child->root_pa, 0x80000, &child_pa_new));
+ ST_ASSERT(child_pa_new != parent_pa_initial);
+ ST_ASSERT(!pmap::is_cow(child->root_pa, 0x80000));
+
+ /* Parent is still mapped to the old page, still COW because it hasn't written */
+ uint64 parent_pa_current;
+ ST_ASSERT(pmap::lookup(parent->root_pa, 0x80000, &parent_pa_current));
+ ST_ASSERT_EQ(parent_pa_current, parent_pa_initial);
+ ST_ASSERT(pmap::is_cow(parent->root_pa, 0x80000));
+
+ /* Trigger Store Page Fault in parent */
+ ST_ASSERT_EQ(vmm_handle_page_fault(parent, 0x80000, VmFaultCause::Write), 0);
+ ST_ASSERT(!pmap::is_cow(parent->root_pa, 0x80000));
+
+ child->destroy();
+ parent->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: gap search finds next free range");
+ {
+ VmSpace* space = VmSpace::create();
+ vmm_map_user(space, 0x1000, 0x3000, VM_READ);
+
+ uint64 va = mm_find_free_va(space, 0x1000, kPageSize);
+ ST_ASSERT_EQ(va, 0x4000ULL);
+
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: reject overlapping mapping");
+ {
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT_EQ(vmm_map_user(space, 0x20000, 0x2000, VM_READ), 0);
+ ST_ASSERT_EQ(vmm_map_user(space, 0x21000, 0x1000, VM_READ), -EINVAL);
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: partial unmap splits a single VMA");
+ {
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT_EQ(vmm_map_user(space, 0x80000, 0x3000, VM_READ | VM_WRITE), 0);
+
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x80000, VmFaultCause::Read), 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x81000, VmFaultCause::Read), 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x82000, VmFaultCause::Read), 0);
+
+ ST_ASSERT_EQ(vmm_unmap_user(space, 0x81000, 0x1000), 0);
+
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x80000, VmFaultCause::Read), 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x82000, VmFaultCause::Read), 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x81000, VmFaultCause::Read), -EFAULT);
+
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: mprotect read-only blocks write faults");
+ {
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT_EQ(vmm_map_user(space, 0x90000, 0x2000, VM_READ | VM_WRITE), 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x90000, VmFaultCause::Read), 0);
+
+ ST_ASSERT_EQ(vmm_protect_user(space, 0x90000, 0x1000, VM_READ), 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x90000, VmFaultCause::Write), -EFAULT);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0x91000, VmFaultCause::Write), 0);
+
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: PROT_NONE unmaps existing pages");
+ {
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT_EQ(vmm_map_user(space, 0xA0000, 0x1000, VM_READ | VM_WRITE), 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0xA0000, VmFaultCause::Read), 0);
+
+ ST_ASSERT_EQ(vmm_protect_user(space, 0xA0000, 0x1000, 0), 0);
+ ST_ASSERT_EQ(vmm_handle_page_fault(space, 0xA0000, VmFaultCause::Read), -EFAULT);
+
+ space->destroy();
+ }
+ st_pass();
+
+ st_begin("vma: range covered check");
+ {
+ VmSpace* space = VmSpace::create();
+ ST_ASSERT_EQ(vmm_map_user(space, 0xB0000, 0x2000, VM_READ), 0);
+
+ ST_ASSERT(vma_range_covered(space, 0xB0000, 0xB1000));
+ ST_ASSERT(!vma_range_covered(space, 0xB0000, 0xB3000));
+
+ space->destroy();
+ }
+ st_pass();
+}
diff --git a/test/test_vmm.cc b/test/test_vmm.cc
new file mode 100644
index 0000000..635e3f3
--- /dev/null
+++ b/test/test_vmm.cc
@@ -0,0 +1,58 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+/*
+ * Boot Self-Test: VMM and Address Translation
+ */
+
+#include <lume/selftest.h>
+#include <lume/klog.h>
+#include <lume/config.h>
+#include <lume/addr.h>
+#include <lume/vmm.h>
+#include <lume/pmm.h>
+#include <arch/config.h>
+
+namespace {
+constexpr uint64 kUartBase = 0x10000000ULL;
+constexpr uint64 kPageOffsetMask = kPageSize - 1;
+}
+
+void selftest_vmm() {
+ st_begin("vmm: virtual/physical address translation");
+ {
+ PhysAddr pa = pmm_get_mem_base();
+ VirtAddr va = pa_to_va(pa);
+ ST_ASSERT_EQ(va.raw, arch::g_direct_map_base + pa.raw);
+ ST_ASSERT_EQ(va_to_pa(va).raw, pa.raw);
+
+ Pfn p = pa_to_pfn(pa);
+ ST_ASSERT_EQ(p.raw, pa.raw / kPageSize);
+ ST_ASSERT_EQ(pfn_to_pa(p).raw, pa.raw);
+ }
+ st_pass();
+
+ st_begin("vmm: page alignment macros");
+ {
+ uint64 base = pmm_get_mem_base().raw;
+ uint64 addr = base + kPageOffsetMask;
+ ST_ASSERT_EQ(page_align_down(addr), base);
+ ST_ASSERT_EQ(page_align_up(addr), base + kPageSize);
+ ST_ASSERT_EQ(page_align_down(base), base);
+ ST_ASSERT_EQ(page_align_up(base), base);
+ }
+ st_pass();
+
+ st_begin("vmm: mapping kernel MMIO (driver API)");
+ {
+ /* Use UART MMIO address (0x10000000 on RISC-V virt).
+ * Must NOT use pmm_get_mem_base() — that's PMP-protected firmware. */
+ uint64 mmio_pa = kUartBase;
+ vmm_map_kernel_mmio(mmio_pa, kPageSize);
+
+ // Verify it doesn't crash and address is reachable in higher half
+ uint64 va = pa_to_va(phys_addr(mmio_pa)).raw;
+ ST_ASSERT(va >= arch::g_direct_map_base);
+ }
+ st_pass();
+}
diff --git a/test/test_waitqueue.cc b/test/test_waitqueue.cc
new file mode 100644
index 0000000..7f50d6b
--- /dev/null
+++ b/test/test_waitqueue.cc
@@ -0,0 +1,91 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/* Copyright (C) 2026 Richard Qin */
+
+#include <lume/selftest.h>
+#include <lume/waitqueue.h>
+#include <lume/task.h>
+#include <lume/sched.h>
+
+static WaitQueue g_test_wq;
+static volatile bool g_thread_woken = false;
+static volatile bool g_thread_started = false;
+static volatile int g_wake_count = 0;
+
+extern TaskControlBlock* kthread_create(const char* name, void (*entry)(void*), void* arg, uint8 priority);
+
+static void waitqueue_test_thread(void*)
+{
+ g_thread_started = true;
+ g_test_wq.sleep();
+ g_thread_woken = true;
+
+ extern void task_exit(int code);
+ task_exit(0);
+}
+
+static void waitqueue_test_thread_all(void*)
+{
+ g_test_wq.sleep();
+ __sync_fetch_and_add(&g_wake_count, 1);
+
+ extern void task_exit(int code);
+ task_exit(0);
+}
+
+void selftest_waitqueue()
+{
+ st_begin("waitqueue: sleep and wake_one");
+
+ g_test_wq.init("test_wq");
+ g_thread_started = false;
+ g_thread_woken = false;
+
+ TaskControlBlock* child = kthread_create("test_wq_child", waitqueue_test_thread, nullptr, 0);
+ ST_ASSERT(child != nullptr);
+
+ sched_yield();
+
+ ST_ASSERT(g_thread_started == true);
+ ST_ASSERT(g_thread_woken == false);
+ ST_ASSERT(!g_test_wq.is_empty());
+
+ kprintf("[test_wq] waking child\n");
+ bool woken = g_test_wq.wake_one();
+ ST_ASSERT(woken == true);
+ ST_ASSERT(g_test_wq.is_empty());
+
+ kprintf("[test_wq] yielding to child again\n");
+ sched_yield();
+ kprintf("[test_wq] returned from yield 2\n");
+
+ ST_ASSERT(g_thread_woken == true);
+
+ st_pass();
+
+ st_begin("waitqueue: wake_all");
+ g_wake_count = 0;
+ for (int i = 0; i < 3; i++) {
+ kthread_create("test_wq_all", waitqueue_test_thread_all, nullptr, 0);
+ }
+
+ /* Give all 3 threads a chance to start and sleep */
+ kprintf("[test_wq] yielding 1 for wake_all\n");
+ sched_yield();
+ sched_yield();
+ sched_yield();
+
+ ST_ASSERT(!g_test_wq.is_empty());
+
+ int woken_count = g_test_wq.wake_all();
+
+ ST_ASSERT_EQ(woken_count, 3);
+ ST_ASSERT(g_test_wq.is_empty());
+
+ /* Give all 3 threads a chance to wake up and run */
+ sched_yield();
+ sched_yield();
+ sched_yield();
+
+ ST_ASSERT_EQ(g_wake_count, 3);
+ st_pass();
+}