Syscalls and the Linux ABI: Phases
Two parts. A rebuilds the layer the current 73 syscalls stand on, so that the next three hundred can be added and tested one at a time. L adds them, by capability, each phase closed by programs this project did not write. Numbers per phase are from syscalls.md.
Every step, in both parts, is done the way this project does things: a gate or host test that fails if the step regresses, a sabotage case that proves the test can fail, a row in the tracker for what it found, and the docs updated in the same change.
Part A - the refactor of what exists
A0. The corpus and the measurement
Objective. Know, per program, which syscalls it needs and which of them are missing - before writing any.
- The corpus: BusyBox as the boot image stages it (Ubuntu's
busybox-static, which is linked against glibc), the musl test programs, and - from their released sources, built against musl -sqlite3,lua, and LTP'stestcases/kernel/syscalls. - A checked-in needs list per workload, measured under strace on Linux, with the system files each opens.
- A report: for each workload, needed / done / partial / missing; across the corpus, the missing ones by phase and by who asks.
Done when the report exists for every corpus program and runs in the nightly.
Status (2026-09-29): done. tests/corpus/ holds 25 workloads - sixteen of
BusyBox, the six musl test programs, two of sqlite3 and one of lua - measured
by scripts/dev/corpus-measure.sh and reported by scripts/dev/corpus-report.py
in corpus.md: 80 distinct syscalls asked for, 41 done, 16 partial,
23 missing, 13 workloads ready (Lua among them; sqlite3 waits on fcntl
locks, pread64/pwrite64 and fsync). The musl programs come out ready,
which is the report's own check. scripts/dev/corpus-build.sh downloads SQLite,
Lua and LTP from pins with their SHA-256 (tests/corpus/sources.txt) and builds
them static against musl. 1,530 of LTP's syscall tests build: 1,331 matched to
320 syscalls - an oracle for 73 of L1's 78 - and three directories do not build
(fmtmsg, timer_create, utils). The nightly job corpus-needs builds SQLite
and Lua, measures again and fails on drift.
The first LTP build produced 378 tests, not 1,530: its top-level make stops at
the first directory that fails, -k or not, and fmtmsg fails early in the
alphabet. The script builds each directory on its own now. LTP's tests are
measured as an oracle - which exist and build - not traced one by one: most
need root on the host, and what they exercise is by construction the syscall
they are named after.
On the way, trace-linux-binary.sh - the tool this whole method rests on -
turned out to report full coverage for every binary: it looked for handlers as
case LSYS_ in arch_hw.c, gone since C4, and its name extraction no longer
matched strace, so both lists were empty. It reads the table now (M-077).
A1. The registry
Objective. One source of truth for all 373 numbers (invariant 6), and two counters where there is one (invariant 7).
- A registry file (number, name, state, phase, reason) replaces
scripts/dev/linux-syscall-numbers.txt, which listed only the implemented ones. It is generated once from the kernel'sarch/x86/entry/syscalls/syscall_64.tbland edited by hand after that. - The dispatcher answers a refused number with its registry errno and counts
abi_refused; a missing number countsabi_missing. The gate fails onabi_missingfrom a corpus program, as it already does for an unimplemented call today. - syscalls.md is generated from the registry by a script in the book build, instead of by hand.
check-syscall-checks.pyalso checks each row against the registry.
Done when the three statements of a number (row, registry, table) cannot
disagree without check.sh failing, and a sabotage that marks a refused number
missing turns the gate red.
Status (2026-09-29): done. The registry is kernel/abi/linux_syscalls.def,
an X-macro list - SYSCALL(number, name, state, phase, errno, "why") - that the
C code and the scripts both read: 373 Linux numbers seeded from Linux's
asm/unistd_64.h and the plan's table, plus VibeOS's two own. States are DONE,
PARTIAL, MISSING, DEFERRED and REFUSED; each of the 33 refusals carries its own
reason, seven of them answer EPERM rather than ENOSYS.
- The dispatcher answers a number with no row from its line: a refusal with its
errno, counted as
refused; a deferred call with ENOSYS, counted and reported by the gate by number (abi_deferred_syscall_nr); a missing one as before (abi_unimplemented_syscall_nr).helloasks forioplon purpose and expects EPERM, so the refusal count is seen moving on every boot (abi_refused_unprovenotherwise). - The boot refuses to start when a row and its line disagree in either
direction, and
check-syscall-checks.pyholds the same rule against the sources - reading the registry instead oflinux-syscall-numbers.txt, which is gone. A registry line it cannot parse is a failure: the first version skipped one with a space before a comma, which the compiler accepted. - syscalls.md is written by
make-syscall-table.pyfrom the registry, andcheck.shruns it with--check, so the published table fails the build when it drifts. Not generated in the book build, as planned: a stale page is caught before the push, which is earlier. - Host test
test_linux_registry; sabotageabi-registry.txt(iopl missing, iopl deferred, read without a row - each red for its own reason) andabi-registry-dispatch.txt(the refusal path removed, the count removed).check-sabotage-anchors.pydid not know.deffiles and reported the first as anchored nowhere; it does now.
A2. Handlers leave the architecture
Objective. Invariant 9: kernel/abi/linux/ stops including
arch_hw_internal.h.
- A declared interface for what a handler needs: the current task and its identity, its descriptor table, its address space (map, protect, unmap, fault a user range in), the filesystem, the network stack, and wait/wake. Implemented by the architecture; a fake implementation for host tests.
hw_sys_*renamedlinux_sys_*; the helpers that only handlers use move with them, as in C4.- Host tests per handler, beginning with the 16 partial rows, whose gaps are written as failing expectations that the owning L phase turns green.
Measure. The ~700 references to hw_*/g_tasks in kernel/abi/linux/
go to zero; blast radius for "add a syscall" goes to one file plus its row.
Done when the Linux ABI builds into the host test binary and the existing boot gate is unchanged.
Status (2026-09-29): done. Nothing under kernel/abi/ names the architecture
any more: no hw_*, no g_tasks, no architecture global, no inline assembly,
no arch_hw_internal.h. Every hw_sys_* is linux_sys_*.
- The interface is not Linux's. It is
include/vibeos/ksvc.h, kernel services, with aks_prefix: 92 functions over tasks, locks and waiting, user memory, the address space, processes, registers and devices. A second personality - Windows is planned - is another directory besidekernel/abi/linuxthat includes the same header. The architecture implements it inkernel/arch/x86_64/ksvc.c, mostly one line over a function that already existed. What is more than a line is what used to sit in the handlers and is really about the machine: the mm lock's interrupt window, a fresh page's leaf bits, reading an entry forpageinfo, a fork's or a thread's registers, an exec's entry, the signal frame, the TLS MSR. - Portable structures. The process state, the image and the lock layout
moved to
include/vibeos/procstate.h, and the architecture keeps its names for them as typedefs. Linux errno and signal numbers moved toabi_linux.h. The layer's exports to the kernel - the syscall entry, signal delivery, descriptor copies, futex wake - areinclude/vibeos/linux_exports.h. - Reusable across personalities. The pointer checks a row declares run in
kernel/abi/abi.c(vibeos_abi_check_pointers, handed the personality's own range check and bad-address error), and the row macros areinclude/vibeos/abi_rows.h. The architecture's entry only reads registers; the dispatcher islinux_syscall(frame, nr, a[6]). - Moved, not rewritten. The futex table and its wake left the
architecture's task code for
kernel/abi/linux/futex.c, with the wait. The descriptor claim and a pipe end's release left it forfs.c. mmap's two mapping loops, which differed only in the leaf, became one. - Host tests.
tests/kernel/ksvc_fake.cis the other implementation: a task table, user memory with a window that can be made to fault (a sibling's munmap between the check and the copy), a filesystem and a network stack in memory, and waits, exits and panics that return to the test instead of hanging it.linux_abi_tests.cruns the handlers throughlinux_syscall: pipes, dup2, the console, the pointer engine, sigset numbering, sigaction, kill across sessions, a forged signal frame, the registry's three answers, fork. Every PARTIAL row has a gap expectation written as what Linux does; it fails today for the reason the registry names, a gap that starts passing fails the test until the line says DONE, and a gap for a line that is not PARTIAL fails too.rseq, partial by decision (phase R), asserts the decision instead. - Checks.
check-abi-layering.py(incheck.sh): no personality file names the architecture - the forbidden globals are read fromarch_hw_internal.h, after the first version knew onlyg_tasksand let a sabotage readingg_timer_ticksthrough - and both implementations define every service.check-syscall-checks.pyfollowed one-line definitions into the next function (its body ended at the next\n}\n), which madeclose"reach" the user-range check throughks_lock; it matches braces now, and its listing is identical to the one before the refactor for all 68 operations.check-chokepoints.pycountsinclude/too and theks_doors beside thehw_ones. 26 sabotage anchors were re-pointed, 12 by applying the code's own renames and 14 by hand. - Sabotage.
abi-host-handlers.txt(sigset numbering, a gap closed without the registry, a forged frame resumed) andabi-layering.txt- all red for their stated reason.
Measure. References to hw_*/g_tasks in kernel/abi/linux/: ~700 to 0.
Blast radius for "add a syscall" stays 2 (abi.h and the file with the handler),
plus its registry line - not the one the plan hoped for: the second is the
operation's declaration in abi.h, which every personality shares and which is
kept on purpose.
A3. Descriptors as Linux has them
Objective. Fix the two descriptor defects under every I/O syscall.
- An open file description: a refcounted object holding the offset, the
flags and the type-specific state, shared by
dup,dup2,dup3,forkandSCM_RIGHTSlater. The descriptor table holds references to it, not copies. - A table that grows to
RLIMIT_NOFILE(default 1024), withFD_CLOEXEChonoured at exec andclose_range. - One operations table per file type - regular file, directory, pipe, socket,
terminal, and later eventfd/timerfd/signalfd/epoll - so
read,write,lseek,fstatandioctldispatch by type instead of by theifchains they use today.
Done when a host test shows two duped descriptors sharing an offset, a
program opens 200 files, and descriptor sharing between threads (M-072) still
passes.
Status (2026-09-30): done.
- Open file descriptions (
include/vibeos/file.h,kernel/fs/file.c): a counted object holding the offset, the status flags and each type's state, in a pool of 256 with its own lock. The last reference runs the type's release - a pipe end given back, a socket closed, a written file committed - and a slot is not handed out again until that release has finished. A put with nothing to put isfile_put_underflowin the must-be-zero registry. - The table (
fdtable.h) holds references and one flag per number, close-on-exec, and grows a page of 256 at a time up to 1024 (RLIMIT_NOFILE, which prlimit now reports). Descriptors 0-2 are ordinary entries: a spawned program gets one console description on all three, so closing 1 and opening a file gives the file 1, as every shell's redirection expects. fork copies references, exec copies and then drops the close-on-exec numbers, and the last thread to leave destroys the table. - One operations table per type, in
kernel/abi/files/beside the personalities - regular file, directory, pipe end, socket, console - written againstvibeos/ksvc.h, so a Windows personality uses the same ones.read,write,lseek,fstat,ioctlandgetdents64dispatch by type: a pipe is a FIFO and a socket a socket to fstat, both ESPIPE to lseek. The socket waits (connect, accept, recvfrom) moved with the type, leaving the Linux handlers the sockaddr translation only. Pipe ends are counted per description, so dup and fork no longer touch a pipe's counts; the five sites that had to remember an acquire became none. - Every call holds a reference to the description it works on for its whole length (Linux's fdget/fdput), so a sibling thread's close takes the number away and not the file. A socket call still re-checks the socket's own tenancy (M-020), because a process's exit releases the sockets it owns.
- New and finished syscalls:
dup3(DONE),fcntl(PARTIAL: descriptor and status flags, F_DUPFD and F_DUPFD_CLOEXEC; record locks answer ENOLCK rather than pretend),close_range(PARTIAL: CLOSE_RANGE_UNSHARE refused),pipe2honours O_NONBLOCK and O_CLOEXEC,openhonours O_CLOEXEC,sockethonours SOCK_CLOEXEC.openstays PARTIAL for the working directory only. - Host tests: dup shares an offset, 200 files at consecutive numbers, a
closed stdout reused, a pipe is a FIFO with ESPIPE and non-blocking EAGAIN,
close-on-exec through open, dup3, F_DUPFD_CLOEXEC and close_range, and a
forked child reading on from its parent's offset; the table and description
layers have their own. The
opengap for four descriptors closed on its own when the table grew and failed the test until the registry said so, which is the gap mechanism doing its job; its expectation is the working directory now, and fcntl and close_range carry theirs. - Checks:
check-net-stable.pyfollows the waits intosocket.cand matches braces - its one-line signature parser saw two of four waits and said so instead of passing.check-task-identity.py's count of lines indexing the table's arrays went to zero. The host test runner is unbuffered: a sabotage of the table's page clearing was named by its test and the line was lost when a later group crashed on the same garbage. - Sabotage:
fs-file.txt(3),fs-fdtable.txtrewritten (4 - the C5 cases named code that no longer exists),abi-files.txt(3), ten anchors re-pointed; all red for their reason, the page-clearing case by its test and then a crash. - Not done: the write-back still replaces a whole file with at most 512
buffered bytes, so O_RDWR on an existing file opens for reading only - a gap
of the filesystem layer, recorded in
regular.c. And eventfd, timerfd, signalfd and epoll are future types (L4).
A4. Paths
Objective. Relative paths, a working directory, and the *at family.
- A per-process working directory and root, inherited by fork and threads.
- One path walk through the mount table:
.,.., mount crossings, symbolic links where the filesystem has them,ENAMETOOLONG/ELOOP/ENOTDIRas Linux reports them. dirfdhonoured everywhereAT_FDCWDis today.- The
/lib/ld-musl-x86_64.so.1substitution in the loader is deleted, as CLAUDE.md says it should be the day the layout becomes real.
Done when cd in a shell works, relative open works, and the loader finds
its interpreter by path.
Status (2026-09-30): done.
- One path walk (
include/vibeos/path.h,kernel/fs/path.c), shared by every personality. A path is made absolute and normalised lexically against a root and a working directory -.,..and repeated slashes, with..stopping at the root - and then looked up through the mount table, so a path under a mount point reaches that filesystem rather than the boot volume. Every component before the last must be a directory (ENOTDIR otherwise), a component over 255 bytes or a result over 256 is ENAMETOOLONG rather than cut short.vibeos_path_parentanswers for a name about to be created: its parent must exist, the name need not. - A working directory and a root per process, in the process state beside the descriptor table and under its lock, inherited by fork, threads and exec. An open file description remembers the mount it was opened on, so a read, a directory listing and a write-back reach the right filesystem.
dirfdis honoured by every *at call (linux_path_at): AT_FDCWD is the working directory, a directory descriptor its own path, anything else EBADF or ENOTDIR. New and finished rows:chdir,fchdir,getcwd(ERANGE for a short buffer),mkdirat,openat,openall DONE;unlinkatPARTIAL, since AT_REMOVEDIR has no filesystem here that removes a directory.readlinkatandnewfstatatresolve relative paths too.- exec resolves its path against the working directory, and
argv[0]stays the name the caller gave - BusyBox still dispatches on it. - The interpreter substitution is deleted. The loader is staged at
/lib/ld-musl-x86_64.so.1on the boot volume and the kernel opens whatever path the program's PT_INTERP names.check-exec-layering.sh, which existed to keep the substitution to one function, went with it. - FAT reads long names. It had to: the loader's name is not 8.3. VFAT
entries are collected in front of their short entry and used only when their
checksum matches it, so an orphaned long name left by a tool that did not know
VFAT is ignored rather than misattributed. The ESP writer emits them, and the
gate mounts a second image written by mtools (
FATLONG.IMG, at/fatlong) and reads a file through a long directory name - an artefact neither side of the test produced, after the ISO9660 lesson. - Gated: the boot self-test runs
cd -P /DOCS && pwd -P && cat NOTES.TXTin the shell;shell_cd_did_not_workfails the boot unless the program printed/DOCSand the relativecatsucceeded. The musl interpreter is loaded by path on every boot that runs a dynamic binary. - Host tests:
path_tests.c(normalisation, the root floor, NAME_MAX, the walk across two mounts, ENOTDIR and ENOENT, the parent rule) and, inlinux_abi_tests.c, the working directory, relative open, the at calls with a directory descriptor, the errors, and fork inheriting the directory. - Sabotage:
fs-path.txt(4, host),abi-paths.txt(2),fs-fat-longnames.txt(2), and a case for the new image row inio-filesystems-gate.txt; every one red for its reason. One of them only on the host: a directory descriptor ignored in favour of the working directory boots green, run to confirm it, because nothing the self-test runs opens relative to one. Breaking the long-name reader fails the image row and the dynamic binary - the loader's name is a long one. - Not done: symbolic links and ELOOP - no filesystem here has them, so
there is nothing to follow;
chrootstays refused, although the root is now a real field. Both belong to L1.
A5. Layouts and errno from Linux's headers
Objective. Invariant 8 for structures: stat, statx, dirent64,
timespec, sigaction, siginfo_t, sockaddr_*, rlimit, utsname,
termios.
- A host test compiled against the host's
linux/*.huapi headers asserts every offset and size the kernel writes, and every errno value it returns.
Done when the test exists and a sabotage that moves one field turns it red.
Status (2026-09-30): done.
- One declaration of Linux's layouts (
include/vibeos/linux_layout.h):stat,__kernel_timespec,new_utsname,sysinfo,sockaddr_in,iovec,rlimit64, the kernel'ssigaction, and the getdents64 record, with Linux's field names and padding named too - plus every constant a handler compares a user's argument against: the *at flags, fcntl commands, close_range, clone, wait4, mmap, prctl, arch_prctl, futex, rlimit, sigprocmask, socket and dirent-type numbers. The handlers used to write byte offsets and bare numbers (rec[18] = 4,STAT_OFF_UID 28u,domain != 2u); TIOCGPGRP and AT_FDCWD were spelled twice in two files, and the registry spelled EPERM and ENOSYS a second time. Each handler now fills a structure and copies it out once. - The file layer's numbers are Linux's by decision, and named so: the open
flags and mode bits were already
VIBEOS_O_*andVIBEOS_S_IF*; seek origins (VIBEOS_SEEK_*) and the console's ioctl requests (VIBEOS_IOCTL_*) joined them. getdents64 is the one file operation whose output is a personality's, and says so. - The test (
tests/kernel/linux_layout_tests.c) compiles against the host's uapi headers and makes 232 comparisons: every field's offset and size, every structure's size, every errno value the kernel returns, every signal number, flag and constant. What uapi does not carry -struct dirent64,AF_INET,SOCK_*,DT_*, and theS_IF*types thatlinux/stat.hwithholds from a glibc build - comes from the C library's headers, in a second file (linux_layout_libc.c), because the two sets of headers cannot share one. On a non-Linux host it says it was skipped; on Linux the includes are unconditional, so a runner without the headers fails to build rather than passing having compared nothing. - The test cannot fall behind the header:
check-linux-layout.py(incheck.sh) fails when a field, a structure's size or a Linux-valued constant is declared and not compared, and when a file underkernel/abi/defines a constant under Linux's own spelling. Its first run foundFUTEX_CMD_MASK, which is Linux's name for a different value (~(PRIVATE | CLOCK_REALTIME), not0x7F); it isVIBEOS_FUTEX_OP_BITSnow. - Sabotage:
abi-layout.txt(5: two fields swapped at equal size, a field widened, the dirent64 order,mem_unitwidened where the structure's size cannot tell, AT_FDCWD off by one),abi-errno.txt(2),dev-linux-layout.txt(3, against the check); all red for their reason. AT_FDCWD is named by the layout test and by the handler tests, which run first. - The sabotage tool had a hole, and two cases had never run.
sabotage.pydrops lines starting with#as comments, so an anchor that is a#definevanished, the case ran with an empty anchor, changed nothing and scored NOT RED - which is how the errno cases first came back.check-sabotage-anchors.pyskipped an empty anchor too. Both refuse one now, and\#at the start of a line is a literal#. That foundcore-dispatcher.txt's fence case andservices.txt's "only one service is started", which had never tested anything; both are escaped and red now - the fence case bycheck-reachable.py, the manifest case at boot asmissing:[CRASH] end: with one service started, svc-crash never runs and the crash record the gate waits for never comes. - Not declared, because nothing writes them yet:
statx(L1),siginfo_tand a Linuxucontext(the signal frame is this kernel's own layout, and SA_SIGINFO handlers get no siginfo - L2),termios(the console answers ENOTTY). Each arrives with the phase that writes it, and the check makes it arrive with its comparison. - Found on the way: getdents64's gap reason said "FAT names", which has been wrong since A4 gave FAT long names; names are cut at 15 bytes by the listing buffer, which is what the registry says now. Still L1's.
Part L - the syscalls, by capability
Each phase names its target programs. It is done when they run under the gate and their LTP cases pass, not when its rows exist.
L1. Files and paths (72 missing, plus 6 partial finished)
stat, lstat, access, pread64/pwrite64, fcntl, flock, fsync,
truncate, rename, rmdir, link/symlink/readlink, chmod/chown,
umask, utimensat, statfs, the whole *at family, dup3, preadv/pwritev,
statx, copy_file_range, sendfile, close_range, openat2, sync.
Extended attributes answer ENOTSUP from the filesystem.
A minimal terminal: the console becomes a TTY file type answering TCGETS,
TCSETS, TIOCGWINSZ and TIOCGPGRP/TIOCSPGRP, which every interactive shell
asks for first.
Programs: BusyBox's file applets and its test suite (ls -l, cp -a, mv,
find, tar, sort, grep -r, sed -i), sqlite3 on a file.
Where it starts from (2026-09-30). The syscalls are the small part. Only FAT
is writable, and only a whole file at a time: a descriptor buffers 512 bytes and
the file is rewritten from them when the last reference goes, so a program that
writes more gets short writes. The filesystem interface (vibeos/vfs.h) has
lookup, read, whole-file write, list, unlink and mkdir - no write at an offset,
truncate, rename, rmdir, links, attributes, statfs or sync. ext2, exFAT, NTFS
and ISO9660 are read-only. There is no wall clock (time is uptime) and no
credential model (L2). stat, rename, chmod and symlink need something
true to report before they are worth a row, so L1 builds that first:
- The filesystem interface grows the operations - write and truncate on a node, create, rmdir, rename, link, symlink and readlink, attributes (mode, owner, times, link count), statfs and sync - optional per driver, with the wrapper answering as Linux does when one is missing: EROFS from a read-only filesystem, EPERM from one that cannot represent the thing. The path walk follows symbolic links (ELOOP after 40) and answers lstat's question too.
- tmpfs (
kernel/fs/tmpfs.c): the filesystem with all of it - modes, owners, hard and symbolic links, timestamps, rename over an existing name, sparse files - mounted at/tmp. Portable, host-tested, and a nightly torture against a model, per the rule that every module gets one. - Writing through a descriptor: regular files write at their offset through
the node, with O_CREAT/O_EXCL/O_TRUNC/O_APPEND as Linux means them, and
pread64,pwrite64,preadv(2),pwritev(2),truncate,ftruncate,fsync,fdatasync,sync,syncfs,fallocate,fadvise64,readahead,copy_file_range, andsendfileserved rather than refused. A filesystem without in-place writes keeps the whole-file path, without the 512-byte ceiling. - FAT writes in place: write at an offset, truncate, rename and rmdir in the FAT driver, so the boot volume is a real filesystem too.
- Names and metadata:
stat,lstat,statx,accessand thefaccessats,renameandrenameat(2),rmdirand AT_REMOVEDIR,link,symlink,readlinkagainst real links,creat,mknod,openat2, thechmod,chownandutimefamilies,umask,statfs/fstatfs. - The rest of the file calls:
flockand fcntl record locks,getdents, getdents64 without its 15-byte names, the xattr calls (EOPNOTSUPP from every filesystem here),sync_file_range, close_range's UNSHARE. - A minimal terminal: the console answers TCGETS, TCSETS, TIOCGWINSZ and the process-group requests, with ECHO and ICANON honoured.
- The programs: the BusyBox file workloads of the corpus run in
/tmpunder the gate,sqlite3on a file, and the LTP cases for these syscalls staged and run.
Step 1 (2026-09-30): done.
vibeos_fs_node_tcarries mode, link count, owner and three times. The lookup wrapper zeroes the node before asking the driver and completes what the driver left out (a type fromis_dir, 0755 or 0644, one link), so five drivers written before the fields existed hand no stack garbage to stat.- Eleven operations beside the original six -
write_at,truncate,create,rmdir,rename(with NOREPLACE),link,symlink,readlink,setattr,statfs,sync- each optional, returning 0 or a negated errno. A missing one is EROFS from a filesystem that writes nothing, EPERM from one that writes but cannot do this, EOPNOTSUPP forwrite_at/truncate/createwhere the caller keeps a whole-file fallback.vibeos_fs_set_clockis the registration timestamps read from. The five drivers' tables are designated initialisers now; positional ones were one field away from a hole. vibeos_path_walkreplacesvibeos_path_lookupandvibeos_path_parent: it takes a root, a base directory and the path as written, follows links one component at a time (a relative target from the link's directory, an absolute one from the root, ELOOP after 40), applies ".." to the path resolved so far - solink/..is the parent of where the link points - and with CREATE answers for a missing last component where it would be made. NOFOLLOW and a trailing slash behave as Linux's do.- The Linux handlers walk through
linux_walk_at;newfstatathonours AT_SYMLINK_NOFOLLOW and reports owner, link count and times,unlinkatandmkdiratdo not follow the last component, exec runs the program a link points at, andreadlinkatreads real links (the/proc/self/exeanswer is kept, recognised before a walk would call/procmissing). - Errno values for what comes next (EACCES, EBUSY, EXDEV, EFBIG, ENOSPC, EROFS, EMLINK, ENOTEMPTY, ELOOP, EOPNOTSUPP), and S_IFMT, S_IFBLK, S_IFLNK - all compared with Linux's headers, which the layout check made non-optional.
- Host tests: 16 walk cases (links relative, absolute, chained, across a
mount, in the middle of a path, cyclic, dangling, a target shorter than the
link with path after it) and 12 for the wrappers. Sabotage:
fs-path.txtgained 5 cases andfs-vfs.txthas 4; all red. Removing the link bound is red with ENAMETOOLONG rather than a hang: every splice leaves a separator behind, so the pending buffer refuses a cycle too - a second defence, and not the right errno.
Step 2 (2026-09-30): done.
kernel/fs/tmpfs.c: 1024 inodes; a file's data in pages through sixteen direct pointers, one indirect and one double-indirect page (up to about a gigabyte), a page never written is a hole; a directory is a file of fixed-size records holding whole names. Modes, owners, three times, hard and symbolic links, rename over an existing name (NOREPLACE checked first, as Linux does; a directory cannot move inside itself), rmdir of empty directories, statfs with TMPFS_MAGIC. A freed inode's generation moves on, so a description left holding it gets ENOENT rather than the next file's bytes - the known gap is that the last unlink frees at once, before the last close.- It never allocates under its own lock. The allocator can reclaim, and the lock masks interrupts; so an operation takes up to three spare pages first, the locked code takes from them, and the rest go back after. Writes go a page at a time for the same reason. The host test's allocator fails the test if it is called with the lock held.
- Mounted at
/tmpat boot, 32 MiB at most, its pages through the admitted door for user memory; timestamps read uptime until L2 has a wall clock. The gate assertstmpfs_not_mountedfrom its own line, and the shell writes a file there and reads it back (tmpfs_round_trip_failed- the contents are the shell's arithmetic, so the echoed command cannot stand in for them). - A must-be-zero,
tmpfs_bad_record: a directory record naming a free inode, or one without its page, is refused as EIO and counted. - Host tests (
tmpfs_tests.c, 70 checks besides the helper) and a torture against a model that shares no code with it (tmpfs_torture.c: eleven paths, twelve operations, every return value, every byte read, every path's type, size and link count, and the pages and inodes held, after every round). It is the nightlytmpfs-torturejob, 300 seeds sanitized, which coverskernel/fs- the nightly-coverage baseline goes from 8 to 7. - Sabotage:
fs-tmpfs.txt, 11 cases, all red under the host tests and 10 of them under the torture as well. Two lessons on the way. "The source's record is not found again" went NOT RED twice: with the target last nothing moves, and with the target just before the source the stale slot is exactly the new end, so the wrong removal removes the right record - only a record between them exposes it. And the torture could not see a stale generation until it learned to keep a node across rounds, as an open description does.
Step 3 (2026-10-01): done.
- A regular file writes at its offset. On a filesystem with
write_ata description is "direct":writegoes to the node through a kernel page,readcomes from it, and the 512-byte buffer that used to be the whole file is not involved. A filesystem that only stores whole files - FAT, until step 4 - keeps the old path: a file opened O_WRONLY or O_CREAT is replaced on release from what was written. - open's flags mean what Linux means. O_CREAT makes the file at open, with the mode given less the process's umask; O_EXCL refuses an existing name and does not follow a link there; O_TRUNC empties at open; O_APPEND finds the end at every write, so two appenders interleave; O_RDWR reads and writes; O_DIRECTORY and O_NOFOLLOW are honoured; an access mode of 3 is EINVAL. O_WRONLY without O_CREAT on a missing file is ENOENT - it used to make one.
- File types gained four operations -
pread,pwrite,truncate,sync- and their tables became designated initialisers. A type without positions is ESPIPE to the positional calls, as on Linux.
- Twenty rows:
pread64,preadv,sendfile,fsync,fdatasync,creat,umask,sync,syncfs,sync_file_range,readahead,fadvise64andcopy_file_rangeDONE;pwrite64,pwritev,truncateandftruncatePARTIAL until FAT writes in place (step 4);preadv2/pwritev2PARTIAL (RWF_ flags are refused, not ignored);fallocatePARTIAL (it guarantees the size, not the space, and only mode 0 and KEEP_SIZE). The registry stands at 75 done, 22 partial, 193 missing. - sendfile and copy_file_range copy in the kernel, through one page whose
address goes where a user address usually goes: every file type copies
through
vibeos_uaccess_copy, which does not care whose memory it is. At most a megabyte a call, because a syscall runs with interrupts masked. - The umask is per process, beside the working directory, inherited by fork and exec.
- Host tests run on a tmpfs the fake kernel mounts at
/tmp, as the real one does: five groups, 70 checks - the flags, a 5000-byte write, the positional calls and their vector forms, truncation, durability, the advice calls, both kernel copies, and the whole-file filesystem still working. At boot the shell writes 820 bytes in twenty writes, appends five with>>and counts them withwc -c; the gate wants 825 (tmp_write_path_failed). - Sabotage:
abi-write-path.txt(7) andabi-file-calls.txt(6), all red. Three went NOT RED first, and each was read before anything was changed:- ftruncate on a read-only descriptor was refused for the wrong reason. A
read-only description was not "direct", so it fell into the whole-file
branch - and its
fstatand SEEK_END reported the size from when it was opened while another descriptor grew the file. A defect, fixed: "direct" is the filesystem's property, not the access mode's. - the whole-file writer taken for one that writes in place: the test only created new files, which take the create path; replacing an existing one is tested now.
- umask keeping bits above 0777: the test never set one.
- ftruncate on a read-only descriptor was refused for the wrong reason. A
read-only description was not "direct", so it fell into the whole-file
branch - and its
- A check had stopped watching.
check-user-access.pylooked inkernel/abi/linux/only; A3 moved read, write, getdents64, the console and the sockets tokernel/abi/files/, so the sites it was written for (M-050 to M-052) had been unwatched since. The case that hands read()'s user buffer to the filesystem went NOT RED, which is how it was found. It scans all ofkernel/abi/now: 53 casts, none raw.
Step 4 (2026-10-01): done.
- The driver left the architecture first (
kernel/fs/fat.c,include/vibeos/fat.h): it reaches the disk, its lock and the mount hook through registrations, so the host tests link the same file the kernel runs. - A file is its directory entry. The node id is where the 32-byte entry sits (sector and slot), which is the one thing FAT has that names a file without naming its path; every operation on a node reloads the entry from there and refuses a slot that no longer holds a file (ENOENT). The known gap is the one that follows from it: a description held across an unlink or a rename loses its file, and after the slot is reused it names another. FAT has no inode to keep; an in-memory one is a later step's work if a program needs it.
- Writes in place:
write_atgrows the chain as needed and zeroes the gap when the offset is past the end - FAT has no holes and a cluster comes back from the allocator with whatever its last file left;truncatecuts the chain or grows it with zeros; a write that runs out of space keeps what fitted (the clusters that were left) and reports the short count. - Names: an entry is created with its long name whenever the name is not
8.3 upper case - the pieces, the checksum, a
~nalias that no other entry in the directory has - and an 8.3 name in lower case is stored with the two NT flags rather than a long name, as Windows does. A directory grows a cluster when it fills (the fixed FAT16 root cannot, and says ENOSPC), and a name that used up the end-of-directory marker writes a new one. - rename, rmdir, setattr, statfs, sync: rename writes the new name before removing the old, replaces an existing file (NOREPLACE honoured), refuses a directory moved into itself, and updates a moved directory's ".."; rmdir wants it empty; setattr keeps the read-only bit and the modification time, and refuses an owner other than root (EPERM) - FAT has nowhere to put one.
- The whole-file path is gone. With no filesystem left that stores whole
files,
kernel/abi/files/regular.clost the 512-byte buffer, the write-back on release and the "direct" flag;pwrite64,pwritev,truncateandftruncateare DONE. The registry stands at 79 done, 18 partial, 193 missing. - Host tests (
tests/kernel/fat_tests.c): a volume the driver formats in memory, filled with 0xE7 first so nothing is zero by luck - names of every kind, in-place writes, gaps, truncation, a directory grown to forty long names, rename in each of its cases, times, statfs, a volume filled to its last cluster, three hundred create/unlink cycles in the root. - Somebody else's reader. Those tests can only say the writer and the
reader agree.
scripts/dev/verify-fat-mtools.sh(incheck.sh) has mformat make the volume and mcopy put a file on it, runs the driver over it (tests/kernel/fat_exercise.c), and compares what mdir and mcopy find with what the driver believes: names, contents, free space. mtools follows whatever the table says, soscripts/dev/fat-fsck.pychecks what it cannot - lost and cross-linked clusters, chains against sizes, "..", orphaned long names - and is run on mformat's own volume first. - At boot the shell writes 866 bytes on the boot volume in twenty-two
writes and counts them, writes and reads back a file under a long name
(
fat_write_path_failed), and cuts a two-cluster file with O_TRUNC. When the machine has stopped the gate runs fat-fsck on the image it left (boot_volume_inconsistent:<what>) and has mtools read the long-named file (mtools_cannot_read_what_the_guest_wrote). - Sabotage:
fs-fat.txt(15, all red under the host tests or mtools, each case says which),fs-fat-boot.txt(2, red),abi-write-path.txtre-pointed (6, red). What went NOT RED, and why:- Under mtools, three cases at once, because mformat's volume was zeros: an
unzeroed gap, an unzeroed directory cluster and a missing end marker all
read as correct on a disk that is zero wherever nobody wrote. The image is
filled with 'A' before mformat now, and fat-fsck has a
--dirtymode that overwrites everything past each directory's marker. - An unzeroed directory cluster, still: with room left in the old cluster the marker stays there and the new cluster is never read. The exercise fills a directory exactly to the end of its cluster. And then the missing marker - written down at first as unverifiable here - turned out to be what had been hiding it.
- The short write: the test's volume happened to have exactly the clusters
the write needed. It checks its own arrangement now (
0 < free < 4). - A sector that reaches the cache and not the disk, at boot: green, the
volume consistent and the file readable. Recorded in
fs-fat-boot.txtas something this environment cannot tell, with the likely reason.
- Under mtools, three cases at once, because mformat's volume was zeros: an
unzeroed gap, an unzeroed directory cluster and a missing end marker all
read as correct on a disk that is zero wherever nobody wrote. The image is
filled with 'A' before mformat now, and fat-fsck has a
- Still open: timestamps are uptime, so every file is dated 1980-01-01 until there is a wall clock (L2); the exec page cache is not told about a write to a file it holds.
Step 5 (2026-10-01): done.
- Thirty-two rows, twenty-nine in a new file,
kernel/abi/linux/names.c, and three infs.c:stat,lstat,statx;access,faccessat,faccessat2;rename,renameat,renameat2;rmdirand unlinkat's AT_REMOVEDIR;link,linkat,symlink,symlinkat,readlink;chmod,fchmod,fchmodat,fchmodat2;chown,fchown,lchown,fchownat;utime,utimes,futimesat,utimensat;statfs,fstatfs;mknod,mknodat;openat2. The registry stands at 108 done, 21 partial, 161 missing. - What Linux decides before a filesystem is asked is decided in the
handler, so every filesystem answers alike: a rename or a link across two
mounts is EXDEV, a mount's root is EBUSY to rename and rmdir, a directory
goes only over a directory and never into itself,
rmdirof "." is EINVAL and of ".." ENOTEMPTY (read off the string the program wrote - the walk has already resolved both), a hard link to a directory is EPERM, an empty symlink target ENOENT. What a filesystem cannot do it says itself through the wrappers - EROFS, or EPERM for a symbolic link or an owner on FAT - and nothing in the handlers asks which filesystem it is. - One user, root, until L2. No call refuses for want of permission, and
accessanswers as it does for root on Linux: W_OK is EROFS on a filesystem that writes nothing, X_OK is EACCES on a regular file nobody may run. st_devis the mount. Every filesystem reported device 0, so a file in/tmpand one on the boot volume with the same inode number were one file tocpandmv, which compare the pair to refuse copying a file onto itself. A mount's place in the table, from 1; 0 is a pipe or a socket.- A descriptor's file is walked again from its path (
linux_walk_fd): fchmod, fchown, futimens, fstatfs and every AT_EMPTY_PATH. The gap is the one a path for an identity always has - after a rename the description's path names nothing, or something else - and it is the same one step 4 recorded for FAT's nodes. - chown clears set-user-id on a regular file, and set-group-id when the
file is group-executable, as Linux does even for root.
mkdirpasses its mode, less the umask, where it used to drop it.utimensattakes UTIME_NOW and UTIME_OMIT and, with no path, is futimens. - statx fills the basic set whatever was asked and says so in
stx_mask; Linux has kept adding fields to the end of the structure, so only the part filled is declared field by field and the rest is "up to the size", compared as that (TAILin the layout test). Six structures and twenty-four constants joinedlinux_layout.h; the layout test makes 366 comparisons. - Partial, and why:
mknod/mknodatmake regular files only - a FIFO or a device node is EPERM, because no filesystem here has anywhere to keep one and a name that looked like a FIFO and opened as an empty file would be worse;renameat2refuses EXCHANGE and WHITEOUT (EINVAL, Linux's answer from a filesystem with neither);openat2honours RESOLVE_NO_MAGICLINKS only - the others are ENOSYS, which a caller already handles by falling back to openat, where EINVAL would say its arguments were wrong. - Host tests: four groups in
linux_abi_tests.con tmpfs, on the fake kernel's root (which can neither rename nor link) and on a filesystem mounted for the purpose that writes nothing. At boot the shell runs mv, ln, ln -s, chmod, mkdir, rmdir and stat in/tmpand the gate reads stat's own line,META_600_2_2(names_and_metadata_failed); then mv, mkdir and rmdir on the FAT volume (fat_rename_failed), which the gate's consistency check judges after the machine stops. - Sabotage:
abi-names.txt(31),abi-names-fs.txt(6),abi-names-boot.txt(2) and two more infs-fat-boot.txt, all red. Two went NOT RED first: RENAME_NOREPLACE left to the filesystem, and an empty symlink target accepted. tmpfs refuses both itself, so on tmpfs the handler's check could not be seen. What the check is for is a filesystem that cannot rename or link at all giving Linux's answer (EEXIST, ENOENT) and not its own (EPERM) - so both are asked of the fake's root now. A FAT rename that leaves the old name behind is invisible from inside the guest and red asboot_volume_inconsistent:cross_linked. - Still open: an open description does not follow its file across a
rename;
fchmodandfchownon a pipe or a socket are EINVAL where Linux changes an inode nobody can see; the remaining handler checks that tmpfs repeats (a file over a directory, a directory into itself) have no case of their own for the reason above.
Step 6 (2026-10-01): done.
- Locks (
kernel/fs/filelock.c,include/vibeos/filelock.h): one table, personality-neutral, of two kinds kept apart as Linux keeps them. Record locks are byte ranges held by an owner - a process forfcntl's F_SETLK, F_SETLKW and F_GETLK, an open file description for the F_OFD_ forms, both in one space so each sees the other's. An owner asking again replaces what it held: the table splits a lock whose middle is unlocked and joins two when the gap between them is filled, so an owner's locks are always disjoint and never two where one would do. Whole-file locks areflock's, held by the description. The table never waits and never allocates; it refuses with the owner in the way, and checks it has room for the worst case before it changes anything. - The handlers do the waiting, the way a pipe's reader waits, and stop for a signal. Before a process waits for a record lock the table is asked whether the holder is, through however many others, waiting for it: EDEADLK instead of two waits for ever. Linux looks for no deadlock among description-owned locks, and neither does this.
- Lifetimes, which are the hard part of POSIX's locks: closing any descriptor a process has for a file gives back every record lock the process holds on it; a process's locks end at its exit and are not inherited by fork; a description's locks - flock, OFD - go when the description does, so a dup and a fork share them. A lock at SEEK_END is where the file ends when it is taken; a length of 0 runs to the end wherever that goes; a negative one is the bytes before the start.
- Directories: the file layer hands out entries by position
(
vibeos_dirent_t, thereaddiroperation that replacedgetdents), and the record is the personality's - sogetdents64and the oldergetdentsare two formats over one reader, and a Windows directory query will be a third. Names are whole (they were cut at fifteen bytes), "." and ".." are in every directory once, each entry carries the inode number stat reports and its real type (a symbolic link was listed as a file, and a program that walks a tree trusts that), and the position is the description's own, solseek(fd, 0)rewinds and ad_offcan be returned to. - Extended attributes: the twelve calls say what Linux says of a filesystem that stores none - EOPNOTSUPP to setting, getting and removing, an empty list - after refusing what Linux refuses first, in its order: a flag, a name (ERANGE), a size (E2BIG), and the file itself, so a mistyped path is ENOENT and not "not supported".
- close_range's UNSHARE asks for nothing in a process of one thread and is honoured there. With threads it is still refused: a thread cannot hold a table apart from its process until L6, which owns that row now.
- Sixteen rows closed:
flock,getdents, the twelve xattr calls, andfcntlandgetdents64from partial to done. The registry stands at 124 done, 19 partial, 147 missing. struct linux_dirent, the old call's record, is declared by nobody - Linux keeps it to itself and no C library has used the call in years. A fixture this project wrote would only agree with the declaration this project wrote, so the layout test asks the host's kernel to list "/" with that call and reads the answer through our structure: the records have to chain, and "." and ".." have to be there with an inode and a directory's type in the last byte.- Host tests: the table on its own (
filelock_tests.c), four groups of handlers inlinux_abi_tests.c, and a torture in the nightly (filelock_torture.c, 300 seeds sanitized, three seeds incheck.shand in every host sabotage run). Its model has no ranges: a file is seventeen cells, an owner paints them, and every round compares the answer, a probe of every cell for every owner, and the number of locks held against the number of runs of equal cells - one more is a join that was missed. - At boot the ring-3 self-test takes a record lock and a flock, forks, and
the child finds both in its way (
file_locks_selftest_failed) - nothing else the boot runs takes a lock, and the BusyBox on the image has no flock applet; the shell lists a directory holding a long name and a dangling link, and the gate reads ls's own line andDIRS_4_1(directory_listing_failed). - Sabotage:
fs-filelock.txt(15),abi-locks.txt(20),abi-readdir.txt(5),abi-xattr.txt(6) and one more infs-file.txton the host;abi-locks-boot.txt(2) andabi-readdir-boot.txt(2) at boot. All red, each for the expectation it names. One went NOT RED first: a pipe that cannot be told from another pipe. The test locked one pipe, and one of anything has an identity whatever it is computed from; it locks two now, and the other end of the first. - A new must-be-zero,
filelock_unlocked: the table used with no lock registered, as the mount table counts it. - Still open: an exec keeps every record lock the process holds, those on its close-on-exec descriptors included, where POSIX gives those back as the descriptors close; a lock is found by the filesystem's identity for the file, so on FAT it follows the directory entry (step 4's gap); mandatory locking does not exist, as on current Linux.
Step 7 (2026-10-01): done.
- The console is a terminal (
include/vibeos/tty.h,kernel/abi/files/console.c). It was a character device that answered ENOTTY to every terminal question but the foreground process group - by design, and truthfully - read a line at a time, echoed always, and handed back whatever had been typed when the keyboard went quiet, half a line included. It has modes now, one set for the one terminal, in Linux's numbering as the open flags are; another personality translates its console modes into them. - What is honoured: ICANON - a read returns one finished line, erasable until Enter, and waits for it; without it a read returns as soon as MIN bytes are typed, with MIN 0 at once. ECHO, ECHOE, ECHONL. The erase, kill and end-of-file characters. ICRNL on input; OPOST with ONLCR on output. Typed- ahead input is taken from the keyboard only as far as a read needs it, so what comes next is read in whatever mode the terminal is in by then. Everything else is stored and given back as set - which is what lets a program save the modes and restore them - and no more: the interrupt character interrupts with ISIG clear, and TIME is not a timer.
- The requests are the personality's, as their structures are: TCGETS,
TCSETS, TCSETSW, TCSETSF, TIOCGWINSZ and TIOCSWINSZ on the console, ENOTTY
on anything else; and four any descriptor answers - FIOCLEX, FIONCLEX,
FIONBIO, FIONREAD (a file, a pipe, the terminal).
ioctlstays PARTIAL for what is left: ISIG, TIME, TCFLSH, TIOCSCTTY and the rest. - A terminal changes what every program does, and the boot found out at
once. A C library buffers a terminal by lines and writes a line as two pieces
of one
writev, which this kernel wrote as two writes:THREADS_C5_EXEC_OKarrived asTHREADS_C5_EXECand_OKwith a log prefix each, and five gate assertions failed on output that was correct. Awritevthat fits a page is gathered and written once, as Linux writes it.lslays its names out in columns for the width TIOCGWINSZ reports. - And a shell on a terminal is interactive. BusyBox's ash prints a prompt,
puts the terminal in raw mode and edits its own command line, asking
poll()before every key - a syscall this kernel did not have, so the first boot printed "poll: Function not implemented" once per character of the self-test.+idoes not turn that off in BusyBox. Sopollis here ahead of L4, as much of it as the terminal needs: each file type says what it can do without waiting (ready: the console, a pipe; a file is always ready), and the wait is the pipes' wait. PARTIAL, owned by L4: a socket always reports ready. The self-test's shell session is a real interactive one now, every command typed a key at a time through raw mode. - A defect three steps old, found by reading the log this step changed.
The native shell's
writeopened its file withO_CREATalone, which is read-only. That worked while a file was replaced on release from whatever had been written; from step 4, when a write went into the file at once, the write was refused, nothing looked at its result, the shell printed "written", and the file was empty. Everycat DOCS/NOTES.TXTin the boot - five of them, four programs - printed nothing for three steps, and the gate stayed green: the line "persistent hello" moved the gate's reported phase and was asserted by nothing. It is asserted now (notes_file_not_read_back, five times), the shell opens to write and checks what write returns, and the sabotage case is the defect as it shipped. - Host tests: the fake kernel has a keyboard that can be typed at
(
kf_type); two groups cover the modes and the requests, poll and the gathered writev. At boot: the ring-3 self-test asks TCGETS and expects a canonical, echoing terminal (it used to expect ENOTTY); the shell's prompt (shell_not_interactive); and the word "echo" arriving as four writes of one character - the shell showing each key itself, the kernel's echo off (terminal_raw_mode_failed). - Sabotage:
abi-tty.txt(15),abi-tty-fs.txt(13),abi-tty-pipe.txt(2) on the host;abi-tty-boot.txt(2),abi-tty-console-boot.txt(1) anduser-sh-boot.txt(1) at boot. Three host cases went NOT RED first, and each was the test's:- poll waits with a timeout of 0. The fake kernel abandons a call that waits, and an abandoned call leaves 0 behind - which is what poll returns when nothing is ready. The test compared the number. It asks now whether the call returned at all.
- poll reports reading to whoever asked about writing. Nothing asked about writing on a descriptor that could be read.
- a read returns every finished line at once. The terminal takes input only until one line is finished, so two never waited together - except after raw mode, where FIONREAD takes everything typed. That is the arrangement the test makes now.
- Still open: ISIG and TIME; TCSETSF throws away the terminal's own line and not what is still in the keyboard's queue behind it; there is one terminal and no /dev/tty to name it; a program that dies in raw mode leaves the next one in it, as on a real terminal, and nothing here resets it.
Step 8 (2026-10-01): done, except LTP, which waits for L3.
- The corpus runs on VibeOS and is compared with Linux.
tests/corpus/run-l1.shruns fifteen workloads - twelve of BusyBox's (the shell, ls, cp/mv/rm, find and grep, grep -r, tar, sort/sed/awk, sed -i, touch/chmod/ln/stat, mkdir/rmdir, a pipeline, mv), SQLite on a file and in memory, and a Lua script - and prints every line of every answer tagged with the workload's name.scripts/dev/corpus-expect.shruns the same script under the same BusyBox on Linux and keeps what it printed (tests/corpus/l1-expected.txt, 100 lines). The boot gate stages the script on the boot volume, the self-test's shell runs it in/tmp, and the gate compares the two line for line:corpus_answers_differ:<workloads>,corpus_did_not_finish. All fifteen match. - The oracle is Linux. Nobody wrote down what
ls -laortar tfshould print; the commands are shaped so that what two machines may differ in - the date, the owner's name, a directory's size, the order it lists in - is not in the answer, and everything left has to be identical. The nightly re-runs the script on its own Linux first (corpus-expect.sh --check), so a BusyBox that starts answering differently is named before the boot is compared with a stale file. - SQLite and Lua are staged when
corpus-build.shhas built them, which a plain build does not do. Without them their workloads answer "absent", which the gate accepts and says (corpus_absent=on the verdict line); withVIBEOS_SMOKE_CORPUS=require, which the nightly's corpus job sets, absent is a failure.sqlite3makes a database, indexes it, vacuums it, and a second process reopens it and finds the first one's rows - record locks, pread and pwrite, fsync, truncate - with no syscall missing. - What running real programs found, none of it in the syscalls this step
set out to prove:
execveof/proc/self/exe. BusyBox's shell runs every applet not built into it by executing "the program I am" under the applet's name. That was refused as not found, the shell fell back to a file called after the applet on its PATH, and four such files had been staged - which is whycatandlsworked andsort,sed,tarandawknever could have.readlinkhas answered for that name since A4;execvedoes now.- A forked child did not know what program it was. Fork copied the image's
entry point and nothing else of it, so the path it was started from - what
/proc/self/exeanswers - was the slot's last tenant's. With the fix above the shell's child executed "itself" and started the thread tests, which forked children that did the same: the boot did not end. The whole image is copied now. - The user stack was sixteen kilobytes and did not grow. BusyBox's
sedhas a function with an 18 KiB frame; every workload prints throughsed, so every workload ended in a segmentation fault. 256 KiB, mapped at exec. Growth on demand is L3's. - Files on FAT were not executable. Reported 0644 where Linux reports a FAT
volume's files 0755;
execnever asked, and the script's[ -x ]did - so SQLite and Lua were on the volume, judged not runnable, and reported absent. This one was only visible because "absent" is printed in the verdict: the boot was green. /dev/nulldoes not exist (L2, with/procand/etc); the script does not use it.
- LTP is staged and run, and does not run yet.
scripts/dev/ltp-run.shstages any of the 1,530 built tests on the boot volume, runs them after the workloads and reads each one's verdict out of the log. Every one is "broken" before its first check: LTP's harness keeps its results in a page shared between the test and the child that runs it, mapped from a file -mmap(MAP_SHARED)of a descriptor - and file-backed mappings are L3's (as is keeping a shared page shared across fork). None of the built tests uses LTP's older harness, which did not need it. So L1's 416 tests are an oracle that exists and cannot be consulted until L3; what proves L1 until then is the corpus above and the handlers' host tests. Recorded under L3, which now owes L1 that run. - Sabotage: five cases at boot, all red. execve refusing
/proc/self/exeiscorpus_did_not_finish- the shell cannot startshitself; arenamethat reports success and does nothing iscorpus_answers_differ:bb-cp-mv-rm+bb-sed-inplace+bb-mv, exactly the three workloads that move files; fork copying only the entry point is a boot that never ends; a four-page stack is 23 ring-3 faults; a self-test that does not run the script iscorpus_did_not_finish. - One boot in eighteen failed, with M-070's signature - the open swap-slot double release under the reclaim load - and for the first time with that finding's instrumentation in the log: init killed on a swapped-out entry whose slot was already free when its address space was torn down. Not this step's defect and not chased here; the evidence and what it narrows are in the tracker under M-070. What this step may have changed is how often: sixty more anonymous pages per process is sixty more for reclaim to take. Twelve boots of twelve were clean afterwards.
L1 is closed as far as it can be without L3: the registry stands at 124
done, 20 partial, 146 missing; 23 of the corpus's 25 workloads have nothing
missing, and fifteen of them are run and compared on every boot. Open, and
written where they belong: LTP (L3); mknod of a FIFO, renameat2's EXCHANGE,
openat2's RESOLVE flags, ioctl's ISIG and TIME (L1 partials nothing has
asked for yet); a description that does not follow its file across a rename.
L2. Processes, credentials and time (47)
Sleeping and timers (nanosleep, clock_nanosleep, alarm, setitimer,
POSIX timers), resource limits and usage, waitid, process groups and
sessions completed, the rest of signals (sigaltstack, rt_sigsuspend,
rt_sigtimedwait, rt_sigqueueinfo, SA_SIGINFO with a real siginfo_t),
execveat, clone3, pidfds.
The credential model arrives here: real, effective, saved and filesystem ids, supplementary groups, and permission checks in L1's file operations. Setting the clock stays EPERM until there is a privilege to allow it.
A minimal /proc (/proc/self/exe, /proc/self/fd, /proc/self/maps,
/proc/meminfo, /proc/cpuinfo) belongs here as well: it is a filesystem, not a
syscall, but ps, top and most C libraries read it.
Programs: BusyBox sh scripts with trap, timeout, sleep, ps, time;
LTP's signal and process cases.
L2 was taken after L3, because L3's oracle said what it wanted most: of the
LTP tests for L1 that did not pass, most stopped at something of L2's - a
credential call, alarm, a file under /proc or /dev. Seven steps, in the order
that pays that debt first:
- Credentials: who a process is, the calls that change it, and permission checks in L1's file operations.
- Signals completed: a fault delivered to a handler that asked for it
(SIGSEGV, SIGBUS, SIGFPE, SIGILL) with a real
siginfo_t,sigaltstack,rt_sigsuspend,rt_sigpending,rt_sigtimedwait, the queueing calls,pause, and SIGCHLD raised when a child ends. - Timers:
alarm,setitimer, POSIX timers,clock_getres,gettimeofday,times- which is also LTP's own timeout. Setting the clock stays EPERM. - Limits and usage:
getrlimit/setrlimit/prlimit64enforced,getrusage, priorities,personality. - Processes:
waitid,getpgid,execveat,clone3, pidfds. - /proc and /dev:
/proc/self/*,/proc/<pid>,/proc/cpuinfo,/proc/mounts;/dev/null,/dev/zero,/dev/urandom,/dev/tty. - The programs: the corpus's shell workloads with
trap,timeout,ps,time; LTP for L2's own calls.
(nanosleep and clock_nanosleep were done in L3 step 3, which could not run
its oracle without them.)
Step 1 (2026-10-02): done.
- A process is somebody. Real, effective, saved and filesystem ids for
user and group, and thirty-two supplementary groups, in the process's state
(
include/vibeos/cred.h); all zero - root - for a process nobody changed, a copy for a forked child, kept across exec. The rules for changing them are Linux's without capabilities ("privileged" is effective user 0) and live in one portable file of pure functions,kernel/sched/cred.c. - Sixteen calls:
getuid,geteuid,getgid,getegidreport them (they answered 0);setuid,setgid,setreuid,setregid,setresuid,setresgid,getresuid,getresgid,setfsuid,setfsgid,getgroups,setgroups.setuid(1000)used to be EPERM: there was one identity. - The file operations ask who is asking. The walk needs search permission
on every directory it looks a name up in;
openneeds read or write by its flags (and write to truncate - asked before anything is cut); a name is made or removed only in a directory the caller may write, and in a sticky one (/tmp) only by the file's owner or the directory's;chmodand a chosen time are the owner's,chownthe superuser's except for the owner's own groups;truncate,chdirandexecveask for write, search and execute. What a process makes is its own.accessanswers for the real user andAT_EACCESSfor the effective one. The superuser is refused nothing but running a file with no execute bit at all - and for it nothing more is looked up than before. - Signals: a process may signal another only if it is the superuser's or the target's real or saved user is its real or effective one - on top of the session rule already there.
- Not done, and said: capabilities; the set-user-id bit on exec; a new
file taking its directory's group under a set-group-id directory;
setgroupswith more than thirty-two groups (the registry's gap); FAT has no owners, so everything on the boot volume is root's and nobody else may write there. - At boot the self-test's child gives up root for good and is refused
root's file, root's directory and root's name in /tmp, owns what it makes,
and cannot take root back (
credentials_selftest_failed). - Sabotage:
sched-cred.txt(8),abi-cred.txt(8),abi-cred-names.txt(6) against the host tests,abi-cred-boot.txtandabi-cred-exec.txtagainst the boot; all red. One went NOT RED first - "the owner's bits do not decide for the owner" - because no test had an owner whose own bits were the stricter ones; there is one now. And the boot's own case went NOT RED: the child was refused creating a file in root's directory, which the directory's write bit refuses with or without the walk's search check; it reads a file anybody may read there now. - Found by a failed boot, not by a test: exec built the new process state
without the credentials, so a program was whoever had held the slot before -
one boot in six, root's shell was the self-test's user 1000 and every create
on the boot volume was "Permission denied". exec copies them, a new slot
starts as root, and the self-test's child now runs BusyBox
test -ron root's file and must see it refused (abi-cred-exec.txt, red). Nine boots of nine after the fix. - LTP, L1's 382 again: 154 passed (131 before it), 16 failed, 144 broken, 65 not applicable, 2 did not run. What is left is mostly a loop device,
alarm, and files under /proc and /dev.
Step 2 (2026-10-03): done.
- The frame is Linux's. A handler is entered with the signal, a
siginfo_tand aucontext- the interrupted registers by Linux's names, the mask to return to, the alternate stack - and the vector registers in an FXSAVE area thatfpstatepoints at.rt_sigreturnreads the frame back as the program left it, so a handler that moves the saved rip moves where the program resumes; the architecture still decides what of it may be trusted (ks_regs_set: selectors and privileged flags forced, a resume address outside user memory refused, H-018). Until this step the frame was private to the kernel - a magic, the mask, the raw trap frame - which served a handler that only returns, and nothing that asks why or where. The registers cross the boundary by name (vibeos_uregs_t), the frame's layout is the Linux layer's, and the layouts are compared with the host's headers. - Why a signal came travels with it (
include/vibeos/siginfo.h): who sent it, which fault, how a child ended - facts, which the Linux layer puts intosi_codeand the rest. Raising and taking are one critical section each (ks_signal_send,ks_signal_take), under a lock per task with interrupts off. A blocked signal is kept even when ignored, as Linux keeps it, sosigtimedwaitcan take it. - A fault goes to a handler that asked for it. SIGSEGV, SIGBUS, SIGFPE and
SIGILL with
si_addrand the rightsi_code, the trap number, error code and cr2 in the frame; blocked or ignored, it kills as before. Nothing about a fault the program takes is printed as a trap:[SIG] fault taken by the program's handler. The null page of a Linux program is present - the kernel's identity map lies under the low window - so the CPU says "protection" where Linux says "nothing mapped"; the program is told whether it had a mapping. - Calls:
sigaltstack(withSS_AUTODISARM),rt_sigpending,rt_sigsuspend(the handler returns to the program's own mask, not the temporary one),pause,rt_sigtimedwait,rt_sigqueueinfo,rt_tgsigqueueinfo;SA_ONSTACK,SA_NODEFER,SA_RESETHANDhonoured. - SIGCHLD is raised when a child ends, with its pid, uid, status and how it ended. It never was.
- Not done, and said: a real-time signal queued twice is delivered once
(the registry's gap on 129 and 297); signals sent to a process go to its
leader rather than to any thread that does not block them; SIGCHLD on stop
and continue,
SA_NOCLDWAIT, and a parent that ignores SIGCHLD reaping its children automatically. - At boot the musl program that already checked handlers now also recovers
from a fault on its alternate stack by moving the saved rip, keeps a vector
register across a handler that overwrites it, reads
sigqueue's value, takes signals withsigtimedwait, waits insigsuspendandpause, and is told of its child's end by SIGCHLD (signal_l2_checks_failed). - Sabotage:
abi-signals.txt(10) andabi-signal-calls.txt(8) against the host tests;abi-signals-boot.txt(3),abi-signals-fpu.txtandabi-signals-value.txtagainst the boot; all red. One went NOT RED first - a fault the program blocks offered to it anyway - and correctly: delivery skips a blocked signal and the task is killed either way; what the check keeps from happening is a signal left pending in a task about to die, which the test now asks about. Nine boots of nine after it. - LTP: not measured in this step. The first test of the signal list, pause01, waits for its child to show as sleeping in /proc/
/stat (step 6), and LTP's own timeout is setitimer (step 3), so the first test that waits forever takes the rest of its boot: 3 of 48 reported (kill08 passed; kill05 needs System V shared memory; kill06 failed with ESRCH killing a process group, not yet looked into). Measured again with step 3, when the timeout works.
Step 3 (2026-10-03): done.
- Timers that belong to a process (
kernel/sched/ptimer.c, portable, host-tested): one table for the machine with a lock of its own, armed by the process's calls on any core, counted down by every core's tick and fired by the clock owner's. The callback that raises the signal runs outside the table's lock, because it takes the scheduler's; and a timer whose process is gone is counted (ptimer_orphan, must be zero) rather than fired at whoever has the pid next. The exit path takes a process's timers with it; exec takes its POSIX timers and leavesalarm, as Linux does. - Calls:
alarm,setitimer,getitimer(real, virtual and profiling);timer_create,timer_settime,timer_gettime,timer_getoverrun,timer_deleteon the machine's clocks and on the process's and a thread's CPU time, withSIGEV_SIGNAL,SIGEV_NONEandSIGEV_THREAD_ID;clock_getres,gettimeofday,times;clock_gettimeon the CPU-time clocks.clock_gettimeused to answer every clock number with uptime, so a program asking for its CPU time got the wall clock; an unknown clock is EINVAL now. Setting the clock is REFUSED (EPERM). - What the tick can tell: a tick is the resolution, and
clock_getressays so. CPU time is charged by the tick to whatever each core was running, as user time when it interrupted ring 3 - which is whatITIMER_VIRTUALneeds. A timer never fires early: it is armed a tick late, and that tick is not reported back. - Overruns are Linux's: an expiry that finds its timer's signal still
pending adds to that signal's count, which
si_overrunandtimer_getoverrunreport; periods that went by with no tick to fire them count too. - What LTP found once its timeout worked, none of it in the timer code:
a thread's id did not name its process for
killandrt_sigqueueinfo; a signal to a process already exiting was EINVAL instead of 0;tgkilllooked up ids that are not positive;waitpid(INT_MIN)was ECHILD, not ESRCH; SA_RESETHAND cleared SA_SIGINFO;rt_sigactionandrt_sigprocmaskaccepted a sigset of any size; andgetpgiddid not exist - musl'sgetpgrp()isgetpgid(0), so every musl program's process group was -ENOSYS (taken early from step 5). And the CPU time of a process in a recycled slot included everybody who had held the slot: the scheduler's accounting is per slot, so each task now records where it started (cpu_base).signal06signals itself thirty thousand times and the console line per signal ran its boot out of time; only the first 64 are printed now. - Not a kernel defect: LTP's x86-64
rt_sigactionwrapper fetches its restorer from the C library'ssigactionold action, and musl does not fill that field, sort_sigsuspend01andrt_sigaction01return through an uninitialised pointer. Built against glibc they would not. - Not done, and said:
times()reports no system time, and a thread that has exited takes its CPU time with it (the registry's gap on 100); the CPU clocks of another process (negative clock ids);SIGEV_THREADis the C library's and reaches the kernel as a signal to the process. Signal 64 has no bit in a 64-bit mask numbered by signal (sighold02,sigrelse01).clock_settimestays refused, and four LTP tests say root should be able to set the clock: an offset to the timer's uptime would do it, the day a program needs it. - At boot the musl signal program waits in
pauseforalarm(1), counts four expiries of a periodicsetitimer, spins untilITIMER_VIRTUALandITIMER_PROFfire, takes a POSIX timer's signal withsigtimedwaitand checksSI_TIMERand its value, waits for a timer on its own CPU time, usesSIGEV_THREAD_ID, and reads the clocks (timers_did_not_fire); a child that arms an alarm and exits leaves nothing behind (ptimer_orphan). - Sabotage:
sched-ptimer.txt(10),abi-timers.txt(9) andabi-cpu-base.txtagainst the host tests, with four more inabi-signal-calls.txtand one inabi-signals.txtfor what LTP found;abi-timers-boot.txt(2) andabi-timers-tick.txtagainst the boot; all red. Two went NOT RED first, both tests right about the outcome and wrong about the arrangement: the wall clock and the process's CPU clock both read seven ticks, since the process had done nothing but run; and a bad flag was tried together with a time that was not one. Nine boots of nine after it. - LTP: the timer tests and step 2's signal tests together (tests/corpus/ltp-l2.txt's subset, 70 tests): 37 passed, 1 failed (times03: no system time), 24 broken, 7 did not run (kill10 takes the rest of its boot: step 5). Before this step the same list ran 3 of 48. Of the broken: /proc/
/stat and /proc/cpuinfo (step 6), setrlimit (step 4), clock_settime refused, System V shared memory, signal 64, and LTP's musl restorer.
Step 4 (2026-10-03): done.
- Limits (
kernel/abi/linux/limits.c): Linux's sixteen, soft and hard, in the process state - a child has its parent's, exec keeps them, and the first process starts with Linux's defaults where this machine can honour them (linux_procstate_defaults, called by the architecture'shw_procstate_new: the third writer every field of the process state has).getrlimit,setrlimitandprlimit64(on another process too, for the superuser or a caller whose ids match) with Linux's rules: soft above hard is EINVAL, raising a hard limit is the superuser's, NOFILE stops at what the descriptor table holds. - Enforced where the mechanism already was: NOFILE is the descriptor
table's own limit; FSIZE cuts a write at the limit in the one place every
regular-file write passes, and past it is SIGXFSZ and EFBIG; DATA stops
brk; NPROC refuses fork and clone for a user who is not root; CPU is two timers in the process-timer table (step 3) on the process's CPU time - SIGXCPU at the soft limit and every second after, SIGKILL at the hard one. The rest are kept and reported, and the registry says so. getrusagereports CPU time (all of it user time);getpriorityandsetpriorityare the scheduler's nice, answering 20 - nice raw as Linux does, with Linux's rules for who may renice whom and RLIMIT_NICE's allowance;personalityis kept and reported, and changes nothing.- Found on the way, and not in this step's code: the scheduler's policy had
never run on the machine (M-084).
kernel/sched/sched_policy.c- classes, nice-weighted fairness, affinity - was written on 2026-09-02, host-tested and tortured, and the architecture admitted every task into it and asked it to pick on every tick. It never initialised it. Every admission into a table of no slots was refused, every refusal was ignored, the picker returned nothing and the machine fell back to round robin; nor was the policy ever charged the virtual time it picks by, so initialising it alone would have picked the lowest slot for ever. setpriority returned 0 and changed nothing, which is how it was found. The architecture initialises it and charges it on every tick now; a refused admission issched_admit_refused, must be zero, and the gate asserts the policy was charged (sched_policy_never_charged). - Not done, and said: RSS, AS, MEMLOCK, STACK and the rest are kept, not
enforced (RLIMIT_AS is the registry's gap on 160 and 302);
getrusagehas no resident-set high-water mark (98); no personality flag changes behaviour (135). - At boot SIGNAL.ELF runs into NOFILE, FSIZE (dies of SIGXFSZ, and with it
ignored gets EFBIG) and CPU (SIGXCPU after a second of running), and checks
priorities,
getrusageandpersonality(limits_not_enforced). - Sabotage:
abi-limits.txt(12),abi-limits-paths.txt(2),abi-limits-mm.txtandabi-limits-fork.txt(3) against the host tests,abi-limits-boot.txtandsched-policy-live.txt(2) against the boot; all red. Eighteen boots of eighteen after it - double the usual, because the scheduler's policy going live changes every program's scheduling. - LTP: the limit tests (22): 17 passed; the rest need 512 MB free (getrusage03), /proc/cpuinfo (getrusage04, step 6), sched_getaffinity (nice05, L6), select (personality02), /bin/true on the volume (setrlimit04), and useradd (setpriority01, which then waits out its timeout - not looked into). With step 3's 70 alongside: 54 of 94 passed.
Step 5 (2026-10-03): done.
- One way to wait. wait4 and waitid share one engine, which selects every
child, one by pid, or a process group's. wait4 used to hear only "any" and
"this pid":
waitpid(0)andwaitpid(-pgid)- a shell's job control - were matched as pids and answered ECHILD. wait4 fills its rusage now.waitidreports what ended as a siginfo (CLD_EXITEDorCLD_KILLED, the pid, the user it was when it ended, the status), honoursWNOWAIT- look without reaping - andWNOHANGwith a record of zeroes, and takes a pidfd asP_PIDFD. - Pidfds (
kernel/abi/files/pidfd.c): a description that names a process by its pid and the tenancy of its slot, so that a pid reused after the reap is not mistaken for it - the reason a program holds one. Readable once the process has ended.pidfd_open(a process, not a thread; always close-on-exec) andpidfd_send_signal(kill, or sigqueueinfo with a siginfo; ESRCH once the process is reaped, whoever has the number now). It is what a Windows process handle is, which is why it lives with the file types and not in the Linux layer. clone3is clone's two paths with the arguments in a structure - a thread, or a fork - plusCLONE_PIDFD;execveatruns a program relative to a directory descriptor, or the file a descriptor names withAT_EMPTY_PATH, and refuses a link underAT_SYMLINK_NOFOLLOW.- Not done, and said: stopped and continued children are never reported
(waitid's gap);
clone3, likeclone, makes no process that shares its parent's memory and takes no chosen pid or cgroup (its gap). - At boot SIGNAL.ELF looks at a child with
WNOWAITand then reaps it, waits for a group, signals a child through a pidfd, sees the pidfd become readable and waits for it withP_PIDFD, runs BusyBox throughexecveaton a descriptor, and forks withclone3andCLONE_PIDFD(process_calls_failed). - Sabotage:
abi-processes.txt(6),abi-pidfd.txtandabi-pidfd-calls.txt(3) against the host tests,abi-processes-boot.txtagainst the boot; all red. Six boots after it: 6 of 6 clean. - LTP: the step's own 41 (
build-gcc-Release/ltp-l2s5.txt): 12 passed, 2 failed, 26 broken, 1 not applicable. It found two defects in this step's clone3 - the exit signal a caller names is ignored and SIGCHLD sent instead (clone301), and five of the argument checks Linux makes are missing (clone302) - and two in older layers that decide most of the rest. Eight tests waited out their timeout; waitid04, the one read, passed its checks and then hung in LTP's checkpoint, a futex shared between processes through a MAP_SHARED page, which the futex table keys per process. And an orphan is never given to init here, so the children of a test killed by its timeout stay zombies nobody reaps: the task table filled, and every fork after that was EAGAIN (seven tests). All three are fixed in the next commits. The rest want /proc (step 6), unshare, cgroups, a loop device, core dumps, ns_last_pid, or LTP resources that are not staged (execveat01, execveat02).
Step 6 (2026-10-04): done.
- /proc is about processes (
kernel/fs/procfs.c, rewritten).selfis a link to the asking process's directory, and each process has one:stat(Linux's 52 fields),statm,status,cmdline,comm,maps,mounts, the linksexe,cwdandroot, andfd/, a link per descriptor to what it was opened as. The machine hascpuinfo(CPUID under Linux's names, a clock measured from the TSC, the brand that tells LTP it is virtualised),mounts,version,uptimeandloadavg. The filesystem formats and keeps nothing; what it says about processes it asks of a source in the Linux layer (kernel/abi/linux/procsrc.c), which copies it out under the locks that hold it. A file is generated straight into the reader's buffer at any offset, never whole on a kernel stack. - /proc/self/exe is a walk. execve and readlink recognised the name by its spelling, because there was no /proc to hold it; both special cases are gone, deleted rather than generalised.
- A process that waits shows S. Every wait here keeps its task runnable, so the task's state could not say; a wait's block point marks it and the dispatcher clears the mark when the call returns. LTP's harness waits to see exactly that before it signals a child that pauses.
- /dev (
kernel/fs/devfs.c):null,zero,full,random,urandom,tty,console, and the linksfd,stdin,stdout,stderr. A node is a type and a number; what it opens as is decided by the number in the file types (kernel/abi/files/chrdev.c), and/dev/ttyis the console's own description, which answers as a terminal. stat reports the device number. - Random numbers (
kernel/core/random.c): a ChaCha20 key everything is mixed into and every read replaces, tested against RFC 8439's vector. Fed by RDRAND when the processor has it and by every core's timer interrupt - ready, under QEMU's TCG processor which has no RDRAND, within a second of the timer starting.getrandomwaits until then (EAGAIN with GRND_NONBLOCK, what there is with GRND_INSECURE); it answered ENOSYS before, under a registry row that said done./dev/randomwaits,/dev/urandomdoes not. - What step 5's LTP run found, fixed. A futex word on a MAP_SHARED page is the page's, the same word in every process that maps it - LTP's checkpoints - and FUTEX_WAIT's timeout is honoured; both had been missing since L3 made shared mappings, under a comment that said there were none. A process's children go to init when the last of its tasks ends: none ever did, so every child of a killed test stayed a zombie holding its slot. clone and clone3 send the exit signal they were given, and clone3 checks what Linux checks. And exit clears a task's pointer to its process state under the scheduler's lock before giving its reference back, so whoever finds the state under that lock - /proc now, prlimit before it - can hold it.
- At boot SIGNAL.ELF reads its own /proc files, sees a paused child as S,
uses /dev/null, zero, full and urandom and getrandom, waits on a futex in a
shared file page from another process, is sent SIGUSR2 by a child cloned with
it, and sees an orphan adopted (
proc_dev_checks_failed). - Sabotage:
core-random.txt(4),fs-procfs.txt(6),fs-devfs.txt,abi-chrdev.txt,abi-futex-shared.txtandabi-clone3-checks.txt(3 each) against the host tests,abi-procdev-boot.txtagainst the boot; all red. The pool's key not being replaced after a read is not a case, and the case file says why: nothing a caller can see changes. Six boots after it: 6 of 6 clean. - LTP: the step's own tests with step 5's again (
build-gcc-Release/ltp-l2s6.txt, 79): 41 passed, 9 failed, 21 broken, 4 not applicable, 4 did not run (kill10 still takes the rest of its boot). Step 5's 41 among them: 23 passed, 12 before. What it found, for step 7: a process group left orphaned with stopped members is never sent SIGHUP and SIGCONT, as POSIX says it must be - waitpid13's child moves half its children into a group of its own, they stop themselves, LTP's kill of the test's group does not reach them, and they hold their slots until a fork is EAGAIN (pause01);/proc/<pid>/task, which futex_wait03 reads to see its thread asleep and waits out its timeout without; a/proc/<pid>directory used as a pidfd (pidfd_send_signal01, 02); /dev/random's ioctls (ioctl07); and one clone3 check more (clone302's invalid pidfd). Capacity, not defects: waitpid03 forks 25 children and futex_wake02 clones more threads than a process may have, on a table of 32 slots. The rest want stopped and continued children reported (waitid07, waitid08, waitpid08, waitpid13), core dumps, /proc/sys, a loop device, mknod of a device, unshare and cgroups.
Step 7 (2026-10-04): done. L2 is closed, with its gaps named.
- Eight workloads more in the corpus, twenty-six in all, compared line for
line with what Linux printed under the same BusyBox:
bb-trap(traps a shell takes from signals it sends itself, an exit trap's code),bb-kill-wait(children ended by TERM and KILL,waitwoken by a kill,jobs),bb-timeout,bb-time(time -p, its CPU time read from wait4's rusage),bb-ps(a sleeping child and its parent by state and group),bb-proc,bb-dev(device numbers and modes, the links into /proc/self/fd,/dev/full's ENOSPC) andbb-limits(ulimit, a file-size limit run into,renice). Six of eight matched on the first boot. Of the other two, one was the workload's:niceis not one of this BusyBox's applets, and Linux had answered from coreutils' - it usesrenicenow. The other was the kernel's. - kill had a rule Linux does not have. A signal to another session was
refused, a rule from before processes had owners. BusyBox's
timeoutleaves a watcher in a session of its own (bb_daemonizecallssetsid) that askskill(pid, 0)each second whether the program is still there; refused, it concluded the program had ended and never stopped it. The credentials (step- are the rule now, as on Linux, with SIGCONT allowed within a session whoever owns the target; a group is signalled member by member under that rule, and is EPERM when it has members none of whom may be signalled.
- A stop holds. Any signal used to wake a stopped task, which then ran its handler and its program with nobody having sent SIGCONT; and a task stopped on a system call's way out returned to its program BLOCKED and ran until the next tick. Only SIGCONT and SIGKILL end a stop now; the system call's exit waits while the task is stopped and delivers what came meanwhile; a child's end no longer wakes a stopped parent. The timer's path cannot wait - it runs on an interrupt stack a second tick would overwrite - so a stop taken there still runs to the end of its tick.
- Orphaned process groups are hung up (step 6's LTP run). A group left with no member whose parent is outside it in the same session, and with a stopped member, is sent SIGHUP and then SIGCONT, as POSIX requires: LTP's waitpid13 moves half its children into a group of their own and stops them, LTP's kill of the test's group did not reach them, and they held their slots until fork was EAGAIN for the tests after.
- /proc about threads.
/proc/<pid>/statandstatusgive the leader's state, as Linux does; they gave "running" if any thread ran, so a process whose main thread waited while another watched for it never looked asleep (futex_wait03)./proc/<tid>exists for every thread, unlisted, andtask/<tid>is the same directory; a thread's files give its own id, state, name, signals and CPU time, and its process's everything else. - What else step 6's LTP run found, fixed: an open
/proc/<pid>is a pidfd topidfd_send_signal, as on Linux; the random devices answerRNDGETENTCNTwith what the new/proc/sys/kernel/random/entropy_availprints - one function for both; clone3 judges where a pidfd is to be written before there is a child (EFAULT, and nothing made). - What this step's own LTP run found, fixed:
setfsuid(-1)andsetfsgid(-1)are how a program asks for the current id, and root's call stored -1 as its id (setfsuid02, setfsgid01);clock_nanosleepanswered EINVAL for every clock but three, under a registry row that said done - it sleeps on TAI, the alarm clocks and the process's own CPU time now, and a thread's CPU time and the raw and coarse clocks are EOPNOTSUPP, as on Linux (clock_nanosleep01). - Found by CI, not by a test here: the native programs' three-argument syscall wrappers left r10, r8 and r9 holding whatever the compiler had put there, and wait4 has filled its rusage since step 5. Built with clang, init kept its pointer to the service table in r10, every reap wrote a rusage over the services, and init died reading a service name that was the number 1 - CI's clang Release boots were red while every local (gcc) boot was green. Every wrapper passes zero now. And the nightly's corpus job said Linux answered differently because the CI runner starts its steps with SIGPIPE ignored; the expectation is made with every signal at its default.
- At boot SIGNAL.ELF stops a child waiting in
pause()and one running its program, sends each a SIGUSR1 that must wait for the SIGCONT, ends a stopped child with SIGKILL, and leaves a stopped child in a group that its parent's end orphans, which must be hung up (job_control_broken). - Sabotage:
abi-signal-reach.txt(4),abi-procsrc-threads.txt(2), two cases more infs-procfs.txtand inabi-sleep-restart.txt, one each inabi-random-ioctl.txt,abi-clone3-checks.txtandsched-cred.txt, andabi-pidfd-calls.txt's andabi-sleep-restart.txt's first cases re-pointed at the code that moved, against the host tests; all red. The clone3 case went red first by crashing the suite - outside its address-space window the fake kernel takes an address for a host pointer - and is caught by its test now. One offs-procfs.txt's, "a directory lists a name once", had been written down as unverifiable until a second file lived under /proc/sys/kernel; this step put one there.abi-jobs-boot.txt(2) against the boot: both red - the first on job_control_broken only once a running child was asked about too; its first run went red on the transition table instead, and the case file says why. Fifteen boots across the step's three rounds of validation (six, six and three), all clean, and one of the clang Release build. - LTP for L2's own calls (
tests/corpus/ltp-l2.txt, 188 tests): 106 passed, 6 failed, 26 broken, 50 not applicable, and none left unrun - kill10 no longer takes the rest of its boot. The three it found in the calls themselves are fixed above, and their calls' ten tests run again after it passed ten of ten. What is left: setting the clock (refused, and five tests set it),adjtimex,unshare,select; (2026-10-05, after the close: the registry still named four calls L2's -adjtimex,clock_adjtime,settimeofday,restart_syscall- so "closed" was true of the plan's seven steps and not of the registry. They are served now: the first three read the clock's state or judge their arguments and refuse a setting, EPERM, asclock_settimeis refused - partial by decision, phase R - andrestart_syscallis Linux's EINTR, since no call here leaves a restart block.abi-clock-discipline.txt, four cases, all red. L2 has no row left.) stopped and continued children reported by wait (waitid07 and 08 wait out their timeout);times()reports no system time; the wall clock reads 1970 at boot, so an absolute time far enough in the past is negative and refused (timer_settime03); a C library's restorer (rt_sigsuspend01, musl's); and resources LTP stages that this run does not (/bin/true, the execveat helpers,/proc/sys/kernel/core_pattern, a loop device). Not looked into: timer_settime02 fails outside the thirty lines a run keeps, and pidfd_open04 waits out an LTP checkpoint. Capacity, not a defect: pause01 forks a child per iteration and reaps them at the end, and the ninth is past the eight children a process may have unreaped on a table of thirty-two slots.
What L2 leaves open, all in the registry or above: stopped and continued
children are never reported by wait; capabilities, set-user-id on exec and a
set-group-id directory's group; a real-time signal queued twice is delivered
once, and a signal to a process goes to its leader rather than to a thread that
does not block it; times() has no system time; setting the clock, and a wall
clock that starts at 1970 because nothing reads the hardware's; RSS, AS and
the other limits that are kept but not enforced; no personality flag changes
anything; and a task table of thirty-two slots.
L3. Memory (10, plus mmap finished)
File-backed mappings - private and shared - through the page cache, MAP_FIXED,
mremap, madvise, msync, mincore, the mlock family, memfd_create.
This is the phase that makes glibc possible: its dynamic loader maps
libraries with MAP_FIXED.
A user stack that grows: a fault below it maps a page (step 5, done second).
Programs: the corpus rebuilt against glibc, dynamically linked; sqlite3
with memory-mapped I/O; lua. And LTP, for every phase before this one:
its harness maps a file MAP_SHARED for its results and needs that page to
stay shared across fork, so not one of its tests can start until this phase.
L1's 416 are the first owed (scripts/dev/ltp-run.sh).
Six steps, in the order that pays L1's debt first:
- mmap by the rules: the flags read as Linux reads them (one of private
and shared, required),
MAP_FIXEDandMAP_FIXED_NOREPLACE, an address hint honoured when it is free, the arena stepping over what a fixed mapping took, and private file mappings - the file's bytes in the process's own pages. - Shared mappings:
MAP_SHARED, anonymous and of a file whose filesystem can hand out its pages (tmpfs), staying shared across fork;msync. This is what LTP's harness needs. - LTP, for L1: its 416 tests run through
scripts/dev/ltp-run.sh, and what they find fixed or written down. - The rest of the calls:
madvise,mincore, themlockfamily,memfd_create,mremap. - A stack that grows, and the 256 KiB mapped at exec given back.
- The programs: the corpus against glibc, dynamically linked;
sqlite3with memory-mapped I/O; LTP's own tests for these calls.
Step 1 (2026-10-01): done.
- mmap places a mapping where the program says.
MAP_FIXEDmaps at the address given and takes the place of whatever was there - how a dynamic loader lays a library's segments over the range it reserved - andMAP_FIXED_NOREPLACErefuses if anything was (EEXIST). An address hint is honoured when the range is free. The arena is still a cursor that moves up, but it steps over what is already mapped: a fixed mapping can be ahead of it now, and it used to hand out the same pages. Where a program may put a mapping is the architecture's to say (ks_user_fixed_ok: the high user window); an address outside it is ENOMEM. - The flags are read as Linux reads them: exactly one of private and
shared, or EINVAL;
MAP_ANONYMOUSignores the descriptor (it used to refuse one that was not -1). - A private mapping of a file is the file's bytes in pages of the process's own, read when the mapping is made, from any filesystem - the page is mapped writable, filled through the file's own read, and given the protection asked for. A store stays in the process; the mapping outlives the descriptor. Past the end of the file it reads zeros, where Linux raises SIGBUS beyond the file's last page. EACCES for a descriptor that cannot be read, ENODEV for what is not a file.
- Still refused, and said: a shared mapping of a file (ENOSYS); a shared
anonymous one is made and is private after a fork. Both are step 2, and the
registry's
mmapline names them. - The fake kernel has page tables. The memory handlers could not run in
the host tests - the fake failed every mapping and said why. It now runs
kernel/mm's frame layer and address-space layer over a few megabytes, as their own tests set them up; a mapped address is read and written through the tables (kf_peek,kf_poke), a kernel-mode store takes the copy-on-write fault, and fork clones the space. So mmap, munmap, mprotect and brk are host-tested as handlers for the first time, fork's copy-on-write included. - At boot the ring-3 self-test maps over a page at a fixed address and reads zeros where it had written, is refused by NOREPLACE, and maps its own image off the FAT volume privately: the ELF magic, and a store that sticks.
- Sabotage:
abi-mmap.txt(18) on the host,abi-mmap-boot.txt(1). Three went NOT RED first; two were the test's - a pattern that repeated every 256 bytes, so the byte at offset 4096 was the byte at 0 - and one was the code's: the handler's unmap before a fixed mapping is not what releases the old pages (the address-space layer does that), it is what lets the region list describe the new mapping. - A race in fork, found while reading for M-070, and fixed. For a page
that needs no conversion - read-only, or copy-on-write already - fork reads
the entry and, some instructions later, maps the frame into the child. A
page-out marks only writable pages, so it can evict that page and release
the frame in between, with nothing in the entry either side would notice:
the child is mapped onto a free frame, which the allocator hands to somebody
else.
clone_onenow pins the frame only if it still has an owner and reads the entry again before mapping. A host test puts the page-out exactly there through the fork hook, with the frame left free and with it reused; before the fix it found the child on a frame the page-out had given away.fork_frame_goneandfork_entry_movedcount the two cases on every boot. - M-070 itself is not fixed, and is more frequent than it was. The swap
map's double release - not seen in ninety boots before L1 step 8 - has been
seen three times in about sixty since that step gave every process sixty
cold stack pages, the third time with the fix above in place and its
counters at zero: so that race, real as it is, is not what these boots hit.
What the three captures agree on: the reclaim load is running; a process is
killed reading a page whose entry is a valid swapped one (read-only, user);
its slot is found already free when the address space is torn down, last
given back by a page-in. What they do not say is whose page-in, or why the
one that killed the process failed. The kernel says both now -
SWAP_IN_FAILEDwith the reason and the slot's history, and each slot's last writer and last releaser by process - and sixteen boots after adding that were clean, so the next capture is still owed. The boot gate is therefore flaky, at something like one boot in twenty, and it was this phase's predecessor that made it so. Step 5 (a stack that grows, no pages mapped that nobody touched) removes what raised the rate; it does not explain the defect.
Step 2 (2026-10-01): done.
- A shared mapping stays shared.
MAP_SHAREDmarks the page (VIBEOS_PROT_SHARED, bit 53 of the entry), and a fork maps the same frame into the child with the access the parent has, instead of making both sides copy-on-write. A store by either is seen by both, before the fork or after. mprotect changes the access and leaves the kind; a read-only shared page refuses a store rather than copying. - A shared mapping of a file is the file. The filesystem hands out its own
page (
share_page, a new optional operation; tmpfs has it) and that page is what is mapped: a store through the mapping is read back byread, awriteis seen through the mapping, two mappings of one file are one page. A hole is given a page. The page comes held - the reference is taken inside the filesystem's lock, where it can still vouch for it - and each mapping holds it too, so a file truncated or unlinked under a mapping leaves the mapping its page, and the page is freed when the last holder lets go. - Shared and writable needs a descriptor that can write (EACCES); shared and read-only does not, and neither does private and writable.
msyncvalidates and returns: there is no second copy to write back. EINVAL for an address that is not a page's or flags that make no sense, ENOMEM for a range with a hole in it.- A shared page is not paged out. An entry in swap has nowhere to say
"shared", and a page that came back without the mark would be private from
then on (
swap_refused_shared). - What is not done, and said: a shared mapping of a file on a filesystem
that keeps no pages - FAT - is ENODEV, and is the registry's
mmapgap. Only the pages holding some of the file are mapped; one past the end is absent, and touching it is SIGSEGV where Linux says SIGBUS. A mapping does not follow its file: grown after it was mapped, the new pages are not in the mapping; cut and grown again, the mapping still has the old page. mprotect will make writable a shared mapping made from a read-only descriptor. - The layering check gained one exception, written into it: the hold on a
file's page is a frame reference taken outside
kernel/mm, byhw_tmpfs_page_holdand nothing else. - At boot the self-test maps two anonymous pages and a page of a file on
/tmp shared, forks, and the child stores into each: the parent has to see the
stores and
preadhas to return the file's (shared_mapping_selftest_failed); and the fork has to have handed shared pages on (fork_never_kept_a_shared_page). - Sabotage:
abi-mmap-shared.txt(5),mm-shared.txt(4),fs-tmpfs-share.txt(3) against the host tests,arch-shared-boot.txtandarch-shared-leaf-boot.txtagainst the boot; all red. 24 boots of 24 clean. - LTP: its harness maps its results page and gets further - every test now stops one step later, at
tst_memutils.c:94: Failed to open FILE '/proc/meminfo'(eight tests tried). That is step 3's first item, not a mapping's.
Step 3 (2026-10-02): done - the oracle runs, and what it said.
scripts/dev/ltp-run.sh build-gcc-Release --list tests/corpus/ltp-l1.txt runs
the 382 LTP tests for the syscalls L1 owned (scripts/dev/ltp-list.py; the
"416" above was counted against an earlier registry), thirty-two to a boot,
and leaves each verdict in <build>/ltp-results.txt. The first run, before
anything below was fixed: 113 passed. The last: 131 passed, 10 failed, 177 broken, 57 not applicable, 7 did not run.
What had to exist before a test could start. None of it is a mapping's:
/proc, a filesystem (kernel/fs/procfs.c) with what the harness reads:/proc/meminfoand/proc/sys/kernel/pid_max. Generated on read, no state./etc/passwdand/etc/groupon the boot volume: a C library answersgetpwnam("nobody")out of them.nanosleepandclock_nanosleep(L2's, taken early). They did not exist: everysleepandusleepin every program had been returning at once, and nothing checks what they return.- A call a signal cut short is run again when the handler was installed
with
SA_RESTART, or when no handler ran. Every interrupted wait had been EINTR, so a parent whose child signals it - LTP's harness - sawwaitpidfail. A handler returnsVIBEOS_RESTART_CALLwhere a wait can simply be started again (waiting for a child, a terminal, a pipe, a lock); the dispatcher turns it into EINTR and remembers the call; the delivery decides. - More than thirty-two programs in a boot (M-081, below).
What the tests found in L1's own calls, fixed:
F_GETLKnamed whichever conflicting lock the table held first; it names the one that starts first (fcntl11, fcntl21).- A path's first byte was read without asking whose memory it was (M-082,
statx03): ring 0 reads a page that is mapped for nobody, so
statxof a path in aPROT_NONEpage, or in the kernel, answered ENOENT or EFAULT by the byte it found there. - An empty path was looked up from the directory descriptor: ENOTDIR or EBADF where Linux says ENOENT (fchmodat02).
preadv/pwritevwith a negative length were EFAULT, not EINVAL; reading a directory at an offset was ESPIPE, not EISDIR (preadv02, pwritev02).fallocatewithFALLOC_FL_KEEP_SIZEdid not look at how far it reached: past the largest offset is EFBIG (fallocate02).- A process that opened files until refused was refused by the machine (ENFILE at 256) and not by its own limit (EMFILE at 1024): the description pool is 2048 (creat05, fcntl12).
socket()of a family there is none of was EINVAL, which a C library takes for a failure where EAFNOSUPPORT means "no name-service daemon" - sogetgrgidof an unknown group failed (seven tests).
What they found that is written down and not fixed:
-
Credentials are L2's:
setuid,seteuid,setgid,setegid,setreuidandgetpgidare missing or refuse, so every test that becomes "nobody" stops in its setup, and the ones that check a permission is refused cannot (symlink03). The largest group after the next. -
No block device to borrow: about ninety tests ask LTP for a loop device (
Failed to acquire device) to make a filesystem on. Not a syscall's fault. -
alarmandsetitimerare L2's, and LTP's own timeout isalarm(): a test that hangs is not stopped, and takes the rest of its boot (flock03 and the tests after it in that bootdid not run). -
/dev/null,/dev/urandom, a pty,/proc/mounts,/proc/self/maps,/proc/cpuinfo,/proc/version(L2);socketpair(L5);pthread_createrefused under OFD-lock tests (L6). -
Leases (
F_SETLEASE),O_PATHdescriptors answering EBADF to I/O, FIFOs frommknod/mkfifo,RENAME_EXCHANGE: gaps in L1's own rows, the last two already in the registry. -
xattr tests report TCONF, correctly: no filesystem here keeps them.
-
Sabotage:
abi-ltp-l1.txt(6),abi-restart.txt(4),abi-sleep-restart.txt(4),fs-procfs.txt(3), one more infs-filelock.txt; all red. Two went NOT RED first, and the reason was the fake kernel: itsvibeos_uaccess_copyrefused a page not mapped for the user, which the machine's does not - so the test for M-082 could not fail there. The fake's kernel copy now reaches what ring 0 reaches, and the self-test checks the same thing on the machine.check.sh allgreen, the sanitized host tests clean, 20 boots of 20 over two runs.
Step 4 (2026-10-02): done.
madvise. Advice about speed is taken by doing nothing: every page is already there.MADV_DONTNEEDandMADV_FREEare not advice - a program reads zeros afterwards and an allocator relies on it - so a private anonymous page is given back and a zeroed one put in its place. A shared page is left as it is; a locked one refuses. Advice that would change what a later call does (MADV_DONTFORK, the huge-page pair,MADV_REMOVE) is EINVAL rather than a promise not kept. Gap, in the registry:MADV_DONTNEEDon a private page of a file keeps what the program stored, where Linux shows the file again.mincore: a byte a page - in memory, or not (in swap, or a part of a mapping nothing backs).mlock,mlock2,munlock,mlockall,munlockall. A locked page carries a mark in its entry (VIBEOS_PTE_LOCKED, bit 54) and page-out refuses it; a page in swap is brought back when it is locked. The copy a write fault makes, a change of protection and a move keep the mark; a fork does not hand it to the child.MCL_FUTUREis kept: mmap, brk, mremap and the stack as it grows lock their new pages as they are made, and a mapping RLIMIT_MEMLOCK cannot hold is refused (until 2026-10-18 it was accepted and not kept - M-104).mremap. Shrinks; grows where it stands when the address space after it is free; moves when it is not andMREMAP_MAYMOVEallows, or to the addressMREMAP_FIXEDnames. A move takes each entry out of the old address and puts it at the new one - the same frame, the same marks, the same reference; nothing is copied (vibeos_vmspace_move). A failure half-way puts back what was moved. Gap: only a private anonymous mapping grows - a shared one or a file's would need more of what it shares, and the region does not remember where that came from; a zero old length andMREMAP_DONTUNMAPare refused.memfd_create. A file on /tmp that goes when its last descriptor does: memory with a file's interface, to size, map shared and pass on. Gap: it has a name under /tmp while it is open, where Linux's has none anywhere; and there are no seals.- A region says what it is - anonymous, a file's bytes in private pages, or shared - because three of these calls have to ask.
- At boot the self-test discards a page and reads zeros, locks two pages
and asks mincore, grows a mapping that has to move and finds its contents,
and shares a memfd across a fork (
memory_calls_selftest_failed). - Sabotage:
abi-mem4.txt(14),mm-lock-move.txt(4),abi-memfd.txt(3) against the host tests,abi-mem4-boot.txtagainst the boot; all red.check.sh allgreen, the sanitized host tests clean, 21 boots of 21 (run three at a time). - LTP, the tests for these calls and for mmap and msync: 32 tried, 20 pass.
mmap08found one more wrong answer, fixed (a mapping of a descriptor that is not open is EBADF before a zero length is EINVAL). Of the rest:mincore03expects an untouched anonymous page not to be in memory, and here a mapping is populated when it is made;mremap01grows a shared mapping of a file (the gap above); the others wantsetrlimit,/proc/self/maps,shmget, seals ormount.
Step 6 (2026-10-02): done. L3 is closed, with its gaps named.
- glibc, dynamically linked, runs. SQLite's shell and Lua are built a
second time with the host's compiler against the host's glibc
(
scripts/dev/corpus-build.sh:sqlite3-glibc,lua-glibc), and the loader and the libraries they name -ld-linux-x86-64.so.2,libc.so.6,libm.so.6- are copied to the boot volume at the paths the programs ask for them by. glibc's loader reserves a range and lays each library's segments over it withMAP_FIXED, private mappings of the file: step 1. Nothing had to be added for it; the first program ran on the first try. - SQLite with memory-mapped I/O, in write-ahead-log mode. The WAL's index
is a file every connection maps shared - the journal mode cannot be entered
without step 2 - and with
mmap_sizeset the database is read through a mapping. Two connections, four hundred rows, an update, a checkpoint and an integrity check; the-waland-shmfiles are gone afterwards, as on Linux. - Three workloads more in the corpus (
glibc-sqlite,glibc-lua,sqlite-wal-mmap), eighteen in all, compared line for line with what Linux printed. They matched on the first boot. - Sabotage:
abi-corpus-l3-boot.txt, two cases, red - aMAP_FIXEDthat is not honoured takes the glibc workloads down, a shared file mapping made private takes the WAL one. - LTP for L3's own syscalls (
tests/corpus/ltp-l3.txt, 66 tests): 31 of 63 pass, with two left out because they hang (mincore04,mmap18). It found three more wrong answers, fixed:MAP_SHARED_VALIDATEdid not refuse an unknown flag,MAP_LOCKEDwas ignored, andmsync(MS_INVALIDATE)of a locked page was not EBUSY. Not fixed and said: a fault in ring 3 kills the task instead of delivering SIGSEGV or SIGBUS to a handler that asked for it (mmap05,mmap13, and whymmap18hangs);mincore03expects an untouched page not to be resident;mremap01aborts aftermremaprefuses to grow a shared mapping of a file - most likely in its own cleanup, which was not confirmed. The rest wantsetrlimit,/proc/self/mapsandstatus,shmget, seals,mountor a loop device. check.sh allgreen, the sanitized host tests clean, 12 boots of 12 three at a time and 5 of 5 through the full gate. A later run of six in parallel ran out of its 300 seconds with every boot still working through the corpus: the host was at 70% load from something else and everything on it ran at half speed.
What L3 leaves open, all in the registry or above: a region does not
remember its file, which is one cause with three faces (a shared or file
mapping does not grow under mremap, MADV_DONTNEED keeps a private page of
a file, a page past the end of a mapped file is absent rather than SIGBUS); a
shared mapping of a file needs a filesystem that keeps pages, so not FAT; a
mapping is populated when it is made, so mincore never says "not yet";
MCL_FUTURE; seals on a memfd.
Step 5 (2026-10-01), done ahead of its turn. Taken before step 2 because of what step 1 found: the boot gate had become flaky, and the pages mapped at exec that nobody touched were what had made it so.
- The stack grows. Four pages are mapped at exec, as before L1 step 8, and
the two megabytes below the stack's top are one region the process has from
the start. A touch of a page in it that nothing maps is given a zeroed page
by the page fault (
hw_stack_grow), whichever side the touch comes from - ring 3, or the kernel storing a syscall's result into a buffer on the stack. Any address in the region grows it, not only one near the stack pointer: that is what Linux has done since it dropped the stack-pointer test. - The region list is asked, not only the address. A program that unmapped part of its stack, or took its write permission away, is not given a page there. The region's lowest page is never mapped and below it is nothing of the stack's, so running off the end is a fault.
- The range check knows. A syscall's buffer is judged before the handler
runs, and a page of the stack nobody has touched is the process's to use:
refused, a
read()into a large buffer on the stack - where programs keep them - would be EFAULT for every page not happened upon first. - Under the process's mm lock, which is tried and not waited for: this is a fault handler, a thread of the same process may be forking on another core, and returning to fault again is how it waits. The frame comes from the privileged door, as a page coming back from swap does: the stack was promised at exec, and it is bounded.
RLIMIT_STACKreports the two megabytes; it reported the four pages, then the sixty-four.- At boot the ring-3 self-test runs a function with a hundred-kilobyte
frame, has the kernel
read()into a page a quarter of a megabyte below it, and forks a child that stores into the guard page and must die of SIGSEGV (stack_selftest_failed). The kernel counts the pages it gave (stack_grown), and a boot in which it gave none fails (stack_never_grew): the corpus prints through asedthat needs them. - The gate's count of deliberate faults is two now, and each is matched by
the address it touched - null for svc-crash, the guard page for this - where
it used to excuse whichever ring-3 page fault came first. The crash-record
check reads every record now and wants svc-crash's among them: it read the
first one only, and the first 24-boot run failed four times on a record that
correctly named
SELFTEST.ELF, whenever that child died before svc-crash. - Sabotage:
arch-stack-boot.txt, two cases, red. Three went NOT RED first. The compiler probes every page of a large frame on entry, so theread()into the middle of the big array wrote into a page ring 3 had already touched - the test reads below the frame now. Widening the address test to include the guard page changed nothing, because the region list refuses it too. And a loop that zeroed the new page was dead code: the allocator hands out nothing but zeroed pages. - What it did to M-070: 48 boots in two runs, none with its signature (the four that failed were the gate's own crash-record check, above), against three in about sixty before with the stack growing. A boot gives out about sixty stack pages in all, where it mapped sixty per process. The defect is not explained and its instrumentation stays; what changed is how much reclaim has to take that nobody is using.
L4. Event loops (21)
poll, select, pselect6, ppoll, epoll, eventfd, timerfd,
signalfd, inotify - each a file type from A3 with a wait queue, not a special
case in the scheduler.
Programs: BusyBox httpd serving from the guest, nc, tail -f.
The plan (2026-10-04). Measured first, as A0 measured the corpus: the
three programs under strace on Linux, with the BusyBox the image stages.
httpd asks for no event loop at all - it forks a child per connection,
bounds it with alarm, and sends with sendfile, setsockopt and shutdown;
nc, server and client, waits in poll on its socket and on standard input;
tail -f sleeps and looks again (clock_nanosleep, fstat). So what the
programs exercise is poll on a socket - which is what poll cannot answer
today, a socket always saying it is ready - and two of L5's calls, which are
taken here as far as httpd needs them, as L3 took nanosleep. The rest of
the phase has LTP for its oracle, and its own boot checks:
tests/corpus/ltp-l4.txt, 78 tests. scripts/dev/ltp-list.py found only 50
of them at first - LTP names some directories after a family (inotify01,
pselect01, timerfd01, epoll-ltp) and those matched no syscall, and
timerfd01 matched time. It matches whole names now, then families; the
same change found twelve of L1's (pread01, pwrite01 and the rest) that
L1's list never had, and they run with step 1.
No wait queue. Every wait in this kernel is the same: look, give up the
core, look again - pipes, sockets, futexes, poll itself - and what a
description can do is its type's ready. The event loops are built on that,
not beside it: an interest list asks each description's ready at each look.
A wait queue would be a second model of waiting for one phase. Edge-triggered
epoll is a rise in what ready says between two looks.
Seven steps, in the order that pays what is owed first (select is missing,
and L2's personality02 stopped on it):
- One readiness engine, four calls:
pollfinished - a socket says what it can do, from the stack (data to read, a connection to accept, a peer that closed, room to send) - andppoll,select,pselect6on the same engine: a set of descriptors and events, looked at until one is ready, the time is up or a signal needs acting on.ppollandpselect6swap the signal mask for the wait, atomically, asrt_sigsuspenddoes;selectwrites back what was left of its time, as Linux does. eventfd,eventfd2: a counter as a file type -EFD_SEMAPHORE,EFD_NONBLOCK,EFD_CLOEXEC; a read waits at zero, a write at overflow.timerfd_create,timerfd_settime,timerfd_gettime: a timer as a file type, read for how many times it expired; relative or absolute, with an interval.signalfd,signalfd4: a thread's pending signals read as records - the same takesigtimedwaituses - and ready while one of its mask is pending.epoll:epoll_create,epoll_create1,epoll_ctl,epoll_wait,epoll_pwait,epoll_pwait2. An interest list as a file type, each entry a description and its events; level- and edge-triggered,EPOLLONESHOT; an epoll in another epoll's or inpoll's set; an entry goes with the description, not the descriptor, as on Linux.inotify:inotify_init,inotify_init1,inotify_add_watch,inotify_rm_watch. Events from the file layer's operations - made, removed, written, changed, moved, opened, closed - at the one place every filesystem's operation passes, queued per instance, with an overflow.- The programs: BusyBox
httpdserving from the guest to the host,ncboth ways,tail -f; and LTP for L4's own calls.
Step 1 (2026-10-05): done.
- One engine, four calls (
kernel/abi/linux/poll.c): a set of descriptors and what is asked of each, looked at until one can, the time is up, or a signal needs acting on - and what was ready wins over a signal that came meanwhile.pollmoved here fromfs.c;ppoll,selectandpselect6are new. The hangup and the error are reported whether asked for or not;selectcounts them as Linux does (a hangup is readable, an error both). A wait is never shorter than asked - the tick in progress is not counted, as a sleep's is not, whichpollused to get wrong by up to a tick- and
ppoll,selectandpselect6write back what was left of their time, going on without it when the memory cannot be written, as Linux does.
- and
- A mask for the wait.
ppollandpselect6swap the signal mask for the wait asrt_sigsuspenddoes: a signal it lets through is delivered under it and the program resumes under its own; a call that does not end in EINTR has the program's mask back before it returns. - Sockets say what they can do (
vibeos_inet_readyinkernel/net/inet.c,socket.c'sready), as Linux'stcp_pollsays it: a listener only with a connection to accept, a connection with data or after the peer's FIN (alsoPOLLRDHUP), a socket nobody connected writable and hung up, a reset one everything. A socket had noready, which reads as always ready for both, sonc- which waits inpollon its socket - never waited. - Two new readiness bits in the file layer, an error and the peer's half
closed (
VIBEOS_READY_ERR,_RDHUP). A pipe whose reader has gone is an error to its writer, wherepollhad a special case asking which end of a pipe it was looking at; an empty pipe whose writers have gone is a hangup and only that (LTP's poll03 - it was readable too). - What the step's LTP run found in L1: twelve of L1's tests its list never
had (
ltp-list.py, above) ran for the first time, and one failed - underO_APPEND, Linux'spwritewrites at the end and ignores its offset, a documented bug kept for compatibility (pwrite04). It does here now. - At boot
EVENTS.ELF(tests/linux/musl_events.c), the phase's own program, polls three sockets that nothing has happened to, waits inselecton an empty pipe and measures that the wait was not short, sees a byte make it readable, and waits inppollandpselectunder masks that let a pending signal through and keep it out (events_l4_failed). It ran on Linux first: the checks describe Linux. - Sabotage:
net-inet-ready.txt(4),abi-socket-ready.txt,abi-event-loops.txt(11, six of thempoll's fromabi-tty-fs.txt, moved with the code), a case more inabi-tty-pipe.txtand inabi-write-path.txt, against the host tests;abi-events-boot.txtandabi-poll-boot.txt(moved fromabi-tty-boot.txt) against the boot; all red. "select's sets are not judged" went NOT RED, correctly - a set is written back where it was read, and a page the program may not read is one it may not write, so the call is EFAULT either way; what the judgement keeps out is a page ring 0 may read and write and the program may not, which the fake kernel does not have. It is written down in the case file instead of kept. Twelve boots across two rounds (six and six), all clean. - LTP: the step's own fifteen and L1's twelve: 23 of 27 passed. Of the rest,
poll03 and pwrite04 (twice) are fixed above and the six tests of the calls they touch, run again after, passed six of six; select01 makes a
FIFO with
mkfifoin its temporary directory and is refused - L1's gap onmknod, not the event loop's. select02 to 04 pass their own variant and say so of the time64 one, which x86-64 does not have.
Steps 2 to 4 (2026-10-05): done, together. Three descriptors that are
events, each a file type with a ready and a pipe's way of waiting, so poll,
select and (step 5) epoll see them without being told.
eventfd,eventfd2(kernel/abi/files/eventfd.c, personality-neutral): a counter in the description, sodupandforkshare it; every change a compare-exchange, because two processes after a fork can read and write it at the same instant and a description has no lock. A read takes the count, or one underEFD_SEMAPHORE, and waits at zero; a write adds and waits for room under the largest count, 2^64 - 2; the value past it is EINVAL. A read whose buffer cannot take the value gives the count back.timerfd_create,timerfd_settime,timerfd_gettime(kernel/abi/files/timerfd.c): a deadline and a period in ticks, and nothing that fires them - whoever looks, a read or a poll, counts the expiries since the last look. Every clock a timerfd may use counts time passing, which is one clock here, so "how many periods have gone by" is arithmetic, and a timer nobody reads costs nothing and needs no slot in the process-timer table. Relative and absolute, with an interval; a relative time does not count the tick in progress, as a sleep does not; set again, it starts counting again. The alarm clocks are the superuser's, a CPU-time clock EINVAL, as on Linux.TFD_TIMER_CANCEL_ON_SETis accepted and never cancels: no clock here can be set.signalfd,signalfd4(kernel/abi/linux/events.c- its records are Linux's layout,linux_signalfd_siginfo_t): a read takes, of the signals in its mask, those pending for the reading thread, lowest first, as many records as fit - byks_signal_take, the takesigtimedwaituses, so a signal read is not delivered too - and waits while none is. Readable while one of its mask is pending for whoever asks. A second call on it changes its mask.- At boot EVENTS.ELF waits on each in a way only another process or the
clock can end: an
eventfdread woken by a child's write,pollon a periodictimerfd(not early, and every period counted), asignalfdread and apollon one woken by a child'skill. It ran on Linux first. - Sabotage:
abi-eventfd.txt(5),abi-timerfd.txt(4) andabi-signalfd.txt(6) against the host tests, andabi-signalfd-boot.txtagainst the boot (events_l4_failed, for the reason it names); all red. No case removes a wake: every wait here looks again at the next tick, so a missingks_wake_waiterscosts a tick and nothing a test can see. - Sabotage, one more:
abi-signalfd.txt's seventh case checks the pointer before the descriptor, and is red. - LTP: the steps' twenty tests: 16 passed. timerfd_gettime01 failed: with
a closed descriptor and a bad pointer Linux says EBADF, and the row judged the
pointer first - a timerfd setting is written out last now, by the handler, as
Linux orders it, and the test passes. timerfd_settime02 wanted
/proc/sys/kernel/tainted(0 now: nothing here taints the kernel) and then races two threads with LTP's fuzzy sync, which askssched_getaffinity- L6's - and outlasts a boot under TCG. timerfd04 needs a time namespace (unshare(CLONE_NEWTIME)), eventfd06 libaio: neither is this phase's. Six boots, all clean.
Step 5 (2026-10-05): done. epoll_create, epoll_create1, epoll_ctl,
epoll_wait, epoll_pwait, epoll_pwait2 (kernel/abi/linux/epoll.c).
- poll with a set that outlives the call. An entry is a description, the
number it was added under, the events asked and the program's word; a look
asks each description's
ready, as poll does, and the wait is poll's engine (linux_wait_ready, now shared). Nothing wakes an epoll and no file type calls into it. - An entry is the description's. It holds no reference: closing one of
two descriptors leaves it, and the last reference going takes it away - the
file layer calls a hook (
vibeos_file_on_release) before the slot can be handed out again, so an entry never watches a slot's next tenant. A look takes a reference to each description byvibeos_file_try_get, which refuses one whose release has begun. - Edge-triggered is a rise: what
readysays now that it did not at the last look. Linux's is "something happened", which also reports a pipe that was readable and got more; with no event to report here, only a state, a rise is the most a state can say. A program that reads until EAGAIN before it waits again - which is what edge-triggered requires of it on Linux too - sees no difference. - Nested: an epoll is readable while one of its entries would be reported,
so it can be in another's set or in poll's; a cycle, or nesting deeper than
Linux's five, is ELOOP. A look never asks a
readyunder epoll's lock, so an epoll inside an epoll cannot deadlock on it. EPOLLONESHOTdisarms an entry once reported, untilEPOLL_CTL_MOD;EPOLLEXCLUSIVEis accepted with Linux's rules for it and means nothing more: nothing is woken, so there is no herd to keep from waking. The entries are one pool of 512 for every instance.- At boot EVENTS.ELF waits in
epoll_waitfor a child's write to a pipe, edge-triggered, then level-triggered, and polls the epoll. - Sabotage:
abi-epoll.txt(10) against the host tests andabi-epoll-boot.txtagainst the boot. "An entry outlives its description" went NOT RED first, and correctly:try_getskips a released description, so a left-behind entry is invisible until its slot is handed out again. The test makes a readable description in that slot now, and it is red. - LTP: the step's 23 and timerfd01 and timerfd_settime01 again: 16 passed.
epoll_ctl04 failed: it builds a chain of five epolls and asks for a sixth on
top, and the check counted only the chain below the epoll being added. It
counts the whole chain once joined now - what is below and what is above -
and the five epoll_ctl tests, run again, passed five of five; a sabotage case
more (11) keeps it. Five epoll_pwait tests need
socketpairand epoll_pwait06 Unix sockets (L5); epoll_wait05listenon an unbound socket, epoll_wait06F_SETPIPE_SZ: both open, neither epoll's. Six boots, all clean.
Step 6 (2026-10-05): done. inotify_init, inotify_init1,
inotify_add_watch, inotify_rm_watch (kernel/abi/linux/inotify.c).
- The file layer says what happened, once (
vibeos_fs_notify,kernel/fs/vfs.c): every operation on a name - create, mkdir, symlink, link, unlink, rmdir, rename, setattr - tells one registered listener what it did, to which node, at which path, and a rename's two halves share a cookie; the regular-file type tells open, read, write, truncate and close (regular.c). The numbering is the file layer's (VIBEOS_FSN_*), equal to Linux's where both have the event, and a personality maps it. Nothing is looked up for anybody while no instance exists. - A watch names a node - a mount and the filesystem's identity - so on tmpfs
it follows a rename, and on FAT, whose identity is where an entry sits, it
does not: FAT's gap, not inotify's. The node's own watch hears its events
unnamed, its directory's hears them named; a link count is the node's alone,
and a directory's DELETE_SELF and MOVE_SELF carry no IN_ISDIR, as Linux
reports them. A queue of 31 events and then one IN_Q_OVERFLOW, merged when
identical and consecutive; IN_ONESHOT, IN_MASK_ADD, IN_MASK_CREATE,
IN_ONLYDIR, IN_DONT_FOLLOW; FIONREAD; and
/proc/sys/fs/inotify/max_*say the limits, which here are the machine's (8 instances, 128 watches). - At boot EVENTS.ELF watches a directory in
/tmp, polls the instance until a child makes a file there, reads the record, and renames it for the MOVED_FROM/MOVED_TO pair with one cookie. - Sabotage:
abi-inotify.txt(10),fs-notify.txt(4),abi-files-notify.txt(3) andabi-paths-moved.txtagainst the host tests,abi-inotify-boot.txtagainst the boot; all red. "The node hears its directory's events" went NOT RED first: the file's watch asked for nothing the directory hears. It asks for the MOVED_* events too now, and is red. - What the step's LTP run found, of which one defect is not inotify's at
all. inotify02 renames its own working directory and goes on with relative
names: a process stands in a path here, so every name after the rename was
ENOENT, and LTP's own cleanup failed the same way. A directory renamed now
takes the working directories, roots and open descriptions inside it along
(
linux_paths_moved,vibeos_file_paths_moved). inotify04 asked for the link count's IN_ATTRIB without the directory hearing it, and a directory's DELETE_SELF bare; inotify05 and 06 for the/proclimits. All four are fixed with host tests and run again in step 7. inotify03, 07 and 08 need a loop device; inotify09 is a fuzzy-sync race (LTP's harness askssched_getaffinity, L6) and outlasts a boot, so it is intests/corpus/ltp-hangs.txtbeside timerfd_settime02. Six boots, all clean.
Step 7 (2026-10-05): done - the programs. tests/corpus/run-l4.sh, staged
as /corpus/progs.sh and run by the boot's shell before the corpus:
tail -ffollows a file another command appends to - it sleeps and looks again, which needed nothing new.- BusyBox
httpdserves a page from/tmp/www, and the boot gate fetches it from the host through a port QEMU forwards (hostfwd, its own pair of ports per parallel run, checked free before QEMU starts); the gate asserts on the bytes that came back. ncboth ways: to the host's echo server, and listening for a line the gate makes up and sends.
What they needed, measured with strace on Linux before any of it was written:
two of L5's calls, taken as far as these programs go. setsockopt accepts
SO_REUSEADDR (what binding here does anyway) and SO_KEEPALIVE (no probe is
sent); anything else is ENOPROTOOPT, partial in the registry with L5 named.
shutdown half-closes: SHUT_WR sends the FIN and the socket still reads,
a send after it is EPIPE with SIGPIPE, SHUT_RD ends reads once the queue is
empty, and a close after a SHUT_WR lets the exchange finish instead of freeing
the slot under the peer's last FIN.
Three defects the programs found, none in a call the step wrote:
-
The stack's flush could loop for ever with the network lock held. It counted what was left to send as
snd_una + tx_len - snd_nxt, unsigned; a FIN takes a sequence number and no byte, so once the peer had acknowledged the data and not yet the FIN the count was minus one - four billion - and the core sent the buffer's neighbours until the machine was stopped, every other core queued on the lock. Aclosewith data in flight could always have done it; nc, which writes and shuts down at once, did it every time. The FIN is now queued behind the data and sent by the flush, which never counts below zero. Found by the wedge report namingtcp_flushon one core andvibeos_sockfile_stablewaiting on another. -
A wait on a socket heard no signal. accept, the reads and connect looked again every tick and never asked whether a signal needed acting on, so an httpd parked in accept could not be killed, and the machine - which leaves userland only when every user task has ended - never did. They stop for a signal now (accept and the reads restart under SA_RESTART, connect is EINTR), and a socket that may not block does not wait.
-
The host held a port. A forward to a port somebody holds makes QEMU exit at once, which read as "QEMU exited before serial connection"; the gate says
INFRA:and the port now, probing it as QEMU binds it (with SO_REUSEADDR), so that a port in TIME_WAIT is not mistaken for a port in use. -
Sabotage:
net-inet-shutdown.txt(4; the first is run undertimeout, because the defect it restores is a hang),abi-socket-wait.txt(2), and the host tests for the handlers; at boot,l4-programs-boot.txtbreaks each program's half of an exchange in the script (5, one per reason the gate can give:tail_f_did_not_follow,httpd_not_served,nc_out_no_answer,nc_in_not_received,l4_programs_did_not_finish). Two went NOT RED, correctly, and are written down in their case files rather than kept: "the FIN goes ahead of unsent data" - this stack sends every byte at once and leaves data unsent only while ARP is pending, which an established connection never is - and refusing SO_REUSEADDR in the kernel, which BusyBox sets through a helper that ignores the answer. The first run of the boot cases also found the gate's own port probe wrong: it bound without SO_REUSEADDR, so a forwarded port the previous boot had just closed - in TIME_WAIT on QEMU's side - read as taken, and three of the five cases went red forINFRA:instead of their reason. The probe binds as QEMU does now, and those three were run again.
L4 is closed (2026-10-06). LTP for the whole phase - tests/corpus/ltp-l4.txt
and the three clock calls L2 handed over, 85 tests: 83 ran, two left out on
purpose (timerfd_settime02, inotify09, both fuzzy-sync races that need L6's
sched_getaffinity). 58 passed. Two were this phase's and are fixed: inotify10
reads a directory's record and its child's in one instance and wanted the
parent's first, and adjtimex03 wanted adjtime()'s bit (0x8000) without
ADJ_OFFSET refused as EINVAL before the privilege is asked; both
were run again after, with the inotify tests they touch. The rest, by owner:
- L5:
socketpairand AF_UNIX sockets (epoll_pwait01 to 06);listenon a socket nobody bound, which Linux binds to a port of its own (epoll_wait05). - By decision: setting the clock (adjtimex01, clock_adjtime01 and 02, settimeofday01 - refused, phase R, as clock_settime).
- Elsewhere:
capget(settimeofday02, L9); a time namespace (timerfd04);F_SETPIPE_SZ(epoll_wait06);mkfifoin a temporary directory (select01, L1'smknodgap); libaio (eventfd06) and a loop device (inotify03, 07, 08) that the run does not stage. - Open in inotify itself: inotify06 writes the limits in
/proc/sys/fs/inotify, which are read-only here; inotify12 reads an instance's watches from/proc/self/fdinfo, which does not list them; and inotify11, which opens files as fast as another process deletes them, was killed at LTP's timeout - not looked into.
L5. Sockets (11, plus readv/writev on sockets)
The rest of the BSD API (sendmsg/recvmsg, shutdown, getsockname,
getpeername, socketpair, socket options, accept4, sendmmsg/recvmmsg)
and AF_UNIX stream and datagram sockets, with SCM_RIGHTS on A3's open file
descriptions.
Programs: BusyBox wget against the host, a DNS lookup through the C
library, a client and server over a Unix socket.
The plan (2026-10-06). Measured first, with strace on Linux and the
BusyBox the image stages. wget needs little: socket, connect, read and write,
and fcntl turning O_NONBLOCK on and off on the socket. A DNS lookup through
musl's getaddrinfo needs more: /etc/hosts and /etc/resolv.conf, a UDP
socket made with SOCK_NONBLOCK|SOCK_CLOEXEC in its type, bind to port 0,
sendto with MSG_NOSIGNAL, poll, and recvmsg for the answer and the
address it came from. L4 left four things here: socketpair and AF_UNIX
(epoll_pwait01 to 06), listen on a socket nobody bound (Linux binds it to a
port of its own; epoll_wait05), the rest of setsockopt, and a recv that
gives up with EIO after ten seconds where Linux waits. LTP is the oracle for
the calls: tests/corpus/ltp-l5.txt, the phase's own 36 tests and the 21 of
the socket calls that were done before L1 and never had a list (accept, bind,
connect, listen, recv, recvfrom, send, sendto, socket; not socketcall, which
x86-64 does not have).
Four steps:
- The BSD calls on IP sockets:
getsockname,getpeername,accept4, the type flags ofsocket,listenthat binds what nobody bound, socket options as Linux reports them (SO_TYPE, SO_ERROR, SO_DOMAIN, SO_PROTOCOL, SO_ACCEPTCONN, the buffer sizes, SO_LINGER, the timeouts, TCP_NODELAY), the flags of a send and a receive (MSG_DONTWAIT, MSG_PEEK, MSG_NOSIGNAL), a receive that waits as long as Linux's,readvandwritevon a socket, andsendmsg,recvmsg,sendmmsg,recvmmsg. - AF_UNIX stream sockets, a file type of their own beside the pipe: a
name in the filesystem or in the abstract namespace, listen, connect,
accept,
socketpair, poll, shutdown, the names and SO_PEERCRED. - AF_UNIX datagram sockets and SCM_RIGHTS: messages kept whole, a connected or a named peer, and open file descriptions passed with a message - A3's counted descriptions, installed in the receiver's table.
- The programs:
wgetfetching from the host, a lookup through musl's resolver at boot, a client and a server over a Unix socket; and LTP for the phase.
Steps 1 to 3 (2026-10-06). A socket is a description whose type carries a
second table, vibeos_sock_ops_t (include/vibeos/sockops.h): bind, listen,
connect, accept, sendmsg, recvmsg, shutdown, the names and the options, in
neutral terms - an address is vibeos_sockaddr_t, a message vibeos_msg_t
with its vector, its flags and the descriptions it carries. Two types fill it:
IP (kernel/abi/files/socket.c, over the stack) and local
(kernel/abi/files/unixsock.c). The Linux handlers (kernel/abi/linux/net.c)
only translate: sockaddr, msghdr, cmsg and mmsghdr in and out, through two
helpers that judge each user range before it is copied. The options every
socket keeps alike - the buffer sizes, doubled as Linux reports them, the
timeouts, SO_LINGER, the flags - live in the description
(kernel/abi/files/sockopt.c); a type answers only its own (SO_ACCEPTCONN,
SO_ERROR, SO_PEERCRED, FIONREAD's count).
- The stack learned what the calls ask of it: a bind to an address and a
port nobody holds (EADDRINUSE, SO_REUSEADDR as Linux has it for TCP and
UDP), an ephemeral port for port 0 and for a
listennobody bound, a connected UDP peer, MSG_PEEK and a datagram's whole length for MSG_TRUNC, and the names of both ends. The ten-second EIO of arecvwent: a receive waits as long as Linux's, or SO_RCVTIMEO. - Local sockets are a table of endpoints, each with a page of ring. A
stream is bytes; a datagram keeps its length and its sender's name in the
ring with it. A name is a node in the filesystem -
bindmakes one with the newmknodoperation, tmpfs has socket nodes, and opening one is ENXIO- or a run of bytes in the abstract namespace; binding no name autobinds five hex digits there, as Linux does. SO_PEERCRED is who connected, or who listened.
- SCM_RIGHTS carries A3's counted descriptions. The sender's references travel with the message, tied to its place in the ring so a stream read stops at the byte they came with; the receiver installs what its control buffer has room for (MSG_CMSG_CLOEXEC honoured) and gives the rest back, with MSG_CTRUNC. Nothing is put under the socket table's lock: a description's last reference may be another local socket's, whose release takes that lock.
- readv and writev on a socket are one receive or one send over the vector, not one per element - a datagram is not cut into pieces.
One thing found by running the step's program on Linux first: a datagram from
a sender with no name arrives with a name of length 0, not the bare family
getpeername returns. Both the host test and the program had assumed the
family.
- Sabotage:
net-l5-inet.txt(3),abi-sockopt.txt(2),abi-unixsock.txt(4),abi-net-l5.txt(3),abi-socket-l5.txt(3), and the anchors of four older case files moved with the code they test. One went NOT RED - SHUT_WR not told to the peer - because the fake kernel hands back an abandoned wait as 0, the very value the end of a stream reads as; the check asks for the outcome now and the case goes red. One more was seen before it ran: the buffer-size check set 1000, whose double is under the floor too, so "not doubled" could not have shown; a second check sets 5000.
Step 4 (2026-10-06): the programs. tests/corpus/run-l5.sh, staged as
/corpus/progs5.sh, runs at boot after L4's: BusyBox wget fetches a page from
the host - a token the gate makes up for each run, so nothing but a fetch can
print it (wget_not_fetched; the first boot of a run only, as the echo) - a
lookup through musl's getaddrinfo goes to QEMU's resolver at 10.0.2.3 and
from there to the host's (dns_not_resolved; asked only when the host can
resolve the name itself, otherwise the verdict says dns_absent), and
SOCKETS.ELF (tests/linux/musl_sockets.c, written and run on Linux first)
checks the step's calls from ring 3, a Unix-socket server and client in two
processes with a pipe passed back among them (sockets_l5_failed). The volume
gained /etc/hosts and /etc/resolv.conf. All three worked on the first boot.
LTP for the phase - tests/corpus/ltp-l5.txt and the seven epoll tests L4
handed over, 64 - first measured 26 passed. Half of what failed was one
thing: there was no loopback. Every LTP socket test talks to itself through
127.0.0.1, and the stack sent that to the gateway; a SYN nobody answered came
back as ECONNREFUSED after the retries. The stack has one now: a packet for
127.0.0.0/8 or for the machine's own address waits in a ring of sixteen and the
next poll - the timer's - feeds it to the input path, never at once, because
the sender is in the middle of changing the socket the answer is matched
against. 127.0.0.0/8 from the wire is a martian and dropped, and a SYN to a
port nobody listens on is answered with a reset, so a refusal is immediate
rather than a timeout. The rest were orders of refusal, each checked against
Linux before it was changed: getsockopt at a level a socket has not is
EOPNOTSUPP (setsockopt's is ENOPROTOOPT); accept on a socket that does not
listen is EINVAL before its buffer is looked at; getsockname with no length is
EFAULT; a vector length negative as a size is EINVAL before any base is judged
(readv and writev on anything); MSG_ERRQUEUE finds nothing; a stream's
recvfrom never writes a source, so a bad one is no error, though a negative
length still is EINVAL; SO_SNDBUFFORCE and SO_RCVBUFFORCE are the superuser's.
After: 44 passed (40 in the full run, then recv01, recvmsg01, sendmmsg01 and
recvfrom01 run again after the last of these), 1 failed, 13 broken, 6 not
applicable. What is left, by owner:
- Elsewhere: network namespaces (
unshare, eight tests), SysV semaphores (sendmsg02),ip/ifconfig(sendmsg01), IPv6, SCTP and RDS,O_PATH(accept03, L1's open), two CPUs assched_getaffinitycounts them (writev03, L6), kTLS and multicast options (setsockopt10, accept02). - Open here: send02 opens a thousand connections in a row and needs MSG_MORE to hold data back - this stack sends at once and keeps a closed connection's slot four seconds, so the table of sixteen runs out first; recvmmsg01 is killed by SIGSEGV in its main process, not looked into.
The step's six boots found one more, older than the phase: a half-closed
connection lost its slot under an open descriptor. A connection that reached
TIME_WAIT armed the stack's four-second reclaim whether or not its owner had
closed it, and one shut for writing with shutdown gets there with the
descriptor open and the peer's last bytes unread. BusyBox nc -l does exactly
that; under three boots at once it read more than four seconds late, read
nothing, and the call counted sock_fd_aba=2 - L4's nc check had been green on
timing. The reclaim waits for the owner's close now, in TIME_WAIT and after
LAST_ACK alike, and t_loopback holds a half-closed client six seconds before
it reads.
- Sabotage:
l5-programs-boot.txt(4 at boot, one per reason, all red); loopback and the reset innet-l5-inet.txt(the reset's went NOT RED first: the retries running out also ends as ECONNREFUSED, so the check asks how long it took), the orders inabi-net-l5.txtandabi-iov-lengths.txt. One more NOT RED had nothing to do with its test: the anchor of "getsockname takes a null length" also matched the same condition inlinux_sa_out, which comes first, andsabotage.pybreaks the first.check-sabotage-anchors.pynow counts anchors found twice in their target; seven older ones are, and the count may only go down.
L5 is closed (2026-10-06).
L6. Threads and scheduling (20, plus futex and clone finished)
The rest of futex (requeue, wake-op, the futex2 calls; priority inheritance
if a program needs it), robust lists, CPU affinity, the sched_* parameters
mapped onto the scheduler's classes, membarrier, getcpu.
Programs: glibc's pthread test programs, a thread-pool workload across four cores; LTP's futex and sched cases.
The plan (2026-10-09). Measured first: tests/corpus/ltp-l6.txt, the
phase's 59 tests from ltp-list.py L6 and the eight of clone3, close_range,
sched_yield and set_robust_list, which earlier phases own. On the parent
(8c6d6c1) 16 of 67 pass. Thirty-four end in TCONF because the call is
missing - the whole sched_* family, getcpu, ioprio, membarrier,
get_robust_list, futex_waitv - and two more because futex answers ENOSYS
to the bitset and requeue operations. The rest: futex_wake02, futex_wake03 and
futex_cmp_requeue01 cannot create the threads and processes they ask for on a
table of 32 slots; clone's vfork (clone05), CLONE_PARENT (clone08) and one
flag combination (clone02) are refused; clone04 dies of SIGSEGV;
set_robust_list accepts a length that is not its structure's.
Four steps:
- The rest of futex: WAIT_BITSET and WAKE_BITSET with their absolute
timeouts on either clock, REQUEUE and CMP_REQUEUE, WAKE_OP; the futex2
calls (
futex_wait,futex_wake,futex_requeue,futex_waitv); robust lists -set_robust_listchecking its length,get_robust_list, and the list walked at a thread's exit, every lock it held marked OWNER_DIED and one waiter woken. Priority inheritance only if a program asks for it. - Scheduling: the
sched_*calls over the scheduler's classes and weights - SCHED_OTHER and SCHED_BATCH in NORMAL, SCHED_IDLE at the smallest weight, SCHED_FIFO and SCHED_RR in a real-time class between the kernel's and NORMAL, ordered by priority;sched_setaffinityandgetaffinityover the mask the picker already honours;getcpu;sched_rr_get_intervalfrom the class's quantum;ioprio_setandgetkept and reported;membarrier. - clone, finished: CLONE_VFORK, CLONE_PARENT, the flags clone02 and
clone04 use,
close_range's UNSHARE in a process with threads, and a task table large enough for what the tests and the thread pool create. - The programs: glibc's pthread programs and a thread pool across four cores, at boot and against Linux.
Step 1 (2026-10-09): futex, finished. Every operation but priority
inheritance, in kernel/abi/linux/futex.c:
- A sleeper on several words. A waiter's table entry points at its
sleeper's first, the leader, where "woken" and "by which word" are kept, so
futex_waitvis the same wait as FUTEX_WAIT with more entries, and a wake that matches any of them counts once. Enqueueing compares every word under one hold of the lock, as a single wait always did; the sleep is a separate step that needs only the leader, so a 128-word vector is keyed into static arrays and released before the wait. The table is 512 entries (LINUX_FUTEX_TABLE, renamed fromLINUX_FUTEX_WAITERS, which is a bit in a lock word to Linux). - Bitsets, requeue, WAKE_OP. A waiter keeps its bitset and a wake reaches
only those it shares a bit with. Requeue rekeys entries in place - a moved
waiter sleeps on, woken by the second word. WAKE_OP changes its second word
with a real compare-exchange on user memory:
vibeos_uaccess_cmpxchg32(uaccess.S), onelock cmpxchgwith the copy's recovery point, behindks_user_cmpxchg32. A read and a later write would lose another thread's atomic in between. - Absolute timeouts (WAIT_BITSET, futex2) are ticks since boot, which is what every clock here counts, CLOCK_REALTIME included.
- Robust lists.
set_robust_listchecks its length,get_robust_listanswers for a thread the caller could read, and exit walks the list before it clears the join word: a lock word still naming the thread is marked OWNER_DIED, its waiters bit kept, one waiter woken. Bounded by Linux's 2048. The head is a field of the task's identity, set by fork, clone and exec - and by the boot-time spawn, which had never clearedclear_child_tideither. - Found by LTP: futex_waitv01 - a vector word at address 0 was read, not refused. The copy survives a page that went away but does not ask whose it is, and a Linux program's null page is the kernel's, present. Every word the kernel reads to compare is judged first now.
- Tests:
t_futex_l6in the handlers' host tests, and two stages of THREADS.ELF at boot: C6_ROBUST (a child process ends holding a shared robust mutex; the parent's lock is EOWNERDEAD) and C6_COND (a broadcast releases four waiters). - Sabotage:
futex-l6.txt9 of 9 red;futex-l6-boot.txtred (robust_lock_not_released_at_exit). Two first versions proved nothing and were rebuilt: WAKE_OP's "wakes nobody when the comparison fails" had nobody waiting, and C6_ROBUST's thread returned through musl's pthread_exit, which walks the list in user space - the stage passed with the kernel's walk removed. Ending the thread by the raw exit call instead corrupted musl's thread-list lock and hung the boot; a child process is the arrangement in which only the kernel can release the lock.futex-l6-cond-boot.txtrecords why C6_COND has no case: a requeue that moves nobody went NOT RED, so the stage does not depend on it. - LTP: 21 of 67 (16 before): futex_cmp_requeue02, futex_wait_bitset01, futex_waitv01 and 02, get_robust_list01. What is left is steps 2 and 3, the 32-slot task table (futex_wake02 and 03, futex_cmp_requeue01), and SysV shared memory (futex_waitv03, L7).
- Validation: check.sh green, 6 of 6 boots. Three boots in parallel wedged once in the reclaim load while the host ran a game; one boot alone passed, and the six after it, with the host idle.
Step 2 (2026-10-11): scheduling. kernel/abi/linux/sched.c, over the
scheduler's policy:
- A REALTIME class in
sched_policy.c, between the kernel's and NORMAL, with a priority 1-99 that orders the pick before virtual time and preempts a lower one at once. Moving a slot between classes starts its virtual time at the new class's floor, as an admission does. The torture's model learned it (rt_priority_inverted, and a seventh operation that changes class); with the priority order removed it fails 40 seeds of 40. - The policies: SCHED_FIFO and SCHED_RR are REALTIME, SCHED_OTHER and
SCHED_BATCH NORMAL, SCHED_IDLE NORMAL too - the IDLE class is the cores'
idle tasks, behind which a program could starve. FIFO is time-sliced like
RR. The policy, reset-on-fork and the I/O priority are the task identity's;
linux_sched_inherithands them to a fork or a thread, with reset-on-fork honoured. RLIMIT_RTPRIO bounds an unprivileged caller. SCHED_DEADLINE is EINVAL - no admission control to decide it by - which is sched_setattr01's and sched_getattr01's failure. - Affinity, getcpu: over the mask the pickers already honoured. A caller that unpins itself from its core is off it when the call returns, as on Linux (LTP's getcpu01 asks at once).
- ioprio: kept and reported per thread, refused as LTP expects - a data field of 8 is EINVAL, whatever newer kernels call its upper bits; NONE reads back as NONE.
- membarrier: every command but RSEQ's, the barrier being the shootdown IPI to the cores running the address space (or each one running a user space, for GLOBAL), which is an interrupt and so serialises them.
- At boot: THREADS.ELF's C6_SCHED pins a child to core 1 and requires it to run there, sends membarrier's barriers with another thread spinning, and reads SCHED_FIFO back - through the system calls, because musl's sched_setscheduler is ENOSYS on purpose (the first version measured that).
- Sabotage:
sched-l6.txt8 of 8,sched-l6-policy.txt4 of 4,sched-l6-boot.txtred. The priority-range check went NOT RED first: the policy refuses the same priorities later, so only the order of the errors (EINVAL before EPERM, for another user's task) shows the handler's check. - LTP: 48 of 67, then getcpu01 and ioprio_set02 fixed. Left: clone (step 3), the task table (futex_wake02, 03, futex_cmp_requeue01), SCHED_DEADLINE, and what is not this phase's - SysV shared memory (L7), capget (L9), cgroups, a loop device, /proc/sys. clone04 dies of SIGSEGV on Linux too: the musl it is built against writes to a NULL stack before the call.
- Validation: check.sh green, 6 of 6 boots, 30 torture seeds.
Step 3 (2026-10-11): clone, finished.
- A process on its parent's memory. CLONE_VM without CLONE_THREAD was
ENOSYS, and with it musl's and glibc's posix_spawn, which are built on
CLONE_VM|CLONE_VFORK. Such a child now gets a process state of its own - its
descriptors, handlers and identity copied as fork copies them - and the
parent's page tables rather than a copy (
ks_share_aspace), with a copy of the region list to fault and unmap by. Nothing new was needed to keep the tables alive: exit already keeps an address space another task is running in, and exec already leaves it to whoever is. What this does not do: CLONE_FILES, CLONE_FS and CLONE_SIGHAND between two processes copy rather than share (the registry's note, and a gap test), and a child that maps or unmaps while its parent runs leaves the parent's region list behind - a vfork parent is not running, which is what this is for. - CLONE_VFORK keeps the calling thread in clone until the child execs
(
in_vfork, cleared by exec) or ends. CLONE_PARENT gives the child the caller's parent. A forking clone writes its tid words too: the parent's where it says, the child's into the child's own copy before it runs (ks_poke_u32, through the child's tables, breaking copy-on-write as its first store would), and keeps CHILD_CLEARTID. The stack and SETTLS clone is given are the child's. CLONE_SIGHAND without CLONE_VM is EINVAL, as Linux. - The task table is 64 slots, from 32 - as far as the scheduler's 64-bit runnable masks go. LTP's futex_wake02, futex_wake03 and futex_cmp_requeue01 ask for 55 threads, 55 children and 100 waiters, and still do not fit.
- At boot: THREADS.ELF's C6_VFORK - a CLONE_VM|CLONE_VFORK child stores into its parent's memory before clone returns, and musl's posix_spawn runs this program again as its exec probe.
- Sabotage:
clone-l6.txt5 of 5,clone-l6-boot.txt2 of 2. The host test of shared memory went NOT RED first: the fake's kf_ualloc arena is in nobody's page tables, so a store there is seen by every task whether memory is shared or not; the test now stores through the child's tables and reads through the parent's. - LTP: 53 of 67. clone02, clone05 and clone08 pass.
- Validation: check.sh green, 6 of 6 boots.
Step 4 (2026-10-12): the programs. tests/corpus/threadpool.c - a pool of
four workers on a queue, a barrier, bounded semaphores, thread-local storage
and pthread_once, and a cancelled pthread_cond_wait - is built by
corpus-build.sh against musl statically and against glibc dynamically, run
by the corpus in the guest and on Linux, and compared line for line
(l1-expected.txt, refreshed with corpus-expect.sh). Its answers depend on
nothing but the kernel: fixed work, fixed results, no core counts. musl's ran
first time. glibc's found three defects, none in a call this phase wrote:
- A fatal signal ended one thread. The default action of a signal that
kills, a ring-3 fault and a forged signal frame all ended the thread that
took them, where Linux ends the process. glibc's abort in the thread being
cancelled (libgcc_s, which pthread_cancel loads with dlopen, was not on the
volume) killed the main thread and left the cancelled one blocked in a
futex; the machine, which leaves userland only once every user task has
ended, never finished the boot. Every fatal end now goes through
hw_task_kill_group, exit_group's mechanism with the signal kept as every thread's status. A sibling killed by a thread's exec is the exception (exec_killed): the first boot after the change failed THREADS_C5_EXEC, because that sibling's SIGKILL began a group kill that ended the exec'ing thread too. THREADS.ELF's C6_ABORT is the stage: a child aborts with a thread waiting, and nothing may answer to its pid afterwards. - The TLB quarantine leaked when full. A frame unmapped while another core
runs the same address space is parked until that core has flushed, in 512
slots, and a frame that found them full was leaked by design (H-015).
glibc gives a finished thread's stack back with madvise(MADV_DONTNEED) over
two thousand pages, so a pool of four threads lost about fourteen hundred
frames a boot (
userland_frames_lost). Bisected by program - musl clean, glibc not - then by section of the program, then read offtlbq_overflow. A full quarantine now has the cores holding the space flush through the shootdown, releases the frame, and drains. - The mm lock's waiters did not answer a flush. The first boot with that
fix wedged: the holder of the mm lock, in madvise, waited in the shootdown
for two sibling threads that were spinning for the same lock, whose
one-instruction interrupt window QEMU's TCG never took the IPI in. The
spinlocks already answered a flush themselves;
ks_mm_lockdoes now too. fork's shootdown under the same lock had the same exposure. - And libgcc_s.so.1 is staged with glibc's libraries, since ldd does not list what is opened with dlopen.
- Sabotage:
kill-group-boot.txt,exec-kill-boot.txt,tlbq-full-boot.txt,mm-lock-flush-boot.txt;ring3-fault.txtre-anchored;t_sigreturn_forgedasserts the group kill on the host. All four go red for their own reason on an idle host. - Validation: check.sh green; boots 6 of 6. A first run of the same binary measured 0 of 6 - every boot wedged with cores in the disk drivers during the reclaim load, one ran out of time still printing - on a host running a game and, for two minutes, a recursive grep over the build tree. Read as the host's because it was the signature recorded the last time, and confirmed by running again with the host idle, not by the count.
- And the nightly's corpus check, red since step 1: each C6 stage made
THREADS.ELF ask for calls its checked-in needs did not list. They are
measured again (
corpus-measure.sh) anddocs/abi/corpus.mdrewritten. A boot program that gains a stage changes what the corpus says it needs, and only the nightly compares the two.
L6 is closed (2026-10-12). LTP: 53 of 67 from 16. What is left, and why: futex_wake02, futex_wake03 and futex_cmp_requeue01 need more than 64 tasks (the runnable masks are 64-bit); sched_setattr01 and sched_getattr01 use SCHED_DEADLINE, refused for want of admission control; clone04 fails on Linux too (its musl writes to a NULL stack before the call); and futex_waitv03 (SysV shared memory, L7), clone304 (unshare), clone09 (/proc/sys), close_range01 (a loop device), clone11 and sched_setscheduler04 (capget, L9), clone303 (cgroup v2) and futex_wake04 (hugetlbfs) are other phases' or not this kernel's. A CLONE_VM process shares memory but copies CLONE_FILES, FS and SIGHAND (the registry's note).
L7. IPC (21)
System V shared memory, semaphores and message queues; POSIX message queues;
splice, tee, vmsplice.
Programs: LTP's ipc cases; a producer and consumer over SysV shared memory.
The plan (2026-10-12). Measured first: tests/corpus/ltp-l7.txt, the
phase's 81 tests from ltp-list.py L7. On the parent (L6's close) none of
the 81 passes: every one of the 21 calls answers ENOSYS. What the tests ask
for besides the calls is a /proc that LTP's IPC library reads and writes -
/proc/sysvipc/{msg,sem,shm} counted line by line, msgmni, shmmax,
msg_next_id and /proc/sys/fs/mqueue/queues_max written and read back - so
/proc grows its first writable files.
Six steps:
- What the three kinds share, and message queues (
ipc.c): Linux's numbering, ipcperms and the owner rule, the*_STATand*_INFOcommands, the four msg calls; /proc's owner files. - Semaphores (
sem.c): semop whole or not at all, semtimedop, SEM_UNDO. - Shared memory (
shm.c): segments as unnamed tmpfs files, attachments as shared mappings. - POSIX message queues (
mqueue.c). - splice, tee, vmsplice (
splice.c). - The programs: a producer and consumer over SysV shared memory and semaphores, with a round trip through each kind of queue, in the corpus.
The calls are new and touch nothing that runs today, so steps 1 to 5 were written together and validated as one run; step 6 had its own.
Steps 1 to 5 (2026-10-12): the calls. In five files under
kernel/abi/linux/, over one lock (linux_ipc_lock), nothing slow under
it - a message's text is copied through kernel pages outside it.
- Numbers are Linux's:
seq << 15 | index, the index handed out cyclically over at least 64 as Linux's idr has since 5.4 (a queue removed and made again gets the next number, 1 2 3 rather than 1 2 1, checked against WSL's kernel), and the sequence moving on when the cycle restarts.msg_next_id,sem_next_idandshm_next_idask for a number; the next free index at or above it is given, and the setting is used up. Thirty-two objects of each kind are kept, and msgmni, semmni and shmmni say so. - Permissions are ipcperms - owner or creator, group or creator's group, others; the superuser passes - and removing or changing belongs to owner, creator and superuser.
- Message queues: by type, the lowest at or below a bound, MSG_EXCEPT, MSG_NOERROR, MSG_COPY by position; a message taken for a buffer that faults is lost, as on Linux, which copies after the unlink too.
- Semaphores: sets of up to 250; semop tries the list on a copy and
changes the set only if nothing would wait; a waiter is counted on the one
semaphore that stopped it; SEM_UNDO is kept per process and added back when
its last thread leaves (
linux_ipc_exit, from exit), SETVAL and SETALL clearing everybody's adjustment as Linux does. - Shared memory: a segment is a /tmp file nothing names, made as
memfd_create's is (
linux_hidden_file); an attachment is a shared mapping of it whose regions carry the segment's number plus one inbacking_id. The attach count is not kept: it is read off every process's regions when asked (linux_shm_attached), because fork copies a region, exit and exec drop them and munmap can take one, and a counter beside them would be one more field each of those paths had to remember. IPC_RMID while attached marks SHM_DEST and makes the key private; the segment goes at the next shm call after its last detach. mremap carries the tag. - POSIX queues: a descriptor type; messages by priority, each a header page and pages of text the sender allocates, so a queue the superuser makes past the defaults costs only what is in it; mq_notify by signal - SIGEV_THREAD is a netlink socket's to deliver, and there is none.
- splice and tee go through a kernel page and look into the pipe before
they take from it (
vibeos_pipe_peek), so a destination that takes less loses nothing. - /proc takes files from its owner (
vibeos_procfs_extra_t) with their text and, for a tunable, what a write does. Writing follows Linux for the superuser: any file opens for writing, a non-tunable refuses the write with EIO, a name /proc does not have is ENOENT; the mount reads rw. - The registry took sixteen tables of rows and refused a seventeenth with a panic at boot - in the host tests, a panic outside any call and a wall of unrelated failures. It takes 32.
Host tests: t_ipc_msg, t_ipc_sem, t_ipc_shm, t_mqueue, t_splice;
the layout of every structure compared with uapi. Sabotage: ipc-msg-l7,
ipc-sem-l7, ipc-shm-l7, ipc-shm-mm-l7, mqueue-l7, splice-l7 - 30
cases, all red. Validation: check.sh green, boots 6 of 6, LTP 61 of 81.
Step 6 (2026-10-12): the programs. tests/corpus/sysvipc.c - a
producer and a consumer over a sixteen-slot ring in a segment, three
semaphores guarding it (SEM_UNDO on the ring's), then five messages through a
SysV queue by type and four through a POSIX queue by priority - under musl
statically and glibc dynamically, run by /corpus/run.sh and compared with
what Linux printed. The phase's LTP run found one defect of its own: vmsplice into a pipe was
writev, which waits until every byte is in, where Linux puts in what fits
and returns the count - vmsplice01 offers 128 KiB to a pipe it drains
itself, and waited for ever. And F_GETPIPE_SZ answers the pipe's capacity.
Validation: check.sh green, LTP 64 of 81; boots 5 of 6, the failure L6's
(below).
L7 is closed (2026-10-12). LTP: 64 of 81 from 0. What is left, and why: mq_notify01 and mq_notify03 use SIGEV_THREAD, which needs netlink; mq_open01 expects EINVAL for an empty name, which glibc says and the kernel
- Linux's too, under musl - answers ENOENT; msgctl05, semctl08 and shmctl06
need the 32-bit time_high fields x86-64 does not have; shmctl05 needs
remap_file_pages (deferred); shmget02 reads the kernel's .config; splice06
needs pipe-max-size, splice07 userfaultfd, splice08 and splice09 Linux 6.7.
Two gaps were this kernel's and not this phase's, and both were closed the
next day. A pipe held 4 KiB where Linux's holds 64: it is a ring of pages
now, 64 KiB, resizable with F_SETPIPE_SZ up to /proc/sys/fs/pipe-max-size, with
64 pipes where there were 8 - and splice and tee, which read through a one-page
bounce, had been bounding each read by the pipe's free room alone, which with a
64 KiB pipe would have written past the page; they move a page at a time now,
and all the pipe holds up to the length asked, as Linux does (vmsplice01). And
shmctl01's fork was refused by a per-task ceiling of eight children
(
too-many-children), raised to half the table; its count of attachments was then three of twenty, because /proc showed a child woken by a signal still as S - a waiter with a signal it will take is R now, as on Linux. LTP after both: 67 of 81. Left of the two: a sleep lasts at least a tick, so semop04's two thousand microsecond sleeps take twenty seconds and its timeout ends it; and splice05 (pipe to socket) still times out.
Open, and not L7's: C6_SCHED, about one boot in eighteen. A child of THREADS.ELF pinned to core 1 was still not on it after three seconds of sched_yield (exit 11). sched_setaffinity now says, when a second has passed and the caller is still off its allowed cores, what each of those cores is running (an INFO line, code 63). Over some thirty boots it has said: another user task, NORMAL or IDLE class, on most of them - so the caller was released at the end of its slice and waited in a busy core's queue. A latency, and open; the review tracker carries it. A first "fix" that changed the wait from ks_idle to ks_wait_tick was based on a wrong premise - both halt the core and neither schedules, the tick does - and was taken back.
L8. System administration (11, plus uname finished)
mount/umount2 over the mount table, chroot, pivot_root, reboot,
sethostname, swapon/swapoff over the existing swap area, syslog over the
kernel log.
Programs: an init that mounts its filesystems and switches root; BusyBox
mount, dmesg, reboot.
L9. Security (6)
Capabilities (capget/capset) on top of L2's credentials, seccomp in filter
mode, Landlock. This is where ../vision.md's security goals -
isolation, privilege domains, sandboxing - become syscalls rather than host
models.
Programs: a seccomp-sandboxed program killed for a forbidden call; LTP's capability and seccomp cases.
Deferred and refused
Deferred (50): NUMA, legacy AIO, io_uring, namespaces, the new mount API,
fanotify, file handles, quotas, ptrace, key management, memory protection keys
and the rest listed in syscalls.md. ENOSYS, counted as deferred.
Each moves into a phase the day a corpus program asks for it.
Refused by decision (33): syscalls Linux itself removed or never implemented
(uselib, create_module, _sysctl, tuxcall, ...), loading code into the
kernel (init_module, kexec_load, bpf), raw hardware access (iopl,
ioperm, modify_ldt), and rseq, whose ENOSYS the C library already handles.
Each has its errno and its reason in the registry.
Order and dependencies
A0 and A1 first: they make every later step measurable. A2 before A3, because A3's open file description is the first thing the host tests will exercise. A4 depends on A3 (a directory descriptor is a file type). A5 can run beside any of them.
L1 needs A3 and A4. L3 is what unlocks glibc, so it comes before L6, whose
target programs are glibc's. L4 needs A3's file types. L5's SCM_RIGHTS needs
A3's descriptions. L9 needs L2's credentials. L7 and L8 are the least entangled
and can move if a program asks earlier.
L8 waits (decided 2026-10-12, after L7). System administration is the last
of a sequence the project's owner set, because what it administers has to be
defined first: the Windows system calls (the second personality over
vibeos/ksvc.h), the filesystem's hierarchy, the user interface, the whole of
security (of which L9 is the Linux-facing part), the applications that will
manage the system, and the rest of networking - then L8. The order is recorded
in ../roadmap.md.
Where the refactor started from
The layer was built one syscall at a time as each program needed one, which is
how 73 rows got written and is not how 373 will be. C4 (2026-09-21) gave it one
table, one validation engine and one operation vocabulary; A1-A5 give the rows
beneath that table a registry, a portable home, descriptors, paths and layouts
that match Linux. The second syscall dispatcher docs/roadmap.md listed as an
open decision was deleted in C3 and is not part of this plan.