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>
This commit is contained in:
parent
c98bdf0019
commit
a79dfac32b
13 changed files with 624 additions and 16 deletions
|
|
@ -400,16 +400,19 @@ addr_t sys_lseek(void)
|
|||
struct file *f;
|
||||
addr_t off_raw;
|
||||
int whence;
|
||||
if (argfd(0, 0, &f) < 0 || argaddr(1, &off_raw) < 0 || argint(2, &whence) < 0) return (addr_t)-1;
|
||||
if (f->type != FD_INODE) return (addr_t)-1;
|
||||
if (argfd(0, 0, &f) < 0 || argaddr(1, &off_raw) < 0 || argint(2, &whence) < 0) return (addr_t)-EFAULT;
|
||||
if (f->type != FD_INODE && f->type != FD_MEMFD) return (addr_t)-ESPIPE;
|
||||
int64 off = (int64)off_raw, newoff;
|
||||
// SEEK_END needs the length, and a memfd's length is not in an inode.
|
||||
int64 end = (f->type == FD_MEMFD) ? (int64)memfd_size(f->memfd_seg)
|
||||
: (int64)f->ip->size;
|
||||
switch (whence) {
|
||||
case 0: newoff = off; break;
|
||||
case 1: newoff = (int64)f->off + off; break;
|
||||
case 2: newoff = (int64)f->ip->size + off; break;
|
||||
default: return (addr_t)-1;
|
||||
case 2: newoff = end + off; break;
|
||||
default: return (addr_t)-EINVAL;
|
||||
}
|
||||
if (newoff < 0) return (addr_t)-1;
|
||||
if (newoff < 0) return (addr_t)-EINVAL;
|
||||
// Seeking past the end is legal and is how a sparse file is made: the gap
|
||||
// reads as zeros and costs nothing on a filesystem with extents. This used
|
||||
// to clamp to end-of-file, which silently turned "seek to 1 GB and write"
|
||||
|
|
@ -595,8 +598,29 @@ addr_t sys_fstat(void)
|
|||
struct file *f;
|
||||
char *st;
|
||||
int size = myproc()->syscall_map ? sizeof(struct linux_stat) : sizeof(struct stat);
|
||||
if (argfd(0, 0, &f) < 0 || argptr(1, &st, size) < 0) return -1;
|
||||
if (f->type != FD_INODE) return -1;
|
||||
if (argfd(0, 0, &f) < 0 || argptr(1, &st, size) < 0) return -EFAULT;
|
||||
// A memfd has no inode, and its size is the one field with an answer --
|
||||
// which is the field a caller is asking for when it fstat()s a buffer.
|
||||
// This used to refuse anything that was not FD_INODE, so filestat()'s own
|
||||
// memfd branch was unreachable from the syscall that needs it.
|
||||
if (f->type == FD_MEMFD) {
|
||||
uint64 sz = memfd_size(f->memfd_seg);
|
||||
if (myproc()->syscall_map) {
|
||||
struct linux_stat *ls = (struct linux_stat *)st;
|
||||
memset(ls, 0, sizeof(*ls));
|
||||
ls->st_mode = LINUX_S_IFREG | 0600;
|
||||
ls->st_nlink = 1;
|
||||
ls->st_size = (int64)sz;
|
||||
} else {
|
||||
struct stat *ss = (struct stat *)st;
|
||||
memset(ss, 0, sizeof(*ss));
|
||||
ss->type = T_FILE;
|
||||
ss->nlink = 1;
|
||||
ss->size = sz;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (f->type != FD_INODE) return -EINVAL;
|
||||
ilock(f->ip);
|
||||
if (myproc()->syscall_map) stati_linux(f->ip, (struct linux_stat*)st);
|
||||
else stati(f->ip, (struct stat*)st);
|
||||
|
|
@ -852,7 +876,13 @@ addr_t sys_ftruncate(void)
|
|||
addr_t length;
|
||||
if (argfd(0, 0, &f) < 0 || argaddr(1, &length) < 0) return -1;
|
||||
if ((int64)length < 0) return -EINVAL;
|
||||
if (f->type != FD_INODE || !f->writable) return -1;
|
||||
// A memfd starts at size zero and is given its size here. That is the
|
||||
// whole lifecycle a Wayland client performs: memfd_create, ftruncate,
|
||||
// mmap.
|
||||
if (f->type == FD_MEMFD)
|
||||
return f->writable ? memfd_truncate(f->memfd_seg, (uint64)length)
|
||||
: -EBADF;
|
||||
if (f->type != FD_INODE || !f->writable) return -EINVAL;
|
||||
begin_op(); ilock(f->ip);
|
||||
itrunc_to(f->ip, (uint64)length);
|
||||
iunlock(f->ip); end_op();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
#include "file.h"
|
||||
#include "syscall.h"
|
||||
#include "errno.h"
|
||||
#include "uaccess.h"
|
||||
#include "posix.h"
|
||||
#include "pciuser.h"
|
||||
|
||||
|
|
@ -116,6 +117,44 @@ sys_mmap(void)
|
|||
|
||||
int is_fb = 0;
|
||||
int shmid = -1;
|
||||
|
||||
// A memfd. The pages already exist and belong to the segment, so this maps
|
||||
// them rather than allocating -- which is the entire point: the other
|
||||
// process that maps the same segment gets the same physical pages, and a
|
||||
// store by one is a load by the other because there is only one copy.
|
||||
//
|
||||
// MAP_PRIVATE is refused rather than given a copy. Every caller of this is
|
||||
// sharing on purpose, and a private mapping of an anonymous object is an
|
||||
// expensive way to ask for MAP_ANONYMOUS.
|
||||
if (f && f->type == FD_MEMFD) {
|
||||
int id = f->memfd_seg;
|
||||
uint64 have = memfd_size(id);
|
||||
|
||||
if (!(flags & MAP_SHARED))
|
||||
return -EINVAL;
|
||||
if (offset % PGSIZE)
|
||||
return -EINVAL;
|
||||
// Mapping past the end is not an error on Linux -- the pages simply
|
||||
// fault -- but there is no fault handler here to make that true, so
|
||||
// it is refused with the errno that says the range is wrong.
|
||||
if (offset + length > have)
|
||||
return -EINVAL;
|
||||
|
||||
shm_ref(id);
|
||||
for (uint64 o = 0; o < length; o += PGSIZE) {
|
||||
char *pg = shm_page(id, (int)((offset + o) / PGSIZE));
|
||||
if (!pg || mappages(myproc()->pgdir, (void*)(va + o), PGSIZE,
|
||||
V2P((uint64)pg), PTE_W | PTE_U) < 0) {
|
||||
vm_unmap_norelease(myproc()->pgdir, va, o);
|
||||
shm_detach(id);
|
||||
return -ENOMEM;
|
||||
}
|
||||
}
|
||||
v->addr = va; v->length = length; v->used = 1;
|
||||
v->no_free = 1; // the segment owns these pages
|
||||
v->shmid = id;
|
||||
return va;
|
||||
}
|
||||
// File-backed mapping of a regular file.
|
||||
//
|
||||
// This used to be ignored entirely: any file mapping silently returned
|
||||
|
|
@ -323,6 +362,201 @@ shm_detach(int id)
|
|||
release(&shmlock);
|
||||
}
|
||||
|
||||
// --- memfd ------------------------------------------------------------------
|
||||
//
|
||||
// memfd_create(2): a shared memory object reached by *descriptor* rather than
|
||||
// by name or by System V key. That is the whole difference and the whole
|
||||
// reason it exists -- two processes share memory by passing the fd over a
|
||||
// socket (SCM_RIGHTS, which already works), so neither has to agree on a path
|
||||
// or race to invent a key.
|
||||
//
|
||||
// It is the same segment machinery underneath, which is exactly what
|
||||
// docs/plan/foundations.md proposed: shmget()'s pages, refcounts, no_free
|
||||
// VMAs and the fork/exit accounting are all already right. What memfd adds is
|
||||
// a segment that starts empty and is sized by ftruncate(), and a file that
|
||||
// owns one reference to it.
|
||||
//
|
||||
// This is narrower than a page cache and deliberately so. Writable MAP_SHARED
|
||||
// of a *real file* still returns ENODEV, because nothing here could make its
|
||||
// contract true. An anonymous object has no such contract to break.
|
||||
|
||||
// Grow or shrink a segment. Growing allocates zeroed pages; shrinking frees
|
||||
// from the end.
|
||||
//
|
||||
// Only legal while nothing has it mapped. Linux allows shrinking a mapped
|
||||
// memfd and faults the truncated pages out from under the mapper, which needs
|
||||
// a page fault handler that can say "this page is gone"; here there is no such
|
||||
// handler and a freed page would simply stay mapped to memory somebody else
|
||||
// now owns. Refusing is the honest version.
|
||||
static int
|
||||
shm_resize(int id, uint64 newsize)
|
||||
{
|
||||
struct shmseg *sg;
|
||||
int want, i;
|
||||
|
||||
if (id < 0 || id >= NSHM)
|
||||
return -EINVAL;
|
||||
newsize = PGROUNDUP(newsize);
|
||||
want = (int)(newsize / PGSIZE);
|
||||
if (want > SHM_MAXPAGES)
|
||||
return -ENOMEM;
|
||||
|
||||
shm_init_once();
|
||||
acquire(&shmlock);
|
||||
sg = &shm[id];
|
||||
if (!sg->used) { release(&shmlock); return -EINVAL; }
|
||||
// nattach is 1 for the file itself; anything above that is a live mapping.
|
||||
if (sg->nattach > 1 && want < sg->npages) {
|
||||
release(&shmlock);
|
||||
return -EBUSY;
|
||||
}
|
||||
release(&shmlock);
|
||||
|
||||
// Allocate outside the lock: kalloc takes its own, and a page allocator
|
||||
// called with a spinlock held is how the two orders meet.
|
||||
for (i = sg->npages; i < want; i++) {
|
||||
char *pg = kalloc();
|
||||
if (!pg) {
|
||||
// Undo this call's own allocations. Anything from an earlier
|
||||
// successful resize stays: the object is still that size.
|
||||
for (int j = sg->npages; j < i; j++) { kfree(sg->pages[j]); sg->pages[j] = 0; }
|
||||
return -ENOMEM;
|
||||
}
|
||||
memset(pg, 0, PGSIZE);
|
||||
sg->pages[i] = pg;
|
||||
}
|
||||
for (i = want; i < sg->npages; i++) {
|
||||
if (sg->pages[i]) { kfree(sg->pages[i]); sg->pages[i] = 0; }
|
||||
}
|
||||
|
||||
acquire(&shmlock);
|
||||
sg->npages = want;
|
||||
sg->size = newsize;
|
||||
release(&shmlock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One page of a segment, by index. Used by the mmap path; the caller has
|
||||
// already taken a reference, so the page cannot go away underneath it.
|
||||
char *
|
||||
shm_page(int id, int pg)
|
||||
{
|
||||
char *p;
|
||||
if (id < 0 || id >= NSHM || pg < 0)
|
||||
return 0;
|
||||
shm_init_once();
|
||||
acquire(&shmlock);
|
||||
p = (shm[id].used && pg < shm[id].npages) ? shm[id].pages[pg] : 0;
|
||||
release(&shmlock);
|
||||
return p;
|
||||
}
|
||||
|
||||
uint64
|
||||
memfd_size(int id)
|
||||
{
|
||||
uint64 n;
|
||||
if (id < 0 || id >= NSHM)
|
||||
return 0;
|
||||
shm_init_once();
|
||||
acquire(&shmlock);
|
||||
n = shm[id].used ? shm[id].size : 0;
|
||||
release(&shmlock);
|
||||
return n;
|
||||
}
|
||||
|
||||
int
|
||||
memfd_truncate(int id, uint64 len)
|
||||
{
|
||||
return shm_resize(id, len);
|
||||
}
|
||||
|
||||
// read()/write() on a memfd, so it is a file rather than a token that only
|
||||
// mmap understands. Wayland clients use ftruncate and mmap, but a descriptor
|
||||
// that refuses read() is a surprise nobody needs.
|
||||
int
|
||||
memfd_rw(int id, int user, uint64 buf, uint64 off, int n, int writing)
|
||||
{
|
||||
struct shmseg *sg;
|
||||
int done = 0;
|
||||
|
||||
if (id < 0 || id >= NSHM || n <= 0)
|
||||
return 0;
|
||||
shm_init_once();
|
||||
sg = &shm[id];
|
||||
if (!sg->used)
|
||||
return -EINVAL;
|
||||
if (off >= sg->size)
|
||||
return 0; // EOF, or a write past the end
|
||||
if (off + (uint64)n > sg->size)
|
||||
n = (int)(sg->size - off);
|
||||
|
||||
while (done < n) {
|
||||
uint64 pos = off + (uint64)done;
|
||||
int pg = (int)(pos / PGSIZE);
|
||||
int inpg = (int)(pos % PGSIZE);
|
||||
int want = PGSIZE - inpg;
|
||||
if (want > n - done)
|
||||
want = n - done;
|
||||
char *p = sg->pages[pg] + inpg;
|
||||
|
||||
if (writing) {
|
||||
if (user) { if (copy_from_user(p, buf + done, want) < 0) return -EFAULT; }
|
||||
else memmove(p, (char*)(buf + done), want);
|
||||
} else {
|
||||
if (user) { if (copy_to_user(buf + done, p, want) < 0) return -EFAULT; }
|
||||
else memmove((char*)(buf + done), p, want);
|
||||
}
|
||||
done += want;
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
addr_t
|
||||
sys_memfd_create(void)
|
||||
{
|
||||
addr_t namep;
|
||||
int flags, id = -1, fd;
|
||||
struct file *f;
|
||||
|
||||
argaddr(0, &namep);
|
||||
argint(1, &flags);
|
||||
// The name is for /proc and for debuggers. There is no /proc/<pid>/fd
|
||||
// here, so it is read for validation and dropped -- which is honest, and
|
||||
// better than storing a string nothing will ever show.
|
||||
if (namep) {
|
||||
char *nm;
|
||||
if (fetchstr(namep, &nm) < 0)
|
||||
return -EFAULT;
|
||||
}
|
||||
|
||||
shm_init_once();
|
||||
acquire(&shmlock);
|
||||
for (int i = 0; i < NSHM; i++)
|
||||
if (!shm[i].used) {
|
||||
memset(&shm[i], 0, sizeof(shm[i]));
|
||||
shm[i].used = 1;
|
||||
shm[i].key = IPC_PRIVATE;
|
||||
shm[i].size = 0;
|
||||
shm[i].npages = 0;
|
||||
shm[i].nattach = 1; // the file holds this one
|
||||
id = i;
|
||||
break;
|
||||
}
|
||||
release(&shmlock);
|
||||
if (id < 0)
|
||||
return -ENFILE;
|
||||
|
||||
if ((f = filealloc()) == 0) { shm_detach(id); return -ENFILE; }
|
||||
if ((fd = fdalloc(f)) < 0) { fileclose(f); return -EMFILE; }
|
||||
|
||||
f->type = FD_MEMFD;
|
||||
f->memfd_seg = id;
|
||||
f->readable = 1;
|
||||
f->writable = 1;
|
||||
f->off = 0;
|
||||
return fd;
|
||||
}
|
||||
|
||||
addr_t
|
||||
sys_shmget(void)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -144,6 +144,10 @@ SYSCALL(198, 288, accept4)
|
|||
// from one process to another (SCM_RIGHTS). docs/plan/foundations.md item 1.
|
||||
SYSCALL(199, 46, sendmsg)
|
||||
SYSCALL(200, 47, recvmsg)
|
||||
// memfd_create: an anonymous shared memory object reached by descriptor.
|
||||
// docs/plan/foundations.md item 2 -- the thing a Wayland buffer crosses on,
|
||||
// and the reason the descriptor passing above had to exist first.
|
||||
SYSCALL(201, 319, memfd_create)
|
||||
|
||||
// --- time ---
|
||||
SYSCALL( 28, 37, alarm)
|
||||
|
|
|
|||
Loading…
Reference in a new issue