Skip to content

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) ​

InterfaceWhereWhat it says
Module metadatakernel/include/kernel/module.h:23-72ABI 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 unloadmodule.h:123-160, kernel/module/module.cmodule_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 themgit grep over the treeonly 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
Timerskernel/include/kernel/timer.h:5-6,255-271; exports kernel/timer/timer.c:1082-1085caller-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
Threadskernel/include/kernel/thread.h:163-185as 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 ​

IncrementContentNeeds
C8a module state and teardownA 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 archesnothing new in CosmoOS
C8b load and unload cyclesWith 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 goesthe 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 an IrqSpinLock and 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_init makes the state with ? at every allocation and hands it to the glue (Box.intoRaw). novamod_shutdown takes 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 an init that fails part-way drops what it made.

  • c8/kthread.nova: KThread<T: Task> (thread_create; a drop that asks the thread to stop under a spinlock and joins it, so it is sleep).

  • c8/timer.nova: Timer<T: TimerHandler>, CosmoOS's struct timer embedded first. The C callback fire<T> is @callback @context(irq), so a handler that allocates or sleeps does not compile. Drop is timer_cancel_sync.

  • c8.py generates the COSMO_MODULE glue (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) ​

CheckAArch64x86-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 guardexactexact
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 nothingPASSPASS
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 shutdownPASS (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 shutdownPASS: 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 eacheach unload -EBUSY: "still has 1 live object(s) after 5000 ms; kept as a zombie", 3 timesthe same
CosmoOS working tree unchangedPASSPASS

Findings for CosmoOS (reported, not changed) ​

  1. Objects made while a module's init or shutdown runs belong to no module. module_owner_of searches only published modules (kernel/module/module.c:672-689). A module is published after init returns (:428-440) and unpublished before shutdown runs (:614-623). So kobject_init of an object whose release is the module's code, called from init (or from a thread while init or shutdown runs), counts no live object, and module_unload does not wait for it. If such an object outlives shutdown, 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: their shutdown drops everything they made. But a module, in any language, that hands an object made in init to the rest of the kernel is. CosmoOS's selftests make their objects after load (cosmotest_object_take), so they do not see this.
  2. 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.
  3. A module's thread can perturb CosmoOS's scheduler selftests. With an unbounded watcher (waking every millisecond for the whole boot), sched-balance-pull failed 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.

CheckAArch64x86-64
3 negative cases; host testexact; PASSexact; PASS
C8a: init ran, the thread saw the timer fire; no lockdep report, 0 boot modules failed, 522 selftests, clean shutdownPASS (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 shutdownPASS in 17.5 s: modules 14 → 14, kmalloc live 7839 → 7839, large pages 692 → 692, VM regions 88 → 88, anon pages 136 → 136PASS 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 unchangedPASSPASS

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-*.nova match the message, which names the handler); module dependencies (deps); module-exported symbols.

Pre-1.0: the language and its tools are still changing.