C8 — An experimental kernel module (plan and report)
Status: complete (2026-10-28). C8a passed on 2026-10-25. C8b passed then against a copy of CosmoOS with the proposed hook; the hook was merged into CosmoOS as #457 (118e47b7, 2026-10-26), and C8b passed against CosmoOS itself on 2026-10-28 on both arches ("Results against CosmoOS main", below). Until then this read "C8b validated against the proposed CosmoOS hook, which is not yet in CosmoOS". Roadmap acceptance: a Nova .ko with a generated shutdown(); load and unload loops (≥ 1000) with no zombie modules, leaks or lockdep reports, on both arches; signing via modsign.py (roadmap.md). Prompt (cosmoos-native.md C8): compile a small Nova kernel module, load and unload it through CosmoOS's existing mechanism, and verify object lifetime, teardown, symbol compatibility and safety constraints.
Audit (CosmoOS 41368f9e; read, not copied)
| Interface | Where | What it says |
|---|---|---|
| Module metadata | kernel/include/kernel/module.h:23-72 | ABI version 11; COSMO_MODULE(name, version, init, shutdown, deps, caps) puts a 240-byte struct cosmo_module_info in .cosmo.module; init is int (*)(void), 0 or a negative errno; shutdown is void (*)(void) |
| Load and unload | module.h:123-160, kernel/module/module.c | module_load(file, size, origin, out) runs the pipeline and init; module_unload(name): GOING → shutdown() → one grace period → wait up to 5 s (MODULE_UNLOAD_TIMEOUT_MS_DEFAULT) for live_objects (kobjects whose release lives in the module) → free; otherwise the module becomes a zombie, kept mapped, freed by a later unload or swept by the next load |
| Who can call them | git grep over the tree | only built-in kernel code: module_load_boot (once, at boot, for modules/*.ko), and CosmoOS's own selftests (kernel/module/modtest.c, kernel/core/faulttest.c, on the fixture tests/cosmotest.ko). Neither function is exported, no syscall reaches them, and no userland tool exists |
| Timers | kernel/include/kernel/timer.h:5-6,255-271; exports kernel/timer/timer.c:1082-1085 | caller-owned struct timer (a list node: self-referential once armed); the callback runs in interrupt context on the arming CPU; timer_cancel_sync waits until the callback is not running anywhere, after which the timer may be freed |
| Threads | kernel/include/kernel/thread.h:163-185 | as in C7: thread_create, thread_join; an entry that returns exits the thread |
Consequence. Through CosmoOS's own mechanism, at stage S0, a module is loaded once per boot and never unloaded, so its shutdown() never runs. The 1,000-cycle criterion needs kernel-side code that calls the loader, and that means a CosmoOS change. Decision (owner, 2026-10-25): a debug-build test hook in CosmoOS. Under CosmoOS's own rules (its AGENTS.md: owner-chosen units, one branch and PR each, the full validation matrix) that is a CosmoOS unit, so Nova supplies it as a proposal (integrations/cosmoos/proposals/module-cycle/), and the owner schedules it there.
Plan
| Increment | Content | Needs |
|---|---|---|
| C8a module state and teardown | A Nova module whose state is one Nova value: made by init, dropped by shutdown, its fields torn down in declaration order (type-system §14). Bindings for what a module registers: KThread (a kernel thread told to stop and joined on drop), Timer<T> (an armed timer cancelled with timer_cancel_sync on drop, its callback @context(irq)), and kernel objects whose release lives in the module (C7d). Entry glue (COSMO_MODULE, init, shutdown) is generated by c8.py from a fixed protocol, since a compiler-generated COSMO_MODULE would make novac CosmoOS-specific (profiles §5). Tested on the host (teardown order, every registration ended), and in the kernel loaded at boot (init runs, the thread and timer work), on both arches | nothing new in CosmoOS |
| C8b load and unload cycles | With the hook: the module loaded and unloaded ≥ 1000 times per boot in the debug build, on both arches. Every unload returns 0 with no zombie; kernel heap and VM statistics return to their starting values; no lockdep report. A control in which an object outlives shutdown (a reference held outside) must make the module a zombie, then be swept once it goes | the CosmoOS hook |
The hook is specified in the proposal. It must keep the loader API unexported and be compiled only into CONFIG_DEBUG builds (CosmoOS's rule for test seams). Driven from the kernel command line (module.cycle=<archive entry>:<count>), it loads and unloads the named boot-archive module count times after boot, recording vm_stats and module_count() before and after, and logs one result line.
Results
What was built
c8/module.nova, the module. Its state is one Nova value,State { timer, watcher, ticks }:- a
Timer<Ticker>whose handler counts ticks in interrupt context under anIrqSpinLockand re-arms itself every 4 ms (400 ticks at most); - a
KThread<Watcher>that sleeps 1 ms per step, counts its steps (200 at most) and reports what the timer did; - a
KRef<Ticks>that both of them share. Its release is the module's code, so the kernel counts it as one of the module's live objects.
novamod_initmakes the state with?at every allocation and hands it to the glue (Box.intoRaw).novamod_shutdowntakes it back and drops it: the timer is cancelled synchronously, then the thread is stopped and joined, then the last reference is put. That is declaration order, because Nova drops fields in declaration order (type-system §14). Nothing it started outlives shutdown, and aninitthat fails part-way drops what it made.- a
c8/kthread.nova:KThread<T: Task>(thread_create; a drop that asks the thread to stop under a spinlock and joins it, so it issleep).c8/timer.nova:Timer<T: TimerHandler>, CosmoOS'sstruct timerembedded first. The C callbackfire<T>is@callback @context(irq), so a handler that allocates or sleeps does not compile. Drop istimer_cancel_sync.c8.pygenerates theCOSMO_MODULEglue (metadata,init,shutdown, the state pointer), so no hand-written C is needed for a module. It builds the module like C5's:MODULE_CFLAGS,ld.lld,modsign.py,check-module-elf.py, C4's checks.proposals/module-cycle/: the CosmoOS hook (see its README).
Results (run 2026-10-25, CosmoOS 41368f9e, debug build, QEMU -smp 4, macOS host)
| Check | AArch64 | x86-64 |
|---|---|---|
3 negative cases: a timer handler that allocates, one that takes a mutex (both rejected in fire<T>'s instance, the message naming the handler), dropping a KThread under a spin guard | exact | exact |
Host test (CosmoOS's HOST_CFLAGS, harness, spinlock shim, object.c): lifecycle, the teardown order timer_cancel_sync → thread_join (then the release), nothing armed, running or allocated afterwards; init with each of its allocations failing in turn returns -ENOMEM and leaves nothing | PASS | PASS |
C8a: the module loaded at boot from modules/nova_c8.ko: init ran, the thread saw the timer fire; no lockdep report, 0 boot modules failed, CosmoOS's 507 selftests pass, clean shutdown | PASS (52 ticks after 100 steps) | PASS (53 ticks) |
| C8b (CosmoOS plus the proposed hook, a copy): 1,000 load/unload cycles, every load and unload 0, no zombie; the module count, kmalloc's live objects, VM regions and anonymous pages back to their values after the first cycle; no lockdep report; 507 selftests; clean shutdown | PASS: 14 → 14, 5928 → 5928, 88 → 88, 136 → 136 (lockdep classes 136 → 143) | PASS: 14 → 14, 5667 → 5667, 97 → 97, 161 → 161 (classes 137 → 144) |
| C8b zombie control: a module whose thread leaks a kernel-object reference while the module is live; 3 cycles, held 100 ms each | each unload -EBUSY: "still has 1 live object(s) after 5000 ms; kept as a zombie", 3 times | the same |
| CosmoOS working tree unchanged | PASS | PASS |
Findings for CosmoOS (reported, not changed)
- Objects made while a module's
initorshutdownruns belong to no module.module_owner_ofsearches only published modules (kernel/module/module.c:672-689). A module is published afterinitreturns (:428-440) and unpublished beforeshutdownruns (:614-623). Sokobject_initof an object whose release is the module's code, called frominit(or from a thread whileinitorshutdownruns), counts no live object, andmodule_unloaddoes not wait for it. If such an object outlivesshutdown, its release later runs from freed module text. Evidence: the first version of the zombie control leaked its reference from such a window, and every unload returned 0 while kmalloc's live objects grew by 2 per cycle. Nova's own modules are not exposed: theirshutdowndrops everything they made. But a module, in any language, that hands an object made ininitto the rest of the kernel is. CosmoOS's selftests make their objects after load (cosmotest_object_take), so they do not see this. - Lockdep keeps a class per address a callback ever had. A callback's class is named
callback %p(kernel/core/lockdep.c:826-833), so each new load address of a module's timer callback adds a class that never goes away: 136 → 143 over 1,000 cycles (most loads reuse an address), and one per zombie in the control. CosmoOS itself warns that the table is at 496 of 512 in a debug boot without any Nova code (C7). The hook reports the count rather than failing on it. - A module's thread can perturb CosmoOS's scheduler selftests. With an unbounded watcher (waking every millisecond for the whole boot),
sched-balance-pullfailed once ("runnable threads stayed on the CPUs creation order gave them"). With the thread bounded to 200 steps, all 507 pass. This is recorded as an interaction, not as a CosmoOS defect.
Results against CosmoOS main (run 2026-10-28, CosmoOS 46f5d939, debug build, QEMU -smp 4, macOS host)
c8.py --arch <arch> --cycles 1000, now building CosmoOS itself (into Nova's out/) with the merged hook; no copy, no patch. CosmoOS's 522 selftests include A5's scheduler and process changes since the first run.
| Check | AArch64 | x86-64 |
|---|---|---|
| 3 negative cases; host test | exact; PASS | exact; PASS |
| C8a: init ran, the thread saw the timer fire; no lockdep report, 0 boot modules failed, 522 selftests, clean shutdown | PASS (51 ticks after 100 steps) | PASS (57 ticks) |
C8b: 1,000 load/unload cycles through module.cycle=; every load and unload 0; no lockdep report; 522 selftests; clean shutdown | PASS in 17.5 s: modules 14 → 14, kmalloc live 7839 → 7839, large pages 692 → 692, VM regions 88 → 88, anon pages 136 → 136 | PASS in 16.9 s: 14 → 14, 7557 → 7557, 656 → 656, 97 → 97, 162 → 162 |
| C8b zombie control (3 cycles, held 100 ms, a leaked reference) | every unload -EBUSY (3 of 3) | the same |
| CosmoOS working tree unchanged | PASS | PASS |
The merged hook adds a large pages count to its modcycle: line, and the kernel's log now cuts that line before its lockdep class counts, which the first run reported (136 → 143). The run judges lockdep by the absence of any report, as before; the class growth (finding 2) is CosmoOS's backlog LOCK-10.
One failure on the way, in c8.py: its first run against main panicked at boot (preempt_count > 0 in preempt_enable, from aarch64_start's first log line). The output directory still held objects built on 2026-10-25 from the patched copy, newer than main's sources, so make kept them and linked a kernel of two trees. c8.py now records the tree an output directory was built from and starts afresh for another.
What is left
- Nothing for C8's acceptance. Findings 1 and 2 are CosmoOS's (backlog MOD-1, LOCK-10).
- Not in C8: reporting a generic callback's effect error where its instance is made, not in the binding (
c8/negative/handler-*.novamatch the message, which names the handler); module dependencies (deps); module-exported symbols.