Real artifacts that exercise the process subsystem.
Background
Plan 3.C's deliverable was a working fork+exec+wait+exit cycle. The demo: init (PID 1) forks; the child execs /bin/hello, which prints hello from /bin/hello\n and exits; the parent reaps and prints init: reaped.
What happened
src/kernel/user/init.zig:
fn main() noreturn {
const child = fork();
if (child == 0) {
const argv = [_]?[*:0]const u8{ "hello", null };
_ = execve("/bin/hello", &argv, null);
// execve returned → it failed
_ = exit(1);
unreachable;
}
// parent
var status: i32 = 0;
_ = wait4(child, &status, 0, null);
_ = printf("init: reaped\n", .{});
_ = exit(0);
}zig build kernel-fork && zig build run -- zig-out/bin/kernel-fork.elf produces:
hello from /bin/hello
init: reaped
ticks observed: 3
The verifier tests/e2e/fork.zig asserts that exact output (modulo the tick count).
Relevance
This single test exercises every line of proc.fork, proc.exec, proc.exit, proc.wait4. If copyUvm leaks a page, run after run shows decreasing free count. If exec doesn't free the old image, same. If wait doesn't actually reap the slot, subsequent forks fail.
References
src/kernel/user/init.zig,src/kernel/user/hello.zigsrc/kernel/proc.zig(fork,exec,wait4,exit)tests/e2e/fork.zigbuild.zigtargete2e-fork
Background
The Phase 3 §Definition of Done requires "^C cancels foreground program." tests/e2e/cancel.zig is the proof.
What happened
The test pipes 10 bytes through --input: cat\n\x03exit\n.
- Shell reads
cat\n, fork+execs/bin/cat. Cat reads stdin (which has 0 bytes left for now), blocks inconsole.read. - The next byte from
--inputarrives:\x03(Control-C). - UART RX → PLIC → console.feedByte → console recognizes 0x03 → calls
proc.kill(fg_pid=2). - PID 2's
console.readreturns -1 (becauseconsole.readchecks the kill flag). - PID 2's syscall handler returns. Dispatch sees
p.killed = true. Callsproc.exit(p, 1). - Shell's
waitreturns. Shell prints^C\necho, then$prompt. --input's next byte ise(start ofexit). Shell tokenizesexit\n, executes the builtin, exits cleanly.
The asserted output:
$ cat
^C
$ exit
If any step in the kill chain breaks, the test deadlocks (cat never returns) or the output diverges.
References
src/kernel/proc.zig(kill)src/kernel/console.zig(feedByte→ 0x03 arm)src/kernel/syscall.zig(p.killedcheck after every dispatch)tests/e2e/cancel.zig
Background
vm.copyUvm walks the parent's PTEs, allocating a fresh page in the child for each. If allocation fails partway through, the child's partial address space must be torn down — otherwise we leak.
What happened
When proc.fork was first written, the OOM path was:
fn copyUvm(parent: *PageTable, child: *PageTable, sz: u32) !void {
for (i = 0; i < sz; i += PAGE_SIZE) {
const ppa = walk(parent, i).?;
const cpa = page_alloc.alloc() orelse return error.OutOfMemory; // ← leaks!
memcpy(cpa, ppa, PAGE_SIZE);
mapPages(child, i, PAGE_SIZE, cpa, perms);
}
}A test (page_alloc_fork_test) added in Plan 3.B kept forking until OOM. After ~30 iterations, the free count would be much lower than the available pool — leaks were happening.
The fix was to track progress and unwind:
fn copyUvm(parent: *PageTable, child: *PageTable, sz: u32) !void {
var done: u32 = 0;
errdefer vm.unmapUser(child, 0, done); // free pages mapped so far
while (done < sz) : (done += PAGE_SIZE) {
const ppa = walk(parent, done).?;
const cpa = page_alloc.alloc() orelse return error.OutOfMemory;
memcpy(cpa, ppa, PAGE_SIZE);
mapPages(child, done, PAGE_SIZE, cpa, perms);
}
}unmapUser walks the partial table, frees each PTE's PA, frees the L0 table pages. The errdefer runs only on the error path. After the fix, the test ran indefinitely with stable free count.
Relevance
This is a classic OOM-correctness bug. The fix isn't deep code change — it's about making the cleanup path automatic via Zig's errdefer. This pattern is used throughout ccc's kernel.
References
src/kernel/vm.zig(copyUvm+unmapUser)src/kernel/proc.zig(similar pattern infork)
Background
When init_shell does execve("/bin/sh", argv = ["sh", null], envp = null), the kernel must lay out the argv tail on the new user stack precisely so _start can parse it.
What happened
proc.exec allocates one page (USTACK_VA = 0x7FFFF000 to 0x80000000), then writes from the top down:
- String area: "sh\0" at
0x80000000 - 3 = 0x7FFFFFFD. - Align to 4: down to
0x7FFFFFFC. - envp[0] = NULL: at
0x7FFFFFFC. (envp pointer arr always ends in NULL.) - argv[1] = NULL: at
0x7FFFFFF8. - argv[0] = ptr to "sh\0": at
0x7FFFFFF4. Value:0x7FFFFFFD. - argc = 1: at
0x7FFFFFF0.sp = 0x7FFFFFF0.
_start reads:
argc = *(int*)sp = 1.argv = (char**)(sp + 4) = 0x7FFFFFF4.argv[0]points to"sh\0".argv[1] = NULL(terminator).envp = (char**)(sp + 4 + (argc + 1) * 4) = 0x7FFFFFFC.envp[0] = NULL.
Then it calls main(1, argv). The user program sees the shell-like argc/argv it expects.
proc.copyUserStack is the helper that does this layout. It's careful with: alignment (every pointer is 4-byte aligned), buffer-overflow safety (the page is exactly 4 KB; the layout must fit), and the rollback path on OOM.
Relevance
If the layout is wrong by a single 4 bytes, the user's _start reads garbage. Symptoms: random crashes, wrong argc, segfaults reading argv[0]. The tests/e2e/shell.zig is the regression — it relies on shell programs receiving argv correctly.
References
src/kernel/proc.zig(exec→copyUserStack)src/kernel/user/lib/start.S(the_startthat reads argc/argv)- The RISC-V psABI doc (the "Initial Process Stack" section)
Background
The kill flag is set asynchronously (by another process or interrupt handler). For it to actually kill the target, the target has to check it at known safe points. ccc checks once: at the end of syscall.dispatch.
What happened
pub fn dispatch(p: *Process) void {
p.trapframe.a0 = switch (syscall_num) { ... };
p.trapframe.sepc += 4;
if (p.killed) {
proc.exit(p, 1);
// never returns
}
}That if is the entire kill-flag mechanism on the syscall side.
Why this works
A process spends 99% of its time either:
- Running user code (no syscall in flight).
- Inside a syscall, possibly sleeping.
For the running case: the next time it does anything observable (a syscall), the check triggers.
For the sleeping case: proc.kill flips state from Sleeping to Runnable, so it wakes up. Whatever syscall it was sleeping inside returns (often with -EINTR or similar — console.read returns -1 in ccc). The syscall handler returns, dispatch checks killed, calls exit.
The corner case
What if a process is killed while outside a syscall — in the middle of a long for loop in user space? Nothing happens. The process keeps running. Only when it eventually does a syscall (or takes a trap, e.g., a page fault) does the kill take effect.
This is a deliberate design choice: no preemptive signal delivery. POSIX signals would deliver async, but ccc doesn't have signals. The shell-^C use case is the main motivator, and shells always have a foreground process that's blocked on read or busy doing syscalls.
References
src/kernel/syscall.zig(the dispatch tail)src/kernel/proc.zig(kill)src/kernel/console.zig(cooked-mode^Chandling)
Background
Plan 3.B added the multi-process foundation (free-list page allocator, ptable, scheduler, swtch). Before fork existed, the demo had to hand-create two processes at boot.
What happened
kernel-multi.elf embeds two user ELFs (userprog.elf and userprog2.elf). The kmain for this build:
- Allocates ptable[0] for PID 1, copies
userprog.elfin. - Allocates ptable[1] for PID 2, copies
userprog2.elfin. - Both Runnable. Calls
sched.schedule(). - Scheduler picks PID 1 → runs → yields.
- Scheduler picks PID 2 → runs → yields.
- Both alternate, each printing its message.
- Both exit. Tick count printed. Halt.
tests/e2e/multiproc.zig asserts both hello from u-mode\n and [2] hello from u-mode\n appear in the output. The interleaving order doesn't matter.
This was the proof-point that the scheduler + swtch worked without fork. Once it passed, Plan 3.C added fork to make process creation dynamic.
References
src/kernel/user/userprog2.zig(the second user payload)src/kernel/kmain.zig(the multi-proc setup arm)tests/e2e/multiproc.zigbuild.zigtargete2e-multiproc-stub