Audience: Kernel hackers, subsystem authors
Execution context: Kernel —
core::ProcesslifecycleMaturity: v0 stable
core::Process is the unit that owns user-visible state: a private
mm::AddressSpace, a CapSet (u64 bitmask of privileges), pid, name,
VFS root, frame budget, and CPU-tick budget. Every ring-3-bound Task
belongs to exactly one Process; kernel-only Tasks keep
process == nullptr.
The scheduler caches process->as on the Task so the CR3 flip on
context switch remains a single pointer load.
kernel/core/process.{h,cpp}—Process,CapSet,CapSetEmpty,CapSetTrusted,ProcessCreate / Retain / Release,CurrentProcess,CapName.kernel/proc/process.h—Process::root(per-process VFS root).kernel/sched/sched.{h,cpp}—Task::processpointer,SchedCreateUser(..., core::Process*),TaskProcess(Task*).kernel/syscall/syscall.cpp— every privileged syscall gates onCurrentProcess()->capsbefore proceeding.
- Create:
ProcessCreate(name, caps_template)allocates theProcess, attaches a freshmm::AddressSpace(loaded with the higher-half kernel mirror), sets caps, and assigns a pid. - Spawn task:
SchedCreateUser(entry, arg, name, process)makes a ring-3 task whoseTask::processpoints back. Multi-threaded processes share oneProcessacross manyTasks. - Refcount:
ProcessRetain/ProcessRelease. The reaper callsProcessReleaseon each task death; the destructor runs when the last reference drops. - Destroy: Process destructor transitively releases the
AddressSpace. AS destructor unmaps every user-half page and returns frames.
The destructor is the last chance to reclaim anything the guest holds
out of a kernel-wide, fixed-size pool — a slot leaked there is not
a per-process leak, it is gone for the rest of the boot. ProcessRelease
therefore drains, in this order:
| Table | Owns | Released via |
|---|---|---|
win32_section_views[8] |
one section-pool ref per mapped view | SectionUnmap + SectionRelease — before AddressSpaceRelease, which cannot see views (they are borrowed pages with no AS region entry) |
linux_fds[16] |
one ref on a shared open-file description (64 global OFD slots) | LinuxFdClose — before HandleTableDrain, so KFile teardown stays on its normal path |
kobj_handles |
KMutex / KEvent / KSemaphore / IOCP / KFile | HandleTableDrain |
| kernel sockets | pool slot + bound port | SocketReleaseByOwner |
win32_handles[16] |
pipe-pool ref (Pipe slots only; FS slots own nothing) | fs::routing::CloseForProcess |
win32_section_handles[8] |
one section-pool ref per open handle | SectionRelease |
win32_dirs[8] |
KMalloc'd directory snapshot | KFree |
win32_reg_handles[] (borrowed RegKey*) and win32_foreign_threads[]
(an immutable TID) own nothing and need no drain.
win32_proc_handles[8] is the exception and is NOT drained here.
Each slot holds a ProcessRetain on its target, and Process::refcount
is live tasks plus handle holders — so the retained reference is
precisely what keeps the refcount above 0 and makes the destructor
unreachable. NtOpenProcess does not refuse the caller's own pid, so a
self-handle pins a process forever and a mutual pair forms a cycle. The
drop therefore runs at last-task exit, an earlier event than
last-reference-drop: the reaper calls
core::ProcessDropOwnedProcessHandles once
sched::SchedCountLiveTasksForProcess reaches 0.
One consequence at the syscall surface: NtMapViewOfSection now returns
STATUS_NO_MEMORY when the target's 8-entry view table is full, rather
than installing a view whose reference nothing could ever drop.
kernel/proc/spawn.{h,cpp} is the actual process-creation entry
surface — the place that ties an image's bytes to a fresh Process,
an AddressSpace, and a queued ring-3 task. Three entry points, one
per image flavour, all sharing the same parse → AS → ProcessCreate
→ SchedCreateUser pipeline and the same contract (return the new pid
on success, 0 on any failure, with all partial state unwound through
AddressSpaceRelease):
u64 SpawnElfFile (const char* name, const u8* elf_bytes, u64 elf_len, ...); // spawn.h:99
u64 SpawnElfLinux(const char* name, const u8* elf_bytes, u64 elf_len, ...); // spawn.h:113
u64 SpawnPeFile (const char* name, const u8* pe_bytes, u64 pe_len, ...); // spawn.h:122SpawnElfFileloads a native ELF viaElfLoad(see Loader). It auto-detects Linux-ABI images by theirEI_OSABIbyte (ELFOSABI_LINUX= 3) and delegates toSpawnElfLinuxso the task's syscall dispatch lands on the Linux dispatcher.SpawnElfLinuxis the Linux-ABI twin: same load pipeline, but flipsProcess::abi_flavor = kAbiLinuxso ring-3syscallinstructions route throughMSR_LSTAR(the Linux dispatcher) rather than the nativeint 0x80path, and seedslinux_brk_{base,current}linux_mmap_cursor.
SpawnPeFileloads a PE/COFF image via the v0 PE loader. It pre-loads the standard Win32 DLL set into the new AS beforePeLoadruns soResolveImportscan consult their export tables.
kernel/core/service.{h,cpp} is the kernel-resident init equivalent. It
owns a single declarative manifest of the userland programs DuetOS
launches at boot (usershell, hello_native, nat_calc, nat_sysinfo,
duet-pkg) — replacing the hand-unrolled SpawnElfFile blocks that used
to live inline in boot_bringup.cpp. ServiceManagerStartAll() (called
from boot) spawns every autostart entry in manifest order through the
canonical core::Spawn*File API and starts the svcmon supervisor task,
which:
- polls liveness via
SchedProcessAlive(pid)to track each service's state (Running→Exited). This walks the scheduler's all-tasks registry, so a daemon parked in a blocking syscall (e.g.netdinaccept(),TaskState::Blockedon a WaitQueue) correctly reads as alive —SchedFindProcessByPidonly walks the runqueue/sleep/zombie lists and would mistake a healthy blocked daemon for a dead one. Monotonic PIDs mean a "not alive" verdict can never be a reused id; - respawns
ServiceRestartPolicy::Alwaysservices on exit with fault-domain-style crash-loop protection (≤ 5 respawns / 60 s, elseFailed).
The svc shell command drives the set at runtime (list for any user;
start/stop/restart <name> admin-gated). v0 scope: services run with
the trusted cap-set (a per-service sandbox profile is a future knob). The
five boot programs are oneshot (Never); netd — a resident TCP echo
server on :7777 (userland/native-apps/netd, using the native-libc BSD
socket wrappers in duet/socket.h) — is the first Always entry, so the
respawn path is exercised by a real resident process as well as by
ServiceManagerSelfTest's crash-loop-rate-limiter unit test. So that a
crashed daemon can actually re-bind its port on respawn, kernel sockets
are now owner-stamped and reclaimed on process exit
(SocketReleaseByOwner, called from ProcessRelease). Why
kernel-resident rather than
a /sbin/init ELF: a userland PID-1 needs ring-3 process-spawns-process
plumbing that does not exist yet; the supervisor lives where the other
system services (heartbeat, selfthink, autonomic) already live, and a
future userland init can adopt the same manifest shape.
[proc] create pid=0x1 name="ring3-smoke-A" caps=0x2
[proc] create pid=0x2 name="ring3-smoke-B" caps=0x2
[proc] create pid=0x3 name="ring3-smoke-sandbox" caps=0x0
Hello from ring 3! <- pid=1
Hello from ring 3! <- pid=2
[sys] denied syscall=SYS_WRITE pid=0x3 cap=SerialConsole
[proc] destroy pid=0x1
[proc] destroy pid=0x2
[proc] destroy pid=0x3
The sandbox task hits the denial path on SYS_WRITE because it has
zero caps; clean exit follows because the user payload ignores the
return value.
A process running with CapSetEmpty is bounded by five orthogonal
walls:
- Per-process address space — private PML4, kernel half mirrored.
- Capability-gated syscalls — the locked effective Process capability snapshot is checked at every privileged surface.
- VFS namespace jail —
Process::rootrooted at a per-process subtree...is rejected outright. - W^X enforcement —
AddressSpaceMapUserPagepanics on write+execute combinations.kPageGlobalis also refused on user pages. - Per-AS frame budget + per-process CPU tick budget — bounded resource exhaustion.
See Sandboxing for the full layered story.
- Cap numbering is ABI. Always add at the end of the enum; never reuse a retired number.
- Empty cap set isn't
1 << kCapNone.kCapNone = 0is a sentinel; real caps start at bit 1. - Sentinel
kCapCountis the last enum entry, not a live cap;CapSetTrustedloops[1 .. kCapCount). - Kernel threads have no Process. SchedCreate leaves
t->process = nullptr. Reaper'sprocess != nullptrguard lets kernel threads (idle, reaper, workers, keyboard reader) fall through with no state change.
- A
Processis shared by all its ring-3Tasks, so per-process mutable state (the cap set is read-mostly after spawn; the Linux fd table, OFD pool, and VFS root are read/write) is protected by the process's own locks rather than a global one. - Spawn (
SpawnElfFile/SpawnElfLinux/SpawnPeFile) runs in the caller's task context. It allocates anAddressSpaceand maps user pages before any second task can observe the process, so the load itself needs no cross-task synchronisation; the cleanup-on-failure path (AddressSpaceRelease) is single-owner. - Refcounting (
ProcessRetain/ProcessRelease) is the cross-context safe handle: the reaper releases on task death from a different context than the spawner, and the destructor runs only when the last reference drops.
These carry live // GAP: markers in
kernel/proc/process.cpp:
- Pre-OFD open status flags seeded empty. Opens that predate the
OFD (open-file-description) tracking get their description flags
seeded to
0rather than the real status flags (process.cpp:1445) — revisit whensys_open/pipe2/socketroute through the OFD pool. - Inline offset writers don't propagate to dup siblings. Syscall
TUs that write
linux_fds[fd].offsetdirectly (read, write, lseek, sendfile, splice) bypassLinuxFdSetOffset, so the shared offset isn't propagated todup'd siblings until those write sites migrate to the accessor (process.cpp:1591). The OFD is the source of truth; the inline field is a per-fd cache. forkunder OFD-pool pressure loses offset sharing. If the OFD pool is exhausted atforktime, the child's fd is degraded to a private offset for that fd (safe, but no longer shared) (process.cpp:1667) — revisit by growingkOfdPoolCap.
- Memory Management — owns AddressSpace
- Scheduler — owns Tasks
- Capabilities
- Sandboxing
- VFS — per-process root