Skip to content

linux: the files, /proc, /dev and system facts a Linux program looks for - #587

Open
eKisNonos wants to merge 36 commits into
linux/go-guestsfrom
linux/guest-files
Open

eKisNonos wants to merge 36 commits into
linux/go-guestsfrom
linux/guest-files

Conversation

@eKisNonos

@eKisNonos eKisNonos commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Linux programs go looking for a lot more than read and write. They open /dev/null, read /proc/self/exe, call statfs before writing a big file, take flock on a lockfile, ask sysinfo how much memory there is, and Go's runtime wants a signal stack. This branch serves all of that for a Linux family on NONOS.

The rule I held to: every file that describes the system says only what NONOS declares, or what the kernel measured of the family's own threads. Nothing from the host, another capsule or another family leaks into /proc, /sys or any syscall answer.

This builds on #582 (linux/go-guests); the 36 commits on top of it are the work here. Each commit carries one change and builds on its own.

What works now

  • File calls: pwrite64, preadv/pwritev and the v2 forms with the RWF_ flags Linux 6.1 knows, sendfile, copy_file_range, truncate and ftruncate, fallocate, fadvise64, close_range, sync/syncfs/fdatasync, the xattr calls, and fcntl F_DUPFD / F_DUPFD_CLOEXEC.
  • One copy of each file for the whole family, so two processes see each other's writes and share an offset across fork, as on Linux. A big write is taken whole in one call.
  • flock and fcntl record locks between processes, with Linux's rules: a blocking lock waits, and locks go away at close and at exit.
  • Path walking one name at a time, so .. after a symlink goes where Linux sends it. openat2 serves RESOLVE_BENEATH, IN_ROOT, NO_SYMLINKS and NO_MAGICLINKS.
  • stat, statfs, access and readlink answer as Linux does. statfs's fields sit at Linux's offsets, the sizes are the room the family really has, and a full store gives ENOSPC instead of EIO.
  • /dev, /proc and /sys, built from the declared surface and the family's own processes. A family only ever sees its own pids and cannot climb above its root. /proc//mem is refused.
  • sysinfo, getrusage, times, the id/group/priority calls and personality. The load average is measured, not zeros, and the family's file copies show up as Cached and Shmem.
  • Signals: the frame's registers sit where Linux's ucontext has them, and each thread has its own sigaltstack with SA_ONSTACK handlers running on it (Go needs this).
  • fork of a process with a region over 1 MiB. Before this, a process holding a 3 MiB buffer could not fork at all (ENOMEM).
  • inotify is refused by name rather than silently missing.

How it was checked

Three proof guests were written for this and run twice: once on NONOS (QEMU, one CPU) and once on the host under sh/oracle.sh (root without capabilities, its own pid namespace and /proc, a tmpfs /tmp, no tty). Nothing they print depends on the machine, so the two runs can be compared line by line.

Guest On NONOS On the host
cfiles (file and system calls) PASS, 15 parts, unserved=0 PASS, 15 parts
cproc (/dev, /proc, /sys, isolation) PASS, 10 parts, unserved=0 PASS, 10 parts
goos (Go's os, io/fs, path/filepath, flock) PASS, 5 parts, unserved=0 PASS, 5 parts
bbsuite (busybox, 40 applets) 148 lines in 27 sections equal the host's the expected output is the host's own run

cfiles and goos print exactly the same lines on both. cproc differs on two lines, on purpose: Linux lets a process open /proc/self/mem and names the real CPU in /proc/cpuinfo, and NONOS does neither.

bbsuite has one section it can't finish yet: (echo bg1 > f1) & (echo bg2 > f2) & wait spins, because ash's wait needs rt_sigsuspend and SIGCHLD, which aren't served. With that one line taken out, everything else matches the host.

The guests that were already there still pass from a clean build of this branch, all with unserved=0: gohello, goconc, gopoll (three boots), cthreads, threadfault (the worker's fault still ends the whole process) and cwait.

Negative controls

Every proof was checked the other way round too: take one piece of the fix out with a one-line change, rebuild, boot, and watch the matching part fail while the rest keeps passing.

Taken out What failed
statfs counting what is used cfiles statfs
the measured load average cproc load
the cache's byte count cproc memory (Shmem/Cached)
taking a write whole cfiles pwrite
chunked fork copies cfiles hangs at the first fork with a 3 MiB buffer
switching to the alternate stack goos stops after its first part
startcode/endcode cproc stat
RESOLVE_IN_ROOT cfiles openat2
RWF_APPEND cfiles preadv
the pwrite64 entry cfiles pwrite, plus unserved nr=18
parking a lock that has to wait cfiles flock and fcntl_lock
the kernel showing a supervisor its guests' rows cfiles usage
/proc itself cproc self and maps
refusing /proc//mem cproc mem
O_APPEND bbsuite, 1 line of 148 differs
cpuinfo's fixed model name (reading CPUID instead) cproc facts, which caught "QEMU TCG CPU version 2.5+"
a grandchild's counts reaching its grandparent cproc stat (10 switches where there were 459)
absolute names in chdir cproc isolation

Two real bugs came out of this, and both are fixed here. A parent never received the faults and switches of a grandchild its child had waited for, which Linux does count. And chdir to an absolute path failed from any directory other than /, which is what broke busybox's subshell section.

Build and gates

  • Every commit was checked on its own: the capsule builds with no warnings, the host-side proofs pass (112 tests), the C guests compile clean with -Wall, and goos passes go vet.
  • The kernel shows the same 43 warnings as the base, line for line.
  • The repo gates give the same output as the base. check_unreachable exits 1 on both for the same pre-existing site, which this branch doesn't touch.
  • The only kernel change is procstat_redact.rs (+7 -1). The kernel's PT_LOAD segments and section sizes are identical to the base, and only the .text bytes differ.
  • Every file this branch adds is at most 75 lines, comments are /* */, and there's no #[allow].

What the system reports

What a program reads What it gets
uname, /proc/version, hostname Linux 6.1.0, x86_64, host and domain nonos, version "NONOS Linux personality"
CPUs one, a "NONOS virtual CPU" with the plain x86-64 baseline flags
MemTotal, totalram 2147479552 bytes, the family's memory limit
MemFree, MemAvailable the limit less what the family's processes hold, as the kernel counts it
Cached, Shmem, sharedram the bytes held by the family's copies of files it writes
uptime, btime time since the family started
/proc/stat and loadavg the family's own threads' ticks, switches and load
pid_max, pipe-max-size, overcommit, hugepage size 32768, 65536, 1, 2 MiB
statfs on / what the store holds under the family's root, nothing free
statfs on the private tmpfs half the family's memory (tmpfs's own default) less what's already kept there
ids and capabilities root (0/0), no groups, no capabilities

A few figures are 0 because the kernel doesn't keep them, and the code writing each one says so: ru_maxrss is the resident size at the call (no peak is kept), ru_nivcsw is 0 because switches aren't split into voluntary and involuntary, ru_inblock/oublock are 0, and /proc//stat's flags and signal are 0. Minor fault counts are small but true: NONOS backs anonymous memory when it's mapped, so a process takes no fault where Linux would take one per page.

Not done here

  • rt_sigsuspend and SIGCHLD at a child's exit, which is what bbsuite's last section needs.
  • inotify is refused, not served.
  • A file the family writes can be at most 8 MiB (EFBIG past that), which is the size of the copy it keeps. The private quota can't be filled inside the 16 MiB test store, so no proof here runs the store full.
  • A few files that were already over 75 lines before this branch grew by a line or two: file/mod.rs, call/mod.rs, serve/family.rs, serve/waits.rs, abi/nr_path.rs and Guests.mk. The new guests' make rules went into GuestProofs.mk and GuestOnly.mk to keep Guests.mk from growing more.

Follow-ups outside this branch

  • F_GETFL still reports O_RDWR for every file. It should report how the file was opened, using desc::reads and Fd::writable.
  • PR_GET_NAME answers an empty name until PR_SET_NAME has been called. Linux answers the comm, which file::exe_of(pid).comm already holds.
  • A forked child should start with the parent thread's alternate stack, and a thread that ends should drop its entry (call::sigstack::altstack::set).
  • /proc/<pid>/exe is recorded from argv, so an exec whose argv[0] isn't the program's path reads ENOENT. program.path in exec_load.rs is the right source.
  • exec_resolve.rs still normalises .. before following links. file::follow walks it the way Linux does.
  • ru_maxrss would be Linux's if the kernel kept a peak next to mem_kb.
  • The test store holds at most 16 MiB, so build test images with LINUX_GUEST_ONLY="<names>" to carry only the guests they boot.

The file table held every call that names a file or a descriptor in one
match of 77 lines, and the calls this lane adds would lengthen it
further.

Now stat and its forms, access, statfs, statx and readlink are answered
from serve/table_meta.rs, which the file table falls back to when it
has no arm. The table is the same; nothing is answered differently.
Nothing checked that a real Linux tool's output under the personality
matches Linux's. bbsuite is a busybox sh script of 28 sections that runs
more than forty applets: file and directory work, text tools, archives,
pipes, redirects, subshells, background jobs, traps, ps, df and /dev.
The same busybox runs it on the host at build time, through the same
applet links and with the guest's environment and nothing of the host's
own tools on the path; that output is the oracle. On NONOS the script
compares its own output with the oracle's, less the lines that start
with @ (they name a time or a pid), and prints PASS or FAIL with the
number of lines that differ. /etc/passwd and /etc/group name root, as an
Alpine tree does, so ls and ps print owners by name on both sides.
MkProcStat showed a caller its own row in full and every other row with
its counters zeroed, unless it held AttestRead or ProcessControl. The
Linux personality answers getrusage, times and /proc for the guests it
hosts, and could read none of their ticks, faults or resident memory:
every such figure would have had to read as zero. A supervisor now sees
the full row of each process registered as its foreign guest, and still
sees nothing more of anyone else's. The kernel reports; the personality
decides what a guest is told.
A written file lived in the descriptor that wrote it: its bytes in the
descriptor's own buffer, flushed whole at close, and its offset in the
descriptor alone. A dup or a fork copied the descriptor without either,
so the copy read EBADF and a write through it replaced the file at close
with only what it wrote. A descriptor opened O_APPEND wrote at offset 0,
and a write-only open that did not truncate lost the file's old bytes. A
shell's own output and the output of the commands it forks overwrote
one another in a redirected file: busybox sh left 3 of 148 lines.

Now the family keeps one copy of each file it writes, by path (held/cache/):
every descriptor on it, in every process, reads and writes that copy, as
Linux's page cache gives them, and stat's size is its size. Each open
makes an open file description (held/desc/), which a dup and a fork share:
its number, O_APPEND, whether it reads, and its offset, so a child's
write moves the parent's offset. A dup or fork's descriptor opens its
own stream from the store when it reads. The copy goes to the store at
close and fsync. A write open of the read-only tree is EROFS at open, a
directory opened to write is EISDIR, O_EXCL is honoured, and fdatasync
is fsync; a pipe or a socket cannot be synced (EINVAL).
F_DUPFD answered ENOSYS, because a duplicate needed a second handle on
the store, and F_DUPFD_CLOEXEC was EINVAL. busybox sh moves the file it
reads a script from, and the descriptors it saves around a builtin's
redirection, above 10 with F_DUPFD_CLOEXEC, so it died on its first
line: "sh: 3: Function not implemented". A file's description is the
family's now and a duplicate reads its own stream when it needs one, so
both commands make a second descriptor on the same open file at the
lowest free number at or above the one asked for, EINVAL past the
descriptor table and EMFILE when it is full. This is two arms in
call/ctl.rs, a file of lane A's; the work is in file/calls/fdup.rs.
stat reported every file 0644 and every directory 0755 whatever chmod
had said, one link, no times, and uid and gid 0 only by accident of a
zeroed buffer; lstat followed links, so ls -l showed no link as a link;
newfstatat refused a directory descriptor (ENOSYS) and ignored
AT_SYMLINK_NOFOLLOW and AT_EMPTY_PATH; statx gave a third answer; access
never said EROFS or EACCES; utimensat refused any time but "now for
neither"; a listing had no "." or ".."; unlink could not remove a
symbolic link; and a file the family had made but not yet written out
did not exist to stat, ls or rmdir.

Now one function answers for a path or a descriptor (meta/node/), and
stat, lstat, fstat, fstatat and statx all fill from it. The store keeps
no modes and one time, so the family keeps the rest for its own files:
the mode each was made with less the umask, or chmod's (held/modes.rs), and
the times utimensat, utimes and utime set, which stand until the next
write (held/times.rs). The shared tree is read-only (EROFS for chmod, rename,
unlink, mkdir, utimensat and access W_OK). A pipe or the console fstats
as a FIFO, a socket as a socket. Directory descriptors keep their path
across a chdir, a dirfd's high 32 bits are ignored as Linux ignores
them, mkdir takes its mode, open takes O_CREAT's mode and O_NOFOLLOW,
and rename and unlink carry the family's copy, mode and times along.
A guest found no /dev, /proc or /sys. busybox ps listed nothing, df found
no mounts, Go read /sys for its huge-page size and /proc/self/exe for
os.Executable and got ENOENT, and a shell's 2>/dev/null had no file.

Now the personality makes the three trees at every read. /dev has null,
zero, full, random, urandom and tty with Linux's numbers and behaviour
(full is ENOSPC, tty ENXIO with no terminal), and the stdin, stdout,
stderr and fd links into /proc/self/fd. /proc has a directory for each
process of the family and none other, under the number the family's pid
namespace gives it: exe, cwd, root, fd, fdinfo, maps, status, stat,
statm, cmdline, environ, comm, limits, mounts, mountinfo, cgroup and
task/<tid>; mem is listed and refused at open. The system-wide files
answer from one table of what NONOS declares (file/system/declared/, also now
uname's source) and from the kernel's counts for the family's own
threads (file/system/cpu/); nothing of the machine, another capsule or
another family is in any of them. /sys has the one file Go reads.

Only the family knows its processes and their numbers, so it lends /proc
a view of itself for each call that may read /proc and takes it back
after (serve/family_view/): two lines in Family::answer, a file of
lane A's. Paths follow /proc/self, /dev/fd, /proc/<pid>/cwd, root and exe
as links, never out of the family's root. statfs and st_dev come from
the family's mount table, and getppid now names a forked child's
parent, as /proc/<pid>/stat does.
pwrite64, preadv, pwritev, preadv2, pwritev2, sendfile, copy_file_range,
truncate, fallocate, fadvise64, close_range, openat2, sync, syncfs,
creat and the twelve xattr calls answered ENOSYS, and ftruncate refused
a regular file. busybox cat copies with sendfile, Go's io.Copy between
files uses copy_file_range, and os.Truncate and the xattr calls are
os and x/sys routines.

Each now answers as Linux does. The positional and vector forms read
and write at an offset without moving the descriptor's (ESPIPE on a pipe),
and the v2 forms take -1 for the descriptor's own offset and flags 0.
sendfile copies a file to a file or to the console, and answers EINVAL
for a pipe or a socket, as Linux does for an output it cannot splice
into, so its callers fall back to read and write. copy_file_range copies
between two files. truncate and ftruncate resize the family's copy.
fallocate is tmpfs's, since the family's writable directories are its
tmpfs mounts: mode 0 grows, KEEP_SIZE keeps, PUNCH_HOLE with KEEP_SIZE
zeroes, and the rest is EOPNOTSUPP. close_range closes or marks a range
close-on-exec. openat2 honours RESOLVE_BENEATH, NO_XDEV, NO_SYMLINKS and
NO_MAGICLINKS, answers CACHED with EAGAIN as Linux may, and refuses
IN_ROOT with EINVAL, as a kernel refuses a flag it does not know. sync
and syncfs put every file the family is writing into the store. The
family keeps its files' extended attributes as tmpfs keeps them, with
Linux's names, flags and errors; the read-only tree has none (EROFS).
inotify_init, inotify_init1, inotify_add_watch and inotify_rm_watch
answered as unserved numbers. None of the programs this lane proves asks
for them: the host's strace of busybox running bbsuite, of Go's os,
io/fs and path/filepath test suites, and of the regression guests shows
no inotify call, and Go's runtime makes none. The store sends no change
events a watch could be built on. So each is refused on purpose, with
its reason on the console and ENOSYS, which a program that can do
without it (tail -f, a file watcher's polling fallback) takes as "not
here" and goes on.
flock answered ENOSYS and fcntl's F_GETLK, F_SETLK, F_SETLKW and their
OFD forms EINVAL, so Go's syscall.Flock and any program that locks a
file failed.

The family keeps one lock table (file/locks/lock/) with Linux's three kinds.
A flock lock belongs to the open file description, so a dup or a fork
shares it; it goes when the last descriptor on the description closes,
anywhere in the family. A POSIX record lock belongs to the process and
goes when it closes any descriptor on the file, or exits. An OFD lock is
a record lock owned by a description. flock locks never meet record
locks; POSIX and OFD locks meet each other, by overlapping byte ranges,
a process's own POSIX locks replace and split each other, and F_GETLK
reports the lock in the way with its owner's pid as the guest knows it.
LOCK_NB and F_SETLK answer EAGAIN; flock without LOCK_NB and F_SETLKW
wait, parked like a read on an empty pipe and tried again after every
call, so the close, unlock or exit that frees the lock lets them in.

The waiting route is one arm in serve/dispatch.rs, a file of lane A's,
and one in waits::attempt; the fcntl commands are new arms in
call/ctl.rs.
sysinfo, getrusage, times, getgroups, setgroups, setresuid, setresgid,
getpriority, setpriority and personality answered ENOSYS, and
getresuid and getresgid as unserved numbers although their constants
were there. busybox free and uptime read sysinfo, time and Go's
os.ProcessState read getrusage, and every shell asks for its groups.

sysinfo answers from the declared system: the family's uptime, its
memory limit as totalram and that less the family's resident memory as
freeram, its thread count, and zero where the personality keeps no such
thing (shared, buffer and swap memory, and the load, which is not
measured). getrusage and times report what the kernel counted for the
process's own threads, the calling thread's for RUSAGE_THREAD, and for
RUSAGE_CHILDREN what each child used, recorded when it exits and counted
once its parent has waited for it, as Linux counts it; a figure that is
not measured is zero. A process's exit now also drops its POSIX locks at
once and puts every file the family is writing in the store.

Every id is root's and no guest holds a capability, so the rules are
those of a process without CAP_SETUID, CAP_SETGID or CAP_SYS_NICE: the
ids can be set only to 0, groups not at all (EPERM), and nice only
raised (EACCES); the nice value is kept and shown in /proc, not acted
on. personality answers PER_LINUX to a query and refuses any change by
name.
The store loads at most 16 MiB and each guest's proof is 327526 bytes, so
the test image had room for about the guests it already had: adding
three more made nonos-store-pack refuse it, 19031316 payload bytes
against 16777216, and every lane adding guests will meet the same wall.
LINUX_GUEST_ONLY now names the programs under /linux/bin an image
carries; every other entry stays. Unset, the image is what it was. The
guests left out are still built, signed and enrolled.
On a failure bbsuite ran diff and counted the lines starting with < or
>, but busybox's diff prints the unified form, so it counted 0 of 107
differing lines, and only the diff's first line reached the console. It
now counts the unified form's lines and prints every line the run made,
each prefixed "[C] bbsuite got: ", so one boot's console holds what is
needed to diff it against the host's on the host.
Nothing proved the file calls beyond read and write, the locks, or the
system-information calls. cfiles runs fourteen parts, each printing
only what must read the same on Linux: pwrite64; preadv, pwritev and the
v2 forms; sendfile; copy_file_range; truncate and ftruncate; fallocate
as tmpfs serves it, and fadvise64; flock across a dup, another open and
a forked child that waits; fcntl record locks by range, with a waiting
F_SETLKW, F_GETLK's pid, release at close and at exit, and OFD locks;
close_range; openat2's RESOLVE_ flags; sync, syncfs, fsync and
fdatasync; the xattr calls; sysinfo, and getrusage and times against a
busy loop and a waited child; the ids, groups, nice and personality.
Every part runs, and a failing one names its line and the numbers seen.

sh/oracle.sh runs a program as the personality runs it: root with no
capability, in its own pid namespace with its own /proc, a tmpfs at
/tmp, no terminal, and the guest's environment. On the host it prints
"[C] cfiles PASS: 14 parts".
Nothing proved what a program finds in /dev, /proc and /sys, or that it
finds nothing that is not its own. cproc runs seven parts. dev: each
device's type, numbers and mode, and its reads and writes, /dev/tty's
ENXIO with no terminal, and the /dev/stdin and /dev/fd links. self: its
own stat, status, cmdline, environ, comm, exe, cwd, fd and fdinfo
against what the calls say. maps: its stack in maps, statm, limits
against getrlimit, mounts, mountinfo, cgroup, and a second thread under
task/. system: meminfo against sysinfo, cpuinfo against the CPUs it may
run on, uptime, loadavg, version and /proc/sys/kernel against uname, a
boot id that holds and a uuid that does not, /sys's huge-page size.
isolation: of pids 1 to 4096 only its own family's exist in /proc, a
forked child's /proc/<pid>/stat and getppid both name its parent, and
no way through /proc/self/root, a link or ".." climbs above the root,
and a directory descriptor keeps its place across a chdir. mem: NONOS
refuses /proc/self/mem, which Linux opens for the process itself. facts:
no file names the machine's CPU brand, which the program reads with
CPUID, nor the build host's CPU model, boot id or name, which the build
puts in /etc/cproc-host.

On the host, through sh/oracle.sh, it prints "[C] cproc PASS: 7 parts";
there mem opens and facts finds the host's CPU in /proc/cpuinfo, as each
part says it expects of Linux.
Nothing proved that Go's file packages work under the personality. goos
makes a tree with MkdirAll and reads it back with ReadDir, WalkDir and
Glob; makes a file and a directory with CreateTemp and MkdirTemp and
removes them; copies a file with io.Copy, which Go does with
copy_file_range; and checks Chmod, Chtimes, Truncate, Rename, Symlink,
Readlink and Lstat, Executable, Hostname against uname, NumCPU against
the CPUs /proc/cpuinfo lists, Getwd, and an exclusive syscall.Flock
refusing a second open until it is released. Nothing it prints depends
on the machine. On the host, through sh/oracle.sh, it prints
"[GO] goos PASS: 5 parts".
A path was normalised as text before any link in it was followed, so a
".." removed the name before it whether or not that name was a link.
cproc caught it: stat("/proc/self/root/..") answered /proc/<pid>, where
Linux, which follows root to / first, answers /. A link /tmp/x to /etc
made /tmp/x/.. /tmp, where Linux makes it /. No path left the family's
root either way, but the answers were not Linux's.

Now walk::walk takes a path a name at a time: it follows each link
where it stands, the image's and the ones /dev and /proc make, then goes
on from where the link led, and ".." goes to the parent of that; ".." at
the root stays there. A trailing slash follows the last name, and 40
links in one walk is where Linux stops. named_at and chdir join a name
to its directory, an absolute name standing as it is, and leave all of
this to the walk. openat2 checks its
RESOLVE_ rules against the same walk, step by step, so BENEATH now also
refuses a step out through a link, and an absolute or magic link, as
Linux does.
openat2 refused RESOLVE_IN_ROOT with EINVAL, so a program that opens a
name as if the directory were its root, as container tools do, could
not open anything that way.

The path walk now takes a root. An absolute name or link target starts
from it and ".." never climbs above it, and openat2 walks an IN_ROOT
name that way from the directory descriptor. As on Linux, IN_ROOT
follows no /proc magic link, and IN_ROOT with RESOLVE_BENEATH is
EINVAL. Every other walk passes "/" as the root and walks as before.
preadv2 and pwritev2 answered EOPNOTSUPP for any RWF_ flag, so a
program that asked for RWF_APPEND or RWF_DSYNC, as databases and log
writers do, failed where Linux serves it.

Now the flags Linux 6.1 knows are served. RWF_APPEND writes at the end
of the file whatever the offset, and at offset -1 moves the
descriptor's offset to the end and past what it wrote. RWF_DSYNC and
RWF_SYNC put the write in the store before answering, as fsync would.
RWF_HIPRI and RWF_NOWAIT hold as they are, since a read or write here
never blocks. Any other flag is still EOPNOTSUPP.
cfiles did not ask for preadv2 and pwritev2's RWF_ flags or for
openat2's RESOLVE_IN_ROOT, so nothing showed whether either behaves as
on Linux.

Its vector part now writes with RWF_APPEND|RWF_DSYNC at offset 0 and
with RWF_APPEND at -1, checks where the bytes and the offset land, and
checks that an unknown flag is EOPNOTSUPP. Its openat2 part opens "/f",
"../../../f" and an absolute link under IN_ROOT, all of which land
inside the directory, and checks that IN_ROOT with BENEATH is EINVAL.
It also includes grp.h for setgroups. The host oracle passes all 14
parts built with gcc and with musl-gcc.
A signal frame put the sigcontext at +48 in the ucontext, and the
blocked mask right after its 18 register words. Linux's ucontext is
flags, link and a 24-byte stack_t, so the sigcontext is at +40. The mask
is at +296, after the whole 256-byte sigcontext. A handler that reads or
edits the interrupted registers through its ucontext found every
register one word off, and Go's preemption does exactly that to inject a
call. A handler that reads the mask found a register word there.

The ucontext's layout now has a file of its own (call/sigstack/ucontext.rs), with
Linux's offsets: the sigcontext at +40 and the mask at +296, and
uc_stack reporting SS_DISABLE, since no alternate stack is kept yet.
sigframe.rs and rt_sigreturn take the sigcontext's offset from it. The
host proofs mount both files as the capsule does, and one checks the
mask at +296 and SS_DISABLE in uc_stack.

sigframe.rs is lane C's file, changed here because Go's signal handlers
need it; it is no longer than it was.
sigaltstack said no alternate stack was set whatever a thread gave it,
and every handler ran on the interrupted stack. Go gives each thread an
alternate stack and installs its handlers with SA_ONSTACK. Its handler
checks that it runs on that stack or on g0's, and otherwise takes the
path for a thread it does not know, which never returns. A Go thread
interrupted by SIGURG on a goroutine's stack spun there, so goos never
finished.

Now the family keeps each thread's stack (call/sigstack/altstack.rs), and
sigaltstack (call/sigstack/sigaltstack.rs) sets and reports it. It reports
SS_ONSTACK while the thread runs on the stack, SS_DISABLE when there is
none, and 0 otherwise. It refuses a change while the thread is on the
stack with EPERM, a stack smaller than MINSIGSTKSZ with ENOMEM, and
unknown flags with EINVAL, and a refused request leaves the stack as it
was. A handler installed with SA_ONSTACK gets its frame at the top of
that stack unless the thread is on it already, and the ucontext's
uc_stack reports the stack, as Linux's get_sigframe and __save_altstack
do. An exec drops the process's stacks, as on Linux. The host proofs
build frames with no alternate stack as before, and one checks that a
frame lands at the top of an alternate stack with uc_stack naming it.

signal.rs, deliver.rs and exec_load.rs are lane C's files, changed here
because Go's os tests cannot finish without this. signal.rs gave its
sigaltstack to the new file and is shorter; the other two gained a line
each.
statfs wrote f_fsid at 48, f_namelen at 56, f_frsize at 64 and f_flags
at 72. Linux's struct statfs has f_files and f_ffree at 40 and 48, so
those four are at 56, 64, 72 and 80. A program read the fsid as the
free inode count, the name length as the fsid, the fragment size as the
name length and the flags as the fragment size. f_flags also carried
only ST_RDONLY, where Linux always sets ST_VALID and adds the mount's
nosuid, nodev, noexec and relatime.

Now each field is at Linux's offset, and f_flags is ST_VALID plus the
bits for the options the family's mount table gives the mount, the
same options /proc/self/mounts lists.
Nothing checked statfs, so its fields could sit at the wrong offsets
without a proof failing.

cfiles now checks, for /tmp and /proc, the filesystem type, a name
length of 255, a fragment size equal to the block size, and f_flags
equal to ST_VALID plus the bits for the options /proc/self/mounts gives
the same mount. It also checks that fstatfs on a file in /tmp agrees.
The host oracle passes all 15 parts built with gcc and with musl-gcc.
Every write that reached the store answered EIO when the store refused
it: close, fsync and sync putting a file in the store, mknod, link and
rename. The store names its reason, "no space left" for a full store,
so a program told EIO could not tell a full disk from a broken one.

Now the reason becomes the errno a Linux filesystem gives: ENOSPC for a
full store, EFBIG for a file too large for it, and EROFS, ENOENT,
EACCES, EEXIST, EISDIR or ENOTEMPTY where the store says so. A reason
with no Linux name is still EIO.
statfs gave every mount the same 1048576 blocks of 1024 bytes, half of
them free, whatever was written. /proc and /sys seemed to have a
gigabyte of room, the read-only root seemed half empty, and df could not
show a file taking space.

Now the sizes come from the family's own files and from nothing of the
machine's. The read-only root is the bytes the store holds under the
family's root, with none free. /dev, /proc and /sys have no blocks, as
Linux reports for trees that hold no bytes. The private directories
share one quota, half the family's memory, as Linux sizes a tmpfs it is
given no size (declared::PRIVATE). Their free room is that quota less
what the family keeps there, in the store and in copies not yet
written. Putting a copy in the store that would go past the quota fails
with ENOSPC. Blocks are 4096 bytes, as tmpfs counts them.
cfiles checked statfs's fields but not its sizes, so constant figures
passed.

Its statfs part now checks that /proc has no blocks and that free is at
most the total, that writing 256 KiB to a file in /tmp takes exactly 64
blocks from the free count, and that unlinking the file gives them
back. The host oracle passes all 15 parts built with gcc and with
musl-gcc.
/proc/loadavg read "0.00 0.00 0.00" always, and sysinfo's loads were
zero, because load was not measured. uptime, top and anything that
throttles on load were told the family was idle while it was busy.

Now both come from one measure (file/system/load/). Linux averages the tasks
running or waiting to run, every five seconds, with fixed decay factors
for one, five and fifteen minutes. The kernel does not say how long a
thread waited for the CPU, so this averages what it does say: the share
of each period the family's threads ran, from their ticks, counting
processes that have exited. The factors, the fixed point and the
rounding are Linux's, so a family that ran all of the last minute reads
0.63 as Linux does. A read gives the periods since the last read the
share the family ran across all of them, since nothing samples in
between. A host proof checks a busy minute, then an idle one, against
values worked out with Linux's calc_load.
/proc/meminfo said Cached and Shmem were 0, and sysinfo's sharedram was
0, while the family held in memory the copy of every file it was
writing. free(1) could not see those bytes, and MemFree counted them as
free.

Now the bytes the family's copies hold are Cached and Shmem in
meminfo, as a tmpfs file's pages are on Linux, and sysinfo's
sharedram. MemFree and freeram leave them out, and MemAvailable counts
them back in, since a copy can be put in the store. Buffers, SwapCached,
SwapTotal and SwapFree stay 0, because there are no block-device
buffers and no swap.
/proc/<pid>/stat gave 0 for a process's children's CPU times and minor
faults, though the family keeps what each waited-for child used. It also
gave 0 for where the program's code and data start and end, and for the
stack it started on, though the family placed all three. ps and top
showed no child time, and a debugger found no code range.

Now cutime, cstime and cminflt are what the children the process has
waited for used. startcode and endcode bound the program's executable
regions, and start_data and end_data its writable ones, leaving out the
interpreter's. startstack is the stack pointer it started with, where
argc is, as Linux's start_stack is. The fields are grouped as proc(5)
groups them. The zeros left are Linux's own, and two are this view's
limits, as the code says: no kernel flags are kept, and pending signals
are not in the family's view.
write and pwrite64 on a file took at most 1 MiB and returned that
count for anything larger. Linux writes a regular file whole, and code
that checks write(fd, buf, n) == n without a retry loop, which is most
code, failed on a 4 MiB write.

Now a write to a file goes on in 1 MiB copies out of the guest until
all of it is written. A failure after some bytes have landed returns
the count so far, as on Linux. Pipes and sockets are unchanged.
fork mapped each region of the parent into the child with one peer
call, and copied it with one more. The kernel takes at most a megabyte
in one peer call, so a process with a larger region failed to fork,
with ENOMEM. cfiles, with 3 MiB of static buffers, got ENOMEM from fork
and then waited forever on a pipe its child was to write; cproc, with
5 MiB, got ENOMEM from its first fork.

Now each region is mapped and copied a megabyte at a time, as the
personality's own mappings and its reads and writes of guest memory
already are.

fork_copy.rs is lane C's file, changed here because the proof guests'
large buffers found it.
cproc did not look at /proc/self/stat's code, data and stack bounds or
its children's fields, at the load average, or at Shmem, so zeros there
passed.

Three parts now check them against facts the program sees itself. stat:
main lies between startcode and endcode, an initialised global between
start_data and end_data, and argv sits right after argc at startstack.
After a child that touched 1 MiB of fresh memory and ran half a second
is waited for, cutime and cstime are at least 20 ticks and cminflt at
least 64. load: after eleven seconds on the CPU, the one-minute average
in /proc/loadavg is above zero and within 0.05 of sysinfo's. memory: 4
MiB written to an open file in /tmp raise Shmem by at least 3 MiB, the
host's own traffic allowed for, with Cached at least Shmem and sysinfo's
sharedram at least 3 MiB. The host oracle passes all 10 parts built with
gcc and with musl-gcc.
Nothing wrote more than 1 MiB in one call, so a write cut short passed.

cfiles's pwrite part now writes 3 MiB with one write and 2 MiB with one
pwrite, and checks that each returns its whole length and that the
offset ends at 3 MiB. The host oracle passes all 15 parts built with
gcc and with musl-gcc.
A process that exited passed its parent its own counts and the times of
the children it had waited for, but not those children's page faults
or context switches. A parent whose child ran a grandchild saw the
grandchild's time in RUSAGE_CHILDREN and /proc/<pid>/stat's cutime,
and not its switches or faults: cproc's parent saw 10 switches where
the grandchild alone had 459.

Now the waited children's faults and switches go up with their times,
as Linux adds a child's cmin_flt and cnvcsw into its parent's.
cproc's stat part wanted at least 64 minor faults from a waited child
that touched a fresh megabyte. NONOS backs a readable and writable
anonymous mapping when it is made and a fork copies eagerly, so such a
child takes no page fault and its true count is 0. The part failed on
NONOS with a correct cminflt, and it passed on the host only because
Linux maps on first touch.

Now the child waits for a grandchild that spins and reports its own
minflt and switches before it exits. The parent's cminflt and its
RUSAGE_CHILDREN switches must be at least those, and its cutime must
hold the grandchild's run. Without the previous commit the switch check
fails on NONOS (10 against 459); the host passes it as before.
@eKisNonos
eKisNonos changed the base branch from main to linux/go-guests September 29, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant