SchOS/kernel/fs/file.c
Nexoniarz a79dfac32b memfd: a shared memory object you reach by descriptor
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>
2026-09-22 09:47:31 +02:00

337 lines
9.1 KiB
C

#include "types.h"
#include "defs.h"
#include "stat.h"
#include "param.h"
#include "fs.h"
#include "spinlock.h"
#include "sleeplock.h"
#include "file.h"
#include "fsstat.h"
#include "posix.h"
#include "errno.h"
extern struct devsw devsw[];
struct {
struct spinlock lock;
struct file file[NFILE];
} ftable;
void
fileinit(void)
{
initlock(&ftable.lock, "ftable");
}
struct file*
filealloc(void)
{
struct file *f;
acquire(&ftable.lock);
for(f = ftable.file; f < ftable.file + NFILE; f++){
if(f->ref == 0){
f->ref = 1;
FSSTAT_TAKE(fsstat.file_used, fsstat.file_peak); // 0 -> 1
// Cleared here rather than left to the caller: every creator sets
// readable/writable/off, but only open() sets nonblock, so a struct file
// recycled from a non-blocking device would hand its flag to the next
// pipe or socket that happened to land in this slot.
f->nonblock = 0;
release(&ftable.lock);
return f;
}
}
release(&ftable.lock);
return 0;
}
struct file*
filedup(struct file *f)
{
acquire(&ftable.lock);
if(f->ref < 1)
panic("filedup");
f->ref++;
release(&ftable.lock);
return f;
}
void
fileclose(struct file *f)
{
struct file ff;
acquire(&ftable.lock);
if(f->ref < 1)
panic("fileclose");
if(--f->ref > 0){
release(&ftable.lock);
return;
}
ff = *f;
f->ref = 0;
f->type = FD_NONE;
FSSTAT_DROP(fsstat.file_used); // 1 -> 0
release(&ftable.lock);
if(ff.type == FD_MEMFD)
shm_detach(ff.memfd_seg); // the last mapper frees the pages
else if(ff.type == FD_PIPE)
pipeclose(ff.pipe, ff.writable);
else if(ff.type == FD_SOCKET)
sockclose(ff.sock);
else if(ff.type == FD_INODE){
// Give a device a chance to put the hardware back before the inode goes.
// Done here rather than in exit() because a program that closes the
// framebuffer without exiting has finished with it just as thoroughly.
if(ff.ip && ff.ip->type == T_DEV && ff.ip->major >= 0 &&
ff.ip->major < NDEV && devsw[ff.ip->major].close)
devsw[ff.ip->major].close(ff.ip);
begin_op();
iput(ff.ip);
end_op();
}
}
int
filestat(struct file *f, struct stat *st)
{
// A memfd has no inode, so the size is the only field with an answer. It is
// the field that matters: mmap length and ftruncate round-trips are checked
// against it, and a caller that fstat()s to find out how big the buffer is
// gets the truth.
if(f->type == FD_MEMFD){
memset(st, 0, sizeof(*st));
st->type = T_FILE;
st->size = memfd_size(f->memfd_seg);
st->nlink = 1;
return 0;
}
if(f->type == FD_INODE){
ilock(f->ip);
stati(f->ip, st);
iunlock(f->ip);
return 0;
}
return -1;
}
int
fileread(struct file *f, char *addr, int n)
{
int r;
if(f->readable == 0)
return -1;
// A memfd is a file: read() and write() move bytes in and out of the shared
// pages, and the descriptor carries its own offset like any other. mmap is
// what it is usually for, but a descriptor that refuses read() is a surprise
// nobody needs.
if(f->type == FD_MEMFD){
r = memfd_rw(f->memfd_seg, 1, (uint64)addr, f->off, n, 0);
if(r > 0)
f->off += r;
return r;
}
// O_NONBLOCK on a pipe or a socket. Reported here rather than inside
// piperead/sockread so that the blocking implementations stay unchanged:
// this is a check of the same condition their sleep loops wait on, made one
// level up. Writes still block -- fcntl documents that.
if(f->type == FD_PIPE){
// POLLHUP counts as readable here, and leaving it out was a bug: a pipe
// whose writers have all gone is not "nothing yet", it is end of file, and
// the answer to a read is 0. Told EAGAIN instead, a caller that polls and
// reads spins forever waiting for an end that has already happened.
// sockreadable() has always included the equivalent condition.
if(f->nonblock &&
!(pipe_poll(f->pipe, f->readable, f->writable) & (POLLIN | POLLHUP)))
return -EAGAIN;
return piperead(f->pipe, addr, n);
}
if(f->type == FD_SOCKET){
if(f->nonblock && !sockreadable(f->sock))
return -EAGAIN;
return sockread(f->sock, addr, n);
}
if(f->type == FD_INODE){
ilock(f->ip);
if(f->ip->type == 3){
if(f->ip->major < 0 || f->ip->major >= NDEV || !devsw[f->ip->major].read){
iunlock(f->ip);
return -1;
}
// O_NONBLOCK. Without this a game that opens an input device
// non-blocking and drains it once per frame stops dead on the first
// frame -- it blocks in read() waiting for a key, and the result looks
// exactly like a hung renderer rather than a blocking read.
if(f->nonblock && devsw[f->ip->major].ready &&
!devsw[f->ip->major].ready(f->ip)){
iunlock(f->ip);
return -EAGAIN;
}
r = devsw[f->ip->major].read(f->ip, f->off, addr, n);
if(r > 0)
f->off += r;
iunlock(f->ip);
return r;
}
if((f->ip->rwmode & 2) == 0){
cprintf("SchOS: permission denied (read)\n");
iunlock(f->ip);
return -1;
}
// The "recycle bin" check that used to sit here is gone.
//
// It made a file that still existed, still had a name and still had blocks
// refuse every read with a printf -- a state no POSIX program is prepared
// for. It was reachable only through `delete`, a NO_LINUX syscall from the
// third-party filesystem this file derives from, which the decision to keep
// only Linux numbering had already orphaned.
//
// The on-disk `showmode` field stays for now: removing it changes the inode
// layout, and this whole file is being replaced by SchFS. It is simply
// never consulted.
if((r = readi(f->ip, 0, (uint64)addr, f->off, n)) > 0)
f->off += r;
iunlock(f->ip);
return r;
}
panic("fileread");
}
int
filewrite(struct file *f, char *addr, int n)
{
int r;
if(f->writable == 0)
return -1;
if(f->type == FD_MEMFD){
r = memfd_rw(f->memfd_seg, 1, (uint64)addr, f->off, n, 1);
if(r > 0)
f->off += r;
return r;
}
if(f->type == FD_PIPE)
return pipewrite(f->pipe, addr, n);
if(f->type == FD_SOCKET)
return sockwrite(f->sock, addr, n);
if(f->type == FD_INODE){
ilock(f->ip);
if(f->ip->type == 3){
if(f->ip->major < 0 || f->ip->major >= NDEV || !devsw[f->ip->major].write){
iunlock(f->ip);
return -1;
}
r = devsw[f->ip->major].write(f->ip, f->off, addr, n);
if(r > 0)
f->off += r;
iunlock(f->ip);
return r;
}
if((f->ip->rwmode & 1) == 0){
cprintf("SchOS: permission denied (write)\n");
iunlock(f->ip);
return -1;
}
// Recycle-bin check removed here too; see the note in fileread().
iunlock(f->ip);
// How much may go in one call is the filesystem's business. v1 is bounded
// by its log; SchFS is not bounded at all and would rather have the lot,
// because it makes one transaction out of whatever it is given.
uint max = f->ip->ops->max_write;
int i = 0;
while(i < n){
int n1 = n - i;
if(max && (uint)n1 > max)
n1 = (int)max;
begin_op();
ilock(f->ip);
if ((r = writei(f->ip, 1, (uint64)(addr + i), f->off, n1)) > 0)
f->off += r;
iunlock(f->ip);
end_op();
if(r < 0)
break;
if(r != n1)
panic("short filewrite");
i += r;
}
return i == n ? n : -1;
}
panic("filewrite");
}
// poll(2) readiness for one open file. Returns a POLL* bitmask restricted
// to the requested events (plus error/hangup conditions, which POSIX says
// are always reported). Pipes and the console tty have real state; other
// inodes (regular files, block-ish devices) are always ready, per POSIX.
int
file_poll(struct file *f, int events)
{
if(f->type == FD_PIPE)
return pipe_poll(f->pipe, f->readable, f->writable) &
(events | POLLERR | POLLHUP);
if(f->type == FD_SOCKET){
int rev = 0;
if(sockreadable(f->sock))
rev |= POLLIN;
// A stream socket is writable whenever it has a peer. The buffer can
// still be full, in which case the write blocks -- same latitude poll()
// has always had, and the same answer a pipe gives.
if(sock_hup(f->sock))
rev |= POLLHUP;
else
rev |= POLLOUT;
return rev & (events | POLLERR | POLLHUP);
}
if(f->type == FD_INODE){
// An input device is only readable when an event is waiting. Reporting it
// always-ready -- which is what the generic answer below does -- turns a
// poll loop into a busy loop that blocks in read().
if(f->ip && f->ip->type == T_DEV && f->ip->major == INPUT_MAJOR){
int rev = 0;
if((events & POLLIN) && f->readable && input_ready(f->ip->minor))
rev |= POLLIN;
if(events & POLLOUT)
rev |= POLLOUT;
return rev;
}
if(f->ip && f->ip->type == T_DEV && f->ip->major == CONSOLE){
int rev = 0;
if((events & POLLIN) && f->readable && console_input_ready(f->ip ? f->ip->minor : 1))
rev |= POLLIN;
if((events & POLLOUT) && f->writable)
rev |= POLLOUT;
return rev;
}
return events & (POLLIN | POLLOUT);
}
return POLLNVAL;
}