docs: four directories, and a rule for when a thing leaves the roadmap
docs/ was ten files where a plan, a description, a guide and a bug report all
sat next to each other. It is now architecture/ (what SchOS is), plan/ (what it
will be), guides/ (how to work on it) and history/ (why it is like this), with
an index that states the one rule holding it together: when something is
finished it moves from plan/ to architecture/ and is rewritten as a description
of the thing rather than a record of building it.
A ticked checkbox says the work happened. It does not say what the work
*produced*, and six months later the checkbox is all that is left. The network
stack proved the point: finished, verified end to end, and for a month the only
description of it anywhere was a checkbox four levels of indentation deep.
So roadmap.md went from 534 lines to 181 -- nothing deleted, the done work
graduated. Seven subsystems that were finished and undescribed now have
descriptions: boot, kernel, syscalls, filesystem, networking, graphics, usb,
init. Every one ends with "What it does not do", which is the section that pays;
a document listing only strengths is marketing, and the reader who needs it most
is the one about to trip over a limitation nobody wrote down.
schfs.md still opened with "phases 1 through 3 1/2 are done, phase 4 is next"
long after all five had landed. It is a description now, with the phase record
and the hard-won bugs moved to history/.
Three corrections found on the way, all load-bearing:
* projects/schcom/README.md claimed SCM_RIGHTS was done. There is no
sendmsg/recvmsg in the kernel at all, and an entire GUI plan rested on it.
* The root README said "No TCP/IP" months after the stack landed.
* history/kernel.md sec.7 was marked "in progress" on a problem that process
groups solved.
Also adds plan/1.0.md, which draws a finish line so the open list stops being
infinite, and marks schwm/ and schde/ superseded: in Wayland the compositor is
the window manager, so that split dissolves rather than being abandoned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
parent
817d666068
commit
57aff40228
59 changed files with 3981 additions and 1124 deletions
54
docs/history/README.md
Normal file
54
docs/history/README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# History
|
||||
|
||||
**Why SchOS is the way it is.** Bugs whose cause was nowhere near their symptom,
|
||||
and the few write-ups that have nowhere better to live.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [kernel.md](kernel.md) | The expensive ones. Boot, SMP, signals, ABI assumptions, `/proc`, input. |
|
||||
| [schfs.md](schfs.md) | How the filesystem was built, and what each phase cost. |
|
||||
| [moving-to-linux.md](moving-to-linux.md) | The Windows-to-Fedora move. |
|
||||
|
||||
---
|
||||
|
||||
## What belongs in here
|
||||
|
||||
> **A bug whose cause is not visible from the code that fixed it.**
|
||||
|
||||
That is the whole test. If reading the fix explains the problem, the fix is the
|
||||
documentation and nothing needs writing down. If the fix looks arbitrary — a
|
||||
constant moved by eight, a field reordered, a check inverted — then the
|
||||
strangeness is load-bearing and the next person to touch it deserves to know
|
||||
why before they change it back.
|
||||
|
||||
Every entry here is a thing that looked like a different problem than it was.
|
||||
|
||||
## What does not belong
|
||||
|
||||
- **A changelog.** `git log` is the changelog.
|
||||
- **A bug that was found and fixed in an hour.** Cost is the filter.
|
||||
- **How something works.** That is [../architecture/](../architecture/), written
|
||||
in the present tense for somebody who does not care what it used to be.
|
||||
- **Anything still open.** An unfinished investigation goes in
|
||||
[../plan/](../plan/) or in the code as a comment. This directory is past tense.
|
||||
|
||||
## How to write one
|
||||
|
||||
**Symptom first, then diagnosis, then the fix, then the transferable part.**
|
||||
|
||||
The transferable part is the reason the entry exists. `__thread` being wrong for
|
||||
per-CPU data is worth more than the specific null dereference it caused;
|
||||
"a layout constant that several artifacts must agree on wants a check that stops
|
||||
the build, not a comment asking people to remember" is worth more than the
|
||||
bootloader offset that taught it.
|
||||
|
||||
**Say how it was found**, when the technique was the hard part. A hardware
|
||||
watchpoint through QEMU's gdb stub named an instruction on the first run after
|
||||
three rounds of `cprintf` had narrowed it to nothing — that is reusable, and the
|
||||
bug is not.
|
||||
|
||||
**Do not clean up the embarrassing part.** The near-miss where a change passed
|
||||
every assertion green *without having been applied at all* is one of the most
|
||||
useful entries in this directory, and it only works because it admits that a
|
||||
`grep` returning three hits was read as confirmation without counting what they
|
||||
were.
|
||||
698
docs/history/kernel.md
Normal file
698
docs/history/kernel.md
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
# Kernel history
|
||||
|
||||
Bugs that were expensive to find and whose causes are not visible from the code
|
||||
that fixed them, plus a few subsystem write-ups that have nowhere better to live.
|
||||
|
||||
**Every entry here is a thing that looked like a different problem than it was.**
|
||||
What each subsystem *is* today is [../architecture/](../architecture/); this is
|
||||
only how it got that way.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [1](#1-fixed-boot-didnt-reliably-reach-a-working-shell) | Boot did not reliably reach a working shell — ten bugs behind one symptom |
|
||||
| [2](#2-naming-standardization) · [3](#3-coreutils) | Naming, and where the userland comes from. Not bugs. |
|
||||
| [4](#4-two-stage-bootloader--psogm-splash) | The two-stage BIOS bootloader. **Since removed** — kept for the bugs. |
|
||||
| [5](#5-at-syscalls-ignoredrejected-real-dirfds) | `*at()` ignored real dirfds, and `ls -l` hung |
|
||||
| [6](#6-boot-speed) | Boot speed, in the loader |
|
||||
| [7](#7-at-syscalls-real-signal-delivery-and-mksh-job-control) | Real signal delivery, and mksh's job control |
|
||||
| [8](#8-two-abi-assumptions-that-only-gcc-was-keeping-true) | Two ABI assumptions only GCC was keeping true |
|
||||
| [9](#9-the-boot-looked-like-a-hobby-os-and-two-things-were-why) | The boot looked like a hobby OS |
|
||||
| [10](#10-a-full-screen-program-typed-the-whole-session-into-the-shell) | A full-screen program typed the session into the shell |
|
||||
| [11](#11-ps-and-top-and-why-they-never-worked) | `ps` and `top`, and why they never worked |
|
||||
| [12](#12-a-test-program-crashed-at-random-and-had-been-typing-its-own-launch-command) | A program crashed at random, typing its own launch command |
|
||||
|
||||
The SMP bring-up bugs are not here — they are summarised in
|
||||
[../architecture/boot.md](../architecture/boot.md#4-smp), because the lesson
|
||||
(`__thread` is wrong for per-CPU data) is load-bearing on the current code
|
||||
rather than merely historical.
|
||||
|
||||
> **Where the old design plan went.** Sections 1-6 of this file used to hold a
|
||||
> "Segregated Kernel" design: a Ring 0 Alpha / Ring 0 Beta split with driver
|
||||
> modules isolated by CR3 switching, from an `IDEA.txt` that no longer exists.
|
||||
> None of it was ever implemented, and it is superseded by
|
||||
> [../plan/schapi.md](../plan/schapi.md) — a macOS-shaped system answers driver isolation with
|
||||
> userspace drivers and a personality boundary, not a bespoke in-kernel ring
|
||||
> split. It also referenced a Zig toolchain that is now clang/LLVM. Removed
|
||||
> rather than left to be mistaken for a plan.
|
||||
|
||||
---
|
||||
|
||||
## 1. Fixed: boot didn't reliably reach a working shell
|
||||
|
||||
Investigated 2026-07-05, before phase 1 started (predates this design, not
|
||||
caused by it). Originally manifested as `ksh` (mksh) crashing on every
|
||||
launch, apparently inside musl's mallocng allocator (`get_meta`,
|
||||
`src/malloc/mallocng/meta.h`, deliberately executing `hlt` on detecting
|
||||
"corrupted" heap metadata — a `trap 13` GPF at a fixed rip). It wasn't a
|
||||
musl bug. Six real kernel bugs were found and fixed across three
|
||||
investigation passes, and mksh boots and runs cleanly with all of them in
|
||||
place — the calloc assertion was a symptom of real memory/pointer-handling
|
||||
bugs downstream in the kernel, not anything musl or mallocng did wrong.
|
||||
|
||||
Fixed:
|
||||
- `copyuvm()` (`kernel/arch/x86_64/vm.c`, used by `fork()`) never copied
|
||||
`mmap()`-backed `proc->vma[]` regions into the child — only `[0, sz)`.
|
||||
Fixed via new `copy_vma_range()`, called from `fork()` (`kernel/proc/proc.c`).
|
||||
- `kmem.freelist` (`kernel/core/kalloc.c`) could get corrupted by a wild
|
||||
write elsewhere and would silently hand back a bad pointer, panicking far
|
||||
from the actual cause. Added a canary check (`check_run()`) at the top of
|
||||
both `kalloc()`/`kfree()` so corruption panics immediately at the point
|
||||
it's touched, with a clear message.
|
||||
- No TLB invalidation existed anywhere except the full `lcr3` reload on
|
||||
context switch — `mappages()`/`vm_unmap_range()` never called `invlpg`.
|
||||
Fixed by adding `invlpg()` (`include/x86.h`) to both functions.
|
||||
- **`MSR_STAR` was misconfigured** (`syscallinit()`, `kernel/arch/x86_64/vm.c`):
|
||||
`SYSRETQ` computes the returned `cs`/`ss` as `STAR[63:48]+16`/`+8`, which
|
||||
requires the *compat* 32-bit code selector (`SEG_UCODE32`) in that field —
|
||||
the kernel was putting the real 64-bit `USER_CS` there instead. Every
|
||||
`sysretq` silently loaded an invalid `cs` (`0x3b`, pointing at the TSS
|
||||
descriptor)/`ss` pair; QEMU's TCG doesn't validate segment descriptors on
|
||||
`SYSRET`, so this went unnoticed until a hardware interrupt forced a
|
||||
strictly-validated `IRETQ` with that bogus `cs` still loaded, which
|
||||
correctly `#GP`'d.
|
||||
- `sys_mmap`'s non-`MAP_FIXED` path (`kernel/sys/sys_mem.c`) assigned a fixed
|
||||
8MB virtual-address window per `vma[]` slot regardless of the requested
|
||||
length. A single allocation over 8MB (e.g. `realloc()` growth past that
|
||||
size) mapped straight into a neighboring live mapping and hit
|
||||
`mappages()`'s `panic("remap")`. Replaced with a real bump allocator
|
||||
(`proc->mmap_next`, `include/proc.h`), so every mapping gets its own
|
||||
disjoint, exactly-sized range regardless of size.
|
||||
- **`uva2ka()`** (`kernel/arch/x86_64/vm.c`) returned only the page-aligned
|
||||
base address of a user pointer's page, silently dropping the in-page byte
|
||||
offset. Direct callers (`sys_ioctl`'s `TIOCGPGRP`/`TCGETS`, `sys_getcwd`,
|
||||
signal masks, `times()`, `sysinfo()`, ...) read/wrote near the wrong
|
||||
address whenever the pointer wasn't page-aligned — i.e. almost always.
|
||||
Fixed at the source so every caller benefits.
|
||||
- **`fetchint`/`fetchaddr`/`fetchstr`/`argptr`** (`kernel/sys/syscall.c`)
|
||||
rejected any address `>= proc->sz`, wrongly assuming every valid user
|
||||
pointer lives in the heap. Stack addresses and `mmap()`'d buffers (used
|
||||
constantly by real programs, e.g. path-resolution buffers) live above
|
||||
`proc->sz` and were being rejected outright. Fixed to rely solely on
|
||||
`uva2ka()`'s page-table validation.
|
||||
|
||||
Bringing in toybox (see "Coreutils" below) surfaced four more, all fixed:
|
||||
- Linux syscall 58 (`vfork`) wasn't in the syscall-ABI translation table at
|
||||
all (`kernel/sys/linux_abi.c`) — every external command spawn from a
|
||||
program that prefers `vfork()` over `fork()` failed outright. Mapped to
|
||||
the existing `fork()` (a fully correct, if slightly slower, substitute).
|
||||
- `sys_stat`/`fstat`/`fstatat` (`kernel/sys/sys_fs.c`) filled in SchOS's
|
||||
own native `struct stat` layout regardless of the caller — musl/toybox
|
||||
parse a fixed Linux ABI byte layout instead, so `st_mode` was garbage and
|
||||
tools like `ls` couldn't tell files from directories, or executables from
|
||||
non-executables. Added `struct linux_stat` + `stati_linux()`, gated on
|
||||
Linux-ABI mode (`proc->syscall_map`).
|
||||
- `sys_open`/`sys_openat` required `omode == O_RDONLY` *exactly* to open a
|
||||
directory, but `opendir()`-style calls legitimately OR in `O_DIRECTORY`/
|
||||
`O_CLOEXEC` alongside it. Fixed to check just the access-mode bits
|
||||
(`O_ACCMODE`, added to `include/fcntl.h`).
|
||||
- `sys_getdents64` read one `struct dirent` (16 bytes) at a time — a full
|
||||
`bmap()`+`bread()` per slot. Directories here are hash-indexed (see
|
||||
`tree_search()`/`tree_expand()` below), so a directory with a few dozen
|
||||
real entries can have a size in the hundreds of KB, nearly all empty
|
||||
slots — this made listing such a directory look hung (it wasn't a
|
||||
deadlock, just thousands of redundant single-slot disk reads). Rewrote it
|
||||
to read a full block at a time and resolve each entry's type via
|
||||
`iget(inum)` (exported from `fs.c`, it was `static`) instead of
|
||||
re-searching the whole directory by name with `dirlookup()` for every
|
||||
entry. That rewrite's first version introduced a **new** self-deadlock:
|
||||
the "." entry's inode number equals the directory's own, and `iget()`
|
||||
correctly hands back the *same, already-locked* `struct inode*` — calling
|
||||
`ilock()` on it again sleeps forever on a lock the process already holds.
|
||||
Fixed by special-casing `xde->inum == ip->inum` (we already know it's a
|
||||
directory, no need to re-lock at all).
|
||||
|
||||
A synthetic stress test (interleaved malloc/free, realloc growth crossing
|
||||
`MMAP_THRESHOLD`, calloc, large allocations) runs to completion with zero
|
||||
crashes, and mksh now boots straight to an interactive prompt, runs external
|
||||
programs, pipes, and `ls`/`mkdir`/`grep` without issue.
|
||||
|
||||
## 2. Naming standardization
|
||||
|
||||
SchOS standardizes on exactly one name per role, regardless of the
|
||||
underlying implementation: the shell is always installed and exec'd as
|
||||
`/bin/sh` (currently mksh, built from `ports/mksh/`), and PID 1 is always
|
||||
`/bin/init`. `cmd/init/main.c`'s exec loop only ever tries `/bin/sh` — no
|
||||
ksh/mksh/dash/zsh/other-name fallbacks. dash was tried first
|
||||
(BSD-licensed, minimal) while the boot crash above was still open, then
|
||||
replaced with mksh once the underlying kernel bugs were fixed and dash's
|
||||
lack of interactive features (history, completion, line editing) made it
|
||||
feel unusably bare for day-to-day use; the dash port's source tree was left in
|
||||
place rather than deleted, in case it's useful again (e.g. as a `/bin/sh`
|
||||
POSIX-strict target distinct from an interactive login shell).
|
||||
|
||||
## 3. Coreutils
|
||||
|
||||
`ports/toybox/` (0BSD-licensed, https://landley.net/toybox/) provides `ls`,
|
||||
`cat`, `cp`, `mv`, `rm`, `mkdir`, `grep`, `sed`, `find`, `chmod`, `date`,
|
||||
`sort`, and more — see the `APPLETS` list in `ports/toybox/Makefile`. It builds
|
||||
as a single multicall binary; `tools/mkfs.c` doesn't understand symlinks or
|
||||
hardlink dedup, so `ports/toybox/Makefile`'s `install` target copies the built
|
||||
binary once per applet name into `sysroot/bin/` rather than symlinking
|
||||
(costs disk space per applet — currently ~44MB total — instead of one
|
||||
shared inode, but needed no mkfs changes).
|
||||
|
||||
## 4. Two-stage bootloader + PSOGM splash
|
||||
|
||||
> **This describes the BIOS loader, which no longer exists** — see
|
||||
> [`boot/README.md`](../../boot/README.md) for why it was removed and what
|
||||
> replaced it. The section is kept because four of the bugs in it are about
|
||||
> things that are still true: extended scancodes, register hand-off across a
|
||||
> mode switch, and layout constants that several artifacts must agree on.
|
||||
|
||||
`boot/` used to be a single BIOS boot sector doing everything (VBE mode
|
||||
set, A20, GDT, protected-mode switch, multiboot kernel scan/load) — silent,
|
||||
and packed to within 4 bytes of the hard 510-byte MBR limit, so there was
|
||||
no room to add so much as a print statement.
|
||||
|
||||
Split into two stages, the standard way real bootloaders solve this:
|
||||
|
||||
- **Stage 1** (`boot/bios/bootasm.S`, still the literal MBR sector, still
|
||||
≤510 bytes) does exactly one thing: raw IDE PIO (LBA28) reads
|
||||
`STAGE2_SECTORS` (16) sectors starting at disk sector 1 into memory at
|
||||
`0x7E00`, then jumps there. Nothing else fits, nothing else is needed.
|
||||
(Caught a real bug writing this: `outb $0x1F7,%al`-style immediates
|
||||
silently truncate to 8 bits — ports 0x1F2-0x1F7 are all > 0xFF and need
|
||||
the `%dx` register form, or you're hitting the wrong I/O port entirely.)
|
||||
- **Stage 2** (`boot/bios/stage2.S` + `boot/bios/stage2main.c`, loaded at `0x7E00`,
|
||||
occupies disk sectors 1-16) does everything stage 1 used to skip straight
|
||||
to, plus a boot banner (BIOS teletype, real mode, before the VBE mode
|
||||
switch — note this only reaches the video device, not serial, so it
|
||||
won't show up in a `-serial file:...`-captured log), a splash screen, a
|
||||
real-time loading bar, and an F8 boot menu (all described below). Not
|
||||
size-constrained the same way as stage 1 — `tools/padstage2.pl` zero-pads
|
||||
it out to a fixed `STAGE2_SECTORS*512` (8192) bytes so the kernel's
|
||||
on-disk starting sector stays predictable regardless of stage2's actual
|
||||
compiled size, and errors out (like `tools/sign.pl` does for stage 1) if
|
||||
it ever grows past that budget instead of silently corrupting the layout.
|
||||
|
||||
The kernel now starts at disk sector 17 instead of 1 (`schos.img`'s `dd
|
||||
... seek=17` in the root `Makefile`, and the equivalent `+(1+STAGE2_SECTORS)`
|
||||
in `stage2main.c`'s `readseg()` — all three places that encode
|
||||
`STAGE2_SECTORS` have to agree, or the kernel won't be found on disk at
|
||||
all). `boot/bootmain.c` no longer exists; its logic was split between
|
||||
`stage2.S` (real-mode/protected-mode setup) and `stage2main.c` (splash +
|
||||
the unchanged multiboot-header-scan-and-load logic).
|
||||
|
||||
The VBE mode info block itself lives at `0x30000`, not the classic
|
||||
single-stage bootloader's `0x9000` — stage2's own code now runs from
|
||||
`0x7E00` up to nearly `0x9000` once the boot-menu/progress-bar logic is
|
||||
included, and a block at `0x9000` gets partially overwritten by stage2's
|
||||
own tail end (found this the hard way: the kernel's `LFB:` cprintf started
|
||||
reporting a different wrong value on every boot, because it was reading
|
||||
whatever stage2 code/data now happened to occupy that address).
|
||||
`kernel/dev/vga.c`'s `vga_init()` has to agree on `0x30000` too, since it
|
||||
independently re-reads mode fields from the same block.
|
||||
|
||||
Splash/progress bar/boot menu rendering is a small standalone pixel
|
||||
renderer in `stage2main.c` (not the kernel's `vga.c` — paging isn't set up
|
||||
yet, so the LFB physical address from the VBE block is used as a plain
|
||||
pointer) reusing the kernel's own font data (`include/font.h`) directly.
|
||||
It fills the framebuffer with PSOGM's purple (matching
|
||||
`kernel/core/console.c`'s panic screen) and draws "SCHOS" centered, then:
|
||||
|
||||
- **Real-time loading bar**: `readsect()` (in `stage2main.c`, not the
|
||||
kernel) calls `progress_tick()` after every 512-byte disk read, updating
|
||||
an actual pixel-filled bar against the real total byte count — first a
|
||||
small one while scanning for the multiboot header, then a second,
|
||||
properly-sized one once the header's found and the true kernel size is
|
||||
known, labeled "Loading kernel...". Not simulated/timed — it reflects
|
||||
real bytes read off disk.
|
||||
- **F8 boot menu**: `wait_for_f8()` busy-polls the keyboard controller
|
||||
(`inb(0x64)`/`inb(0x60)`, raw scancodes — real-mode BIOS keyboard calls
|
||||
aren't available once we're in protected mode) for about a second after
|
||||
the splash appears. F8 (scancode `0x42`) drops into `boot_menu()`: three
|
||||
options (boot normal, boot verbose, reboot to firmware setup) navigated
|
||||
with up/down arrows and Enter, plus a "detected drives" panel from
|
||||
`ata_present()` — a standard floating-bus presence check (select the
|
||||
drive, write a pattern to two registers, read it back) on the primary
|
||||
IDE channel's master/slave (the only channel this project ever actually
|
||||
puts drives on; QEMU's IDE emulation answers the same test
|
||||
"successfully" on the unpopulated secondary channel too, so that channel
|
||||
isn't shown — it'd just be confidently wrong). "Reboot to firmware
|
||||
setup" pulses the keyboard controller's reset line
|
||||
(`outb(0x64, 0xFE)`, same technique as `kernel/sys/sys_proc.c`'s
|
||||
`sys_reboot()`); a real bootloader can't jump into BIOS/UEFI setup
|
||||
directly, so this is the closest honest equivalent — it gives the user
|
||||
a fresh POST during which their firmware's own setup-menu key works.
|
||||
- **Verbose boot**: picking "Boot SchOS (verbose)" writes a flag byte to
|
||||
`BOOT_VERBOSE_FLAG_ADDR` (`VBE_INFO_ADDR + 0x100`, i.e. just past the VBE
|
||||
block) before jumping to the kernel. `kernel/core/main.c`'s `kmain()`
|
||||
reads it into a global `boot_verbose` as its very first action and, if
|
||||
set, prints a line after every subsystem init call
|
||||
(`uartearlyinit`, `kinit1`, ..., `sinit`) — real per-step boot logging,
|
||||
not a canned message.
|
||||
|
||||
Two bugs worth remembering if this code gets touched again:
|
||||
|
||||
- **Keyboard extended-scancode handling**: `0xE0` (the prefix byte for
|
||||
arrow keys and friends) also has bit 7 set, the same bit used to detect
|
||||
key-*release* ("break") codes. A naive "skip anything with bit 7 set,
|
||||
then check for 0xE0" loop skips the 0xE0 byte itself before ever testing
|
||||
for it, so extended keys silently never register. The 0xE0 check has to
|
||||
come before the break-code check, not after.
|
||||
- **The kernel entry call is not a real C function call.** Both the
|
||||
original single-stage `bootmain()` and the new `stage2main()` do
|
||||
`((void(*)(uint32))entry)(fbaddr)` — normal cdecl, which pushes `fbaddr`
|
||||
on the stack. But `kernel/arch/x86_64/entry.S`'s `mboot_entry` reads its
|
||||
framebuffer argument out of **EAX** (a multiboot-style raw-register
|
||||
handoff, then `mov %eax,%ebp` / `mov %ebp,%ebx` / `mov %rbx,%rdi` carries
|
||||
it through the 32→64-bit transition into `kmain()`'s first argument).
|
||||
The cdecl call never puts anything in EAX — it "worked" for years purely
|
||||
because simple bootmain.c code happened, as a side effect of its own
|
||||
codegen, to leave the right value sitting in EAX right up until the
|
||||
`call` instruction. Adding the splash/menu/progress-bar code changed
|
||||
that codegen and broke it silently: the kernel booted fine and reported
|
||||
a plausible-looking but wrong `LFB:` address (garbage left over in EAX
|
||||
from unrelated prior code, different on every boot). Fixed by pinning
|
||||
the argument down explicitly with `asm volatile("jmp *%0" : : "r"(entry),
|
||||
"a"(fbaddr))` instead of hoping the compiler's register allocation
|
||||
cooperates. If this code moves around again, re-verify with a screendump
|
||||
taken *immediately* before the jump compared against the kernel's own
|
||||
`LFB:` log line — they silently diverging is exactly this bug.
|
||||
|
||||
## 5. `*at()` syscalls ignored/rejected real dirfds
|
||||
|
||||
`ls -l`/`ls -lR` (and, by the same code path, `find`/`du`/anything using
|
||||
toybox's `lib/dirtree.c`) would hang on `ls -l` and either hang or silently
|
||||
misbehave on recursive listings. Root cause: `kernel/sys/sys_fs.c`'s
|
||||
`*at()` syscalls (`openat`, `fstatat`, `unlinkat`, `renameat`, `linkat`,
|
||||
`symlinkat`, `mkdirat`, `mknodat`, `readlinkat`, `faccessat`) only handled
|
||||
two cases for their `dirfd` argument — `AT_FDCWD` (resolve relative to
|
||||
`proc->cwd`) or an absolute path (resolve from root, ignoring `dirfd`
|
||||
entirely). A **real** dirfd with a **relative** path — e.g.
|
||||
`fstatat(open("/bin", O_RDONLY), "ls", &st, ...)`, the standard, efficient
|
||||
way any directory-tree walker avoids rebuilding full path strings for every
|
||||
entry — was either rejected outright (`fstatat`, `unlinkat`, ...) or
|
||||
silently ignored in favor of `proc->cwd` (`openat`, which never checked at
|
||||
all — worse than rejecting, since it can silently resolve the *wrong*
|
||||
file if cwd happens to contain a same-named entry).
|
||||
|
||||
Fixed by adding a real dirfd-relative path resolver: `kernel/fs/fs.c`'s
|
||||
`namex()` was refactored into `namex_from(struct inode *start, ...)`,
|
||||
parameterizing what used to be a hardcoded `idup(proc->cwd)`, with two new
|
||||
exported entry points, `nameifrom()`/`nameiparentfrom()`. `sys_fs.c` gained
|
||||
`at_dir(dirfd)` (resolves `AT_FDCWD` or a real fd to a starting inode) and
|
||||
`at_namei()`/`at_nameiparent()` built on top of it, plus a `createat()`
|
||||
(parameterizing `create()`'s hardcoded `nameiparent()` call the same way).
|
||||
Every `*at()` syscall now uses these instead of its own inline
|
||||
absolute-path/`AT_FDCWD`-only check.
|
||||
|
||||
**Known follow-on issue, not yet root-caused**: heavy `ls`/`ls -lR` use
|
||||
across many commands in one long-running shell session can eventually make
|
||||
a *later*, unrelated directory hang (reproduced with `ls -lR /var` after
|
||||
~10 prior commands in the same session; the identical command succeeds
|
||||
immediately on a fresh boot). Not an inode-table or file-table exhaustion
|
||||
— both `iget()` and `filealloc()` fail cleanly (panic / return 0
|
||||
respectively) when full, and neither fired. Smells like a lock or
|
||||
reference-count leak somewhere in the *at() paths above, or in toybox's
|
||||
own fd handling, that only manifests after enough accumulated opens/closes.
|
||||
Next step: instrument `itable`/`ftable` occupancy counts and watch them
|
||||
across a long `ls`-heavy session to see what's actually climbing.
|
||||
|
||||
## 6. Boot speed
|
||||
|
||||
Two cheap, real wins in `boot/bios/stage2main.c`, no functional changes:
|
||||
|
||||
- `progress_tick()` (called once per 512-byte disk sector) used to trigger
|
||||
a full progress-bar redraw — border plus fill, a few thousand pixel
|
||||
writes — on *every* sector. For the ~330KB kernel image that's ~650 full
|
||||
redraws (the multiboot-header-scan phase adds another ~32) for a bar
|
||||
that only has 101 visually distinct states (0-100%). Split into
|
||||
`progress_border()` (drawn once, in `progress_start()`, since the border
|
||||
never changes) and `progress_fill()`, and `progress_tick()` now skips the
|
||||
redraw entirely unless the percentage actually changed. This was the
|
||||
single biggest chunk of the bootloader's own wall-clock time.
|
||||
- `fill_rect()` (the full-screen splash fill, 480,000 pixels at 800x600,
|
||||
run at least twice per boot) used to call `draw_pixel()` per pixel, which
|
||||
recomputes `g_fb + y*stride + x*4` from scratch every time. Now computes
|
||||
each row's base pointer once and indexes into it.
|
||||
- The F8 boot-menu wait was shortened from ~1s (`5000000` busy-loop
|
||||
iterations) to ~0.6s (`3000000`) — flat overhead on every boot whether
|
||||
or not the menu is wanted, so it's the most direct lever available
|
||||
without giving up the feature entirely.
|
||||
|
||||
## 7. `*at()` syscalls, real signal delivery, and mksh job control
|
||||
|
||||
> **Resolved since this was written.** Process groups and sessions are real
|
||||
> (`pgid`/`sid` in `struct proc`, `setpgid`/`setsid`/`getpgid`/`getsid`, and
|
||||
> signal delivery to a whole group), which was the first of the three suspects
|
||||
> listed at the end. mksh's job control works. The investigation is kept in full
|
||||
> because the signal-delivery mechanism described below is still exactly how the
|
||||
> kernel does it, and because the reasoning about *why SIGCHLD was left enabled
|
||||
> while broken* is the transferable part.
|
||||
|
||||
`ls -l`/`ls -la` hanging outright turned out to be the same root cause
|
||||
described in §5 above (dirfd-relative path resolution) — fixed
|
||||
and verified extensively (plain `ls`, `ls -l`, `ls -la`, `ls -lR`, piped
|
||||
output, all confirmed working repeatedly).
|
||||
|
||||
A *second*, deeper issue was found investigating a follow-up report
|
||||
(`cd` with no `HOME` set, then `cd var`, then `ls` hanging): mksh's
|
||||
interactive job control (`ports/mksh (mksh R59c source): jobs.c`) blocks in
|
||||
`sigsuspend()` waiting for `SIGCHLD`, expecting its own handler
|
||||
(`j_sigchld()`) to run, reap the child via `waitpid(-1, &status, WNOHANG)`,
|
||||
and update its internal job-state tracking. This kernel had **no real
|
||||
signal delivery at all** — `sys_rt_sigaction()` was a no-op stub, so no
|
||||
handler was ever invoked for anything, and `sigsuspend()` just slept out
|
||||
one tick and returned `-EINTR` unconditionally. mksh's wait loop had no
|
||||
way to ever learn the child had exited, hanging forever.
|
||||
|
||||
Real signal delivery was implemented from scratch:
|
||||
- `struct proc` (`include/proc.h`) gained `sig_handler[32]`/
|
||||
`sig_restorer[32]` (populated by `sys_rt_sigaction()`,
|
||||
`kernel/sys/sys_sig.c`), `saved_sigtf` (stashed pre-signal trapframe),
|
||||
and `in_signal` (re-entrancy guard). `allocproc()` zeroes these for a
|
||||
freshly claimed ptable slot (they weren't before — a reused slot
|
||||
silently inherited a previous, unrelated process's handler addresses,
|
||||
which crashed the *next* process to occupy that slot the moment
|
||||
anything tried to delivver a signal to it). `fork()` copies handler/
|
||||
restorer/mask from parent to child (POSIX semantics) but not pending
|
||||
signals.
|
||||
- `kernel/proc/proc.c`'s `exit()` sets `SIGCHLD` pending on the parent.
|
||||
- `kernel/sys/syscall.c`'s `syscall()` calls `deliver_signals(tf)` in its
|
||||
tail, right before returning to user mode: if there's a pending,
|
||||
unblocked signal with a registered handler, it redirects the trapframe
|
||||
to jump to the handler (`rdi`=signal number per SysV ABI, `rip`=handler)
|
||||
instead of resuming the interrupted code, having first stashed the
|
||||
original trapframe in `proc->saved_sigtf`.
|
||||
- The return address is **not** `sig_restorer[sig]` — checked, `SA_RESTORER`
|
||||
is not defined anywhere in this musl build, so `sigaction()` never fills
|
||||
in a restorer; it's always 0, and pushing 0 as a return address crashes
|
||||
with `rip=0` the instant the handler function returns. Instead,
|
||||
`deliver_signals()` injects a tiny 7-byte trampoline (`mov eax,15;
|
||||
syscall` — `rt_sigreturn`) directly onto the user's own stack, below the
|
||||
current `rsp` (clear of the SysV red zone), and points the return
|
||||
address at that — the standard technique for when the C library doesn't
|
||||
supply its own restorer.
|
||||
- `sys_rt_sigreturn()` restores `*proc->tf` from `proc->saved_sigtf`,
|
||||
clearing `in_signal`.
|
||||
|
||||
**A real bug found and fixed along the way**: the first version of
|
||||
`sys_rt_sigaction()` translated the user's `struct sigaction` pointer
|
||||
*once* via `uva2ka()` and then did a bulk `memset`+copy across the whole
|
||||
~152-byte structure. `uva2ka()` only guarantees the *page containing that
|
||||
address* is mapped, not everything up to some further offset — when the
|
||||
user's pointer landed close enough to a page boundary, the write ran past
|
||||
the mapped page into whatever physical page happened to be next in kernel
|
||||
address space (P2V is a flat offset, so "next page" there has nothing to
|
||||
do with the user's actual next page), corrupting unrelated kernel memory.
|
||||
This crashed every freshly `exec`'d `/bin/sh` almost immediately, since
|
||||
mksh calls `sigaction()` during its own startup. Fixed by only touching
|
||||
the two individual 8-byte fields actually needed (`sa_handler` at offset
|
||||
0, `sa_restorer` at offset 144), each through its own `uva2ka()` call.
|
||||
|
||||
`sys_waitpid()`/`sys_wait4()` (`kernel/sys/sys_proc.c`) were also rewritten
|
||||
on top of a new `waitpid(pid, status, options)` in `kernel/proc/proc.c`
|
||||
(refactored from the old any-child-only, always-blocking `wait()`, which
|
||||
is now just `waitpid(-1, 0, 0)`): a specific `pid` (not just "any child"),
|
||||
`WNOHANG` (return 0 immediately instead of blocking), and — importantly —
|
||||
a genuinely optional status pointer. The old code ran a NULL status
|
||||
pointer through `argptr()`, which rejects NULL/unmapped addresses outright
|
||||
(page 0 is deliberately unmapped) — but `waitpid(pid, NULL, WNOHANG)` is
|
||||
completely standard, and is exactly what mksh's job control does.
|
||||
|
||||
**Status: signal delivery mechanically works** — verified directly (a
|
||||
debug build logged the handler being invoked with the correct trapframe
|
||||
and `sys_rt_sigreturn()` restoring cleanly, no crash, repeatable). **mksh's
|
||||
job control still doesn't reliably progress afterward**, including for
|
||||
plain non-piped foreground commands, not just the original piped-into-head
|
||||
case. Root cause not yet isolated. Leading suspects, in order of
|
||||
likelihood:
|
||||
- `sys_setpgid()`/`sys_getpgid()` are no-op stubs (`kernel/sys/sys_proc.c`)
|
||||
— `j_sigchld()` (`ports/mksh (mksh R59c source): jobs.c:1344-1348`) has an early-out
|
||||
keyed on `j->ppid == procpid`, which may never resolve the way mksh
|
||||
expects without real process-group tracking.
|
||||
- `getrusage(RUSAGE_CHILDREN, ...)` — called at the top of `j_sigchld()` —
|
||||
isn't verified to behave sanely under this kernel's implementation
|
||||
(`kernel/sys/sys_proc.c`'s `sys_getrusage()` only fills in `ru_utime`
|
||||
from the *calling* process's own tick count, not accumulated child
|
||||
time; `RUSAGE_CHILDREN` isn't distinguished from `RUSAGE_SELF` at all).
|
||||
- Possibly something in how `j_sigchld()` matches a reaped `pid` against
|
||||
its own `job_list` (`ports/mksh (mksh R59c source): jobs.c:1372-1385`) silently fails
|
||||
to find the job for reasons not yet traced.
|
||||
|
||||
This was left here deliberately rather than continuing to iterate blind:
|
||||
SIGCHLD delivery is left **enabled** (`kernel/proc/proc.c`'s `exit()`)
|
||||
since disabling it guarantees zero chance of mksh's wait loop ever exiting,
|
||||
whereas leaving it on at least gives the cases that don't hit the
|
||||
still-unknown remaining gap a chance to work. Next session should start
|
||||
by instrumenting `j_sigchld()`'s own control flow (or strace-equivalent
|
||||
logging in the kernel around `sys_setpgid`/`sys_getrusage`/`sys_waitpid`
|
||||
calls specifically while reproducing `cd; cd var; ls` or even simpler
|
||||
`ls -l /bin` followed by a second command) rather than re-deriving the
|
||||
signal-delivery mechanism, which is now confirmed solid.
|
||||
|
||||
---
|
||||
|
||||
## 8. Two ABI assumptions that only GCC was keeping true
|
||||
|
||||
Found when the build moved from GCC-in-WSL to clang-on-Windows. Neither is a
|
||||
clang bug; both are places where the code relied on GCC doing something it
|
||||
never promised, and both had been latent for years.
|
||||
|
||||
### `%fs:0` was being used as scratch — it is the TCB self-pointer
|
||||
|
||||
`syscall_entry` (`kernel/arch/x86_64/trapasm.S`) stashed `%rax` at `%fs:(0)`
|
||||
while it fetched `proc` to find the kernel stack. The comment called it a
|
||||
"per-CPU scratch slot". It is not: the x86-64 TLS ABI reserves the word *at*
|
||||
the thread pointer to hold the thread pointer, and a compiler may read it to
|
||||
materialise the TP before applying a variable's offset.
|
||||
|
||||
GCC compiles `__thread` access to a direct `%fs:-8` segment reference and never
|
||||
reads that word, so the clobber was invisible. clang at `-O0` emits
|
||||
`movq %fs:0, %rax; addq $-8, %rax; (%rax)` — reading back the syscall number
|
||||
this code had just stored. The first process's first syscall is
|
||||
`exec` (7), so `proc` resolved to address `7 - 8 = -1` and the kernel
|
||||
page-faulted recursively until the stack was gone.
|
||||
|
||||
Fixed by moving the scratch to `%fs:(8)`. Positive offsets are safe: the TLS
|
||||
block lives at negative offsets and `seginit` places the thread pointer 2048
|
||||
bytes into a 4096-byte page.
|
||||
|
||||
**How it was found** is the part worth copying next time. Three rounds of
|
||||
`cprintf` narrowed it to "the word is right at `mpmain`, wrong by the first
|
||||
trap" and no further — the instrumentation perturbed the timing enough to move
|
||||
the symptom. A hardware watchpoint through QEMU's gdb stub named the
|
||||
instruction on the first run:
|
||||
|
||||
```
|
||||
qemu ... -S -gdb tcp::26000
|
||||
gdb -batch -x tcp.gdb # hbreak mpmain; watch *(unsigned long*)kernel_fs_base
|
||||
```
|
||||
|
||||
`seginit` also now writes the self-pointer, which it never did. That is ABI
|
||||
-correct regardless and costs one store per CPU.
|
||||
|
||||
### `_start` read incoming registers with `register ... asm("rdi")`
|
||||
|
||||
`lib/glue.c` captured `argc`/`argv` from `%rdi`/`%rsi` using GCC local register
|
||||
variables. Both compilers only guarantee that binding for inline-asm operands;
|
||||
using it to pick up an incoming argument is undefined. clang ignored it and
|
||||
read two uninitialised stack slots:
|
||||
|
||||
```asm
|
||||
movl -0x4(%rbp), %eax # should have been %edi
|
||||
movq -0x10(%rbp), %rax # should have been %rsi
|
||||
```
|
||||
|
||||
Every program still ran. `init`, `shmtest` and `socktest` take no arguments and
|
||||
passed their entire suites; the only symptom in the whole system was
|
||||
`inputtest -n 6` ignoring its limit. Moved to `lib/start.S`, where reading
|
||||
incoming registers is expressible and correct — `exec` passes argc/argv in the
|
||||
SysV argument registers already, so `_start` calls `main` without touching
|
||||
them.
|
||||
|
||||
---
|
||||
|
||||
## 9. The boot looked like a hobby OS, and two things were why
|
||||
|
||||
Not a bug — a design problem, fixed by looking at a screenshot instead of the
|
||||
code.
|
||||
|
||||
**The display was the kernel log.** Boot printed `pci: 6 devices` and
|
||||
`vga: display controller 1234:1111, aperture fd000000 (16 MB)` to the screen
|
||||
the user is looking at. That single fact is most of what separates a hobby
|
||||
project from a product; every shipping system puts that behind a flag.
|
||||
|
||||
`cons_serial_only` already existed for panic, so the fix was to start it *set*.
|
||||
Everything still goes out the UART — which is where `tools/windows/bootcheck.ps1` reads
|
||||
it from, so all five suites kept passing untouched — and `console_takeover()`
|
||||
clears it on the first `exec()`, the moment userspace genuinely starts.
|
||||
`vga_init` no longer clears the screen, so the splash survives the whole of
|
||||
boot rather than being wiped a few hundred milliseconds in.
|
||||
|
||||
**There were three unrelated designs.** The splash was deep purple with gold
|
||||
text, deliberately matching the *panic* screen. The console was cream with
|
||||
near-black text. So a healthy boot was dressed as a crash, then abruptly
|
||||
became an amber terminal. `include/theme.h` is now the one palette, and the
|
||||
rule is: **normal states are calm and dark; only failure is loud.** PSOGM keeps
|
||||
the purple entirely to itself, which is what makes it mean something.
|
||||
|
||||
The splash copy went from "SchOS Boot Manager / version 1.0 / Memory: 1023 MB
|
||||
/ Press F8 for boot menu..." to the product name, a rule in the accent colour,
|
||||
and the one hint that is actionable — moved to the bottom, where a hint
|
||||
belongs. No shipping OS names its bootloader on the splash.
|
||||
|
||||
### Three latent bugs it flushed out
|
||||
|
||||
Retheming meant `stage2main.c` included `types.h` properly for the first time,
|
||||
and the 32-bit build stopped compiling:
|
||||
|
||||
- `_Static_assert(sizeof(uint64) == 8)` fired. `uint64` was `unsigned long`,
|
||||
which is **4 bytes** in the `-m32` loader — the file even carried a local
|
||||
`e820_u64` workaround with a comment saying so. Now conditioned on
|
||||
`__SIZEOF_LONG__` rather than on the platform, which covers LP64, LLP64 *and*
|
||||
ILP32 correctly.
|
||||
- `x86.h`'s `stosq` and `xchg` use `rep stosq` and `xchgq`, which do not exist
|
||||
in 32-bit mode. They only ever compiled because `uint64` was silently
|
||||
narrowed. Now behind `#ifdef __x86_64__`.
|
||||
- The progress bar computed `(uint64)done * 100 / total`, a 64-bit divide that
|
||||
needs libgcc's `__udivdi3` — which a freestanding loader does not link. Now
|
||||
scaled to KiB and done in 32 bits.
|
||||
|
||||
All three had been sitting in a bootloader that worked fine, waiting for
|
||||
someone to make `uint64` mean what it says.
|
||||
|
||||
---
|
||||
|
||||
## 10. A full-screen program typed the whole session into the shell
|
||||
|
||||
Keys reached `/dev/input/event0` **and** the tty line discipline at the same
|
||||
time. That is correct for two consumers of one keyboard and completely wrong
|
||||
for a full-screen program: while it had the display, the shell behind it was
|
||||
quietly accumulating every keystroke, and the first Enter afterwards ran
|
||||
whatever had piled up. Arrow keys arrived as CP437 glyphs on the console for
|
||||
good measure.
|
||||
|
||||
The fix is the standard one and it is a *primitive*, not a patch: an exclusive
|
||||
grab, `EVIOCGRAB`, with Linux's ioctl number. While a device is grabbed its
|
||||
events still flow to `/dev/input/eventN` but both keyboard drivers withhold the
|
||||
decoded character from the console.
|
||||
|
||||
Three details that matter more than the feature:
|
||||
|
||||
- **The grab is released by the devsw `close` hook**, not by the program. A
|
||||
program that grabs the keyboard and then crashes would otherwise leave the
|
||||
console permanently deaf, with no way to type the command that fixes it.
|
||||
- **Modifier state is still tracked while grabbed** (`kbdgetc` keeps updating
|
||||
shift/caps and only skips the character). Otherwise releasing the grab would
|
||||
hand the line discipline a stuck Shift.
|
||||
- **The USB path updates `prev[]` before returning**, or the next report would
|
||||
look like every held key had just been pressed again.
|
||||
|
||||
It is also exactly what Schcom will need, which is why it lives in the input
|
||||
layer rather than in any one client.
|
||||
|
||||
### The near-miss worth remembering
|
||||
|
||||
The first attempt appeared to work: the harness passed, every assertion green.
|
||||
It had not worked at all — the ioctl call was never inserted into the client
|
||||
source, because a scripted string replacement silently failed to match. The
|
||||
`grep` that should have caught it printed three hits (a comment, a `#define`,
|
||||
and the *release* call) and was read as confirmation without counting what
|
||||
they were.
|
||||
|
||||
What actually caught it was reading the transcript rather than the verdict:
|
||||
four bare `#` prompts after quitting, which is precisely the symptom the change
|
||||
was supposed to remove. `input_grab()` now announces itself, so "did the grab
|
||||
happen" is never again a thing anyone has to infer.
|
||||
|
||||
---
|
||||
|
||||
## 11. `ps` and `top`, and why they never worked
|
||||
|
||||
/proc was a set of character devices: init ran `mknod("/proc/uptime", 6, 2)` and
|
||||
friends, so every file was a real inode on the root filesystem with a
|
||||
major/minor behind it. That is fine for a fixed list and cannot work at all for
|
||||
the thing /proc is mostly for -- one directory per process. You cannot mknod a
|
||||
directory for a process that does not exist yet and will be gone in a minute.
|
||||
`ps` calls `dirtree_flagread("/proc")`, got a listing with no numbers in it, and
|
||||
reported nothing. It was never a `ps` problem.
|
||||
|
||||
So /proc is now a real filesystem behind the same `struct fs_ops` vtable SchFS
|
||||
uses (`kernel/fs/procfs.c`, `PROCFS_DEV`), mounted by init with
|
||||
`mount("proc", "/proc", "proc", ...)`. Nothing is stored: directory listings
|
||||
are generated from the process table when asked, file contents when read.
|
||||
|
||||
Three things worth keeping in mind:
|
||||
|
||||
- **The split is deliberate.** `kernel/fs/procfs.c` owns the *namespace* (what
|
||||
exists); `kernel/dev/procfs.c` keeps the *generators* (what the global files
|
||||
say). They change for different reasons, so `proc_gen_global()` is the only
|
||||
thing that crosses between them.
|
||||
- **procfs never touches ptable.** `proc_snapshot()` copies what is needed out
|
||||
from under the lock, so a process that exits between the readdir that listed
|
||||
it and the open that reads it produces a stale line rather than a
|
||||
use-after-free.
|
||||
- **Sizes are reported as zero**, exactly as Linux does. A synthetic file has
|
||||
no length until it is generated, and generating every one on every `stat()`
|
||||
to fill in a number almost nothing reads would be absurd.
|
||||
|
||||
### What the suite caught
|
||||
|
||||
Marking every procfs inode read-only broke `echo s > /proc/sysrq`, and three
|
||||
USB checks failed on it within a minute of the change -- they drive the disk
|
||||
through sysrq. /proc is read-only *except* sysrq, which is the one deliberate
|
||||
write target, and the code now says so in one place.
|
||||
|
||||
### Still not right
|
||||
|
||||
`ps -o comm` prints `[init]` rather than `init`. That field comes from
|
||||
`/proc/<pid>/exe`, which Linux makes a **symlink** and toybox `readlink()`s;
|
||||
ours is a regular file, so the readlink fails and toybox brackets the name as
|
||||
it would for a kernel thread. Default `ps` and `top` output are unaffected.
|
||||
Fixing it needs symlink support in procfs, which belongs with the rest of the
|
||||
VFS work rather than as a one-off.
|
||||
|
||||
---
|
||||
|
||||
## 12. A test program crashed at random, and had been typing its own launch command
|
||||
|
||||
Symptom: a full-screen framebuffer program, launched with arguments, worked
|
||||
four times and then died with
|
||||
|
||||
pid 9 <prog>: trap 14 err 4 on cpu 0 rip 0x2269be addr 0x18--kill proc
|
||||
|
||||
`err 4` is a user-mode read of a non-present page and `addr 0x18` is a null
|
||||
pointer at offset 24. `llvm-nm` put `rip` inside the routine that opens the
|
||||
program's overlay view, which dereferences a pointer the program only fills in
|
||||
once a level is loaded -- at offset 0x18 on x86-64, because the three 64-bit
|
||||
words of a base struct sit in front of it. So the program had been asked to open
|
||||
that view before there was anything to show.
|
||||
|
||||
**The technique is the transferable part:** the fault address alone said
|
||||
"null pointer plus 24", which is nearly useless; `llvm-nm` on the faulting `rip`
|
||||
turned it into a function name, and the offset then identified the exact field.
|
||||
Reach for that before adding printfs to a program you did not write.
|
||||
|
||||
The cause was ours, and it was visible in the log the whole time: every run
|
||||
printed a burst of garbage bytes *before* the grab was taken, and the amount
|
||||
differed each run.
|
||||
|
||||
**`/dev/input/eventN` had no notion of being opened.** Events were queued from
|
||||
boot onwards whether or not anything was listening, into one 256-entry ring
|
||||
with one read cursor. When the program opened the device it inherited the entire
|
||||
backlog -- including the keystrokes of the command line that had launched it,
|
||||
which the shell had already consumed -- and replayed them as input. Whether that
|
||||
crashed depended on which keys happened to be sitting there, which is exactly
|
||||
the "sometimes" in the report.
|
||||
|
||||
Fixed by giving `struct devsw` an `open` hook to match `close`, and having
|
||||
`/dev/input`:
|
||||
|
||||
- drop the backlog on open (`r = w`) -- a reader starts from *now*;
|
||||
- only queue events while `opens > 0`, which is what Linux does: an evdev
|
||||
device with no clients does not accumulate.
|
||||
|
||||
Linux gets both for free by giving every open its own client queue. One shared
|
||||
ring plus "start from now" is the same answer for a single reader, which is the
|
||||
only case that exists today; per-client queues become necessary the moment
|
||||
Schcom and a client both want the keyboard.
|
||||
|
||||
### Also fixed
|
||||
|
||||
`cd` into a file reported "Operation not permitted". It was right to fail, but
|
||||
`sys_chdir` returned a bare `-1`, so the shell printed whatever errno happened
|
||||
to be lying around. It returns `-ENOTDIR` now, and `-ENOENT`/`-EFAULT` on the
|
||||
other two failure paths -- "Not a directory" sends you to the right place,
|
||||
"Operation not permitted" sends you looking at permissions.
|
||||
146
docs/history/moving-to-linux.md
Normal file
146
docs/history/moving-to-linux.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
# Moving the project to a Linux machine
|
||||
|
||||
**This move happened.** The tree lives on Fedora now, and the checkout is at
|
||||
`/home/nexoniarz/Dokumenty/Projects/SchOS` rather than the path written below.
|
||||
|
||||
It is kept in `history/` rather than `guides/` because most of it is a
|
||||
one-time procedure that has been performed — but three parts recur and are
|
||||
worth knowing before the next machine:
|
||||
|
||||
- **what is and is not in the repo** (§1), which decides what a `git clone`
|
||||
gives you versus an `rsync`
|
||||
- **the memory key is derived from the absolute path** (§2), so it differs per
|
||||
machine and `memory-sync.sh` exists for exactly that
|
||||
- **the two things that behave differently on Linux** (§4) — `make ports` works,
|
||||
and the SchFS round-trip test actually runs, because it needs symlinks and
|
||||
hard links that Git Bash cannot create
|
||||
|
||||
Nothing here is Fedora-specific except the package names. For building on any
|
||||
host, see [../guides/building.md](../guides/building.md).
|
||||
|
||||
## 1. Copy the repo
|
||||
|
||||
Copy the whole directory, `.git` included. Everything that matters is committed
|
||||
— 17 commits, no remote — and that includes the project memory, which lives in
|
||||
`.claude/memory/` precisely so it survives this.
|
||||
|
||||
```sh
|
||||
# whatever transport suits: a USB stick, rsync, scp
|
||||
rsync -a /path/to/SchOS/ /home/nexoniarz/Dokumenty/Projects/SchOS/
|
||||
cd /home/nexoniarz/Dokumenty/Projects/SchOS
|
||||
git log --oneline | head -3 # should show the same commits
|
||||
git status # should be clean
|
||||
```
|
||||
|
||||
### What is not in the repo
|
||||
|
||||
`build/`, `sysroot/` and `dist/` are all gitignored, so `rsync -a` of the
|
||||
working tree carries them but a `git clone` would not. Two are worth thinking
|
||||
about:
|
||||
|
||||
- **`dist/`** holds the upstream tarballs — musl, toybox, mksh, the DOOM WADs,
|
||||
`usb.ids`. 61 MB of other people's release artefacts. Copy it to save the
|
||||
download, or let the build fetch them again; `make ports` calls `curl` and
|
||||
verifies each against a known SHA-256, so a re-download is safe either way.
|
||||
Without a network connection, copy it.
|
||||
- **`sysroot/`** is assembled by the build, so it does not need copying — and
|
||||
on this machine it should not be. Its `bin/` holds musl, toybox and mksh,
|
||||
staged once because Windows cannot build them. Linux can, so let `make ports`
|
||||
produce them rather than carrying stale binaries across: the staged copies
|
||||
predate the rename, and `sysroot/bin/sh` still contains `__schunix_tp`.
|
||||
- **`build/`** is pure output. Delete it and forget it.
|
||||
|
||||
## 2. Restore the memory
|
||||
|
||||
Claude Code keeps per-project memory under a key derived from the project's
|
||||
absolute path, so it differs per machine:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `D:\DEV\SchOS` | `D--DEV-SchOS` |
|
||||
| `/home/nexoniarz/Dokumenty/Projects/SchOS` | `-home-nexoniarz-Dokumenty-Projects-SchOS` |
|
||||
|
||||
One command puts it where this machine looks:
|
||||
|
||||
```sh
|
||||
tools/posix/memory-sync.sh status # shows both sides, changes nothing
|
||||
tools/posix/memory-sync.sh import # repo -> ~/.claude/projects/<key>/memory/
|
||||
```
|
||||
|
||||
That writes to
|
||||
`/home/nexoniarz/.claude/projects/-home-nexoniarz-Documents-SchOS/memory/`.
|
||||
|
||||
After a session that learned something, `export` and commit, so the knowledge
|
||||
travels back:
|
||||
|
||||
```sh
|
||||
tools/posix/memory-sync.sh export && git add -A && git commit -m "memory: ..."
|
||||
```
|
||||
|
||||
## 3. Install the toolchain
|
||||
|
||||
```sh
|
||||
tools/posix/setup-fedora.sh --check # report what is missing
|
||||
tools/posix/setup-fedora.sh # install it
|
||||
```
|
||||
|
||||
That covers clang, lld, llvm, make, gcc, perl, python3, qemu-system-x86,
|
||||
edk2-ovmf, xorriso and mtools. See [../guides/building.md](../guides/building.md) for what each is
|
||||
for and for the other supported hosts.
|
||||
|
||||
**PowerShell is the one thing dnf cannot supply.** `tools/windows/bootcheck.ps1`
|
||||
is the boot test suite, and `pwsh` runs it on Linux perfectly well — there is
|
||||
simply no Fedora package. The script prints the two ways to get it. Nothing
|
||||
else needs it: `make`, `make schfs-check` and `make qemu` all work without.
|
||||
|
||||
## 4. Build and check
|
||||
|
||||
```sh
|
||||
make # kernel + root filesystem
|
||||
make schfs-check # B-tree, object and image suites -- all host-native
|
||||
make qemu # boot it
|
||||
```
|
||||
|
||||
Two things will behave differently here, both improvements:
|
||||
|
||||
- **`make ports` works.** musl, toybox and mksh build from source. On Windows
|
||||
the build skips them and uses whatever is staged.
|
||||
- **The SchFS round-trip test runs.** It needs symlinks and hard links, which
|
||||
Git Bash cannot create, so on Windows it skips with a message. This is the
|
||||
first machine that can actually exercise that path — worth running early,
|
||||
since it is the one part of SchFS never yet tested.
|
||||
|
||||
## 5. KVM
|
||||
|
||||
A Ryzen 7000 has SVM, so QEMU can run at native speed instead of emulating:
|
||||
|
||||
```sh
|
||||
ls -l /dev/kvm # should exist
|
||||
sudo usermod -aG kvm $(id -un) # if it exists but is not readable
|
||||
```
|
||||
|
||||
Then add `-enable-kvm` to a QEMU command line. It is deliberately **not** the
|
||||
default in the Makefile: the test suite's timings were established under TCG,
|
||||
and changing the execution engine underneath them is a variable worth
|
||||
introducing on purpose rather than by accident.
|
||||
|
||||
## 6. UEFI
|
||||
|
||||
`make qemu-uefi` boots the UEFI path under OVMF. The Makefile searches the
|
||||
usual locations for the firmware and Fedora's `edk2-ovmf`
|
||||
(`/usr/share/edk2/ovmf/OVMF_CODE.fd`) is among them.
|
||||
|
||||
Note the open bug before spending time on it: under OVMF the kernel reports
|
||||
`e820: only 8 MB usable`, because `e820_init` takes the usable run *containing
|
||||
the kernel* and OVMF puts an ACPI NVS region at 0x800000, genuinely fragmenting
|
||||
it. The loader's map is correct; the kernel's one-contiguous-run assumption is
|
||||
BIOS-shaped. The fix is to free each usable region rather than one range, which
|
||||
touches `kalloc`.
|
||||
|
||||
## The hardware, for reference
|
||||
|
||||
Fedora 44, Ryzen 5 7000-series, RTX 4050, 16 GB DDR5. Nothing in the build cares
|
||||
about the GPU — QEMU runs headless for tests and uses its own emulated display
|
||||
otherwise. The Nvidia driver matters only if you want a QEMU window, and the
|
||||
`nouveau`/proprietary choice makes no difference to SchOS either way.
|
||||
370
docs/history/schfs.md
Normal file
370
docs/history/schfs.md
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
# SchFS — how it was built
|
||||
|
||||
The filesystem as it is now is [../architecture/schfs.md](../architecture/schfs.md).
|
||||
This is the record of building it: the phase plan, what each phase actually
|
||||
produced, and the bugs that cost real time.
|
||||
|
||||
**All five phases are complete.** The plan is kept because the reasoning in it
|
||||
is still the reasoning behind the format, and because the ordering argument —
|
||||
*do not start phase 2 before phase 1 is solid, the B-tree is the load-bearing
|
||||
piece* — is the transferable part.
|
||||
|
||||
---
|
||||
|
||||
## The phases, as they were planned and as they went
|
||||
|
||||
Each phase leaves a bootable system. Do not start phase 2 before phase 1 is
|
||||
solid — the B-tree is the load-bearing piece and everything else assumes it.
|
||||
|
||||
**Phase 1 — the B-tree, in userland first.** Write the CoW B-tree as a plain C
|
||||
library with a block-device abstraction, and test it on a *host* file with a
|
||||
fuzz/torture harness (random insert/delete/lookup, simulated crash at every
|
||||
write point, verify the tree always mounts). This is the one component where a
|
||||
subtle bug is unfixable later, and testing it in the kernel is far harder. It is
|
||||
also portable straight into `tools/mkfs`.
|
||||
|
||||
**Phase 2 — read-only mount. DONE.** New magic, uberblock scan, tree walk, inode
|
||||
and dirent key types. `tools/mkschfs` writes images; the kernel only reads them.
|
||||
The system boots from a ramdisk root (see [../guides/layout.md](../guides/layout.md) and the ramdisk work)
|
||||
so a broken disk cannot stop you from booting.
|
||||
|
||||
Went further than planned in two places, both cheaply: data blocks carry
|
||||
checksums too (a `CSUM` key type, as btrfs does — without it "end-to-end
|
||||
checksums" would cover only metadata, which is the part least likely to rot),
|
||||
and extents landed early because reserving a file's blocks in one go was
|
||||
simpler than not doing it.
|
||||
|
||||
Not yet done: SchFS files are not reachable through `open(2)`. The mount is
|
||||
observable through `/proc/schfs` and through the kernel API in `schfs_int.h`,
|
||||
but wiring it into `namei`/`readi` means the VFS work that phase 3 needs anyway.
|
||||
|
||||
**Phase 3 — writes, transaction groups. DONE.** Every `schfs_k*` call that
|
||||
changes anything is one transaction group, committed by writing a new uberblock
|
||||
before it returns. The full write API — create, link, unlink, rename, write at
|
||||
an arbitrary offset, truncate — lives in `objw.c` and is exercised by
|
||||
`tools/schfs_objtest.c`.
|
||||
|
||||
Two things went differently from the plan above, both for reasons worth
|
||||
recording.
|
||||
|
||||
*Free space is not tracked as tree keys.* Allocating from a free map that lives
|
||||
in the tree mutates the tree, which allocates, which changes the map; btrfs
|
||||
resolves that with a fixpoint loop and ZFS with log-structured space maps. SchFS
|
||||
sidesteps it: the committed tree already says exactly which blocks are live, so
|
||||
the map is **derived** by walking the tree once at mount and held only in RAM.
|
||||
It cannot disagree with the filesystem, because it is computed from it, and a
|
||||
crash raises no question at all. The cost is mount time proportional to metadata
|
||||
size, and a ceiling on volume size set by how much bitmap fits in memory. If
|
||||
mount time ever hurts, the map can be cached on disk purely as a hint,
|
||||
validated against the generation and discarded on a mismatch — an addition, not
|
||||
a format change. See `kernel/fs/schfs/space.c`.
|
||||
|
||||
*Data blocks needed a generation stamp too.* The B-tree already rewrites its own
|
||||
nodes in place within a group, since a block that no committed root can reach is
|
||||
nobody's fallback. Data blocks had no such marker, so every hundred-byte write
|
||||
copied a whole 4 KB block and split an extent record around it. `schfs_extent`
|
||||
gained a 64-bit `gen`, and the same rule now applies to file data.
|
||||
|
||||
**Phase 3½ — reachable through `open(2)`. DONE.** SchFS is a mounted filesystem:
|
||||
|
||||
```
|
||||
mkdir /mnt
|
||||
mount -t schfs /dev/hdb /mnt
|
||||
ls /mnt ; echo hi > /mnt/tmp/w ; cat /mnt/tmp/w ; mkdir /mnt/tmp/d
|
||||
```
|
||||
|
||||
The VFS grew a real dispatch layer rather than a set of `if (dev == SCHFS_DEV)`
|
||||
checks. `struct fs_ops` (include/file.h) is one table per filesystem, chosen by
|
||||
`iget()` from the device number and hung off the inode; v1's implementations
|
||||
keep their bodies and are simply named `v1_*`. Adding a third filesystem is a
|
||||
new table and nothing else. `kernel/fs/schfs/vfs.c` is SchFS's, and it is pure
|
||||
translation — object ids in, inodes out — because all the filesystem logic
|
||||
already lives below it.
|
||||
|
||||
Three things had to change on the way, none of them SchFS's fault:
|
||||
|
||||
- `struct inode.inum` and `.size` were 32-bit, capping objects at 2³² and files
|
||||
at 4 GiB on a filesystem that promises far more. Both are `uint64` now, as are
|
||||
`readi`/`writei` offsets and `struct file.off`.
|
||||
- `struct dirent` carries a 16-bit inum and a 14-byte name, so it cannot
|
||||
represent a SchFS entry at all. `getdents64` is now an fs_op: each filesystem
|
||||
emits the record stream itself, and `f->off` is whatever position it needs to
|
||||
resume. v1 walks its hash buckets; SchFS walks a key range and synthesises
|
||||
"." and "..", which it does not store.
|
||||
- Mounting is a substitution made during path resolution (`mount_add` /
|
||||
`mount_cross` in `fs.c`). Nothing on either disk records it. The reverse
|
||||
direction matters as much as the forward one: `..` at a mounted root has to
|
||||
leave the mount, and the filesystem underneath has no idea it is mounted
|
||||
anywhere.
|
||||
|
||||
Three long-standing bugs surfaced the moment real commands ran against it, all
|
||||
in the shared VFS rather than in SchFS:
|
||||
|
||||
- `include/fcntl.h` used xv6's open flags while every program in the userland
|
||||
sends Linux's. `O_APPEND` (0x400) landed on `O_TRUNC`, so `cmd >> file`
|
||||
*truncated* it; `O_TRUNC` (0x200) landed on `O_CREATE`, so `cmd > file` left
|
||||
whatever was past the new end in place. Nothing about either pointed at a
|
||||
header.
|
||||
- `sys_openat` silently dropped `O_TRUNC` and `O_APPEND` while `sys_open`
|
||||
honoured both, so the same redirection behaved differently depending on which
|
||||
entry point libc happened to call.
|
||||
- `rmdir` had no Linux syscall number, so every Linux-ABI program got ENOSYS.
|
||||
|
||||
**Phase 3¾ — durability, and the cost of it. DONE.** Three things that the
|
||||
earlier phases had assumed rather than arranged.
|
||||
|
||||
*The commit barrier.* Copy-on-write promises that a crash lands on either the
|
||||
old tree or the new one, but only if the blocks a root points at reach the
|
||||
medium *before* the block naming that root. Nothing enforced that: `bwrite`
|
||||
order is not platter order on any drive with a write cache. `struct schfs_bdev`
|
||||
gained a `bflush` hook, `schfs_vol_commit` calls it either side of the uberblock
|
||||
write, and the kernel implements it as "drain this device's dirty buffers, then
|
||||
ATA FLUSH CACHE" (`ideflush`, and SCSI SYNCHRONIZE CACHE for a USB stick).
|
||||
|
||||
*Falling back through the ring.* Mount took the highest-generation uberblock
|
||||
that validated, checked the tree it named, and gave up if that failed — with up
|
||||
to 127 older, entirely intact roots sitting on the disk. It now walks the ring
|
||||
newest-first and adopts the first root it can actually read, counting what it
|
||||
skipped into `vol->recovered` so the rollback is reported rather than silent.
|
||||
|
||||
*Deferred writes, and brackets.* The B-tree seals and writes a node on every
|
||||
record it inserts, so a leaf collecting one file's data checksums went to the
|
||||
disk once per 4 KB of that file. The kernel's block hook marks buffers dirty
|
||||
instead (`bdirty`), and the commit barrier is what makes that safe. On its own
|
||||
that changes little, because a commit *contains* a flush and every `schfs_k*`
|
||||
call was one commit — so `schfs_ktx_open`/`schfs_ktx_close` bracket a run of
|
||||
calls into one group, and `schfs_vfs_write` wraps a whole `write(2)` in one.
|
||||
|
||||
Measured over 1 MB of file data, in block writes reaching the device:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| committing every 4 KB | 1947 | 1410 |
|
||||
| one commit for the megabyte | 1798 | **267** |
|
||||
|
||||
267 device writes for 256 blocks of data. Note that the two changes are worth
|
||||
almost nothing separately — deferral cannot help while every commit flushes,
|
||||
and batching cannot help while every write goes straight to the disk — which is
|
||||
why the first attempt at this measured an 8% difference and concluded batching
|
||||
was not the problem.
|
||||
|
||||
**Phase 3⅞ — where the time actually went. DONE.** The filesystem was correct
|
||||
and durable and the system still felt slow: `ls /bin` took the better part of a
|
||||
second. Two causes, neither of them the disk.
|
||||
|
||||
Measured first, through `/proc/fsstat` (counters added for exactly this). The
|
||||
decisive number was **398,999 block reads against 445 actual disk misses** —
|
||||
99.9% of all block reads were re-reading a block already in the buffer cache. So
|
||||
the disk was never the problem, and every plan that started with "cache more" or
|
||||
"read ahead" would have been aimed at 1% of the cost.
|
||||
|
||||
*The cursor re-read its own buffer.* `cur_load` called `node_read`
|
||||
unconditionally, and `schfs_cursor_val` calls `cur_load` on **every access to
|
||||
the current record** — deliberately, so that a callback disturbing the shared
|
||||
block buffer cannot corrupt a directory walk. But `node_read` copies 4096 bytes
|
||||
out of the buffer cache and then runs CRC32c over all 4096. Listing a 243-entry
|
||||
directory paid both once per entry. The cursor now remembers which block its
|
||||
buffer holds and skips the reload, cross-checked against the block header (so a
|
||||
second cursor sharing the page cannot fool it) and against a global epoch that
|
||||
every mutation bumps (so a block rewritten in place cannot be served stale).
|
||||
|
||||
*The checksum was bit-at-a-time.* `schfs_crc32c` did eight shifts per byte —
|
||||
32,768 iterations to checksum one block — to stay usable before any
|
||||
initialisation. It builds a 1 KB table on first use now, which keeps that
|
||||
property and is eight times faster. This only became the top cost *after* the
|
||||
cursor fix, which is the usual shape: removing the largest item promotes
|
||||
whatever was beneath it.
|
||||
|
||||
| cycles, one session | before | after |
|
||||
|---|---|---|
|
||||
| `getdents` | 26.8 G | **0.14 G** (196×) |
|
||||
| `read` | 26.4 G | **5.5 G** (4.8×) |
|
||||
| `exec` | 18.7 G | **4.0 G** (4.7×) |
|
||||
| block reads | 398,999 | **127,713** |
|
||||
|
||||
`ls /bin` went from ~400 ms to ~20 ms. Verified by reading an 802 KB binary
|
||||
through the whole path and checking its md5 against the host's copy.
|
||||
|
||||
There was also a third of the problem that was not the kernel at all: `make
|
||||
qemu` ran QEMU without `-enable-kvm`, so every instruction was emulated. The run
|
||||
targets probe `/dev/kvm` now. Measure against the KVM number or you are
|
||||
measuring TCG.
|
||||
|
||||
**Phase 4 — extents with refcounts. DONE.** `cp` that costs nothing.
|
||||
|
||||
A reference count per run of blocks, in its own tree (`SCHFS_KEY_EXTREF` under
|
||||
`SCHFS_EXTREF_OBJECTID`) rather than in the file's own extent record — because a
|
||||
count kept per file cannot be decremented by the *other* file sharing the run.
|
||||
`schfs_file_reflink` copies one extent record per run and bumps the count; both
|
||||
files stay ordinary and writable, and the first write to either copies only the
|
||||
run it touches. Reachable from userland as `FICLONE`, Linux's ioctl, so
|
||||
`cp --reflink` works unmodified; `cmd/reflink` is a two-line caller of it.
|
||||
|
||||
Measured on the running system: cloning a 411 KB file moved `df` by 24 KB.
|
||||
|
||||
Three things had to be right, and each was wrong first:
|
||||
|
||||
- **The count is "how many extent records point into this run", not "how many
|
||||
files".** Splitting a shared extent around a written block leaves a head *and*
|
||||
a tail, which is one more reference than before. Missing that leaked four
|
||||
blocks per split.
|
||||
- **A file's extent may point into the middle of a run.** Records are keyed by
|
||||
the block a run was *allocated* at, so every lookup is a range query
|
||||
(`extref_find`), not an exact one. An exact lookup finds nothing for a split
|
||||
tail, "nothing" means unshared, and the tail is then freed while another file
|
||||
still reaches it.
|
||||
- **The space scan has to walk the reference records too.** A file truncated
|
||||
inside a shared run describes fewer blocks than exist; the rest would be
|
||||
derived as free while still referenced, and the allocator would hand out a
|
||||
block that a later release frees a second time.
|
||||
|
||||
None of those is visible until every holder of a run is deleted, which is why
|
||||
the test that matters is the least glamorous one: after deleting every file that
|
||||
shared a run, free space must be back to exactly what it was.
|
||||
|
||||
**Phase 5 — attribute index and query. DONE.** The reason the design was one
|
||||
B-tree with many key types from the start: the "database filesystem" feature
|
||||
turned out to be `kernel/fs/schfs/attr.c` and nothing else.
|
||||
|
||||
An attribute is a named value on an object, stored under `(ino, ATTR,
|
||||
hash-of-name)` and packed exactly as directory entries are — same collision
|
||||
argument, same shape. An attribute may be *indexed*, and an indexed attribute
|
||||
gets a second set of records keyed by value: `(hash-of-name, INDEX,
|
||||
hash-of-value)` holding the object ids that carry it. So "every object whose
|
||||
`tag` is `holiday`" is one tree descent instead of a walk of every object.
|
||||
|
||||
There is no query language, no parser and no planner, and §6 above explains why:
|
||||
the useful half of "query the filesystem" is the index, and WinFS spent three
|
||||
years and a large team on the other interpretation.
|
||||
|
||||
```
|
||||
schattr index tag # declare the index
|
||||
schattr set /tmp/a tag holiday
|
||||
schattr find tag holiday # one descent, whatever the volume holds
|
||||
```
|
||||
|
||||
Four decisions worth keeping:
|
||||
|
||||
- **Which attributes are indexed is cached in the volume**, read once at mount.
|
||||
Every `setxattr` has to ask, and the answer is almost always no; a tree
|
||||
descent to say so would make every attribute write pay for a feature it is
|
||||
not using.
|
||||
- **The value is stored in the index entry, not just its hash.** Two values can
|
||||
hash alike, and a query that returned the wrong file would be far worse than a
|
||||
slow one.
|
||||
- **An index covers values written after it is declared.** There is no
|
||||
reindexing pass. Saying so is better than a half-truth that makes a query
|
||||
answer confidently and wrongly.
|
||||
- **A query on an unindexed attribute is refused, not answered by scanning.** A
|
||||
query that silently degrades into a full walk teaches you to use it where it
|
||||
will not scale.
|
||||
|
||||
Reachable as `setxattr`/`getxattr`/`removexattr` at Linux's syscall numbers, so
|
||||
anything that already knows how to attach metadata needs no changes, plus two
|
||||
native calls (`schfs_index`, `schfs_query`) that have no Linux equivalent
|
||||
because asking a filesystem a question does not.
|
||||
|
||||
**Phase 5 — attribute index and query.** See below.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## What was reused from v1
|
||||
|
||||
Not starting from zero:
|
||||
|
||||
- **`struct schfs_extent`** is already defined in `include/fs.h` and unused.
|
||||
The extent concept is already sketched; v2 gives it refcounts and 64-bit
|
||||
fields.
|
||||
- **CRC32c checksum fields** already exist in `dinode` and `superblock`, and
|
||||
mkfs already computes them. Same algorithm, relocated into parent pointers.
|
||||
- **`kernel/fs/log.c`** is a working transaction log with `begin_op`/`end_op`
|
||||
call sites throughout the FS. Under CoW the log is not needed for consistency,
|
||||
but `begin_op`/`end_op` are exactly the right places to bracket a transaction
|
||||
group, so the *call sites* survive even though the implementation is replaced.
|
||||
- **The buffer cache** (`bio.c`) is format-agnostic and needs no change beyond
|
||||
the block-size constant.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## What it was estimated to cost
|
||||
|
||||
Phases 1-3 are roughly 3,000-4,000 lines and are the bulk of the work. Phase 1
|
||||
alone (B-tree + torture harness) is worth doing carefully and unhurriedly; every
|
||||
later phase compounds on it.
|
||||
|
||||
This is the largest single piece of work in SchOS. It is also entirely
|
||||
ordinary filesystem engineering with excellent published references
|
||||
(Giampaolo for BeFS; the btrfs design papers; the ZFS uberblock/checksum-in-
|
||||
parent scheme) — there is no research risk here, only volume.
|
||||
|
||||
**Do this before the installer.** The installer's entire job is to create and
|
||||
populate this format. Building it against v1 means building it twice.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Things learned the hard way
|
||||
|
||||
Recorded because each one cost real time and none is obvious from the code.
|
||||
|
||||
- **The root's object id must be reserved.** `next_objectid` started at
|
||||
`SCHFS_FIRST_OBJECTID`, which *is* the root's id, so the first directory
|
||||
created silently became the root. It then contained itself, and every tree
|
||||
walk recursed until the stack ran out. `schfs_fsck --check` now detects
|
||||
directory loops explicitly rather than crashing.
|
||||
- **Cursor buffers alias.** A range callback gets a pointer into the cursor's
|
||||
block buffer, and almost every readdir caller immediately looks the entry up,
|
||||
which overwrites it. Directory iteration copies each entry out before calling
|
||||
back. Extracting a tree with directories reparented under their own children
|
||||
is what this looks like when it goes wrong.
|
||||
- **A 4096-byte block does not fit on a 4096-byte kernel stack.** The cursor
|
||||
exists precisely to avoid one block buffer per tree level, but
|
||||
`schfs_vol_open` still kept a whole uberblock as a local and blew the stack on
|
||||
the first boot. The kstack canary caught it cleanly.
|
||||
- **Extents only merge if you allocate the run first.** Writing an extent record
|
||||
allocates blocks for its own copy-on-write path, so allocating data one block
|
||||
at a time guarantees the blocks are never adjacent and every block needs its
|
||||
own extent. `schfs_file_reserve` claims the whole run up front; an 822 KB
|
||||
binary is one extent instead of 201.
|
||||
- **Freed blocks are reusable during a build.** Copy-on-write forbids reusing a
|
||||
released block because the previously committed tree still points at it — but
|
||||
a build has committed nothing. Recycling them cut the test image from 3596
|
||||
blocks to 374.
|
||||
- **Reproducibility dies from atime.** mkschfs reads every source file, which
|
||||
updates its atime, so copying atime into the image made two builds of the same
|
||||
tree differ. All three timestamps come from mtime.
|
||||
- **An optional hook must still be initialised.** `struct schfs_bdev` gained
|
||||
`bflush`, and every host tool built one on the stack field by field. The tools
|
||||
that did not set the new field handed `schfs_vol_commit` a garbage pointer to
|
||||
test and call, which segfaulted at the first commit. Each of those helpers
|
||||
memsets the struct first now — the fix is not "remember to set it".
|
||||
- **Deferring writes makes an all-dirty buffer cache normal.** `bget` panicked
|
||||
with "no buffers" when it could find nothing clean to recycle, which was
|
||||
correct while a dirty buffer meant "the log has not committed". It now writes
|
||||
the least recently used victim out and takes it; a transaction touching more
|
||||
than `NBUF` blocks reaches that state long before it commits.
|
||||
- **The two performance fixes are worthless apart.** Deferring tree writes buys
|
||||
nothing while every commit flushes, and batching commits buys nothing while
|
||||
every write goes straight to the disk. Measuring either alone says "this is
|
||||
not where the time goes", and both measurements are correct.
|
||||
- **`fsid_t` is eight bytes.** `struct statfs` declared `f_fsid` as `int64[2]`,
|
||||
which is one slot too long, so everything after it landed in the wrong place:
|
||||
userland read `f_frsize` out of `f_namelen` and got 255. df then computed in
|
||||
units of 255 bytes and called a 32 MB root filesystem 1992 KB. It survived
|
||||
because every field in that struct used to be an invented constant, so no
|
||||
number in it had ever been checked against reality.
|
||||
|
||||
---
|
||||
|
||||
## The one about the tests themselves
|
||||
|
||||
`tools/posix/mutate.sh` injects six deliberate bugs and fails if any goes
|
||||
undetected.
|
||||
|
||||
It exists because a suite that passes is not evidence until something has
|
||||
checked that it can fail. The most valuable single assertion in the whole SchFS
|
||||
suite is also the least glamorous: **after deleting everything, free space must
|
||||
be back to exactly what it was on an empty volume.** Every other test still
|
||||
passes on a filesystem that leaks a block on every delete.
|
||||
Loading…
Reference in a new issue