Skip to content

Phase 3 Plan E: FS write path + console fd + shell + utilities#17

Merged
cyyeh merged 47 commits into
mainfrom
phase3-plan-e
Apr 27, 2026
Merged

Phase 3 Plan E: FS write path + console fd + shell + utilities#17
cyyeh merged 47 commits into
mainfrom
phase3-plan-e

Conversation

@cyyeh
Copy link
Copy Markdown
Owner

@cyyeh cyyeh commented Apr 27, 2026

Summary

  • Plan 3.E landed end-to-end: FS write path (writei with bmap lazy alloc, iupdate, ialloc, itrunc, iput-on-zero truncate, real dirlink + dirunlink, fs/fsops.zig create/unlink glue), console as fd 0/1/2 with cooked-mode line discipline (echo + backspace + ^U/^C/^D + \n commit + Raw arm), UART RX through PLIC IRQ web wasm demo + CI #10uart.isrconsole.feedByte, scheduler now WFIs in its idle window so cpu.idleSpin paces --input byte delivery, new syscalls mkdirat (#34) + unlinkat (#35), openat extended with O_CREAT/O_TRUNC/O_APPEND, and write now routes any fd through file.write.
  • User stdlib lands at src/kernel/user/lib/: RV32 _start + 19 syscall stubs + mem*/str* + Stat/O_* constants + minimal printf. Fed by an addUserBinary build helper that packs every userland binary identically.
  • Userland: init_shell (loops fork-exec-sh-wait, exits cleanly on sh status 0), sh (line/token/redirect/builtins/fork+exec, ~250 LoC), ls, cat, echo, mkdir, rm. mkfs.zig learned --init + walks every top-level subdir of --root (so /tmp/ empty-dir staging carries through). shell-fs.img is the parallel image baking init_shell as /bin/init + every utility under /bin/.
  • Two coordinated emulator/kernel changes break a fixed-point loop the WFI addition would otherwise create: s_kernel_trap_dispatch advances sepc past WFI, and cpu.idleSpin checks for pending interrupts before draining the pump (so disk-I/O sleeps don't leak --input bytes mid-boot) and drains one byte per iteration (so cooked-mode echo interleaves with shell prompts instead of bulk-emitting every echo before the second prompt prints).
  • Bonus fix folded in: the post-refactor(syscall) Phase 2 kmain branch never wired ofile[0..2] to a Console fd, so e2e-kernel and e2e-multiproc-stub had silently regressed on Task-33 baseline. Both are restored.

Milestone: e2e-shell runs the canonical scripted session against kernel-fs.elf + shell-fs.img:

$ zig build kernel-fs shell-fs-img
$ zig-out/bin/ccc --input tests/e2e/shell_input.txt --disk zig-out/shell-fs.img zig-out/bin/kernel-fs.elf
$ ls /bin
.
..
cat
init
echo
sh
mkdir
ls
rm
$ echo hi > /tmp/x
$ cat /tmp/x
hi
$ rm /tmp/x
$ exit
ticks observed: 6

46 commits: 1 plan + 32 task implementations + 5 debug fixes (mkfs subdir walker, init_shell clean-exit, WFI/sepc/drain trio, kmain Phase 2 console fds) + 1 README + 7 misc (style, docs, fix-ups).

Test plan

  • zig build test — all unit tests pass
  • zig build e2e — RV32I hello world
  • zig build e2e-mul — RV32IMA demo
  • zig build e2e-trap — privilege/trap demo
  • zig build e2e-hello-elf — Phase 1 §Definition of done
  • zig build e2e-kernel — Phase 2 §Definition of done
  • zig build e2e-multiproc-stub — Plan 3.B PID 1 + PID 2
  • zig build e2e-fork — Plan 3.C fork/exec/wait/exit
  • zig build e2e-plic-block — Plan 3.A IRQ + block round-trip
  • zig build e2e-snake — snake demo deterministic input
  • zig build e2e-fs — Plan 3.D motd round-trip
  • zig build e2e-shell — Plan 3.E milestone (this PR's headline gate)
  • zig build riscv-tests — rv32ui/um/ua/mi/si conformance
  • zig build wasm — wasm cross-build (deck demo)

🤖 Generated with Claude Code

cyyeh and others added 30 commits April 26, 2026 20:39
…de stub)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Enable IRQ_UART_RX in PLIC, call console.init() + file.init(), allocate
one Console File entry with ref_count=3, and install it as ofile[0..2]
on init_p so /bin/init inherits stdin/stdout/stderr.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…atch)

Replace sysWrite's hard-coded UART path with file.write dispatch:
- sysWrite now routes through file.ofile[fd] → file.write → console.write
- sysRead already used this pattern; now both are symmetric
- Return type changed from u32 to i32 for proper error propagation
- Dispatch arm 64 updated with @bitcast to handle signed return
- Dropped unused uart import from syscall.zig

Also install console fds 0/1/2 in FORK_DEMO arm so init inherits
stdin/stdout/stderr (was only in FS_DEMO after Task 9). This ensures
sysWrite through file.write works consistently in both boot modes.

All e2e tests pass: e2e-fs and e2e-fork maintain original behavior
(write to UART via file.write → console.write).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nk==0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cyyeh and others added 17 commits April 26, 2026 23:20
Adds src/kernel/user/ls.zig (~73 LoC): lists directory entries via
DirEntry reads or prints file path+size for regular files. Wires the
kernel-ls build step in build.zig after kernel-cat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- populateFromDir: skip entries whose name starts with '.' (.gitkeep, .DS_Store)
- directory branch: comment confirms Dir inode is created even for empty subdirs (logic was already correct)
- CLI: add --init <path> flag; when given, installs that binary as /bin/init after the --bin walk

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Plan 3.D's mkfs hard-coded /etc as the only subdirectory under root and
silently dropped any other entries. The shell-fs/ staging tree adds /tmp
as an empty directory carrier, so mkfs now walks every top-level entry,
creating an inode for each subdirectory regardless of whether it has
children. /bin is still wired separately via --bin and skipped here.

Closes the gap left by the earlier mkfs commit (e822dfd) which addressed
dot-file skipping + empty-dir handling but kept the /etc-only walker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without this, init's restart loop would keep re-spawning sh after each
clean exit, including the shell's `exit` builtin path. The e2e harness
needs init to halt cleanly so the kernel can write the final
"ticks observed" trailer and stop the emulator. Restart-on-nonzero
remains, so a crash still gets the diagnostic + restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan 3.E's e2e-shell needs `--input` bytes to flow into the UART RX
FIFO while the shell is sleeping in console.read. The 3.D scheduler's
"open SIE briefly" busy-poll never invokes cpu.idleSpin (which is the
only drain site for the rx_pump), so without changes the shell never
sees its keystrokes.

Adding WFI to the idle window fixes that — but on its own creates a
fixed-point loop: cpu.step's prologue raises the pending S-trap before
WFI is fetched, sret returns to WFI, and the same trap fires again on
the next step. Three coordinated changes break the loop and make the
delivery cadence right:

  - sched.zig: WFI inserted between the SIE csrs/csrc pair so the
    emulator actually pauses and runs cpu.idleSpin.

  - trap.zig: s_kernel_trap_dispatch advances sepc by 4 before sret.
    That's safe because the only call site is the scheduler's WFI
    window — sepc points at a 4-byte WFI (or, if the trap fired on
    the boundary just before WFI, the 4-byte csrs that precedes it;
    either way, advancing skips ahead to the trailing csrc which
    closes the window). Without the advance, sret re-executes WFI
    and the scheduler can never re-scan ptable.

  - cpu.zig idleSpin: check_interrupt now fires BEFORE draining the
    rx_pump, so disk-I/O sleeps (block IRQ pending) take their trap
    immediately and don't leak --input bytes into the FIFO mid-boot.
    Drain only happens when the guest is truly idle — i.e., the
    shell prompt is waiting in read(). And drain is now one byte
    per iteration (uart.RxPump.drainOne) so cooked-mode console
    echo interleaves with the shell's per-line prompts instead of
    bulk-emitting every echo before the second prompt prints.

Regressions checked: e2e-fs / e2e-fork / e2e-snake / e2e-plic-block all
still pass. e2e-multiproc-stub remains broken on Task-33 baseline (a
separate pre-existing issue with the Phase 2 kmain branch not setting
up the console file table).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan 3.E milestone (Task 34): boot kernel-fs.elf against shell-fs.img
with --input piping the canonical session "ls /bin / echo hi > /tmp/x
/ cat /tmp/x / rm /tmp/x / exit" through the UART RX path. Asserts
prompt+command echo, ls visibility of the binaries we shipped in /bin,
the cat round-trip ("hi"), and a clean halt.

The shell harness mirrors tests/e2e/fs.zig's spawn + collect + landmark
shape; build.zig wires e2e-shell after e2e-fs and depends on the
shell-fs-img + ccc + kernel-fs.elf install steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After 0f84951 routed sysWrite through file.write, every user proc needs
ofile[0..2] pointing at a Console-typed file or write(1, ...) silently
fails with -1. The FS_DEMO and FORK_DEMO branches already do this; the
plain Phase 2 branch (kernel.elf single-proc + kernel-multi.elf two-proc)
did not, so e2e-kernel and e2e-multiproc-stub regressed to "ticks
observed: N" with no user output.

Initialize the file table once, allocate one Console entry, dup it onto
each proc's fd 0/1/2. PID 2 gets its own three dups so file.close
ref-counting stays accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Status block: bump headline to Plan 3.E done; append a 3.E paragraph
covering FS write path (writei + bmap lazy alloc + iupdate + ialloc +
itrunc + dirlink/dirunlink + fsops glue), console as fd 0/1/2 with
cooked-mode line discipline, UART RX through PLIC IRQ #10, the WFI +
sepc-advance + paced rx_pump trio that lets --input bytes interleave
with shell prompts, the new mkdirat / unlinkat syscalls + openat
extensions, the userland stdlib at src/kernel/user/lib/, and the seven
new userland binaries; close with the canonical e2e-shell session
transcript and Next: Plan 3.F.

Layout: add console.zig, fs/fsops.zig, user/init_shell.zig + sh.zig +
ls.zig + cat.zig + echo.zig + mkdir.zig + rm.zig, user/lib/ with the
four stdlib pieces, userland/shell-fs/ tree, and tests/e2e/shell.zig +
shell_input.txt.

Building: add the seven kernel-* user-binary targets, shell-fs-img,
and e2e-shell.

Final regression sweep (all green): test, e2e-shell, e2e-fs, e2e-kernel,
e2e-multiproc-stub, e2e-fork, e2e-plic-block, e2e-snake, e2e-hello-elf,
e2e, e2e-mul, e2e-trap, riscv-tests, wasm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new chapter slides between the 3.D init-from-disk slide and the
epilogue:

  - Ch 3.E · FS write path: writei + bmap.for_write lazy alloc, iupdate
    / ialloc / itrunc / iput-on-zero, real dirlink + dirunlink, the
    fsops.create/unlink glue, mkdirat/unlinkat + openat O_CREAT/O_TRUNC/
    O_APPEND, and the sysWrite reroute through file.write that surfaced
    the kernel-multi/kernel-fs Console-fd regression.

  - Ch 3.E · console + WFI: console.zig as fd 0/1/2 backing with
    cooked-mode line discipline (echo + ^C/^U/^D + \n commit), UART RX
    via PLIC #10, and the WFI fixed-point loop that adding wfi to the
    scheduler idle window created — broken by sepc+=4 in
    s_kernel_trap_dispatch + paced one-byte-at-a-time idleSpin drain.

  - Ch 3.E · shell + milestone: the userland stdlib at user/lib/, the
    seven binaries (init_shell, sh, ls, cat, echo, mkdir, rm), the
    sh main loop, and the canonical e2e-shell session transcript.

TOC + intro caption: bump the Phase 3 progress line to "3.a + 3.b +
3.c + 3.d + 3.e done" with the 3.E summary; add 3.E to the deck-walks
list. Epilogue: extend the Phase 3 paragraph with the 3.E recap, add a
3.E checklist row, and replace "Next · plan 3.e" with "Next · plan 3.f"
describing the editor + e2e-persist work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cyyeh cyyeh merged commit d9fc33d into main Apr 27, 2026
3 checks passed
@cyyeh cyyeh deleted the phase3-plan-e branch April 27, 2026 03:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant