DuetOS is a from-scratch, general-purpose operating system written in C++/Rust/ASM. Its two defining goals are:
- Run Windows PE executables natively — a first-class Win32/NT subsystem (not a VM, not an emulator layer on top of another host OS). Think of the PE loader, NT syscall surface, and Win32 user-mode DLLs as part of the base system, co-equal with the native DuetOS ABI.
- Run on typical commodity PC hardware — x86_64 from day one (Intel/AMD), with first-class driver support for commodity GPUs (Intel iGPU, AMD Radeon, NVIDIA GeForce). ARM64 is a planned second tier.
This is a greenfield project. Treat every file in the tree as intentionally shaped — there is no "legacy" to work around yet, so the cost of sloppy decisions compounds faster than in a mature codebase. Build it right the first time.
- Kernel: Hybrid (microkernel-style IPC, monolithic-style in-kernel drivers for hot paths). Preemptive, SMP-aware, per-CPU runqueues.
- Boot: UEFI-first (x86_64), with a secondary legacy-BIOS path only if/when a target machine demands it. No MBR-only code paths in new work.
- Memory: 4-level paging (x86_64), NX, SMEP/SMAP, KASLR, per-process address spaces. Physical frame allocator + slab/buddy hybrid.
- Scheduler: MLFQ + per-CPU runqueues, affinity, work-stealing. Real-time class reserved, not the default.
- Filesystem: VFS abstraction. First backend: a native FS tuned for the project's needs. FAT32 and exFAT are read + bounded-write tiers (in-place / append / create / delete / rename; no cluster-chain growth) for interoperability; NTFS read-only tier for interoperability; ext4 read-only tier for Linux data partitions.
- Executable formats: Native ELF-like format and full PE/COFF. The PE subsystem is a peer, not a shim.
- Win32 subsystem: NT syscall layer → user-mode
ntdll,kernel32,user32,gdi32,d3d*,dxgi,winmm,xaudio2reimplementations. Not a Wine fork — studied as prior art, not taken as a dependency. - Graphics: Direct GPU drivers for Intel/AMD/NVIDIA. Kernel-mode DRM-style layer + user-mode API (Vulkan-first, D3D11/D3D12 translation on top for the Win32 subsystem).
- Drivers: PCIe enumeration, NVMe, AHCI/SATA, xHCI/USB, Intel HDA/AC'97, e1000/iwlwifi/rtl8169 NICs. Audio and networking user-mode stacks.
- Security: W^X enforced, ASLR, stack canaries, control-flow integrity. No setuid; capability-based IPC.
- Not a Linux distribution. No Linux kernel, no GNU userland as a base.
- Not a Wine project. Wine's userland reimplementation is useful prior art; we are writing ours.
- Not a research microkernel (L4, seL4). Pragmatism over academic purity.
- Not a rewrite of ReactOS. ReactOS is useful as a reference for Win32 semantics; we are not forking it.
Win32 and Linux subsystems are facades for executing PE/ELF binaries. They never drive DuetOS. The DuetOS kernel — its capability set, scheduler, address-space ledger, filesystem mediation, and IPC — is the authority on every effect a guest binary can have on the system. NT and Linux thunks translate ABI shapes; they don't reach past the syscall boundary.
Concrete rules every subsystem TU and userland DLL must follow:
- No subsystem code mutates DuetOS state without going through a kernel-mediated, cap-gated syscall. A Win32 PE that wants to write a file goes through
SYS_FILE_WRITE(kCapFsWrite). A Linux binary that wants to spawn a thread goes throughSYS_THREAD_CREATE(kCapSpawnThread). The thunk does not get to skip the gate. - Auth and privilege are kernel-owned. A process's authority is its durable caps plus unexpired broker leases, masked by a monotonic grant ceiling;
Process::cap_lockprotects that state and kernel cap gates consume an atomic effective snapshot. Win32-shaped token calls use kernel helpers: disable clears live bits reversibly,SE_PRIVILEGE_REMOVEDpermanently lowers the ceiling, and enabling a missing cap must pass through the elevation broker. The broker is the sole controlled post-spawn grant bridge and may install only a positive-duration lease after role/password policy succeeds; integrity levels and ACL-shaped probes remain facades. - Userland DLLs (
userland/libs/*) are freestanding. They do not include kernel headers and they do not assume kernel internals. They issue syscalls and trust the kernel's return. - In-kernel subsystem code (
kernel/subsystems/win32/,kernel/subsystems/linux/) routes through public kernel APIs (mm::*,sched::*,fs::routing::*,core::Cap*). It does not mutate kernel-internal data structures (regions tables, runqueues, capability bitsets) directly. - No subsystem-to-subsystem coupling. Win32 doesn't call Linux, Linux doesn't call Win32. They both call the kernel.
- One source of truth per resource. One TCP stack, one VFS, one registry, one window manager — each reachable from multiple ABI front-ends, but with one kernel-owned implementation.
Violations of these rules are bugs even if they compile. If you find code that bypasses cap-gating or mutates kernel state from a subsystem, fix it — don't extend the violation. The reviewable signal: "could a malicious PE / ELF use this path to do something a native DuetOS process couldn't?" If yes, the gate is wrong, not the workload.
The full rationale and the audit checklist live in wiki/kernel/Subsystem-Isolation.md.
Compiling is not "done." Before committing / opening a PR, walk this list — the obligations most often missed:
- Re-scan every signal, not just the one you started on — build, tests, clang-format, boot log, CI. Fix what surfaces. (→ Fix Anything You Surface)
- Landed a Roadmap item? Delete its section from
wiki/reference/Roadmap.mdin the same commit. (→ Updating roadmap items) - Update the owning wiki page (
wiki/<area>/…) to reflect the new state — incl.wiki/reference/Win32-Surface-Status.mdif a slice flipped a REAL/STUB/MISSING row. - Append to
wiki/reference/Design-Decisions.mdif the change rules out an alternative a future slice could otherwise pick. - Update
wiki/getting-started/History.mdif a project-level milestone moved. - New subsystem / driver / DLL / spec? Add a wiki page; a one-paragraph addendum amends the existing page instead. (→ When to write a new wiki page)
- STUB/GAP markers present on deliberate omissions, absent on code that does its job.
Step 1 — Git sync (see Git Sync Workflow below for the commands):
Sync your branch with the latest upstream main branch. This is the first thing to do — before reading code, before making changes, before anything else. Feature branches diverge as other PRs merge; without rebasing you'll be working on stale code.
Step 2 — Read the wiki:
The single canonical documentation home is wiki/. Start at wiki/Home.md or wiki/_Sidebar.md for the table of contents. Pending and deferred work lives in wiki/reference/Roadmap.md. The per-DLL / per-method "what's REAL vs STUB vs MISSING" inventory lives in wiki/reference/Win32-Surface-Status.md — keep it in sync when a slice flips a row.
Step 3 — Bloat check (once the tree has real code):
find kernel drivers subsystems userland -type f \
\( -name '*.cpp' -o -name '*.c' -o -name '*.rs' \) | xargs wc -l | sort -rn | head -15If the task involves any file over the threshold, trim it first.
Step 4 — Parallel-session check: if other Claude Code sessions may be
running concurrently, follow the Parallel Sessions
protocol below — run tools/parallel/status.sh and claim your subsystem
before editing.
See CLAUDE_PARALLEL.md — follow this protocol every session when concurrent sessions are possible.
DuetOS may be worked on by several Claude Code sessions at once. File ownership
is coordinated through the tracked coordinator PARALLEL_WORK.md and the
helper scripts under tools/parallel/:
tools/parallel/status.sh # See active/completed sessions + conflicts
tools/parallel/claim.sh <sub> "<files>" "<desc>" # Claim a subsystem before editing
tools/parallel/release.sh <sub> # Push your session branch when done
tools/parallel/release.sh <sub> --merge # ...and merge to main (explicit opt-in)The helpers serialize coordinator mutations with a Git-common-directory lock
and fail closed on malformed entries, overlapping scopes, remote divergence,
commit failure, or push failure. They never auto-rebase a dirty integration
tree and never force-push: each mutation is a signed coordinator-only commit,
published with a normal push, then verified against the exact remote head.
--merge additionally requires a completely clean worktree plus current,
fast-forward-only main; the flag is the explicit opt-in DuetOS requires
before touching main (CI must be green first). Do not hand-edit
PARALLEL_WORK.md.
AI-assisted development has a structural bias toward complexity: adding features "just in case," creating helpers for single uses, over-engineering simple problems, building systems without wiring them in. In an OS codebase — where the wrong abstraction lives forever in the kernel ABI — this bias is more dangerous than in application code. The goal is sanity, not sacrifice — keep code clean without stripping legitimate verbosity or readability.
These are guidelines for when to pause and think, not absolute rules. A clean 450-line .cpp is fine; a cryptic 200-line .cpp is not.
| Thing | Threshold | What to do |
|---|---|---|
.cpp / .c / .rs file size |
~500 lines | Split if doing multiple jobs; leave if one coherent unit |
.h / .hpp file size |
~300 lines | Split if unrelated types; data-heavy headers are fine |
| Public methods per class | ~15 | Ask: "Does each method earn its place?" |
| Function length | ~60 lines | Split if nested branching; clear linear flow is fine |
| Syscall handlers per file | 1 subsystem per file | Consolidate before adding more |
| Parallel subsystems doing the same thing | 0 | Remove the duplicate |
Never sacrifice readability to hit a line count. Keep comments that explain "why," use descriptive variable names (pageTableEntryMask > ptm), maintain vertical whitespace between logical sections, use braces for non-trivial loop bodies, and one statement per line. The question is always: "Does this make sense to someone reading it for the first time, at 2am, during a triple-fault?"
- Does this already exist? Search before writing — especially for low-level primitives (spinlocks, allocators, list helpers).
- Will this be called? If you can't name the caller, don't write it.
- Can existing code do this with a small change? Prefer editing over adding.
- Is this a one-time use? Inline it — no helper function, no new class.
- Am I future-proofing? Stop. Write only what is needed today.
- Adding a new subsystem? Ask if an existing one can be extended instead.
- Adding a new syscall? Syscall numbers are an ABI. Once published, they are forever. Be sure.
- Is the code dead? Delete it. Don't comment it out — git history exists.
- Is a system built but not wired in? Either wire it in or delete it.
- Is this running in kernel or user space? Be explicit. Kernel code has no
malloc, noprintf, no exceptions unless the project explicitly supports them.
- C++23 for kernel and most subsystems (
constexpr,enum class,std::expected-style results, concepts,if consteval). No RTTI, no exceptions in kernel code — results go throughduetos::core::Result<T, E>(seekernel/util/result.h). Preferreturn Err{ErrorCode::Foo};+RESULT_TRY/RESULT_TRY_ASSIGNat call sites overreturn -1 / false / nullptrsentinels. - Rust permitted for greenfield subsystems where memory-safety vs. C++ lifetime invariants matter (filesystem drivers, USB stack, network stack). If you reach for Rust, the subsystem must stand alone — no Rust-in-the-middle of a C++ call chain.
- ASM: NASM (Intel syntax) for x86_64 boot, trap frames, context switch. Keep hand-written assembly to the smallest possible surface.
- Ownership:
std::unique_ptr/UniquePtrowning, raw pointers non-owning. In kernel, use the project's own smart pointer primitives —std::is user-land only. - Const-correctness:
conston all non-mutating methods and parameters.constexprwherever it works. - Naming: PascalCase classes/methods, camelCase locals,
m_prefix members,UPPER_SNAKEmacros and kernel constants,k_prefix for kernel-internal globals. - Headers:
#pragma once, forward-declare where possible, no transitive include bloat. - Style: Allman braces, 4-space indent, 120-col limit (see
.clang-format). LF line endings everywhere (we are primarily Linux-hosted during development). - Zero warnings:
-Wall -Wextra -Wpedantic -Werroron GCC/Clang;/W4 /WXon MSVC. - No naked
new/deletein portable code. Kernel allocations go through the slab/page allocators explicitly, never through a globaloperator new. - No global mutable state outside the kernel's explicit per-CPU areas. If something looks like a singleton, it is probably a per-CPU or per-process structure.
- Stub markers: any handler / thunk / DLL function whose v0 implementation deliberately omits the real semantics carries a
// STUB:comment on or immediately above the line that bakes in the omission. A handler that correctly implements its contract but with a known limitation carries// GAP: <what's missing> — <when to revisit>. Both forms are greppable: re-derive the live inventory withgit grep -nE "// (STUB|GAP):".// STUB:— handler returns a constant / does nothing / returns-ENOSYS/ returns the wrong target. Real callers WILL behave incorrectly. The marker stays until a real implementation lands.// GAP: <missing> — <revisit>— handler is correct for the v0 happy path but a documented edge case is unimplemented (e.g. "no IPv6", "no LFN", "no oversize"). Real callers along the happy path work; the marker pins the known limit so a future audit can find it cheaply.- Do not pepper STUB/GAP markers on code that does its job — the convention exists to bound the gap inventory, not to annotate every line. If removing the marker wouldn't change a maintainer's belief about what works, don't write it.
This tree is aspirational — the directories will appear as the work does. Do not create a directory until the first file legitimately belongs in it.
boot/ — supported GRUB/Multiboot2 release path + experimental direct UEFI loader
kernel/
acpi/ — ACPI tables (RSDP, MADT, FADT) + AML parser
apps/ — In-kernel native apps (calculator, clock, gfxdemo, …)
arch/x86_64/ — Bootstrap, paging, GDT/IDT, trap frames, APIC, context switch
arch/aarch64/ — (later) ARM64 equivalents
core/ — Entry (main.cpp), panic, early init
cpu/ — Per-CPU data structures
debug/ — Breakpoints, probes, syscall scan, exception tables
diag/ — Diagnostic surface: kdbg, crprobe, runtime checker, hexdump, recovery
drivers/ — In-kernel device drivers (see below)
fs/ — VFS, path resolution, FAT32/exFAT/ext4/NTFS, ramfs, GPT
loader/ — ELF + PE loaders, DLL loader, firmware loader
log/ — klog (kernel log ring + sinks)
mm/ — Physical frame allocator, paging, slab, kheap, kstack, address spaces
net/ — Protocol stacks (TCP/IP, UDP, ICMP, ARP, Wi-Fi)
power/ — Reboot / shutdown
proc/ — Process model (process.cpp, ring3 smoke)
sched/ — Scheduler, runqueues, threads, context switch
security/ — Auth/login, stack canary, fault domains, attack sim, pentest, image guard
shell/ — Kernel shell (split across shell_*.cpp TUs)
subsystems/ — Linux ABI, Win32 ABI, graphics, ABI translation
sync/ — Spinlocks, mutexes, RW locks, RCU-lite
syscall/ — Native syscall dispatch + time syscalls
util/ — Result<T,E>, string helpers, types, symbols, random
time/ — HPET clocksource, scheduler tick, timezone
ipc/ — Handle table, KMutex/KEvent/KSemaphore/KMailbox/KFile kernel objects
drivers/
pci/ — PCIe enumeration
storage/nvme/ — NVMe
storage/ahci/ — AHCI/SATA
usb/xhci/ — xHCI host controller
usb/class/ — HID, MSC, hub
net/e1000/ — Intel gigabit NICs
net/iwlwifi/ — Intel Wi-Fi (later)
gpu/intel/ — Intel iGPU (Gen9+)
gpu/amd/ — AMDGPU (GFX9+)
gpu/nvidia/ — NVIDIA Turing+ (via nouveau-style reverse-engineered interface or NVIDIA's open kernel interface)
audio/hda/ — Intel HDA
input/ps2/ — PS/2 keyboard/mouse (legacy fallback)
subsystems/
win32/
loader/ — PE/COFF loader, imports, relocations, TLS
ntdll/ — NT API (NtCreateFile, NtAllocateVirtualMemory, …)
kernel32/ — Win32 base API
user32/ — Window manager interface (USER32 calls → our WM)
gdi32/ — GDI (software path first, GPU-accelerated later)
d3d11/ — D3D11 → Vulkan translation
d3d12/ — D3D12 → Vulkan translation
dxgi/ — DXGI
winmm/ — Windows multimedia (audio, timers)
posix/ — (later) POSIX-ish syscalls for porting Unix userland
graphics/ — WM, compositor, Vulkan ICD
audio/ — Audio server, mixer
userland/
libc/ — Our libc (freestanding + hosted)
init/ — PID 1, service supervisor
shell/ — Command shell
tools/ — Native userland utilities
apps/ — Sample/test apps (native + PE)
third_party/ — Vendored dependencies (compiler-rt fragments, zlib, etc.)
tools/
build/ — Build helpers, image builders, initrd packer
qemu/ — QEMU launch scripts, debug helpers
test/ — Integration test harnesses
tests/ — Unit tests (hosted) + kernel self-tests (on-target)
docs/ — Misc docs not part of the wiki (boot-log examples, sync scripts)
wiki/ — Canonical documentation home (subsystem pages, specs, roadmap)
Supported release path: BIOS or UEFI firmware → hybrid ISO → GRUB → Multiboot2 handoff → kernel entry → per-CPU bringup → init process.
boot/uefi/BOOTX64.EFI is an experimental direct loader, not the supported
or production boot path. It currently proves the PE32+ toolchain and validates
the kernel ELF header, then halts. Do not advertise it as complete until it
loads ELF segments, calls ExitBootServices, supplies a versioned BootInfo,
hands off to the kernel, and passes required CI.
Early console → physmem map → paging on → heap → IDT/GDT → APIC/timer → SMP AP bringup → scheduler online → drivers (PCIe → NVMe → graphics → input) → VFS → init.
- Kernel: IRQ-off critical sections use
spin_lock_irqsave/spin_lock_irqrestore. Sleeping in an interrupt handler is a bug. Document which locks each subsystem owns at the top of its header. - Drivers: Must state their context (IRQ / softirq / process). No driver holds a sleeping mutex across DMA.
- Win32 subsystem: All Win32 DLLs run in the target process's user-mode context; shared state is either per-process or goes through an explicit kernel port.
Planned:
# Configure (pick one)
cmake --preset x86_64-release # Kernel + userland, release
cmake --preset x86_64-debug # Kernel + userland, debug
cmake --preset x86_64-kasan # Debug + KASAN-equivalent
# Build
cmake --build build --parallel $(nproc)
# Run in QEMU
tools/qemu/run.sh build/duetos.img
# Run tests (hosted unit tests)
cd build && ctest --output-on-failurePlanned toolchain baseline: Clang 18+ / GCC 13+, CMake 3.25+, NASM 2.16+, lld preferred as the kernel linker. Rust (if used) via rustup nightly pinned in rust-toolchain.toml.
Until the build system exists, do not invent a fake preset. If a task asks "build it," answer truthfully: the build system is not yet written; here is what needs to happen to land one.
The dev host ships without QEMU/OVMF/GRUB/xorriso/mtools. When a task
legitimately requires a live-boot smoke test (runtime behaviour changed, or a
correctness claim a compile can't prove), install the full toolbox up front —
don't fake a "compiles, therefore works" claim. Package list, the "legitimately
requires" test, and the smoke invocation:
wiki/tooling/Dev-Host-Setup.md.
IMPORTANT: assembly (.S) files are NOT formatted by
clang-format. Never pass a .S file to clang-format -i — it
will parse it as C++ and mangle it. Assembly stays hand-formatted.
Use this for a manual implementation integration from a clean worktree. The parallel coordinator helpers follow their stricter current-session-branch ancestry contract above; do not wrap them in an automatic rebase.
git fetch origin main
git log --oneline HEAD..origin/main | wc -l # check if behind
git rebase origin/main # if behind, rebase
# If conflicts: resolve, git add <files>, git rebase --continueRules:
- Never commit or push while behind the base branch. Always rebase first.
- Prefer upstream changes for auto-generated content (
<!-- AUTO:* -->sections) once docs automation is introduced. - All Claude-driven development happens on the feature branch the harness checked out for the session (
claude/<slug>). Merge target ismain. Do not push to other branches without explicit permission.
Run checks appropriate to the files you changed.
Proofread. Run any doc generators that exist at the time (docs/sync-wiki.sh sync, tools/check-wiki-nav.sh, tools/check-wiki-quality.sh).
# 1. Format check (mirror CI once CI is in place)
find kernel drivers subsystems userland \
\( -name '*.h' -o -name '*.hpp' -o -name '*.c' -o -name '*.cpp' \) \
| xargs clang-format --dry-run --Werror 2>&1
# 2. Fix formatting (if step 1 fails)
find kernel drivers subsystems userland \
\( -name '*.h' -o -name '*.hpp' -o -name '*.c' -o -name '*.cpp' \) \
| xargs clang-format -i
# 3. CMake configure
cmake --preset x86_64-release 2>&1 | tail -20
# 4. Build
cmake --build build --parallel $(nproc) 2>&1 | tail -30
# 5. Tests
cd build && ctest --output-on-failure && cd ..
# 6. QEMU smoke (when there's a kernel to boot)
tools/qemu/run.sh --headless --timeout 30 build/duetos.imgIf any step fails, fix before committing. CI (once wired up) will enforce clang-format on every PR.
After creating or pushing to a PR, always poll CI and fix failures before moving on. Use the GitHub MCP tools available in this environment — do not shell out to gh.
See wiki/tooling/Git-Workflow.md for the polling workflow.
- Do each numbered task ONE AT A TIME. Complete one task fully, confirm it worked, then move to the next.
- Never write a file longer than ~150 lines in a single tool call. If a file will be longer, write it in multiple append/edit passes.
- Start a fresh session if the conversation gets long (20+ tool calls). The error gets worse as the session grows.
- Keep individual grep/search outputs short. Use flags like
--includeand-l(list files only) to limit output size. - If you do hit the timeout, retry the same step in a shorter form. Don't repeat the entire task from scratch.
Every kind of work — adding a feature, fixing a bug, refactoring, auditing, reading the live boot, running CI, running tests, running static analysis, running clang-format, running the linker, running a fuzzer, reviewing a diff, reading a docs page — reveals the next layer of issues. Fix everything that surfaces, even if it predates your slice, even if it's outside the obvious authorship boundary of your task. A "not my code" regression is still visible to whoever picks up the codebase next; deferring just buries the cost and the next session pays interest on it.
This rule supersedes the "anti-bloat / don't add features beyond what the task requires" rule WHEN AND ONLY WHEN the additional work is fixing something concretely broken right now (a failing test, a non-zero CI signal, a warning, a panic, a wrong value returned from a real call, an obviously-stale comment, a probe that emits a sentinel). It does NOT license speculative refactoring, future-proofing, or "while I'm here" rewrites — those remain anti-bloat targets.
The discipline:
-
After every change, re-scan every surface that produces signal, not just the one you started with. The signals are cheap; running them is cheap; the cost of letting one rot is high. Concretely:
- Live boot log:
tools/test/boot-log-analyze.sh <log>— the canonical triage entrypoint (consolidates the regression scan, phase timings, self-test PASS/FAIL, lockdep pairs, stress summary, hypervisor/SMP banner; exits non-zero on a non-deliberate failure so it doubles as a gate; launcher-agnostic — works on QEMU stdout, a VMware/VBox serial-to-file, or a real-HW UART capture). Raw fallback:grep -nE "\[E\] |PANIC|TRIPLE|FAIL|out of range|task-kill|kernel oops" /tmp/duetos-*.log - Build:
cmake --build build/<preset>— everywarning:line is a fix target, every error is. - Hosted tests:
cd build/<preset> && ctest --output-on-failure— every non-PASS is a fix target. - clang-format:
find kernel userland \( -name '*.h' -o -name '*.cpp' \) | xargs clang-format --dry-run --Werror— every violation is a fix target. - Wiki sync:
bash docs/sync-wiki.sh sync— every "stale references" line is a fix target. - Static greps:
git grep -nE "// (STUB|GAP):"— the live inventory is itself the audit list. - CI: every red check on the branch's PR is a fix target. Poll until they're all green.
- Anything else the user invokes during the session.
If a signal source you haven't run could plausibly find something related to your change, run it. "I assumed it was clean" is not a defence when the next boot shows the line you missed.
- Live boot log:
-
Scope is whatever the signal exposes, not whatever fits the commit message. Don't carve "in scope" vs "out of scope" along authorship, file, subsystem, slice-plan, or pretty-PR lines. The question is whether the issue is observable now. If yes, fix it. Commit messages are per-slice; the codebase is shared.
-
A symptom-cluster gets one investigation, not N. When N similar failures appear (several PE smoke tests failing, several identical warnings, several CI checks red for the same reason), trace ONE to its root cause before touching the others. The root usually explains the cluster; fixing it retires N issues at once. The reverse — "patch each symptom locally" — is how a codebase grows the long tail of fragile workarounds that the anti-bloat rule warns against.
-
Class-of-bug pattern matching. Some failure shapes recur across slices; when you see the shape, check for the class before chasing the calling code:
- Lost-page / lost-slot collisions. Two structures share a
randomised base / a fixed VA / a slab class — whichever
landed LATER silently overwrites the EARLIER, and the
EARLIER's callers fault at a valid-looking RIP. Symptom:
ring-3 #GP/#UD/#PF at an address inside a DLL or stub
region; or a kernel value reading-back changed without an
observable writer. Root: two
base=0x...lines at the same address, or two slab callers with the same cache pointer. This was the vcruntime140 memmove crash in 2026-05-11. - Stale-comment drift. A comment claims a behaviour the code no longer implements (e.g. "v0 returns empty cmdline" when the code routes through a populated proc-env page now). Symptom: tests that the comment justifies fail. Root: code moved on, comment didn't. Fix both.
- Sentinel divergence. Two paths claim the same "v0
placeholder" but spell it differently (e.g.
"X:\\"vs"C:\\"). Symptom: a smoke test that checked one sentinel flags the other path. Root: one was updated, the other wasn't. Pick one and align both. - Whitelist incompleteness. A predicate enumerates the
legal set explicitly (
if (x == A) ...) but a new member of the set was added elsewhere and the predicate wasn't updated. Symptom: kernel halts / refuses / mis-routes on the new member. Root: per-call-site allow-lists. Fix: add the new member, AND consider converting to a property test so future additions don't need a new whitelist edit. This was the F9 IrqInstall halt in 2026-05-11. - Refcount asymmetry. Acquire path adds a refcount the release path doesn't drop, or vice versa. Symptom: leaked object pinned forever, or use-after-free. Root: an exit / error / orphan path that bypasses the matching half. Walk EVERY exit from the acquiring scope and verify each one either succeeded-and-handed-off OR failed-and-rolled-back.
- Log-level abuse. A WARN/ERROR log fires on a legitimate
API failure mode (e.g.
WaitForSingleObjecttimeout,ReleaseMutexfrom non-owner). Symptom: the log floods on normal contended workloads. Root: log level wrong. Demote to DEBUG; the calling code's return-value handling is the real notification channel.
- Lost-page / lost-slot collisions. Two structures share a
randomised base / a fixed VA / a slab class — whichever
landed LATER silently overwrites the EARLIER, and the
EARLIER's callers fault at a valid-looking RIP. Symptom:
ring-3 #GP/#UD/#PF at an address inside a DLL or stub
region; or a kernel value reading-back changed without an
observable writer. Root: two
-
Self-tests pass silently by default. A self-test that only emits its FAIL line on failure won't show up when it passes — that's the contract. If you want grep-able proof of PASS, emit one explicit
[<subsys>-selftest] PASS (...)line viaarch::SerialWrite. Do NOT promote every PASS to KLOG_INFO — that defeats the log-level system. The reverse applies too: the absence of a FAIL line is NOT proof of pass — it could be proof the self-test was never called. Verify the BOOT_SELFTEST hook exists. -
One run is not enough for intermittent symptoms. If a test crashes on this run but not the previous one, the bug is ASLR / scheduling / hash-order / GC-timing / cache-warmup / clock- jitter dependent. Re-run a few times to confirm intermittency, then look for the class-of-bug shapes above (collisions and refcount asymmetries are the usual suspects). Don't conclude "the previous run was fine, so this is flaky and not worth fixing." Intermittent bugs ARE bugs — they're just sensitive to randomness, and they hit in production proportionally to that sensitivity.
-
Follow the trail wherever it goes. A regression's root cause doesn't respect file boundaries, slice plans, or your commit message. A bad ASLR delta in
kernel/proc/ring3_smoke.cppis yours to fix even if you opened the session for "ipc: named pipes." If the trail leads to a subsystem you haven't worked in, read enough to understand the fix without breaking invariants, then make it. If the trail leads out of the codebase (build tool, toolchain bug, kernel command-line), document the workaround and what we'd need to fix upstream. -
"No deferring" is the default, not the exception. The user saying "fix everything, no deferring" is the explicit form of a rule that should be the implicit default. Do not propose follow-up slices, do not file "we should address this later" notes in commit messages, do not stash issues in a TODO file. Fix it now or argue concretely why the fix can't land in this session (cyclic dependency that needs a real refactor, change bigger than the context window, fix needs a runtime artefact that doesn't exist yet). "It's not my code" is not such an argument.
A system that exists but is never initialized, called, or connected is worse than not existing. In kernel space, dead code is not merely wasteful — it rots silently until the day a refactor accidentally re-enables it and triple-faults the box.
- Every driver must be probed. If
probe()exists, the bus enumerator must call it for matching devices. - Every syscall handler must be in the dispatch table. A handler that compiles but isn't dispatched is dead code.
- Every initcall must run. If a subsystem has an
init(), it must be on a known init list with a stated ordering. - Every sink must have a source. If a system receives data, something must be sending it.
If you discover a subsystem that is built but not wired in: either wire it in immediately, or delete it.
When diagnosing a bug you almost always end up adding fresh log lines to localise the failure. Don't strip those out once the bug is fixed. They are exactly the lines a future debugger (you, in three months) will want when the next regression appears in the same area. The discipline is:
- Keep the diagnostic. If a log line was useful enough to add during the fix, it's useful enough to leave in. Deleting it just guarantees the next session re-derives it from scratch.
- Gate it appropriately. The diagnostic must respect the kernel's log-level system so it doesn't flood the serial console in production:
- Use
KLOG_WARN(subsys, msg)for the failure summary line — surfaces in any sensible log level, gets a[W]colour, respectslogleveldemotion in release builds. - Use
KLOG_DEBUG_V(subsys, msg, value)/KLOG_DEBUG_S(subsys, msg, label, str)for the verbose detail (observed values, hex dumps, sub-flag breakdowns). Debug-level lines are compiled out underDUETOS_KLOG_COMPILE_FLOOR > 0and runtime-suppressed under release defaults — so the heavy detail only shows when an operator explicitly turns it on. - Avoid raw
arch::SerialWrite(...)for new diagnostic output. Raw serial bypasses log levels and shows up forever, on every boot, in every flavour. Reserve it for the boot bring-up path that runs before klog is online and for the structural sentinels ([smoke] profile=… complete) that CI greps for.
- Use
- Hook the GDB / breakpoint subsystem on the failure path. The kernel's
KBP_PROBE(...)/KBP_PROBE_V(...)macros (seekernel/debug/probes.h) let an attached GDB break the moment a regression first surfaces. When you add a new self-test or assert, fire a probe in its failure leg:- For one-off self-test failures, fire
kBootSelftestFailwith a value that encodes which sub-check tripped. - For new categories of failure, extend
ProbeId+kProbeTable(one row each inprobes.h+probes.cpp) and pickProbeArm::ArmedLogso a clean run logs nothing but a regression run shows up immediately. - Pair the probe with the live GDB stub (
DUETOS_GDB_SERVER=ON, attach viatools/debug/duetos-gdb-attach.sh) — setb duetos::debug::ProbeFireand the debugger halts at the exact frame the regression appeared in.
- For one-off self-test failures, fire
The contract: a clean boot stays quiet at default log levels; a regression boot leaves a WARN sentinel + a probe fire + DEBUG-gated detail behind it, all without an operator having to re-add print statements. If the diagnostic you're considering doesn't earn its place under those rules (one-shot value, not actionable, or already implied by an existing log), don't add it — but if it does, gate it and leave it in.
When you (or a prior session) write a script, harness, or one-off tool that has value beyond the immediate task — a CPU/latency profiler, a log correlator, a repro driver, a measurement rig, a parser for some boot artefact — commit it into the tree instead of leaving it in /tmp. The next session that needs it should ls tools/ and find it, not reverse-engineer it from a transcript (or, worse, not know it ever existed and re-derive it from scratch).
The discipline:
- Where it goes:
tools/qemu/for boot/QEMU-driven rigs,tools/test/for test harnesses,tools/build/for build/codegen helpers,tools/debug/for debugger glue. Match the existing siblings' shape (header comment block: what it does, why, usage, env vars, quick-analysis one-liners). - What qualifies: anything you'd plausibly run again, or that another session investigating the same area would want. A profiler that found a real spike, a script that reproduced a race, a correlator that mapped host metrics to guest time — yes. A throwaway
grep | sortyou typed once — no. - Make it reusable, not task-welded: parameterise the hard-coded paths/timeouts you used during the investigation (env vars or
$1), keep it dependency-light, andbash -n/ syntax-check it before committing. - Commit it with the work it supported. The tool and the fix it measured belong in the same slice — the tool is the evidence the fix works and the means to re-verify it. Mention it in the commit body.
- This rule is the structural form of "no deferring": re-deriving a measurement rig every session is the same wasted-interest cost as letting a signal rot. Pay it once.
The single canonical documentation home is wiki/. Subsystem pages, specifications, the design-decisions log, the shell-command surface, and the project history all live there. The Sidebar is the table of contents.
A new page is worth adding when:
- A new subsystem lands and is wired into the boot path.
- A new driver class is added under
kernel/drivers/. - A new userland Win32 DLL is added under
userland/libs/. - A new specification (ABI, file format, protocol) is committed to the repo.
- A standalone topic accumulates enough cross-page references that inlining everywhere is worse than one canonical page.
A new page is not the right answer when:
- The topic is a one-paragraph addendum to an existing page — amend that page.
- The topic is a transient TODO — add a row to
wiki/reference/Roadmap.mdinstead. - The topic is a one-off slice postmortem whose conclusions are already captured in the relevant subsystem page or in the commit message.
When a slice lands an item from wiki/reference/Roadmap.md:
- Delete its section from the Roadmap in the same commit that delivers the code.
- Update the owning subsystem page (
wiki/<area>/...) to reflect the new state. - Append to
wiki/reference/Design-Decisions.mdif the decision rules out an alternative the next slice could otherwise pick. - Update
wiki/getting-started/History.mdif the landing changes a project-level milestone.
If you discovered something that durably changes a wiki page (a new known-limit, a new threshold, a deprecation), update that page in the same commit as the code. Don't accumulate a separate notes file.