The set libwayland's event loop is made of, in kernel/sys/sys_event.c:
four kinds of descriptor with no inode, FD_EVENT, that read() drains
and poll() reports on. They were table entries returning ENOSYS that
read as done.
epoll registers (descriptor, file) pairs, so a closed descriptor
leaves the set; level- and edge-triggered, EPOLLONESHOT. Waiting is
poll()'s, a tick at a time. The old eventfd and signalfd numbers (284,
282) were aliased to the new calls and read flags from a garbage
register; they are their own entries now, with epoll_create and
epoll_pwait.
cmd/evtest checks each, then all four in one set. The plans are
brought up to date: everything Wayland was waiting on is built except
setsockopt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
Gate 1, item 3, and the last of foundations.md.
bind() creates a T_SOCK node at the path; connect() resolves the path and looks
for the socket bound to **that inode**. Matching on the inode rather than on
the path *string* is the part that matters: the name used to live only in the
socket table, so two spellings of one path missed each other, a relative path
never matched an absolute one, and there was nothing to stat() or unlink().
An inode is the thing a path resolves *to*, so connect() can walk the path
however it likes and arrive at the same socket.
# ls -l /tmp
srwxr-xr-x 1 root root 0 2026-09-22 08:04 socktest
The node is a marker, not the binding. It holds no data and the live
association stays in the socket table, so a socket file left by a crash is
what Linux leaves behind -- a dead file that connect() refuses until something
binds again -- and unlink()ing a live socket's path does not disturb anyone
already connected.
EADDRINUSE comes from the *directory* rather than from a scan of the table,
deliberately: a stale socket file has to refuse a rebind for the same reason a
running server's does, or two servers both believe they own it. The creation
lives in sys_bind() and not in unix.c, because that is the layer with both --
kernel/net/unix.c knows about sockets and has no business calling create().
T_SOCK is a compat addition to SchFS by the rule in schfs.h: a driver that does
not know SCHFS_T_SOCK maps it to a zero-length regular file, so nothing goes
stale and nothing becomes unreadable.
cmd/socktest section 15 asserts the plan's own wording -- bind, stat, see an
`s`, unlink -- plus the two a name table could never fail: a second bind to a
live path is refused, and connecting to a path that is a *regular file* is
refused rather than matched by name.
**And the semantics being real broke socktest on its second run**, correctly:
the socket files persisted and every bind() hit EADDRINUSE. Every real server
unlinks before binding; socktest now does too, which is the change the new
behaviour demands rather than a workaround for it.
Chasing that turned up something worse. `socktest` reported rc=1 with every
check passing, and so did every other first-party program:
- **lib/start.S called main and threw %eax away.** sys_exit() reads the
status from %rdi and nothing put it there, so every program exited with
whatever that register happened to hold. Every test suite ends in
`return failures ? 1 : 0` and none of it reached the shell.
- **exit(void) passed no status either**, for the same reason: the
declaration takes no argument, so %rdi was the caller's litter.
`if socktest; then` works now, and a failing command reports 1 -- checked both
ways, because a fix that makes everything return 0 would look identical from
the success side.
Also factored: unlink_path() out of sys_unlink, so a failed bind can take its
own node back out without going through the syscall entry, and create_sock()
as a named door rather than exporting create() with an arbitrary type.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gate 1, item 2, and the thing a Wayland buffer crosses on.
memfd_create(2) is the same segment machinery shmget() already had -- pages,
refcounts, no_free VMAs, the fork and exit accounting -- reached by
**descriptor** rather than by System V key. That difference is the entire
point and the reason it exists on Linux at all: two processes share memory by
passing the fd over a socket, so neither has to agree on a path or race to
invent a key. It is also why SCM_RIGHTS had to be item 1.
docs/plan/foundations.md proposed exactly this and it was right: what memfd
adds on top of shmget is a segment that starts empty and is sized by
ftruncate(), and a file that owns one reference to it. FD_MEMFD, and
read/write/lseek/fstat all learn about it -- a descriptor that refuses read()
is a surprise nobody needs.
Three things are refused rather than faked, each the honest version of
something this kernel cannot do:
- MAP_PRIVATE on a memfd. Every caller is sharing on purpose; a private
mapping of an anonymous object is an expensive MAP_ANONYMOUS.
- Mapping past the end. Linux faults those pages in; there is no handler
here that could.
- Shrinking while mapped. Linux truncates the pages out from under the
mapper; here a freed page would stay mapped to memory somebody else now
owns.
Writable MAP_SHARED of a *real file* is still ENODEV. That needs a page cache,
and an anonymous object has no such contract to break -- which is precisely
why memfd is the narrow answer.
**cmd/memfdtest**, seventeen checks, written so a permissive implementation
cannot pass: an mmap that quietly returned private zeroed pages satisfies every
return code and fails the one assertion that matters, because the reader is a
different process. It ends with the combination the plan singled out -- a memfd
sent over a socketpair to a receiver that closes every inherited copy first,
maps only the descriptor that arrived, reads what the sender wrote and writes
back where the sender can see it.
Two things were found by writing that test, both of which had been refusing
everything that was not FD_INODE:
- sys_fstat() never reached filestat(), so the memfd branch there was
unreachable from the syscall that needs it. It also refused pipes and
sockets, and returned -1 (which musl reads as EPERM) rather than an errno.
- sys_lseek() the same, and SEEK_END needs a length that is not in an inode.
Verified: 40 consecutive runs leave MemFree at exactly the value it started
at -- 936656 kB before and after -- so nothing leaks across creation, dual
mapping, descriptor passing and exit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sendmsg/recvmsg with SCM_RIGHTS. Item 1 of docs/plan/foundations.md and
mechanism 1 of docs/plan/hybrid.md, which are the same primitive seen from two
directions: Wayland's wl_shm_create_pool passes a file descriptor with no
integer-shaped alternative, and handing a driver process the device it is
allowed to touch is the same act. Both plans stopped here.
Linux's structures byte for byte -- msghdr, cmsghdr, the CMSG_ macros -- because
the programs that need this are not ours. libwayland and toybox both call
recvmsg with Linux's headers and would not notice a friendlier door.
The delivery rule is the whole design. A batch of descriptors is pinned to the
stream position of the first byte of the message that carried it, and that has
two halves: a reader arriving at that position is handed them, and a reader that
has not reached it is stopped SHORT of it. Leave out the second half and a
program asking for 4096 bytes swallows three messages and is handed one
message's descriptors, with nothing in the result saying which. socktest checks
exactly that: two one-byte messages, an eight-byte read, one byte back.
The kernel holds a reference to every descriptor in flight, so a sender may
close its own copy the moment sendmsg returns. Giving that reference back is the
part that leaks if it is not written down, and there are three ways a message
ends:
- received with room installed in the receiver's table
- received without room, or by
plain read(2) closed; MSG_CTRUNC for the first case
- never received, socket closed closed by sockclose
The middle row looks like a choice and is not: holding kernel references for a
receiver that has proved it is not looking is a leak with extra steps.
No garbage collector. A socket descriptor sent over that same socket and never
received holds a reference to the socket that holds the reference to it, and
neither close reaches zero. Linux carries several hundred lines for this.
Nothing on either plan passes a socket over a socket -- Wayland passes memory,
the driver work passes devices -- so it is a bounded leak that is documented
rather than a silent one.
Fourteen checks in cmd/socktest, and every one moves data through the descriptor
rather than inspecting it. The descriptor passed is always a pipe end, because a
pipe announces its own lifetime: a read of a pipe whose writers are all gone
returns 0, which turns "was the kernel's reference released" -- otherwise
invisible from userspace -- into a value a test can compare.
Two bugs fell out, neither in the new code:
- sockpair() never set the socket's domain, so every socketpair() socket
claimed domain 0. Invisible while the data paths asked only whether a socket
was AF_INET or AF_NETLINK, since zero answers "neither" correctly.
SCM_RIGHTS asks "is this AF_UNIX", and zero is wrong for that one.
- pipe_poll() reports POLLHUP but not POLLIN at end of file, so a non-blocking
read of a pipe whose writers had all gone returned EAGAIN forever instead of
0 -- a program that polls and reads spins waiting for an end that already
happened. sockreadable() always had the equivalent condition; the pipe path
did not. Found because the test helper for "is this descriptor closed" is a
non-blocking read of a pipe.
projects/schcom/README.md said SCM_RIGHTS was done for a long time while nothing
in the kernel implemented it, and an entire GUI plan was drawn on that line.
It is true now, which is not the same as it having been right, and the README
says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/ was ten files where a plan, a description, a guide and a bug report all
sat next to each other. It is now architecture/ (what SchOS is), plan/ (what it
will be), guides/ (how to work on it) and history/ (why it is like this), with
an index that states the one rule holding it together: when something is
finished it moves from plan/ to architecture/ and is rewritten as a description
of the thing rather than a record of building it.
A ticked checkbox says the work happened. It does not say what the work
*produced*, and six months later the checkbox is all that is left. The network
stack proved the point: finished, verified end to end, and for a month the only
description of it anywhere was a checkbox four levels of indentation deep.
So roadmap.md went from 534 lines to 181 -- nothing deleted, the done work
graduated. Seven subsystems that were finished and undescribed now have
descriptions: boot, kernel, syscalls, filesystem, networking, graphics, usb,
init. Every one ends with "What it does not do", which is the section that pays;
a document listing only strengths is marketing, and the reader who needs it most
is the one about to trip over a limitation nobody wrote down.
schfs.md still opened with "phases 1 through 3 1/2 are done, phase 4 is next"
long after all five had landed. It is a description now, with the phase record
and the hard-won bugs moved to history/.
Three corrections found on the way, all load-bearing:
* projects/schcom/README.md claimed SCM_RIGHTS was done. There is no
sendmsg/recvmsg in the kernel at all, and an entire GUI plan rested on it.
* The root README said "No TCP/IP" months after the stack landed.
* history/kernel.md sec.7 was marked "in progress" on a problem that process
groups solved.
Also adds plan/1.0.md, which draws a finish line so the open list stops being
infinite, and marks schwm/ and schde/ superseded: in Wayland the compositor is
the window manager, so that split dissolves rather than being abandoned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2