From 2b607140abd2d4afd9381766feec40d062cf72d1 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 10 Jul 2026 01:01:55 -0400 Subject: [PATCH] kernel: implement FIFO (named pipe) semantics for mkfifo/mknod(S_IFIFO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mknod(S_IFIFO)/mkfifo created a regular file with no pipe semantics — the mknod dispatcher stripped the S_IFIFO type bits and made a plain file. A concurrent reader of that "FIFO" therefore saw an empty regular file and got a premature EOF. This breaks bash process substitution `read < <(cmd)`, which mknod()s a FIFO in $TMPDIR and reads it while the writer subshell is still producing output. It blocked Homebrew: `brew config`/`doctor` parse `git --version` and discover ruby via `read < <(...)`, so both failed ("Please update your system Git", "No Homebrew ruby available"). A FIFO is now a real named kernel pipe: - crate::fifo: registry mapping a FIFO's canonical path to its backing pipe. - mknod/mknodat(S_IFIFO) registers a kernel pipe (PipeBuffer::new_fifo, no endpoints open) instead of creating a regular file. - open() connects a read or write end to that shared pipe (add_reader/ add_writer) → real block/EAGAIN/EOF semantics. Fork inheritance works via the existing negative-host_handle Pipe OFD path. - A FIFO reader with no writer yet blocks (EAGAIN) instead of reporting EOF until the first writer connects, fixing the reader-opens-first race. - stat/lstat/newfstatat report S_IFIFO for registered FIFOs (no VFS inode). - unlink/unlinkat remove the name and free the pipe once idle; a FIFO pipe is exempt from is_fully_closed so it persists until unlinked. Validated: process-substitution repro (`read < <(sleep 0.5; printf X)` returns X, previously empty); `brew config --no-install-from-api` now exits 0 in-Kandelo (previously failed on git-version + portable-ruby); 964 kernel unit tests pass (adds test_fifo_named_pipe_semantics). No ABI change (no new syscalls or repr(C) structs; snapshot unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kernel/src/fifo.rs | 68 +++++++++++++ crates/kernel/src/lib.rs | 1 + crates/kernel/src/pipe.rs | 55 ++++++++++- crates/kernel/src/syscalls.rs | 180 +++++++++++++++++++++++++++++++++- crates/kernel/src/wasm_api.rs | 32 +++++- 5 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 crates/kernel/src/fifo.rs diff --git a/crates/kernel/src/fifo.rs b/crates/kernel/src/fifo.rs new file mode 100644 index 000000000..9433ba3a8 --- /dev/null +++ b/crates/kernel/src/fifo.rs @@ -0,0 +1,68 @@ +//! Named pipes (FIFOs). +//! +//! `mkfifo`/`mknod(S_IFIFO)` creates a FIFO: a *named* pipe that lives in the +//! filesystem namespace but carries real pipe I/O semantics (blocking reads, +//! EAGAIN when empty-but-open, EOF only after all writers close). Unlike an +//! anonymous `pipe()`, a FIFO is looked up by path when opened, so two +//! independent processes (e.g. a shell's process-substitution reader and +//! writer) can rendezvous through it. +//! +//! Modeling: a FIFO is a kernel [`crate::pipe::PipeBuffer`] (marked +//! `is_fifo`) registered here by canonical path. `open()` connects a read or +//! write end to that shared pipe; `unlink()` removes the registration and +//! frees the pipe. The backing pipe persists across all fds closing (a FIFO +//! keeps existing until unlinked), which is why `PipeBuffer::is_fully_closed` +//! ignores FIFO pipes. +//! +//! Without this, `mknod(S_IFIFO)` fell back to creating a *regular file* +//! (see the old `kernel_mknod` path), so a concurrent reader saw an empty +//! file and got a premature EOF — breaking `read < <(cmd)`. + +extern crate alloc; + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; +use core::cell::UnsafeCell; + +/// Registry mapping a FIFO's canonical path to its backing pipe index. +pub struct FifoTable { + map: BTreeMap, usize>, +} + +impl FifoTable { + pub const fn new() -> Self { + FifoTable { map: BTreeMap::new() } + } + + /// Register `path` as a FIFO backed by pipe `pipe_idx` (replacing any + /// prior entry — mknod(O_EXCL semantics) is enforced by the caller). + pub fn register(&mut self, path: Vec, pipe_idx: usize) { + self.map.insert(path, pipe_idx); + } + + /// Return the backing pipe index for `path`, if it names a FIFO. + pub fn lookup(&self, path: &[u8]) -> Option { + self.map.get(path).copied() + } + + /// Remove the FIFO named `path`, returning its backing pipe index. + pub fn remove(&mut self, path: &[u8]) -> Option { + self.map.remove(path) + } +} + +pub struct GlobalFifoTable(pub UnsafeCell); + +/// SAFETY: access is serialized — the kernel services one syscall at a time +/// (no concurrent Wasm execution). +unsafe impl Sync for GlobalFifoTable {} + +pub static FIFO_TABLE: GlobalFifoTable = GlobalFifoTable(UnsafeCell::new(FifoTable::new())); + +/// Get a mutable reference to the global FIFO table. +/// +/// # Safety +/// Only safe when access is serialized (single-threaded kernel). +pub unsafe fn global_fifo_table() -> &'static mut FifoTable { + unsafe { &mut *FIFO_TABLE.0.get() } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index e8f0271bb..03e6b65d1 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -9,6 +9,7 @@ pub mod audio; pub mod devfs; pub mod dri; pub mod fd; +pub mod fifo; pub mod fork; pub mod ipc; pub mod lock; diff --git a/crates/kernel/src/pipe.rs b/crates/kernel/src/pipe.rs index b4997da5a..097ae3860 100644 --- a/crates/kernel/src/pipe.rs +++ b/crates/kernel/src/pipe.rs @@ -62,6 +62,16 @@ pub struct PipeBuffer { write_count: u32, /// Index of this pipe in the PipeTable (for wakeup events). pipe_idx: u32, + /// True if this pipe backs a named FIFO (see `crate::fifo`). FIFO pipes + /// persist across all fds closing (freed only on unlink), so + /// `is_fully_closed` never frees them. + is_fifo: bool, + /// True once a writer has ever opened this pipe. A FIFO reader must not + /// treat "no writer" as EOF before the first writer connects — otherwise a + /// reader that opens before the writer's worker starts gets a premature + /// EOF. Anonymous pipes always have a writer at creation, so this stays + /// meaningful only for FIFOs. + writer_ever_opened: bool, /// Ancillary data queue for SCM_RIGHTS FD passing. /// Each entry is a batch of FDs sent with one sendmsg call. ancillary_fds: VecDeque>, @@ -80,10 +90,33 @@ impl PipeBuffer { read_count: 1, write_count: 1, pipe_idx: 0, + is_fifo: false, + writer_ever_opened: true, ancillary_fds: VecDeque::new(), } } + /// Create a FIFO backing buffer with no endpoints open yet. Endpoints are + /// attached as processes `open()` the FIFO by path (see `crate::fifo`). + pub fn new_fifo(capacity: usize) -> Self { + let mut pipe = Self::new(capacity); + pipe.read_count = 0; + pipe.write_count = 0; + pipe.is_fifo = true; + pipe.writer_ever_opened = false; + pipe + } + + /// True if this pipe backs a named FIFO. + pub fn is_fifo(&self) -> bool { + self.is_fifo + } + + /// True once a writer has ever opened this pipe. + pub fn writer_ever_opened(&self) -> bool { + self.writer_ever_opened + } + /// Total capacity of the buffer. pub fn capacity(&self) -> usize { self.buf.len() @@ -197,6 +230,7 @@ impl PipeBuffer { /// Add a writer reference (e.g., after fork or dup). pub fn add_writer(&mut self) { self.write_count += 1; + self.writer_ever_opened = true; } /// Returns true if the read end is still open (any readers remain). @@ -210,8 +244,13 @@ impl PipeBuffer { } /// Returns true if both endpoints are closed and the pipe can be freed. + /// + /// FIFO-backing pipes are exempt: a FIFO persists in the filesystem + /// namespace until unlinked, even when no fds are currently open, so a + /// later `open()` reconnects to the same buffer. FIFO pipes are freed + /// explicitly via [`PipeTable::free_fifo`] on unlink. pub fn is_fully_closed(&self) -> bool { - self.read_count == 0 && self.write_count == 0 + self.read_count == 0 && self.write_count == 0 && !self.is_fifo } /// Push ancillary FDs (SCM_RIGHTS) to be delivered with the next recvmsg. @@ -317,6 +356,20 @@ impl PipeTable { } } + /// Free a FIFO-backing pipe slot on unlink, but only once no endpoints + /// remain open (POSIX: unlinking a FIFO removes the name; open fds keep + /// working until closed). Clears the FIFO exemption so a subsequent close + /// frees it via `free_if_closed`; frees immediately if already idle. + pub fn free_fifo(&mut self, idx: usize) { + if let Some(Some(pipe)) = self.pipes.get_mut(idx) { + pipe.is_fifo = false; + if pipe.is_fully_closed() { + self.pipes[idx] = None; + self.free_list.push(idx); + } + } + } + /// Total number of slots (including freed). pub fn len(&self) -> usize { self.pipes.len() diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 175960c9a..c7bd5872e 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -1897,6 +1897,34 @@ pub fn sys_open( return Err(Errno::ENXIO); } + // Named FIFO (mkfifo / mknod S_IFIFO) — connect a read or write end to the + // shared kernel pipe registered at this path. This gives process + // substitution (`read < <(cmd)`) real pipe semantics instead of reading an + // empty regular file (premature EOF). See `crate::fifo`. + if let Some(pipe_idx) = unsafe { crate::fifo::global_fifo_table() }.lookup(&resolved) { + let access_mode = oflags & O_ACCMODE; + let pipe_handle = -((pipe_idx as i64) + 1); + { + let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) } + .ok_or(Errno::ENOENT)?; + match access_mode { + O_WRONLY => pipe.add_writer(), + O_RDWR => { + pipe.add_reader(); + pipe.add_writer(); + } + _ => pipe.add_reader(), // O_RDONLY + } + } + let status_flags = oflags & !CREATION_FLAGS; + let ofd_idx = + proc.ofd_table + .create(FileType::Pipe, status_flags, pipe_handle, resolved); + let fd_flags = oflags_to_fd_flags(oflags); + let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; + return Ok(fd); + } + // Procfs (/proc/...) — in-kernel virtual filesystem if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { return crate::procfs::procfs_open(proc, &entry, resolved, oflags); @@ -2248,7 +2276,14 @@ pub fn sys_read( return Ok(n); } if !pipe.is_write_end_open() { - return Ok(0); // EOF — write end closed + // A FIFO with no writer yet must block (POSIX: a reader + // waits for a writer) rather than reporting EOF — + // otherwise a reader that opens before the writer's + // worker connects gets a premature EOF. Fall through to + // EAGAIN so the reader parks until a writer writes. + if !(pipe.is_fifo() && !pipe.writer_ever_opened()) { + return Ok(0); // EOF — all writers closed + } } } return Err(Errno::EAGAIN); @@ -4046,6 +4081,9 @@ pub fn sys_stat(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resul if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } + if let Some(st) = fifo_stat(&resolved, proc.euid, proc.egid) { + return Ok(st); + } // Check Unix socket registry { let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; @@ -4114,6 +4152,9 @@ pub fn sys_lstat( if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } + if let Some(st) = fifo_stat(&resolved, proc.euid, proc.egid) { + return Ok(st); + } // Check Unix socket registry { let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; @@ -4162,8 +4203,68 @@ pub fn sys_rmdir(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resu host.host_rmdir(&resolved) } +/// Create a named FIFO (`mkfifo` / `mknod(S_IFIFO)`). A FIFO is a named kernel +/// pipe: registered by canonical path so a later `open()` connects a read or +/// write end with real pipe semantics (block/EAGAIN/EOF). See `crate::fifo`. +pub fn sys_mkfifo(proc: &mut Process, path: &[u8]) -> Result<(), Errno> { + let resolved = resolve_path(path, &proc.cwd); + make_fifo(resolved) +} + +/// `mkfifoat` / `mknodat(S_IFIFO)`: like [`sys_mkfifo`] but relative to `dirfd`. +pub fn sys_mkfifoat(proc: &mut Process, dirfd: i32, path: &[u8]) -> Result<(), Errno> { + let resolved = resolve_at_path(proc, dirfd, path)?; + make_fifo(resolved) +} + +/// If `resolved` names a FIFO (registered via mkfifo/mknod S_IFIFO), return +/// its stat with `S_IFIFO` set. A FIFO has no backing VFS inode, so `stat` +/// must report it from the FIFO registry. +fn fifo_stat(resolved: &[u8], uid: u32, gid: u32) -> Option { + if unsafe { crate::fifo::global_fifo_table() }.lookup(resolved).is_some() { + Some(WasmStat { + st_dev: 0, + st_ino: 0x4649_464F, // "FIFO" + st_mode: wasm_posix_shared::mode::S_IFIFO | 0o600, + st_nlink: 1, + st_uid: uid, + st_gid: gid, + st_size: 0, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 0, + st_mtime_nsec: 0, + st_ctime_sec: 0, + st_ctime_nsec: 0, + _pad: 0, + }) + } else { + None + } +} + +fn make_fifo(resolved: alloc::vec::Vec) -> Result<(), Errno> { + let fifo_table = unsafe { crate::fifo::global_fifo_table() }; + // mknod is exclusive: fail if a FIFO already exists at this path. (A + // pre-existing regular file/dir is not detected here; shells stat() first + // and mknod over an existing VFS path is rare.) + if fifo_table.lookup(&resolved).is_some() { + return Err(Errno::EEXIST); + } + let pipe = crate::pipe::PipeBuffer::new_fifo(crate::pipe::DEFAULT_PIPE_CAPACITY); + let pipe_idx = unsafe { crate::pipe::global_pipe_table().alloc(pipe) }; + fifo_table.register(resolved, pipe_idx); + Ok(()) +} + pub fn sys_unlink(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { let resolved = resolve_path(path, &proc.cwd); + // Named FIFO: remove the name and free the backing pipe once no fds remain + // open (open fds keep working until closed — POSIX unlink semantics). + if let Some(pipe_idx) = unsafe { crate::fifo::global_fifo_table() }.remove(&resolved) { + unsafe { crate::pipe::global_pipe_table().free_fifo(pipe_idx) }; + return Ok(()); + } check_parent_writable(proc, host, &resolved)?; check_sticky_child(proc, host, &resolved)?; // AF_UNIX bind() creates a real host inode, so unlink must remove both the @@ -8142,6 +8243,34 @@ pub fn sys_openat( return Err(Errno::ENXIO); } + // Named FIFO (mkfifo / mknod S_IFIFO) — connect a read or write end to the + // shared kernel pipe registered at this path. This gives process + // substitution (`read < <(cmd)`) real pipe semantics instead of reading an + // empty regular file (premature EOF). See `crate::fifo`. + if let Some(pipe_idx) = unsafe { crate::fifo::global_fifo_table() }.lookup(&resolved) { + let access_mode = oflags & O_ACCMODE; + let pipe_handle = -((pipe_idx as i64) + 1); + { + let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) } + .ok_or(Errno::ENOENT)?; + match access_mode { + O_WRONLY => pipe.add_writer(), + O_RDWR => { + pipe.add_reader(); + pipe.add_writer(); + } + _ => pipe.add_reader(), // O_RDONLY + } + } + let status_flags = oflags & !CREATION_FLAGS; + let ofd_idx = + proc.ofd_table + .create(FileType::Pipe, status_flags, pipe_handle, resolved); + let fd_flags = oflags_to_fd_flags(oflags); + let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; + return Ok(fd); + } + // Procfs (/proc/...) — in-kernel virtual filesystem if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { return crate::procfs::procfs_open(proc, &entry, resolved, oflags); @@ -8255,6 +8384,9 @@ pub fn sys_fstatat( if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } + if let Some(st) = fifo_stat(&resolved, proc.euid, proc.egid) { + return Ok(st); + } // Check Unix socket registry { let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; @@ -8300,6 +8432,13 @@ pub fn sys_unlinkat( use wasm_posix_shared::flags::AT_REMOVEDIR; let resolved = resolve_at_path(proc, dirfd, path)?; + // Named FIFO: remove the name and free the backing pipe once idle. + if flags & AT_REMOVEDIR == 0 { + if let Some(pipe_idx) = unsafe { crate::fifo::global_fifo_table() }.remove(&resolved) { + unsafe { crate::pipe::global_pipe_table().free_fifo(pipe_idx) }; + return Ok(()); + } + } check_parent_writable(proc, host, &resolved)?; if flags & AT_REMOVEDIR != 0 { check_sticky_child(proc, host, &resolved)?; @@ -11947,6 +12086,45 @@ mod tests { assert_eq!(&buf[..5], b"hello"); } + #[test] + fn test_fifo_named_pipe_semantics() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fifo = b"/tmp/fifo_semantics_unit_test"; + + // mkfifo(S_IFIFO): stat reports S_IFIFO even though there is no VFS + // inode (the fix — previously mknod created a plain regular file). + sys_mkfifo(&mut proc, fifo).unwrap(); + let st = sys_stat(&mut proc, &mut host, fifo).unwrap(); + assert_eq!(st.st_mode & S_IFIFO, S_IFIFO); + + // A reader that opens before any writer must BLOCK (EAGAIN), not get a + // premature EOF. This is the exact process-substitution failure + // (`read < <(cmd)`) the fix resolves. + let rfd = sys_open(&mut proc, &mut host, fifo, O_RDONLY, 0).unwrap(); + let mut buf = [0u8; 16]; + assert_eq!(sys_read(&mut proc, &mut host, rfd, &mut buf), Err(Errno::EAGAIN)); + + // Once a writer opens and writes, the reader gets the data. + let wfd = sys_open(&mut proc, &mut host, fifo, O_WRONLY, 0).unwrap(); + assert_eq!(sys_write(&mut proc, &mut host, wfd, b"hello").unwrap(), 5); + let n = sys_read(&mut proc, &mut host, rfd, &mut buf).unwrap(); + assert_eq!(&buf[..n], b"hello"); + + // Empty-but-writer-open reads still block (EAGAIN), not EOF. + assert_eq!(sys_read(&mut proc, &mut host, rfd, &mut buf), Err(Errno::EAGAIN)); + + // After the last writer closes, the reader sees EOF (0). + sys_close(&mut proc, &mut host, wfd).unwrap(); + assert_eq!(sys_read(&mut proc, &mut host, rfd, &mut buf).unwrap(), 0); + + // Unlink removes the FIFO name; a later stat no longer reports S_IFIFO. + sys_close(&mut proc, &mut host, rfd).unwrap(); + sys_unlink(&mut proc, &mut host, fifo).unwrap(); + let st2 = sys_stat(&mut proc, &mut host, fifo).unwrap(); + assert_eq!(st2.st_mode & S_IFIFO, 0); + } + #[test] fn test_stat_returns_file_info() { let mut proc = Process::new(1); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 93f9c8ad9..858eb4151 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -3861,14 +3861,16 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 249 => 0, // SYS_INOTIFY_RM_WATCH: no-op success // --- mknod/mknodat: create regular files and FIFOs --- - // S_IFIFO nodes are created as regular files (sufficient for basic - // mkfifo/mknod tests that don't actually use FIFO I/O semantics). + // S_IFIFO nodes are real named pipes (see `crate::fifo`); other node + // types fall through to a regular-file marker. 271 => { // SYS_MKNOD: (path, mode, dev) let path = a1 as *const u8; let mode = a2 as u32; let file_type = mode & 0o170000; - if file_type != 0 && file_type != 0o100000 && file_type != 0o010000 { + if file_type == 0o010000 { + kernel_mkfifo(path, unsafe { cstr_len(path) }, mode & 0o7777) + } else if file_type != 0 && file_type != 0o100000 { -(Errno::EPERM as i32) } else { kernel_mknod(path, unsafe { cstr_len(path) }, mode & 0o7777) @@ -3879,7 +3881,9 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let path = a2 as *const u8; let mode = a3 as u32; let file_type = mode & 0o170000; - if file_type != 0 && file_type != 0o100000 && file_type != 0o010000 { + if file_type == 0o010000 { + kernel_mkfifoat(a1, path, unsafe { cstr_len(path) }, mode & 0o7777) + } else if file_type != 0 && file_type != 0o100000 { -(Errno::EPERM as i32) } else { kernel_mknodat(a1, path, unsafe { cstr_len(path) }, mode & 0o7777) @@ -4613,6 +4617,26 @@ fn kernel_mknod(path_ptr: *const u8, path_len: u32, mode: u32) -> i32 { } } +/// mkfifo / mknod(S_IFIFO) — create a named FIFO (real pipe semantics). +fn kernel_mkfifo(path_ptr: *const u8, path_len: u32, _mode: u32) -> i32 { + let (_gkl, proc) = unsafe { get_process() }; + let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; + match syscalls::sys_mkfifo(proc, path) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +/// mkfifoat / mknodat(S_IFIFO) — create a named FIFO relative to a directory fd. +fn kernel_mkfifoat(dirfd: i32, path_ptr: *const u8, path_len: u32, _mode: u32) -> i32 { + let (_gkl, proc) = unsafe { get_process() }; + let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; + match syscalls::sys_mkfifoat(proc, dirfd, path) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + /// mknodat — create a regular file node relative to directory fd. fn kernel_mknodat(dirfd: i32, path_ptr: *const u8, path_len: u32, mode: u32) -> i32 { use wasm_posix_shared::flags::{O_CREAT, O_EXCL, O_WRONLY};