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>
This commit is contained in:
Nexoniarz 2026-09-22 10:06:10 +02:00
commit 3f13989883
15 changed files with 339 additions and 45 deletions

View file

@ -12,6 +12,8 @@
#include "types.h"
#include "user.h"
#include "socket.h"
#include "stat.h"
#include "fcntl.h"
static int failures;
@ -54,6 +56,12 @@ streq(const char *a, const char *b)
// Fill a sockaddr_un and return the length to pass to bind/connect: the
// family plus the path plus its NUL, which is what musl computes and what the
// kernel side is written to expect.
// Every bind() here is preceded by an unlink(), which is not tidiness: a bound
// socket is a *file* now, and it outlives the process that bound it. Rebinding
// a path that already has a node is EADDRINUSE, exactly as on Linux, so every
// real server removes the stale one first. Before this change the name lived
// only in the kernel's socket table and vanished when the process did, so a
// test could bind the same path on every run and never notice.
static int
mkaddr(struct sockaddr_un *sa, const char *path)
{
@ -72,7 +80,10 @@ static void
test_roundtrip(void)
{
struct sockaddr_un sa;
int len = mkaddr(&sa, "/tmp/socktest");
int len;
unlink("/tmp/socktest"); // a stale node from an earlier run
len = mkaddr(&sa, "/tmp/socktest");
int srv, pid, cli, n;
char buf[64];
@ -140,7 +151,10 @@ static void
test_errors(void)
{
struct sockaddr_un sa;
int len = mkaddr(&sa, "/tmp/nobody-listening");
int len;
unlink("/tmp/nobody-listening"); // a stale node from an earlier run
len = mkaddr(&sa, "/tmp/nobody-listening");
int s;
puts_("error paths:\n");
@ -181,7 +195,10 @@ static void
test_eof(void)
{
struct sockaddr_un sa;
int len = mkaddr(&sa, "/tmp/socktest-eof");
int len;
unlink("/tmp/socktest-eof"); // a stale node from an earlier run
len = mkaddr(&sa, "/tmp/socktest-eof");
int srv, cli, pid, n;
char buf[16];
@ -743,6 +760,76 @@ test_fdpass_nodata(void)
close(p[0]); close(sv[0]); close(sv[1]);
}
// ---------------------------------------------------------------------------
// 15. A bound socket is a file.
//
// docs/plan/foundations.md item 3, in its own words: bind() a socket, then
// stat() and `ls -l` the path and see an `s`, then unlink() it.
//
// The reason it matters is not tidiness. A Wayland client finds its compositor
// by bind()ing a real path and libwayland locks the file beside it; a name
// table in the kernel can express neither. These checks are what a name table
// cannot pass -- every one of them asks the *filesystem* about the socket,
// not the socket layer.
// ---------------------------------------------------------------------------
static void
test_fsnames(void)
{
struct sockaddr_un sa;
struct stat st;
int len, sv, c;
puts_("a bound socket is a file:\n");
unlink("/tmp/socktest-fs"); // from a previous run
sv = socket(AF_UNIX, SOCK_STREAM, 0);
len = mkaddr(&sa, "/tmp/socktest-fs");
check(sv >= 0, "socket()");
check(bind(sv, (struct sockaddr *)&sa, len) == 0, "bind creates the path");
check(stat("/tmp/socktest-fs", &st) == 0, "and it can be stat()ed");
check(st.type == T_SOCK, "as a socket, not a regular file");
// Binding the same path twice is EADDRINUSE, and it has to come from the
// directory -- a socket file left by a crashed server must refuse a
// rebind for the same reason a running one does.
{
int s2 = socket(AF_UNIX, SOCK_STREAM, 0);
check(s2 >= 0 && bind(s2, (struct sockaddr *)&sa, len) < 0,
"a second bind to the same path is refused");
if (s2 >= 0) close(s2);
}
// Connecting to a path that is not a socket must be refused rather than
// matched by name. A name table could not tell the difference.
{
struct sockaddr_un sb;
int lb = mkaddr(&sb, "/tmp/socktest-notasock");
int fd = open("/tmp/socktest-notasock", O_CREATE | O_RDWR);
if (fd >= 0) close(fd);
c = socket(AF_UNIX, SOCK_STREAM, 0);
check(c >= 0 && connect(c, (struct sockaddr *)&sb, lb) < 0,
"connecting to a regular file is refused");
if (c >= 0) close(c);
unlink("/tmp/socktest-notasock");
}
check(unlink("/tmp/socktest-fs") == 0, "unlink removes it");
check(stat("/tmp/socktest-fs", &st) < 0, "and it is gone from the filesystem");
// The path is free again. On Linux the old socket keeps working for
// anyone already connected; it is simply no longer findable by name.
{
int s3 = socket(AF_UNIX, SOCK_STREAM, 0);
check(s3 >= 0 && bind(s3, (struct sockaddr *)&sa, len) == 0,
"and the path is free to bind again");
if (s3 >= 0) close(s3);
unlink("/tmp/socktest-fs");
}
close(sv);
}
int
main(void)
{
@ -761,6 +848,7 @@ main(void)
test_fdpass_orphaned();
test_fdpass_truncated();
test_fdpass_nodata();
test_fsnames();
if (failures == 0)
puts_("socktest: ALL CHECKS PASSED\n");