SchOS/docs/plan/foundations.md
Nexoniarz 3f13989883 unix: a bound socket is a file
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>
2026-09-22 10:06:10 +02:00

8 KiB
Raw Blame History

Foundations

The kernel work that wayland.md and hybrid.md both stop at. This is the critical path, and it is the reason those two are one plan rather than two.

The insight is small and it decides the schedule: the substrate for passing a buffer to a compositor and the substrate for handing a device to a driver process are the same substrate. Descriptor passing and shared memory, built once.


What is actually in the tree

Measured against the source, not against a README. Each of these was checked before anything below was planned around it:

sendmsg/recvmsg, and therefore SCM_RIGHTS Built. See item 1 below.
Writable MAP_SHARED of a file Refused — kernel/sys/sys_mem.c:131 returns -ENODEV.
memfd_create Does not exist.
AF_UNIX names In a table in kernel/net/unix.c, not in the filesystem.
setsockopt return 0; unconditionally — kernel/sys/sys_net.c:436.
exec ET_EXEC only. No PT_INTERP, no ET_DYN.

projects/schcom/README.md said SCM_RIGHTS was done while nothing in the kernel implemented it — the most load-bearing wrong line in the tree, with an entire GUI plan drawn on top of it. The line is now true, which is not the same as it having been right, and the README says so.

The MAP_SHARED refusal is worth defending rather than fixing carelessly. The code returns -ENODEV honestly: the contract of a writable shared mapping is that stores land in the file and other mappers see them, and there is no page cache that could make that true. Quietly handing back a private copy would be the same lie in a new place.


The five items

In dependency order. The first three are what Wayland cannot start without. Item 1 is built; items 2 and 3 are what a buffer still cannot cross on.

1. sendmsg / recvmsg with SCM_RIGHTS — done

Not optional and not substitutable. wl_shm_create_pool passes a file descriptor; there is no path in the Wayland protocol that passes an integer instead.

The handle_kind field in projects/schcom/proto.h was good foresight and it cannot help here — our protocol is the flexible one, and Wayland's is not.

Built as described: struct msghdr/cmsghdr in Linux's layout, ancillary data queued alongside the byte stream in kernel/net/unix.c, descriptor installation into the receiver's file table, and the three ways a message can end — received, received without room, never received — each releasing the kernel's reference. Fourteen checks in cmd/socktest, every one of them moving data through the descriptor rather than inspecting it. Documented in ../architecture/networking.md.

The delivery rule is the part worth knowing before building on it: descriptors are pinned to the stream position of the first byte of the message that carried them, and a read that has not reached that position is cut short so it stops before it. That is what keeps "which message did these belong to" answerable.

This was also item 1 of the hybrid work, where it is how a driver process is handed the device it is allowed to touch.

Two bugs fell out of building it, neither in the new code:

  • sockpair() never set the socket's domain, so every socketpair() socket claimed domain 0. Invisible for as long as the data paths asked only whether a socket was AF_INET or AF_NETLINK — 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 for a pipe at end of file, so a non-blocking read of a pipe whose writers have all gone returned EAGAIN forever instead of 0. A program that polls and reads would spin waiting for an end that had already happened. sockreadable() always had the equivalent condition; the pipe path did not.

2. A shareable memory object — done

memfd_create, plus mmap(MAP_SHARED) on it genuinely working between two processes. Described in ../architecture/kernel.md; asserted by cmd/memfdtest, seventeen checks including the one that matters — a descriptor sent over a socket, mapped by a receiver that has closed every inherited copy, with stores visible in both directions.

Narrower than a general page cache, and sufficient. An anonymous shared object needs no file behind it, which is exactly why memfd exists on Linux at all — and it means this can be built on the shared-memory machinery already in kernel/sys/sys_mem.c (shmget/shmat/shmdt work, and struct vma already carries shmid and a no_free flag for pages that are not ours to release).

The general answer — a page cache, and writable MAP_SHARED of a real file — stays owed. It is a larger project and nothing on this plan needs it.

3. AF_UNIX sockets in the filesystem namespace — done

Clients find a Wayland compositor by bind()ing $XDG_RUNTIME_DIR/wayland-0 as a real path, and libwayland flock()s the lock file beside it. A name table can express neither.

bind() creates a T_SOCK node and connect() resolves the path and matches the socket bound to that inode — not to a string, which is what made two spellings of one path miss each other. ls -l shows srwxr-xr-x, stat() reports S_IFSOCK, and unlink() removes it. Asserted by cmd/socktest, section 15. flock() is still not implemented; see below.

kernel/net/unix.c's own header comment already predicted this:

"What is lost is ls -l /tmp/schcom showing an s, and unlink() of a socket path failing with ENOENT. Both are worth fixing when the VFS next gets opened up; neither blocks anything."

They block something now. This is that moment, and it is a VFS change rather than a socket change: a socket inode type threaded through iget, dirlookup and stat, plus flock.

4. setsockopt that does not lie

At minimum SO_PASSCRED and SO_SNDBUF. Returning 0 for an option you ignored is worse than ENOPROTOOPT, because the caller adjusts its behaviour on the strength of the lie.

Small, and grouped here because it is in the same file as item 3 and will be found by the same testing.

5. Dynamic linking

PT_INTERP and ET_DYN in kernel/fs/exec.c.

Deferrable, and deferred. Static binaries are enough to reach a working desktop with software rendering. This becomes necessary when Mesa does — EGL means dlopen means a loader — and that is a separate decision made later.


Verification

Each item gets a test before the thing that needs it is built, because a substrate that is subtly wrong fails inside the consumer and looks like the consumer's bug.

  • Item 1: two processes, one passes an open descriptor, the other reads through it. Plus the leak case: a sender that exits with descriptors in flight.
  • Item 2: two processes map the same memfd, one writes, the other sees it.
  • Item 3: bind() a socket, then stat() and ls -l the path and see an s, then unlink() it.
  • Items 1–3 together: a third test that does all of it at once, because that combination is what Wayland actually performs and the three passing separately does not prove it.

These belong in bootcheck as a new switch, following the -Sock model.


What this does not deliver

  • No page cache, so writable MAP_SHARED of a real file stays refused.
  • No dlopen until item 5 is done, which means no Mesa and no third-party shared libraries.
  • SO_* coverage is the two options named, not the full set.
  • No credential passing (SCM_CREDENTIALS) — only descriptors. Nothing on this plan needs it, and a compositor that wants to know who connected will.
  • No flock(). libwayland locks wayland-0.lock beside the socket to stop two compositors claiming one display. The socket itself is now a real path, so the lock file can be created and opened; what is missing is the advisory lock, which is a separate mechanism and has no other caller yet.