104: Contract Test Expansion III — 118 to 151 Tests, 9 Kernel Bugs Fixed, Zero XFAIL

Date: 2026-03-21 Milestone: M10 (Alpine compatibility)

Motivation

After blog 079 brought the contract suite to 112 tests with 8 XFAIL, and blog 093 pushed ARM64 coverage to 95/118, we had solid behavioral coverage of the syscalls musl and BusyBox exercise. But the Alpine apk package manager (blog 103) exposed gaps in areas we hadn't tested: umask wasn't applied during file creation, ppoll ignored its timeout argument, and pipe EOF wasn't visible through select(). These aren't obscure edge cases — they're POSIX fundamentals that every package manager, init system, and shell script depends on.

This session had three goals: (1) add tests for every implemented syscall that lacked coverage, (2) fix every kernel bug the new tests exposed, and (3) eliminate all 12 XFAIL entries so the suite runs 100% clean.

What we added

33 new contract tests across three tiers, organized by impact on real-world application compatibility.

Tier 1: High-impact syscalls (12 tests)

TestSyscalls covered
ioctl_termiosTIOCGWINSZ, FIOCLEX/FIONCLEX, FIONBIO
memfd_create_basicmemfd_create + write/read/fstat/ftruncate roundtrip
clone3_probeclone3 probe+fallback (ENOSYS), EINVAL on small args
flock_basicflock LOCK_EX/SH/UN/NB, EBADF validation
clock_nanosleep_relclock_nanosleep relative, EINVAL on bad clock
clock_getres_basicclock_getres MONOTONIC/REALTIME, NULL res, EINVAL
umask_roundtripumask set/get, file creation mode masking
capget_basiccapget v3 version query, capability read, capset
getsockname_peernamegetsockname/getpeername on socketpair, ENOTCONN
sendmsg_recvmsg_basicsendmsg/recvmsg iov scatter/gather
getresuid_roundtripgetresuid/getresgid, setresuid/setresgid -1 nop
ppoll_basicppoll timeout/readable/zero-timeout/POLLHUP

Tier 2: Medium-impact syscalls (9 tests)

TestSyscalls covered
fchdir_basicfchdir to directory, EBADF, ENOTDIR
fstatfs_basicfstatfs on tmpfs/procfs/devnull, EBADF
fchown_basicfchown/chown roundtrip, -1 nop semantics
unshare_utsunshare(0) nop, unshare(CLONE_NEWUTS), sethostname
pidfd_open_probepidfd_open probe (ENOSYS stub), bad PID rejection
fallocate_basicfallocate basic + KEEP_SIZE, EBADF
sched_setaffinity_basicsched_getaffinity/sched_setaffinity roundtrip
sched_policy_basicsched_getscheduler/sched_setscheduler SCHED_OTHER
timerfd_gettime_basictimerfd_gettime unarmed/armed/disarmed states

Tier 3: Stubs and edge cases (12 tests)

TestSyscalls covered
copy_file_range_basiccopy_file_range with/without offsets, zero-length
tee_xfailtee on pipe pair (EINVAL accepted)
fsync_basicfsync on file, EBADF
fadvise_acceptposix_fadvise NORMAL/SEQUENTIAL/DONTNEED, EBADF
vfork_basicvfork child runs before parent, shared memory, exit status
getpgrp_basicgetpgrp, matches getpgid(0)
getgroups_basicgetgroups count query + retrieval
sethostname_basicsethostname/setdomainname + uname verify
rseq_proberseq probe (ENOSYS), bad length EINVAL
chroot_basicchroot into directory, path resolution
syslog_basicsyslog buffer size query, console level
settimeofday_acceptsettimeofday/clock_settime stubs accepted

Kernel bugs found and fixed

The new tests exposed 9 bugs, ranging from missing POSIX semantics to complete feature gaps.

Bug 1: Umask not applied during file creation

open(), openat(), mkdir(), and mkdirat() passed the raw mode to the filesystem without applying mode & ~umask. Additionally, tmpfs's create_file() ignored its mode parameter entirely, hardcoding 0644.

Impact: Every file created had wrong permissions. apk creates files with mode 0666, expecting umask 0022 to produce 0644 — instead it got 0666.

Fix: Apply FileMode::new(mode.as_u32() & !current.umask()) in all four syscalls. Fix tmpfs to store the requested mode instead of hardcoding.

Bug 2: Pipe POLLHUP missing

PipeReader::poll() returned POLLIN when the write end closed with an empty buffer. POSIX says this is an EOF condition that should report POLLHUP.

Fix: Return POLLHUP when closed_by_writer && !buf.is_readable().

Bug 3: ppoll ignored timeout argument

The SYS_PPOLL dispatch hardcoded timeout=-1 (infinite), ignoring the struct timespec pointer in argument 3.

Fix: Read the timespec, convert to milliseconds, pass to sys_poll().

Bug 4: fchdir accepted non-directory fds

sys_fchdir() resolved any fd's path and called chdir() — even on regular files like /dev/null.

Fix: Check opened_file.inode().is_dir() before proceeding.

Bug 5: chown/fchown ignored -1 ("keep current")

POSIX says uid or gid of -1 (0xFFFFFFFF) means "don't change that field." The kernel passed -1 directly to tmpfs, which stored it as the new owner.

Fix: resolve_owner() helper reads current stat and preserves the field when -1 is passed. Applied to sys_chown, sys_fchown, and sys_fchownat.

Bug 6: flock didn't validate fd

The stub returned Ok(0) for any fd, including closed ones.

Fix: Validate fd exists before returning success.

Bug 7: select() readfds ignored POLLHUP

select() only checked POLLIN for readfds. When a pipe's write end closed with empty buffer, the read fd reported POLLHUP but select didn't consider it ready.

Fix: status.intersects(PollStatus::POLLIN | PollStatus::POLLHUP).

Bug 8: sigaltstack was a complete stub

sys_sigaltstack() returned Ok(0) without storing anything. SA_ONSTACK was ignored in rt_sigaction. Signal delivery always used the current stack.

Fix: Full implementation:

  • Added alt_stack_sp, alt_stack_size, alt_stack_flags to Process
  • Implemented sigaltstack syscall with proper stack_t read/write
  • Added on_altstack flag to SigAction::Handler
  • Signal delivery switches RSP/SP to alt stack top when SA_ONSTACK is set

Bug 9: stdio buffering in fork+_exit tests

Two tests (setsid_session, execve_argv_envp) produced different output on Linux vs Kevlar because _exit() doesn't flush C library stdio buffers, and execve() replaces the process image without flushing. On Linux (pipe-buffered stdout), output was lost; on Kevlar's unbuffered serial, it appeared.

Fix: Add fflush(stdout) before _exit(), remove pre-execve printf.

XFAIL elimination

All 12 XFAIL entries were resolved:

CategoryCountResolution
Output normalization (PIDs, addresses, UIDs, timing)9Removed env-specific values from printf
Kernel bug (select POLLHUP, sigaltstack)2Fixed in kernel
Environment (ns_uts requires root)1Accept EPERM as valid

Results

Before:  118 total — 107 PASS, 1 XFAIL, 10 FAIL
After:   151 total — 151 PASS, 0 XFAIL, 0 FAIL, 0 DIVERGE

Coverage assessment

DimensionBeforeAfter
Contract tests118151
Pass rate91% (107/118)100% (151/151)
XFAIL entries120
Tested syscalls~80~113
Kernel bugs fixed9

The 151 tests now cover ~113 of the ~135 syscalls in the dispatch table. The remaining ~22 untested syscalls are mostly *at-variant duplicates (unlinkat, readlinkat, symlinkat, mkdirat tested indirectly through their non-at counterparts), internal syscalls (rt_sigreturn), and stubs (setns, epoll_pwait2, new mount API).

What's next

The next round of test additions will target the remaining untested syscalls: path-based operations (chmod, chown, utimes), dirfd variants (fchmodat, fchownat, linkat, unlinkat), and system control (pselect6, tkill, exit_group). The goal is full coverage of every non-stub syscall in the dispatch table.