diff --git a/abi/snapshot.json b/abi/snapshot.json index b1f34da08..2ca66a07d 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1050,6 +1050,11 @@ "name": "kernel_pipe_close_write", "signature": "(i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_pipe_has_readers", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_pipe_is_read_open", diff --git a/crates/kernel/src/pipe.rs b/crates/kernel/src/pipe.rs index b4997da5a..891d80128 100644 --- a/crates/kernel/src/pipe.rs +++ b/crates/kernel/src/pipe.rs @@ -60,6 +60,11 @@ pub struct PipeBuffer { len: usize, read_count: u32, write_count: u32, + /// The receive half of a normally closed TCP endpoint remains as an + /// orphaned discard sink until the peer closes its write half. This models + /// TCP's simplex FIN without inventing a fixed number of successful writes + /// after EOF. + orphaned_read: bool, /// Index of this pipe in the PipeTable (for wakeup events). pipe_idx: u32, /// Ancillary data queue for SCM_RIGHTS FD passing. @@ -79,6 +84,7 @@ impl PipeBuffer { len: 0, read_count: 1, write_count: 1, + orphaned_read: false, pipe_idx: 0, ancillary_fds: VecDeque::new(), } @@ -109,6 +115,9 @@ impl PipeBuffer { /// Performs a partial write if the buffer does not have enough free space /// for all of `data`. Returns 0 if the buffer is full. pub fn write(&mut self, data: &[u8]) -> usize { + if self.read_count == 0 { + return if self.orphaned_read { data.len() } else { 0 }; + } let cap = self.capacity(); let n = data.len().min(self.free_space()); if n == 0 { @@ -178,19 +187,46 @@ impl PipeBuffer { /// Close one read end of the pipe. Decrements the read reference count. pub fn close_read_end(&mut self) { self.read_count = self.read_count.saturating_sub(1); + if self.read_count == 0 { + self.orphaned_read = false; + self.head = 0; + self.tail = 0; + self.len = 0; + } // Read end closed → pipe became writable (writers get EPIPE/SIGPIPE) crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_WRITABLE); } + /// Close one TCP read end with orderly-close semantics. + /// + /// The last real reader becomes an orphaned discard sink while a writer is + /// still open. This is the pipe-backed equivalent of an operating system + /// retaining a TCP control block after the application closes its socket. + /// Explicit read shutdown uses `close_read_end` instead. + pub fn close_read_end_orderly(&mut self) { + self.read_count = self.read_count.saturating_sub(1); + if self.read_count == 0 { + self.head = 0; + self.tail = 0; + self.len = 0; + self.orphaned_read = self.write_count > 0; + } + crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_WRITABLE); + } + /// Close one write end of the pipe. Decrements the write reference count. pub fn close_write_end(&mut self) { self.write_count = self.write_count.saturating_sub(1); + if self.write_count == 0 { + self.orphaned_read = false; + } // Write end closed → pipe became readable (readers get EOF) crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_READABLE); } /// Add a reader reference (e.g., after fork or dup). pub fn add_reader(&mut self) { + self.orphaned_read = false; self.read_count += 1; } @@ -201,6 +237,14 @@ impl PipeBuffer { /// Returns true if the read end is still open (any readers remain). pub fn is_read_end_open(&self) -> bool { + self.read_count > 0 || self.orphaned_read + } + + /// Returns true if an application-owned reader remains. + /// + /// Unlike `is_read_end_open`, this excludes TCP's orphaned discard sink so + /// host bridges can distinguish SHUT_WR from a final close. + pub fn has_readers(&self) -> bool { self.read_count > 0 } @@ -211,7 +255,7 @@ impl PipeBuffer { /// Returns true if both endpoints are closed and the pipe can be freed. pub fn is_fully_closed(&self) -> bool { - self.read_count == 0 && self.write_count == 0 + self.read_count == 0 && self.write_count == 0 && !self.orphaned_read } /// Push ancillary FDs (SCM_RIGHTS) to be delivered with the next recvmsg. @@ -499,6 +543,43 @@ mod tests { assert!(pipe.is_fully_closed()); } + #[test] + fn test_orderly_read_close_discards_until_last_writer_closes() { + let mut pipe = PipeBuffer::new(8); + + pipe.close_read_end_orderly(); + assert!(pipe.is_read_end_open()); + assert!(!pipe.has_readers()); + assert_eq!(pipe.write(b"first"), 5); + assert_eq!(pipe.write(b"larger than capacity"), 20); + assert_eq!(pipe.available(), 0); + assert!(!pipe.is_fully_closed()); + + pipe.close_write_end(); + assert!(!pipe.is_read_end_open()); + assert!(pipe.is_fully_closed()); + } + + #[test] + fn test_orderly_read_close_preserves_other_real_readers() { + let mut pipe = PipeBuffer::new(8); + pipe.add_reader(); + + pipe.close_read_end_orderly(); + assert!(pipe.has_readers()); + assert_eq!(pipe.write(b"live"), 4); + let mut buf = [0u8; 4]; + assert_eq!(pipe.read(&mut buf), 4); + assert_eq!(&buf, b"live"); + + pipe.close_read_end_orderly(); + assert!(!pipe.has_readers()); + assert_eq!(pipe.write(b"discarded"), 9); + assert_eq!(pipe.available(), 0); + pipe.close_write_end(); + assert!(pipe.is_fully_closed()); + } + #[test] fn test_pipe_table_alloc_and_free() { let mut table = PipeTable::new(); diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 9464123b5..cb0c75718 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -366,7 +366,9 @@ impl ProcessTable { } } - // Clean up socket OFDs: close pipe endpoints so peers get EOF/EPIPE. + // Clean up socket OFDs. Active TCP streams use the same orderly FIN + // and orphaned receive state as close(2); other socket kinds close + // their pipe endpoints directly. // Without this, a peer process reading from a connected socket would // block forever instead of getting EOF when this process exits. // @@ -380,6 +382,14 @@ impl ProcessTable { let sock_idx = (-(ofd.host_handle + 1)) as usize; if let Some(sock) = proc.sockets.get(sock_idx) { if sock.global_pipes { + let orderly_tcp_close = matches!( + (sock.domain, sock.sock_type), + ( + crate::socket::SocketDomain::Inet + | crate::socket::SocketDomain::Inet6, + crate::socket::SocketType::Stream, + ) + ); // Cross-process socket: close pipe ends in global table if let Some(send_idx) = sock.send_buf_idx { if let Some(pipe) = pipe_table.get_mut(send_idx) { @@ -389,7 +399,11 @@ impl ProcessTable { } if let Some(recv_idx) = sock.recv_buf_idx { if let Some(pipe) = pipe_table.get_mut(recv_idx) { - pipe.close_read_end(); + if orderly_tcp_close { + pipe.close_read_end_orderly(); + } else { + pipe.close_read_end(); + } } pipe_table.free_if_closed(recv_idx); } @@ -1073,6 +1087,53 @@ pub fn current_pid() -> u32 { mod tests { use super::*; + #[test] + fn process_exit_closes_tcp_pipes_orderly() { + use crate::pipe::{global_pipe_table, PipeBuffer, DEFAULT_PIPE_CAPACITY}; + use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; + + let pipe_table = unsafe { global_pipe_table() }; + let send_idx = pipe_table.alloc(PipeBuffer::new(DEFAULT_PIPE_CAPACITY)); + let recv_idx = pipe_table.alloc(PipeBuffer::new(DEFAULT_PIPE_CAPACITY)); + + let mut table = ProcessTable::new(); + table.create_process(950_001).unwrap(); + let proc = table.processes.get_mut(&950_001).unwrap(); + let mut socket = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 6); + socket.state = SocketState::Connected; + socket.send_buf_idx = Some(send_idx); + socket.recv_buf_idx = Some(recv_idx); + socket.global_pipes = true; + let sock_idx = proc.sockets.alloc(socket); + let ofd_idx = proc.ofd_table.create( + FileType::Socket, + wasm_posix_shared::flags::O_RDWR, + -((sock_idx as i64) + 1), + Vec::new(), + ); + proc.fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + + table.remove_process(950_001).unwrap(); + + let send_pipe = pipe_table.get_mut(send_idx).unwrap(); + assert!(!send_pipe.is_write_end_open()); + assert!(send_pipe.is_read_end_open()); + let recv_pipe = pipe_table.get_mut(recv_idx).unwrap(); + assert!(recv_pipe.is_read_end_open()); + assert_eq!(recv_pipe.write(b"after-exit-one"), 14); + assert_eq!(recv_pipe.write(b"after-exit-two"), 14); + assert_eq!(recv_pipe.available(), 0); + + pipe_table.get_mut(send_idx).unwrap().close_read_end(); + pipe_table.free_if_closed(send_idx); + pipe_table.get_mut(recv_idx).unwrap().close_write_end(); + pipe_table.free_if_closed(recv_idx); + assert!(pipe_table.get(send_idx).is_none()); + assert!(pipe_table.get(recv_idx).is_none()); + } + fn install_bound_udp4_socket(table: &mut ProcessTable, pid: u32, port: u16) -> usize { use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index e17d36a15..c2037a712 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -2092,6 +2092,13 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( crate::socket::shared_listener_backlog_table().dec_ref(shared_idx) }; } + let orderly_tcp_close = matches!( + (sock.domain, sock.sock_type), + ( + crate::socket::SocketDomain::Inet | crate::socket::SocketDomain::Inet6, + crate::socket::SocketType::Stream, + ) + ); if let Some(send_idx) = sock.send_buf_idx { let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(send_idx) }; if let Some(pipe) = pipe { @@ -2102,7 +2109,11 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( if let Some(recv_idx) = sock.recv_buf_idx { let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(recv_idx) }; if let Some(pipe) = pipe { - pipe.close_read_end(); + if orderly_tcp_close { + pipe.close_read_end_orderly(); + } else { + pipe.close_read_end(); + } unsafe { crate::pipe::global_pipe_table().free_if_closed(recv_idx) }; } } @@ -7297,7 +7308,8 @@ pub fn sys_getpeername(proc: &Process, fd: i32, buf: &mut [u8]) -> Result { + sock.shut_rd = true; + (None, sock.recv_buf_idx.take(), None) + } + SHUT_WR => { + sock.shut_wr = true; + (sock.send_buf_idx.take(), None, None) + } + SHUT_RDWR => { + sock.shut_rd = true; + sock.shut_wr = true; + ( + sock.send_buf_idx.take(), + sock.recv_buf_idx.take(), + sock.host_net_handle.take(), + ) + } + _ => return Err(Errno::EINVAL), + } + }; - match how { - SHUT_RD => { - sock.shut_rd = true; + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + if let Some(send_idx) = send_idx { + if let Some(pipe) = pipe_table.get_mut(send_idx) { + pipe.close_write_end(); + // Wake a local writer that was blocked before shut_wr became true. + crate::wakeup::push(send_idx as u32, crate::wakeup::WAKE_WRITABLE); } - SHUT_WR => { - sock.shut_wr = true; - if let Some(send_idx) = sock.send_buf_idx { - let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(send_idx) }; - if let Some(pipe) = pipe { - pipe.close_write_end(); - } - } + pipe_table.free_if_closed(send_idx); + } + if let Some(recv_idx) = recv_idx { + if let Some(pipe) = pipe_table.get_mut(recv_idx) { + pipe.close_read_end(); + // Wake a local reader that was blocked before shut_rd became true. + crate::wakeup::push(recv_idx as u32, crate::wakeup::WAKE_READABLE); } - SHUT_RDWR => { - sock.shut_rd = true; - sock.shut_wr = true; - if let Some(net_handle) = sock.host_net_handle { - let _ = host.host_net_close(net_handle); - } - if let Some(send_idx) = sock.send_buf_idx { - let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(send_idx) }; - if let Some(pipe) = pipe { - pipe.close_write_end(); - } - } - if let Some(recv_idx) = sock.recv_buf_idx { - let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(recv_idx) }; - if let Some(pipe) = pipe { - pipe.close_read_end(); - } - } + pipe_table.free_if_closed(recv_idx); + } + if let Some(net_handle) = net_handle { + if crate::socket::host_net_handle_close_ref(net_handle) { + let _ = host.host_net_close(net_handle); } - _ => return Err(Errno::EINVAL), } Ok(()) } @@ -7408,6 +7434,12 @@ pub fn sys_send( if sock.state != SocketState::Connected { return Err(Errno::ENOTCONN); } + if sock.shut_wr { + if flags & MSG_NOSIGNAL == 0 { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + } + return Err(Errno::EPIPE); + } // MSG_OOB: store the last byte as out-of-band data on the peer socket. if flags & MSG_OOB != 0 { @@ -7754,6 +7786,12 @@ pub fn sys_setsockopt_linger( if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } + // Enabled linger needs either blocking queued-send drainage or an explicit + // reset close mode carried through every kernel/host transport. Kandelo has + // neither contract yet, so reject it instead of storing a no-op promise. + if l_onoff != 0 { + return Err(Errno::EOPNOTSUPP); + } let sock_idx = (-(ofd.host_handle + 1)) as usize; let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; sock.linger_onoff = l_onoff; @@ -14990,6 +15028,43 @@ mod tests { assert_eq!(result, Err(Errno::EPIPE)); } + #[test] + fn test_shutdown_rdwr_consumes_pipe_refs_once() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + let (sock0_idx, send_idx, recv_idx) = { + let ofd = proc + .ofd_table + .get(proc.fd_table.get(fd0).unwrap().ofd_ref.0) + .unwrap(); + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx).unwrap(); + (sock_idx, sock.send_buf_idx.unwrap(), sock.recv_buf_idx.unwrap()) + }; + + sys_shutdown(&mut proc, &mut host, fd0, SHUT_RDWR).unwrap(); + sys_shutdown(&mut proc, &mut host, fd0, SHUT_RDWR).unwrap(); + let sock = proc.sockets.get(sock0_idx).unwrap(); + assert!(sock.send_buf_idx.is_none()); + assert!(sock.recv_buf_idx.is_none()); + assert_eq!( + sys_send(&mut proc, &mut host, fd0, b"after-shutdown", MSG_NOSIGNAL), + Err(Errno::EPIPE), + ); + + sys_close(&mut proc, &mut host, fd0).unwrap(); + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + assert!(pipe_table.get(send_idx).is_some()); + assert!(pipe_table.get(recv_idx).is_some()); + + sys_close(&mut proc, &mut host, fd1).unwrap(); + assert!(pipe_table.get(send_idx).is_none()); + assert!(pipe_table.get(recv_idx).is_none()); + } + #[test] fn test_send_recv() { let mut proc = Process::new(1); @@ -15257,9 +15332,20 @@ mod tests { let mut host = MockHostIO::new(); use wasm_posix_shared::socket::*; let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - sys_setsockopt_linger(&mut proc, fd, 1, 5).unwrap(); - let val = sys_getsockopt_linger(&proc, fd).unwrap(); - assert_eq!(val, (1, 5)); + assert_eq!(sys_getsockopt_linger(&proc, fd).unwrap(), (0, 0)); + + sys_setsockopt_linger(&mut proc, fd, 0, 5).unwrap(); + assert_eq!(sys_getsockopt_linger(&proc, fd).unwrap(), (0, 5)); + + assert_eq!( + sys_setsockopt_linger(&mut proc, fd, 1, 0), + Err(Errno::EOPNOTSUPP), + ); + assert_eq!( + sys_setsockopt_linger(&mut proc, fd, 1, 5), + Err(Errno::EOPNOTSUPP), + ); + assert_eq!(sys_getsockopt_linger(&proc, fd).unwrap(), (0, 5)); } #[test] @@ -19421,6 +19507,75 @@ mod tests { ); } + #[test] + fn test_tcp_loopback_close_drains_then_discards_post_fin_writes() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + let server_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut addr = [0u8; 16]; + addr[0] = 2; // AF_INET + addr[2] = 0x23; + addr[3] = 0x9b; // port 9115 + sys_bind(&mut proc, &mut host, server_fd, &addr).unwrap(); + sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); + + let client_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut connect_addr = [0u8; 16]; + connect_addr[0] = 2; + connect_addr[2] = 0x23; + connect_addr[3] = 0x9b; + connect_addr[4] = 127; + connect_addr[7] = 1; + sys_connect(&mut proc, &mut host, client_fd, &connect_addr).unwrap(); + let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); + + let client_sock_idx = { + let entry = proc.fd_table.get(client_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; + let client_send_idx = proc + .sockets + .get(client_sock_idx) + .unwrap() + .send_buf_idx + .unwrap(); + let client_recv_idx = proc + .sockets + .get(client_sock_idx) + .unwrap() + .recv_buf_idx + .unwrap(); + + sys_write(&mut proc, &mut host, client_fd, b"request").unwrap(); + let mut buf = [0u8; 16]; + let n = sys_read(&mut proc, &mut host, accepted_fd, &mut buf).unwrap(); + assert_eq!(&buf[..n], b"request"); + + sys_write(&mut proc, &mut host, accepted_fd, b"queued").unwrap(); + sys_close(&mut proc, &mut host, accepted_fd).unwrap(); + let queued = sys_read(&mut proc, &mut host, client_fd, &mut buf).unwrap(); + assert_eq!(&buf[..queued], b"queued"); + let eof = sys_read(&mut proc, &mut host, client_fd, &mut buf).unwrap(); + assert_eq!(eof, 0); + + assert_eq!( + sys_write(&mut proc, &mut host, client_fd, b"post-fin-one").unwrap(), + 12 + ); + assert_eq!( + sys_write(&mut proc, &mut host, client_fd, b"post-fin-two").unwrap(), + 12 + ); + + sys_close(&mut proc, &mut host, client_fd).unwrap(); + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + assert!(pipe_table.get(client_send_idx).is_none()); + assert!(pipe_table.get(client_recv_idx).is_none()); + } + #[test] fn test_udp_loopback() { let mut proc = Process::new(1); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 1e548456f..12b4963bb 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -10263,7 +10263,7 @@ pub extern "C" fn kernel_pipe_is_write_open(_pid: u32, pipe_idx: u32) -> i32 { if pipe.is_write_end_open() { 1 } else { 0 } } -/// Check if a pipe's read end is still open. +/// Check if a pipe accepts writes through a real reader or TCP discard sink. /// Returns 1 if open, 0 if closed, negative errno on error. /// /// `pid` is ignored for ABI compatibility; `pipe_idx` addresses the global @@ -10278,6 +10278,22 @@ pub extern "C" fn kernel_pipe_is_read_open(_pid: u32, pipe_idx: u32) -> i32 { if pipe.is_read_end_open() { 1 } else { 0 } } +/// Check if a pipe has at least one application-owned reader. +/// Returns 1 for a real reader, 0 for a TCP discard sink or closed read end, +/// and negative errno on error. +/// +/// `pid` is ignored for ABI compatibility; `pipe_idx` addresses the global +/// pipe table. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pipe_has_readers(_pid: u32, pipe_idx: u32) -> i32 { + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + let pipe = match pipe_table.get(pipe_idx as usize) { + Some(p) => p, + None => return -(Errno::EBADF as i32), + }; + if pipe.has_readers() { 1 } else { 0 } +} + /// Look up the recv pipe index for a socket fd. /// Returns the recv_buf_idx or -1 if the fd is not a connected socket. #[unsafe(no_mangle)] diff --git a/docs/architecture.md b/docs/architecture.md index 0600e5894..0bdd4f55b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -512,7 +512,9 @@ Routed virtual IPv4 addresses are explicit backend addresses. For example, the b ### Local Virtual Network -`LocalVirtualNetwork` (`host/src/networking/virtual-network.ts`) is an in-memory `NetworkIO` backend for multiple Kandelo machines in the same JS session. Each machine receives a `VirtualNetworkBackend` with a stable virtual IPv4 address and optional hostnames. The backend delivers UDP datagrams as bounded message queues and creates paired TCP streams for accepted connections. When a machine detaches, its listeners and endpoints are removed and connected TCP peers observe normal close/reset style readiness through the socket layer. +`LocalVirtualNetwork` (`host/src/networking/virtual-network.ts`) is an in-memory `NetworkIO` backend for multiple Kandelo machines in the same JS session. Each machine receives a `VirtualNetworkBackend` with a stable virtual IPv4 address and optional hostnames. The backend delivers UDP datagrams as bounded message queues and creates paired TCP streams for accepted connections. When a machine detaches, its listeners and endpoints are removed. Direct virtual endpoints observe an explicit connection reset; an accepted pipe-bridged endpoint currently maps that reset to EOF/EPIPE because the pipe ABI has no pending-socket-error channel. + +Normal TCP close is distinct from that abort path. Bytes queued by the closing endpoint drain before its FIN, and the peer drains those bytes before `recv` reports EOF. The in-kernel loopback and local virtual transports retain an orphaned receive sink that discards later peer sends until the peer closes its own write half; they do not invent a fixed number of successful writes after FIN. The Node backend uses `net.Socket` half-open state and `destroySoon()` so the operating system determines later reset timing after queued bytes and FIN. Explicit receive shutdown remains a refusal path. Enabled `SO_LINGER` is rejected until reset and timed-close modes can be carried coherently through every transport. This backend is used by `apps/browser-demos/pages/network/`, which boots multiple local machines and verifies UDP datagram delivery with `nc -u`, TCP stream delivery with `nc`, and HTTP over virtual TCP with `curl`. diff --git a/docs/posix-status.md b/docs/posix-status.md index 5212db83e..e62197a9b 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -234,10 +234,10 @@ shortcuts. | `listen()` | Partial | AF_INET TCP delegates to the active HostIO networking backend, including Node `net` and the browser local virtual-network backend. AF_UNIX stream listen is implemented. AF_INET6 `::`/`::1` loopback listeners support same- and cross-process connections; external and virtual IPv6 listeners do not. Datagram listen rejects as unsupported. | | `accept()` / `accept4()` | Partial | AF_INET TCP delegates to the active HostIO networking backend; AF_UNIX and AF_INET6 loopback streams return connected sockets from kernel queues. A dual-stack IPv6 listener reports IPv4 peers as IPv4-mapped `sockaddr_in6`. Linux-style accept does not inherit O_NONBLOCK; accept4 applies SOCK_NONBLOCK and SOCK_CLOEXEC explicitly and rejects other flags before consuming a pending connection. Datagram accept rejects as unsupported. | | `connect()` | Partial | AF_UNIX streams support same- and cross-process pathname or abstract-namespace listeners. AF_UNIX datagrams deliver to a registered peer only within the same process; a missing, wrong-type, or cross-process peer returns ECONNREFUSED until machine-wide datagram routing exists. AF_INET TCP is host-backed and works over Node external TCP or the browser local virtual-network backend. AF_INET UDP connect stores the peer, auto-binds an ephemeral local port when needed, filters receives to the connected peer, and supports AF_UNSPEC unconnect. AF_INET6 streams support same- and cross-process `::1`; AF_INET6 datagrams are process-local and report `IPV6_V6ONLY=1` because dual-stack datagram routing is not implemented. Non-loopback IPv6 fails with EADDRNOTAVAIL for streams and ENETUNREACH for datagrams. External raw UDP also returns ENETUNREACH without another HostIO transport. | -| `send()` / `recv()` | Partial | Unix domain streams and datagrams, AF_INET/AF_INET6 TCP streams, and connected AF_INET/AF_INET6 UDP preserve their socket-family addressing and datagram boundaries. TCP send/recv works over Node external TCP and the local virtual-network backend. Datagram MSG_PEEK and MSG_DONTWAIT are handled through recvfrom. A send to a stream socket whose peer has closed returns EPIPE and raises SIGPIPE, including host-bridged (external and loopback) TCP; MSG_NOSIGNAL suppresses the signal while still returning EPIPE. | +| `send()` / `recv()` | Partial | Unix domain streams and datagrams, AF_INET/AF_INET6 TCP streams, and connected AF_INET/AF_INET6 UDP preserve their socket-family addressing and datagram boundaries. TCP send/recv works over Node external TCP and the local virtual-network backend. Datagram MSG_PEEK and MSG_DONTWAIT are handled through recvfrom. Normal TCP close drains queued bytes before FIN and EOF; no transport invents a fixed post-FIN write count. A send rejected by a closed/reset stream returns EPIPE and raises SIGPIPE, while direct host/virtual handles may preserve ECONNRESET; accepted pipe-bridged resets currently surface as EOF/EPIPE. MSG_NOSIGNAL suppresses SIGPIPE without changing the errno. | | `sendto()` / `recvfrom()` | Partial | AF_INET, AF_INET6, and AF_UNIX datagrams support connected and unconnected send, receive queues, and connected-peer filtering. IPv4/IPv6 return sender addresses; AF_UNIX currently returns only the family. In-kernel IPv4/IPv6 loopback, AF_UNIX datagram, and IPv4 multicast delivery currently reaches sockets in the sender's process only; machine-wide cross-process datagram routing remains unimplemented. Fork preserves kernel-local bind reservations and lookup ownership, but it does not yet share or transfer a host-backed UDP registration. The `10.88.*` LocalVirtualNetwork path can route IPv4 datagrams between attached Kandelo machines through HostIO for the process that registered the endpoint. IPv4 multicast supports interface selection, loop suppression, membership, and source filtering only; IPv6 multicast and external raw UDP are not implemented. | -| `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET exposes SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, and SO_SNDBUF; SO_REUSEADDR affects UDP bind conflicts. SO_LINGER uses `struct linger` and survives fork, but nondefault linger does not yet alter close timing. SO_BINDTODEVICE validates `lo`/`eth0`, supports empty-name unbind, and constrains bind/connect/send routing. TCP_CONGESTION uses a string layout and accepts only the modeled `cubic` policy; selecting unimplemented algorithms fails. IPv4 multicast membership/source-filter options drive process-local loopback delivery. IPV6_V6ONLY controls pre-bind stream dual-stack behavior; AF_INET6 datagrams truthfully remain V6-only. Other accepted IPv6 multicast options are stored but do not provide IPv6 multicast transport. | -| `shutdown()` | Partial | SHUT_RD, SHUT_WR, SHUT_RDWR for stream sockets and UDP readiness/error behavior. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. | +| `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET exposes SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, and SO_SNDBUF; SO_REUSEADDR affects UDP bind conflicts. SO_LINGER uses `struct linger`; its disabled form is stored, while enabling timed or reset-style linger returns EOPNOTSUPP until every transport supports the close mode. SO_BINDTODEVICE validates `lo`/`eth0`, supports empty-name unbind, and constrains bind/connect/send routing. TCP_CONGESTION uses a string layout and accepts only the modeled `cubic` policy; selecting unimplemented algorithms fails. IPv4 multicast membership/source-filter options drive process-local loopback delivery. IPV6_V6ONLY controls pre-bind stream dual-stack behavior; AF_INET6 datagrams truthfully remain V6-only. Other accepted IPv6 multicast options are stored but do not provide IPv6 multicast transport. | +| `shutdown()` | Partial | SHUT_RD, SHUT_WR, and SHUT_RDWR transitions are idempotent within a process and release each owned pipe/host reference once. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. Fork-inherited sockets still clone shutdown flags per process instead of sharing one socket-wide shutdown state, and the external host ABI has no half-shutdown operation. | | `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. Timeout supported via polling loop. | | `poll()` | Partial | Checks readiness for regular files, pipes, and sockets. UDP poll reports queued datagrams, connected-peer filtering, EOF-like read shutdown, write-shutdown hangup, and pending socket errors. Timeout supported via polling loop with 1ms sleep intervals. Returns EINTR on pending signals. | | `ppoll()` | Full | Wraps poll() with atomic signal mask swap: save → set → poll → restore. Timespec converted to timeout_ms in glue layer. | @@ -404,7 +404,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | **RLIMIT_FSIZE partial enforcement** | rlimits | write() and ftruncate() check FSIZE limit (EFBIG + SIGXFSZ). truncate() delegates to ftruncate so also enforced. | | **setpgid() self-only** | process | Only supports setting own pgid. Setting another process's pgid returns ESRCH. | | ~~**realpath() no symlink resolution**~~ | filesystem | **Resolved.** Now resolves symlinks via iterative lstat/readlink with ELOOP after 40 resolutions. | -| **Socket options partially no-op** | socket | SO_REUSEADDR affects UDP bind conflicts. SO_KEEPALIVE, SO_LINGER, SO_BROADCAST, SO_RCVTIMEO, SO_SNDTIMEO, TCP_NODELAY are accepted/stored but have limited or no effect on data transfer. | +| **Socket options partially no-op** | socket | SO_REUSEADDR affects UDP bind conflicts. SO_KEEPALIVE, SO_BROADCAST, SO_RCVTIMEO, SO_SNDTIMEO, and TCP_NODELAY are accepted/stored but have limited or no effect on data transfer. Enabled SO_LINGER is rejected rather than stored as a no-op. | | **POLLERR partial** | I/O multiplex | poll() reports UDP pending socket errors and stream shutdown/error cases. Some edge cases remain implementation-defined. | | **pread/pwrite not multi-process safe** | I/O | Uses save/seek/read/restore pattern — safe only when no other process shares the OFD, but races with shared OFDs across processes. | | ~~**brk not inherited on fork**~~ | memory | **Resolved.** Program break serialized/deserialized in fork state. (`exec` reset is intentional per POSIX; host re-installs from new program's `__heap_base`.) | @@ -556,7 +556,7 @@ Target use case: hosting PHP-WASM (as used by WordPress Playground) on this kern |-----|-----------|-------------|------------| | ~~`connect()` for AF_INET~~ | socket | **Done.** Host-delegated TCP networking. bind/listen/accept/connect/send/recv all functional. Node.js backend uses `net` module; browser backend uses fetch for HTTP. | ~~Hard~~ | | ~~`getaddrinfo()` / `gethostbyname()`~~ | DNS | **Done.** Host-delegated via `host_getaddrinfo` import. Returns AF_INET sockaddr_in. `/etc/hosts` is served from the canonical `rootfs.vfs` mount at `/` for localhost resolution. | ~~Medium~~ | -| ~~`setsockopt()` expansion~~ | socket | **Done.** SO_KEEPALIVE, TCP_NODELAY, SO_REUSEADDR, SO_LINGER, and many more stored. | ~~Easy~~ | +| ~~`setsockopt()` expansion~~ | socket | **Done.** SO_KEEPALIVE, TCP_NODELAY, SO_REUSEADDR, disabled SO_LINGER state, and many more are represented; enabled SO_LINGER remains explicitly unsupported. | ~~Easy~~ | | ~~Async socket polling bridge~~ | socket | **Done.** poll/select/epoll all work with socket fds. The kernel checks readiness inline. | ~~Medium~~ | ### Phase C — Process management (enables wp-cli, Composer, PHPUnit) diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 33e11a460..cd8639838 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -748,6 +748,8 @@ export class CentralizedKernelWorker { * workers), incoming connections are distributed among them. */ private tcpListenerTargets = new Map>(); private tcpListenerRRIndex = new Map(); + /** Virtual-network listener registration key for each shared TCP port. */ + private tcpVirtualListenerKeys = new Map(); /** UDP virtual-network endpoint bindings: "pid:sockIdx" */ private udpBindings = new Set(); /** Separate scratch buffer for TCP data pumping */ @@ -3030,9 +3032,8 @@ export class CentralizedKernelWorker { * (`pendingPipeReaders`). * 2. Wake any process blocked in poll/ppoll/pselect6 whose * `pipeIndices` includes this pipe (`pendingPollRetries`). - * Pass `pidFilter` to restrict the wake to a single owning - * pid — used by the Node TCP bridge when dispatching an - * inbound connection to a specific listener. + * Pass `pidFilter` only when ownership cannot be shared. Accepted TCP + * pipes omit it because fork children can inherit the same connection. * 3. Schedule a broad wake (`scheduleWakeBlockedRetries`) for * everything else. * @@ -7569,18 +7570,28 @@ export class CentralizedKernelWorker { targets.push({ pid, fd }); } - if (this.io.network?.listenTcp) { + if (this.io.network?.listenTcp && !this.tcpVirtualListenerKeys.has(port)) { const result = this.io.network.listenTcp( key, new Uint8Array(addr), port, { - accept: (peer, _local, remote) => - this.handleIncomingVirtualTcpConnection(pid, fd, peer, remote), + accept: (peer, _local, remote) => { + const target = this.pickListenerTarget(port); + if (!target) return 113; // EHOSTUNREACH + return this.handleIncomingVirtualTcpConnection( + target.pid, + target.fd, + peer, + remote, + ); + }, }, ); if (result !== 0) { console.warn(`virtual TCP listener registration failed on port ${port}: errno ${result}`); + } else { + this.tcpVirtualListenerKeys.set(port, key); } } @@ -7597,7 +7608,7 @@ export class CentralizedKernelWorker { const net = this.netModule; const connections = new Set(); - const server = net.createServer((clientSocket) => { + const server = net.createServer({ allowHalfOpen: true }, (clientSocket) => { // Pick target via round-robin among registered processes for this port const target = this.pickListenerTarget(port); if (target) { @@ -7930,10 +7941,14 @@ export class CentralizedKernelWorker { (pid: number, pipeIdx: number) => number; const pipeIsReadOpen = this.kernelInstance!.exports.kernel_pipe_is_read_open as (pid: number, pipeIdx: number) => number; - + const pipeHasReaders = this.kernelInstance!.exports.kernel_pipe_has_readers as + (pid: number, pipeIdx: number) => number; // Queue for incoming TCP data (written to recv pipe) const inboundQueue: Buffer[] = []; let clientEnded = false; + let clientClosed = false; + let guestWriteEnded = false; + let recvPipeWriteClosed = false; let pumpPending = false; let cleaned = false; @@ -7942,15 +7957,30 @@ export class CentralizedKernelWorker { const pipeIsWriteOpen = this.kernelInstance!.exports.kernel_pipe_is_write_open as (pid: number, pipeIdx: number) => number; + const closeRecvPipeWrite = () => { + if (recvPipeWriteClosed) return; + recvPipeWriteClosed = true; + pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + // EOF is readable state even when the peer sent no data. + this.notifyPipeReadable(recvPipeIdx); + }; + // Drain inbound queue into recv pipe const drainInbound = () => { + if (pipeIsReadOpen(GLOBAL_PIPE_PID, recvPipeIdx) === 0) { + inboundQueue.length = 0; + if (clientEnded) closeRecvPipeWrite(); + return; + } const mem = this.getKernelMem(); + let wroteAny = false; while (inboundQueue.length > 0) { const chunk = inboundQueue[0]!; const toWrite = Math.min(chunk.length, 65536); mem.set(chunk.subarray(0, toWrite), scratchOffset); const written = pipeWrite(GLOBAL_PIPE_PID, recvPipeIdx, this.toKernelPtr(scratchOffset), toWrite); if (written <= 0) break; // Pipe full, retry next pump + wroteAny = true; if (written >= chunk.length) { inboundQueue.shift(); } else { @@ -7958,7 +7988,10 @@ export class CentralizedKernelWorker { } } if (clientEnded && inboundQueue.length === 0) { - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + closeRecvPipeWrite(); + } + if (wroteAny) { + this.notifyPipeReadable(recvPipeIdx); } }; @@ -7978,6 +8011,9 @@ export class CentralizedKernelWorker { clientSocket.write(outData); } } + if (totalRead > 0) { + this.notifyPipeWritable(sendPipeIdx); + } return totalRead; }; @@ -7993,20 +8029,30 @@ export class CentralizedKernelWorker { const pump = () => { pumpPending = false; - if (cleaned || !this.processes.has(pid)) { - cleanup(); - return; - } + if (cleaned) return; drainInbound(); const readN = drainOutbound(); - // Check if PHP closed its write end of the send pipe const writeOpen = pipeIsWriteOpen(GLOBAL_PIPE_PID, sendPipeIdx); - if (writeOpen === 0 && readN === 0) { - if (!clientSocket.destroyed) { + const hasReaders = pipeHasReaders(GLOBAL_PIPE_PID, recvPipeIdx); + if (writeOpen === 0 && readN === 0 && !guestWriteEnded) { + guestWriteEnded = true; + if (!clientSocket.destroyed && !clientSocket.writableEnded) { + // SHUT_WR is a half-close: send FIN after queued bytes but keep the + // real receive half alive until the guest closes it or the peer ends. clientSocket.end(); } + } + if (writeOpen === 0 && hasReaders <= 0) { + cleanup(); + return; + } + if (guestWriteEnded && clientEnded && inboundQueue.length === 0) { + cleanup(); + return; + } + if (clientClosed && inboundQueue.length === 0) { cleanup(); return; } @@ -8023,14 +8069,9 @@ export class CentralizedKernelWorker { // Incoming TCP data → write directly to recv pipe, queue overflow clientSocket.on("data", (chunk: Buffer) => { + if (cleaned) return; inboundQueue.push(chunk); - if (!this.processes.has(pid)) { cleanup(); return; } drainInbound(); - // Wake readers + pollers watching this recv pipe + broad wake. - // The pid filter limits the targeted poll wake to this listener - // pid (the recvPipeIdx is per-connection so any matching poller - // is necessarily owned by this pid; the filter is defensive). - this.notifyPipeReadable(recvPipeIdx, pid); // Schedule pump to handle outbound + close detection schedulePump(); }); @@ -8043,10 +8084,18 @@ export class CentralizedKernelWorker { clientSocket.on("error", () => { clientEnded = true; clientSocket.destroy(); + cleanup(); }); clientSocket.on("close", () => { connections.delete(clientSocket); + clientClosed = true; + clientEnded = true; + // A clean close can arrive while pre-FIN bytes are still queued because + // the guest receive pipe is full. Let the pump deliver those bytes + // before releasing the pipe ends. The error path above remains an + // immediate reset/abort. + schedulePump(); }); // Register this connection for piggyback flushing @@ -8061,12 +8110,14 @@ export class CentralizedKernelWorker { const cleanup = () => { if (cleaned) return; cleaned = true; + inboundQueue.length = 0; // Close the host's ends of both pipes: // recvPipe: host is the writer → close write end // sendPipe: host is the reader → close read end - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + closeRecvPipeWrite(); pipeCloseRead(GLOBAL_PIPE_PID, sendPipeIdx); - connections.delete(clientSocket); + // A closed host read end makes any parked guest writer fail with EPIPE. + this.notifyPipeWritable(sendPipeIdx); // Remove from tcpConnections tracking const arr = this.tcpConnections?.get(pid); if (arr) { @@ -8075,7 +8126,9 @@ export class CentralizedKernelWorker { if (arr.length === 0) this.tcpConnections?.delete(pid); } if (!clientSocket.destroyed) { - clientSocket.destroy(); + // Flush queued bytes, send FIN, then release the Node handle. The + // operating system owns subsequent TCP close-state timing. + clientSocket.destroySoon(); } }; } @@ -8118,43 +8171,73 @@ export class CentralizedKernelWorker { (pid: number, pipeIdx: number) => number; const pipeIsWriteOpen = this.kernelInstance.exports.kernel_pipe_is_write_open as (pid: number, pipeIdx: number) => number; + const pipeIsReadOpen = this.kernelInstance.exports.kernel_pipe_is_read_open as + (pid: number, pipeIdx: number) => number; + const pipeHasReaders = this.kernelInstance.exports.kernel_pipe_has_readers as + (pid: number, pipeIdx: number) => number; let cleaned = false; + let recvPipeWriteClosed = false; + let guestReadShutdown = false; + let guestWriteEnded = false; + let pendingInbound: Uint8Array | null = null; let pumpPending = false; const scratchOffset = this.tcpScratchOffset; + const closeRecvPipeWrite = () => { + if (recvPipeWriteClosed) return; + recvPipeWriteClosed = true; + pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + }; + const cleanup = () => { if (cleaned) return; cleaned = true; - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + closeRecvPipeWrite(); pipeCloseRead(GLOBAL_PIPE_PID, sendPipeIdx); peer.close(); - this.notifyPipeReadable(recvPipeIdx, pid); + this.notifyPipeReadable(recvPipeIdx); this.notifyPipeWritable(sendPipeIdx); this.scheduleWakeBlockedRetries(); }; const drainInbound = () => { + if (pipeIsReadOpen(GLOBAL_PIPE_PID, recvPipeIdx) === 0) { + pendingInbound = null; + if (!guestReadShutdown) { + guestReadShutdown = true; + peer.shutdown(0); + } + return; + } for (;;) { let data: Uint8Array; - try { - data = peer.recv(65536, 0); - } catch (e: any) { - if (e?.errno === 11) return; - cleanup(); - return; + if (pendingInbound) { + data = pendingInbound; + } else { + try { + data = peer.recv(65536, 0); + } catch (e: any) { + if (e?.errno === 11) return; + cleanup(); + return; + } } if (data.length === 0) { - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); - this.notifyPipeReadable(recvPipeIdx, pid); + pendingInbound = null; + closeRecvPipeWrite(); + this.notifyPipeReadable(recvPipeIdx); return; } const written = this.writePipeChunked(pipeWrite, GLOBAL_PIPE_PID, recvPipeIdx, data); if (written < data.length) { - // The pipe is full. A later pump tick will retry once the guest reads. + // `peer.recv` consumes bytes, so retain the unwritten suffix while + // the guest receive pipe is full and retry it on a later pump tick. + pendingInbound = data.subarray(written); return; } - this.notifyPipeReadable(recvPipeIdx, pid); + pendingInbound = null; + this.notifyPipeReadable(recvPipeIdx); } }; @@ -8178,16 +8261,19 @@ export class CentralizedKernelWorker { if (cleaned) { return; } - if (!this.processes.has(pid)) { - drainOutbound(); + drainInbound(); + drainOutbound(); + const writeOpen = pipeIsWriteOpen(GLOBAL_PIPE_PID, sendPipeIdx); + const hasReaders = pipeHasReaders(GLOBAL_PIPE_PID, recvPipeIdx); + if (writeOpen === 0 && !guestWriteEnded) { + guestWriteEnded = true; peer.shutdown(1); + } + if (writeOpen === 0 && hasReaders <= 0) { cleanup(); return; } - drainInbound(); - drainOutbound(); - if (pipeIsWriteOpen(GLOBAL_PIPE_PID, sendPipeIdx) === 0) { - peer.shutdown(1); + if (guestWriteEnded && recvPipeWriteClosed) { cleanup(); return; } @@ -8263,24 +8349,38 @@ export class CentralizedKernelWorker { if (filtered.length === 0) { this.tcpListenerTargets.delete(port); this.tcpListenerRRIndex.delete(port); + const virtualKey = this.tcpVirtualListenerKeys.get(port); + if (virtualKey) { + this.io.network?.closeTcpListener?.(virtualKey); + this.tcpVirtualListenerKeys.delete(port); + } } else { this.tcpListenerTargets.set(port, filtered); } } - for (const [key, entry] of this.tcpListeners) { - if (entry.pid === pid) { - this.io.network?.closeTcpListener?.(key); - // Only close the server if no other processes share this port - const hasOtherTargets = this.tcpListenerTargets.has(entry.port); - if (!hasOtherTargets) { - entry.server.close(); - for (const conn of entry.connections) { - conn.destroy(); - } - entry.connections.clear(); + const keyPrefix = `${pid}:`; + for (const [key, entry] of Array.from(this.tcpListeners)) { + if (!key.startsWith(keyPrefix)) continue; + this.tcpListeners.delete(key); + // Accepted sockets have independent pipe ownership and may still belong + // to a fork child. Their pumps close them when the final pipe references + // disappear; listener teardown only stops new accepts. + const remainingTargets = this.tcpListenerTargets.get(entry.port); + if (!remainingTargets || remainingTargets.length === 0) { + entry.server.close(); + } else { + // Fork inheritance adds listener targets without re-running listen(2). + // Keep the shared server reachable under a surviving owner's key so + // final-owner cleanup can close it instead of leaking the port. + const replacement = remainingTargets[0]!; + const replacementKey = `${replacement.pid}:${replacement.fd}`; + if (!this.tcpListeners.has(replacementKey)) { + this.tcpListeners.set(replacementKey, { + ...entry, + pid: replacement.pid, + }); } - this.tcpListeners.delete(key); } } this.tcpConnections.delete(pid); diff --git a/host/src/networking/tcp-backend.ts b/host/src/networking/tcp-backend.ts index cb4307c49..dfa996fb9 100644 --- a/host/src/networking/tcp-backend.ts +++ b/host/src/networking/tcp-backend.ts @@ -53,6 +53,7 @@ interface Connection { socket: net.Socket; recvBuf: Buffer; closed: boolean; + readEnded: boolean; /** True once net.Socket has emitted 'connect' (TCP handshake done). */ connected: boolean; error: Error | null; @@ -69,11 +70,12 @@ export class TcpNetworkBackend implements NetworkIO { connect(handle: number, addr: Uint8Array, port: number): void { const ip = `${addr[0]}.${addr[1]}.${addr[2]}.${addr[3]}`; - const socket = new net.Socket(); + const socket = new net.Socket({ allowHalfOpen: true }); const conn: Connection = { socket, recvBuf: Buffer.alloc(0), closed: false, + readEnded: false, connected: false, error: null, }; @@ -84,11 +86,15 @@ export class TcpNetworkBackend implements NetworkIO { socket.on("data", (data: Buffer) => { conn.recvBuf = Buffer.concat([conn.recvBuf, data]); }); + socket.on("end", () => { + conn.readEnded = true; + }); socket.on("error", (err: Error) => { conn.error = err; }); socket.on("close", () => { conn.closed = true; + conn.readEnded = true; }); socket.connect(port, ip); @@ -116,9 +122,18 @@ export class TcpNetworkBackend implements NetworkIO { const conn = this.connections.get(handle); if (!conn) throw new Error("ENOTCONN"); if (conn.error) throw conn.error; - if (conn.closed) throw new Error("EPIPE"); + if ( + conn.closed || + conn.socket.destroyed || + conn.socket.writableEnded || + !conn.socket.writable + ) { + throw Object.assign(new Error("EPIPE"), { code: "EPIPE", errno: 32 }); + } // `net.Socket.write` buffers internally before the TCP handshake - // completes, so we don't need to gate on `connected`. + // completes, so we don't need to gate on `connected`. With allowHalfOpen, + // this also permits writes after a peer FIN while Node still has an open + // writable half, matching TCP half-close semantics. conn.socket.write(Buffer.from(data)); return data.length; } @@ -139,7 +154,7 @@ export class TcpNetworkBackend implements NetworkIO { return result; } - if (conn.closed) return new Uint8Array(0); + if (conn.readEnded || conn.closed) return new Uint8Array(0); throw new EagainError(); } @@ -151,13 +166,20 @@ export class TcpNetworkBackend implements NetworkIO { if (conn.error) return POLLERR; let revents = 0; - if ((events & POLLIN) !== 0 && conn.recvBuf.length > 0) { + if ((events & POLLIN) !== 0 && (conn.recvBuf.length > 0 || conn.readEnded || conn.closed)) { revents |= POLLIN; } if (conn.closed) { revents |= POLLHUP; } - if ((events & POLLOUT) !== 0 && conn.connected && !conn.closed) { + if ( + (events & POLLOUT) !== 0 && + conn.connected && + !conn.closed && + !conn.socket.destroyed && + !conn.socket.writableEnded && + conn.socket.writable + ) { revents |= POLLOUT; } return revents; @@ -166,7 +188,13 @@ export class TcpNetworkBackend implements NetworkIO { close(handle: number): void { const conn = this.connections.get(handle); if (conn) { - conn.socket.destroy(); + // destroySoon() ends the writable half, flushes queued bytes, and only + // then releases the Node handle. The operating system retains whatever + // TCP close state is needed; no timer or fabricated post-FIN write count + // is imposed here. + if (!conn.socket.destroyed) { + conn.socket.destroySoon(); + } this.connections.delete(handle); } } diff --git a/host/src/networking/virtual-network.ts b/host/src/networking/virtual-network.ts index c790596c8..1e5b54cce 100644 --- a/host/src/networking/virtual-network.ts +++ b/host/src/networking/virtual-network.ts @@ -52,6 +52,7 @@ class VirtualTcpPeer implements TcpConnectionPeer { private peer?: VirtualTcpPeer; private readClosed = false; private writeClosed = false; + private orphanedReceive = false; private reset = false; pairWith(peer: VirtualTcpPeer): void { @@ -69,11 +70,27 @@ class VirtualTcpPeer implements TcpConnectionPeer { err.errno = ECONNRESET; throw err; } - if (this.writeClosed || !this.peer || this.peer.readClosed || this.peer.reset) { + if (this.writeClosed || !this.peer) { const err = new Error("EPIPE") as Error & { errno?: number }; err.errno = 32; throw err; } + if (this.peer.reset) { + const err = new Error("ECONNRESET") as Error & { errno?: number }; + err.errno = ECONNRESET; + throw err; + } + if (this.peer.readClosed) { + const err = new Error("EPIPE") as Error & { errno?: number }; + err.errno = 32; + throw err; + } + // Normal TCP close is simplex: the peer's FIN closes its write half while + // its orphaned receive half continues to consume packets. Keep an explicit + // discard sink rather than inventing a fixed successful-write count. + if (this.peer.orphanedReceive) { + return data.length; + } this.peer.enqueue(data.slice()); return data.length; } @@ -128,7 +145,18 @@ class VirtualTcpPeer implements TcpConnectionPeer { } close(): void { - this.shutdown(2); + this.writeClosed = true; + this.orphanedReceive = true; + this.recvBuf = new Uint8Array(0); + } + + abort(): void { + this.readClosed = true; + this.writeClosed = true; + this.orphanedReceive = false; + this.reset = true; + this.recvBuf = new Uint8Array(0); + this.peer?.resetPeer(); } resetPeer(): void { @@ -205,7 +233,7 @@ export class LocalVirtualNetwork { this.tcpListeners = this.tcpListeners.filter((l) => l.machineId !== machineId); this.udpEndpoints = this.udpEndpoints.filter((e) => e.machineId !== machineId); for (const peer of this.tcpPeersByMachine.get(machineId) ?? []) { - peer.close(); + peer.abort(); } this.tcpPeersByMachine.delete(machineId); backend.resetAllConnections(); @@ -450,7 +478,7 @@ export class VirtualNetworkBackend implements NetworkIO { } resetAllConnections(): void { - for (const conn of this.connections.values()) conn.close(); + for (const conn of this.connections.values()) conn.abort(); this.connections.clear(); this.connectErrors.clear(); } diff --git a/host/src/types.ts b/host/src/types.ts index 1b0e6e354..5d83da83c 100644 --- a/host/src/types.ts +++ b/host/src/types.ts @@ -107,8 +107,12 @@ export interface TcpConnectionPeer { send(data: Uint8Array, flags: number): number; recv(maxLen: number, flags: number): Uint8Array; poll?(events: number): number; + /** Disable one or both directions without resetting the connection. */ shutdown(how: number): void; + /** Orderly close: flush/FIN the write half and orphan the receive half. */ close(): void; + /** Abort immediately and make both peers observe a connection reset. */ + abort(): void; } export interface TcpListenTarget { diff --git a/host/test/tcp-backend.test.ts b/host/test/tcp-backend.test.ts index b979e4b54..cda08a9be 100644 --- a/host/test/tcp-backend.test.ts +++ b/host/test/tcp-backend.test.ts @@ -1,6 +1,53 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import * as net from "node:net"; import { TcpNetworkBackend } from "../src/networking/tcp-backend"; +const LOOPBACK = new Uint8Array([127, 0, 0, 1]); + +async function listenLoopback(): Promise<{ + server: net.Server; + port: number; + accepted: Promise<{ data: string; ended: boolean }>; +}> { + let resolveAccepted!: (value: { data: string; ended: boolean }) => void; + let rejectAccepted!: (error: unknown) => void; + const accepted = new Promise<{ data: string; ended: boolean }>((resolve, reject) => { + resolveAccepted = resolve; + rejectAccepted = reject; + }); + + const server = net.createServer((socket) => { + const chunks: Buffer[] = []; + socket.on("data", (chunk) => chunks.push(chunk)); + socket.on("end", () => { + resolveAccepted({ data: Buffer.concat(chunks).toString("utf8"), ended: true }); + }); + socket.on("error", rejectAccepted); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + return { + server, + port: (server.address() as net.AddressInfo).port, + accepted, + }; +} + +async function waitForConnected(backend: TcpNetworkBackend, handle: number): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + const status = backend.connectStatus(handle); + if (status === 0) return; + if (status > 0) throw new Error(`connect failed with errno ${status}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("connect timed out"); +} + describe("TcpNetworkBackend hostname parsing", () => { it.each([ ["2130706433", [127, 0, 0, 1]], @@ -24,3 +71,111 @@ describe("TcpNetworkBackend hostname parsing", () => { expect(() => backend.getaddrinfo(hostname)).toThrow("ENOENT"); }); }); + +describe("TcpNetworkBackend", () => { + const servers: net.Server[] = []; + + afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => { + server.close(() => resolve()); + }))); + }); + + it("closes TCP sockets with an orderly FIN after queued bytes", async () => { + const { server, port, accepted } = await listenLoopback(); + servers.push(server); + + const backend = new TcpNetworkBackend(); + backend.connect(7, LOOPBACK, port); + await waitForConnected(backend, 7); + + expect(backend.send(7, new TextEncoder().encode("hello"), 0)).toBe(5); + backend.close(7); + + await expect(accepted).resolves.toEqual({ data: "hello", ended: true }); + }); + it("keeps the real writable half open after peer FIN", async () => { + let acceptedSocket!: net.Socket; + let resolveAccepted!: () => void; + let resolveAfterFin!: (value: string) => void; + const accepted = new Promise((resolve) => { resolveAccepted = resolve; }); + const afterFin = new Promise((resolve) => { resolveAfterFin = resolve; }); + const chunks: Buffer[] = []; + + const server = net.createServer({ allowHalfOpen: true }, (socket) => { + acceptedSocket = socket; + socket.on("data", (chunk) => { + chunks.push(chunk); + if (Buffer.concat(chunks).toString("utf8").includes("after-fin-two")) { + resolveAfterFin(Buffer.concat(chunks).toString("utf8")); + } + }); + resolveAccepted(); + }); + servers.push(server); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const backend = new TcpNetworkBackend(); + backend.connect(8, LOOPBACK, (server.address() as net.AddressInfo).port); + await waitForConnected(backend, 8); + await accepted; + + expect(backend.send(8, new TextEncoder().encode("before-fin"), 0)).toBe(10); + acceptedSocket.end(); + + const deadline = Date.now() + 2_000; + for (;;) { + try { + const eof = backend.recv(8, 16, 0); + if (eof.length === 0) break; + } catch (error) { + if ((error as Error & { errno?: number }).errno !== 11) throw error; + } + if (Date.now() > deadline) throw new Error("recv EOF timed out"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + expect(backend.send(8, new TextEncoder().encode("after-fin-one"), 0)).toBe(13); + expect(backend.send(8, new TextEncoder().encode("after-fin-two"), 0)).toBe(13); + await expect(afterFin).resolves.toBe("before-finafter-fin-oneafter-fin-two"); + + backend.close(8); + acceptedSocket.destroy(); + }); + + it("reports a real reset without fabricating a successful write", async () => { + let acceptedSocket!: net.Socket; + let resolveAccepted!: () => void; + const accepted = new Promise((resolve) => { resolveAccepted = resolve; }); + const server = net.createServer({ allowHalfOpen: true }, (socket) => { + acceptedSocket = socket; + resolveAccepted(); + }); + servers.push(server); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const backend = new TcpNetworkBackend(); + backend.connect(9, LOOPBACK, (server.address() as net.AddressInfo).port); + await waitForConnected(backend, 9); + await accepted; + acceptedSocket.resetAndDestroy(); + + const deadline = Date.now() + 2_000; + while ((backend.poll(9, 0x0008) & 0x0008) === 0) { + if (Date.now() > deadline) throw new Error("reset observation timed out"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + expect(() => backend.send(9, new TextEncoder().encode("after-reset"), 0)) + .toThrowError(/ECONNRESET/); + backend.close(9); + }); +}); diff --git a/host/test/virtual-network-e2e.test.ts b/host/test/virtual-network-e2e.test.ts index 0f70d0c08..a7c5b9f80 100644 --- a/host/test/virtual-network-e2e.test.ts +++ b/host/test/virtual-network-e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import * as net from "node:net"; import { resolveBinary, tryResolveBinary } from "../src/binary-resolver"; import { LocalVirtualNetwork } from "../src/networking/virtual-network"; import { NodePlatformIO } from "../src/platform/node"; @@ -28,6 +29,129 @@ function waitMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function unusedTcpPort(): Promise { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = (server.address() as net.AddressInfo).port; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +async function connectLoopbackWithRetry( + port: number, + allowHalfOpen = false, +): Promise { + const deadline = Date.now() + 5_000; + for (;;) { + try { + return await new Promise((resolve, reject) => { + const socket = new net.Socket({ allowHalfOpen }); + const onError = (error: Error) => { + socket.destroy(); + reject(error); + }; + socket.once("connect", () => { + socket.off("error", onError); + resolve(socket); + }); + socket.once("error", onError); + socket.connect({ host: "127.0.0.1", port }); + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ECONNREFUSED" || Date.now() >= deadline) { + throw error; + } + await waitMs(10); + } + } +} + +async function waitForReusableTcpPort(port: number): Promise { + const deadline = Date.now() + 5_000; + for (;;) { + const server = net.createServer(); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "0.0.0.0", resolve); + }); + await new Promise((resolve) => server.close(() => resolve())); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE" || Date.now() >= deadline) { + throw error; + } + await waitMs(10); + } + } +} + +async function readReplyAfterRequest(socket: net.Socket, request: string): Promise { + const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.destroy(); + reject(new Error("TCP reply timed out")); + }, 5_000); + let sawEnd = false; + socket.on("data", (chunk) => chunks.push(chunk)); + socket.once("end", () => { + sawEnd = true; + clearTimeout(timeout); + resolve(Buffer.concat(chunks)); + }); + socket.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + socket.once("close", (hadError) => { + if (!sawEnd && !hadError) { + clearTimeout(timeout); + reject(new Error("TCP socket closed before orderly EOF")); + } + }); + socket.end(request); + }); +} + +async function sendAfterPeerFin( + socket: net.Socket, + request: string, + postFinBytes: number, +): Promise { + const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.destroy(); + reject(new Error("post-FIN exchange timed out")); + }, 5_000); + let sawEnd = false; + socket.on("data", (chunk) => chunks.push(chunk)); + socket.once("end", () => { + sawEnd = true; + socket.end(Buffer.alloc(postFinBytes, 0x78)); + }); + socket.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + socket.once("close", (hadError) => { + clearTimeout(timeout); + if (hadError) { + reject(new Error("post-FIN socket closed with an error")); + } else if (!sawEnd) { + reject(new Error("post-FIN socket closed before orderly EOF")); + } else { + resolve(Buffer.concat(chunks)); + } + }); + socket.write(request); + }); +} + describe.skipIf(!udpServerPath || !udpClientPath)("virtual network guest socket integration", () => { it("routes POSIX UDP sendto/recvfrom between two Kandelo machines", async () => { const network = new LocalVirtualNetwork(); @@ -90,17 +214,29 @@ describe.skipIf(!tcpServerPath || !tcpClientPath)("virtual network TCP guest soc const serverIO = machineIO(network, "server", [10, 88, 0, 2]); const clientIO = machineIO(network, "client", [10, 88, 0, 3]); const port = 24124; + const postFinBytes = 128 * 1024 + 123; const serverRun = runCentralizedProgram({ programPath: resolveBinary("programs/virtual-tcp-echo-server.wasm"), - argv: ["virtual-tcp-echo-server", String(port)], + argv: [ + "virtual-tcp-echo-server", + String(port), + "half-close-bulk", + String(postFinBytes), + ], io: serverIO, timeout: 10_000, }); await waitMs(100); const clientRun = runCentralizedProgram({ programPath: resolveBinary("programs/virtual-tcp-echo-client.wasm"), - argv: ["virtual-tcp-echo-client", "10.88.0.2", String(port), "ping"], + argv: [ + "virtual-tcp-echo-client", + "10.88.0.2", + String(port), + "ping", + String(postFinBytes), + ], io: clientIO, timeout: 10_000, }); @@ -120,6 +256,114 @@ describe.skipIf(!tcpServerPath || !tcpClientPath)("virtual network TCP guest soc }, 15_000); }); +describe.skipIf(!tcpServerPath)("Node TCP guest socket integration", () => { + it("keeps a fork-inherited accepted socket alive after the parent exits", async () => { + const port = await unusedTcpPort(); + const bulkBytes = 2 * 1024 * 1024; + const serverRun = runCentralizedProgram({ + programPath: resolveBinary("programs/virtual-tcp-echo-server.wasm"), + argv: ["virtual-tcp-echo-server", String(port), "fork-bulk", String(bulkBytes)], + io: new NodePlatformIO(), + timeout: 10_000, + }); + + const socket = await connectLoopbackWithRetry(port); + const server = await serverRun; + const response = await readReplyAfterRequest(socket, "ping"); + + expect(server.exitCode).toBe(0); + expect(server.stdout).toBe(""); + expect(server.stderr).toBe(""); + expect(response.subarray(0, 9).toString("utf8")).toBe("echo:ping"); + expect(response).toHaveLength(9 + bulkBytes); + expect(response.subarray(9).every((byte) => byte === 0x78)).toBe(true); + + // The listener must remain tracked by the child after parent cleanup and + // then close when the child becomes the final owner. + await waitForReusableTcpPort(port); + }, 15_000); + + it("wakes a blocked accepted read on a zero-byte peer FIN", async () => { + const port = await unusedTcpPort(); + const serverRun = runCentralizedProgram({ + programPath: resolveBinary("programs/virtual-tcp-echo-server.wasm"), + argv: ["virtual-tcp-echo-server", String(port)], + io: new NodePlatformIO(), + timeout: 10_000, + }); + + const socket = await connectLoopbackWithRetry(port); + await waitMs(100); + const [server, response] = await Promise.all([ + serverRun, + readReplyAfterRequest(socket, ""), + ]); + + expect(server.exitCode).toBe(0); + expect(server.stdout).toBe(""); + expect(server.stderr).toBe(""); + expect(response.toString("utf8")).toBe("echo:"); + }, 15_000); + + it("drains native data queued before FIN after guest SHUT_WR", async () => { + const port = await unusedTcpPort(); + const postFinBytes = 128 * 1024 + 123; + const serverRun = runCentralizedProgram({ + programPath: resolveBinary("programs/virtual-tcp-echo-server.wasm"), + argv: [ + "virtual-tcp-echo-server", + String(port), + "half-close-bulk", + String(postFinBytes), + ], + io: new NodePlatformIO(), + timeout: 10_000, + }); + + const socket = await connectLoopbackWithRetry(port, true); + const [server, response] = await Promise.all([ + serverRun, + sendAfterPeerFin(socket, "ping", postFinBytes), + ]); + + expect(server.exitCode).toBe(0); + expect(server.stdout).toBe(""); + expect(server.stderr).toBe(""); + expect(response.toString("utf8")).toBe("echo:ping"); + }, 15_000); + + it("flushes a queued accepted reply after the guest closes and exits", async () => { + const port = await unusedTcpPort(); + const bulkBytes = 2 * 1024 * 1024; + const serverRun = runCentralizedProgram({ + programPath: resolveBinary("programs/virtual-tcp-echo-server.wasm"), + argv: ["virtual-tcp-echo-server", String(port), "bulk", String(bulkBytes)], + io: new NodePlatformIO(), + timeout: 10_000, + }); + + const socket = await connectLoopbackWithRetry(port); + const responsePromise = readReplyAfterRequest(socket, "ping"); + socket.pause(); + const server = await serverRun; + + // The previous bridge destroyed accepted sockets one second after the + // owning pid exited. Keep the native reader paused beyond that boundary: + // only a connection whose lifetime follows pipe/socket ownership can + // retain and later flush the whole queued response. + await waitMs(1_200); + socket.resume(); + const response = await responsePromise; + + expect(server.exitCode).toBe(0); + expect(server.stdout).toBe(""); + expect(server.stderr).toBe(""); + expect(response.subarray(0, 9).toString("utf8")).toBe("echo:ping"); + expect(response).toHaveLength(9 + bulkBytes); + expect(response.subarray(9).every((byte) => byte === 0x78)).toBe(true); + }, 15_000); +}); + describe.skipIf(!ncPath)("virtual network nc integration", () => { it("uses the packaged nc over the local virtual TCP network", async () => { const network = new LocalVirtualNetwork(); diff --git a/host/test/virtual-network.test.ts b/host/test/virtual-network.test.ts index aab68d10d..dd0fae50b 100644 --- a/host/test/virtual-network.test.ts +++ b/host/test/virtual-network.test.ts @@ -8,6 +8,7 @@ import type { TcpConnectionPeer, UdpDatagram } from "../src/types"; const POLLIN = 0x0001; const POLLOUT = 0x0004; +const POLLERR = 0x0008; const POLLHUP = 0x0010; describe("LocalVirtualNetwork", () => { @@ -72,7 +73,7 @@ describe("LocalVirtualNetwork", () => { expect(client.connectStatus(1)).toBe(VIRTUAL_NETWORK_ERRNO.EHOSTUNREACH); }); - it("wakes TCP peers with EOF and EPIPE when a machine detaches", () => { + it("wakes TCP peers with reset when a machine detaches", () => { const net = new LocalVirtualNetwork(); const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); const client = net.attachMachine({ id: "client", address: [10, 88, 0, 3] }); @@ -89,18 +90,76 @@ describe("LocalVirtualNetwork", () => { net.detachMachine("server"); const revents = client.poll!(7, POLLIN | POLLOUT); + expect(revents & POLLERR).toBe(POLLERR); expect(revents & POLLIN).toBe(POLLIN); expect(revents & POLLOUT).toBe(0); expect(revents & POLLHUP).toBe(POLLHUP); - expect(client.recv(7, 16, 0)).toHaveLength(0); + try { + client.recv(7, 16, 0); + throw new Error("recv after detached peer unexpectedly succeeded"); + } catch (error) { + expect((error as Error & { errno?: number }).errno).toBe(VIRTUAL_NETWORK_ERRNO.ECONNRESET); + } try { client.send(7, new TextEncoder().encode("after-detach"), 0); throw new Error("send after detached peer unexpectedly succeeded"); } catch (error) { - expect((error as Error & { errno?: number }).errno).toBe(32); + expect((error as Error & { errno?: number }).errno).toBe(VIRTUAL_NETWORK_ERRNO.ECONNRESET); } }); + it("drains queued TCP data before FIN and keeps an orphaned receive sink", () => { + const net = new LocalVirtualNetwork(); + const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); + const client = net.attachMachine({ id: "client", address: [10, 88, 0, 3] }); + let accepted: TcpConnectionPeer | null = null; + + expect(server.listenTcp!("srv:1", new Uint8Array([10, 88, 0, 2]), 8080, { + accept(peer) { + accepted = peer; + return 0; + }, + })).toBe(0); + + client.connect(7, new Uint8Array([10, 88, 0, 2]), 8080); + expect(client.connectStatus(7)).toBe(0); + expect(accepted).not.toBeNull(); + + expect(accepted!.send(new TextEncoder().encode("queued"), 0)).toBe(6); + accepted!.close(); + + expect(new TextDecoder().decode(client.recv(7, 16, 0))).toBe("queued"); + expect(client.recv(7, 16, 0)).toHaveLength(0); + const revents = client.poll!(7, POLLIN | POLLOUT); + expect(revents & POLLIN).toBe(POLLIN); + expect(revents & POLLOUT).toBe(POLLOUT); + expect(client.send(7, new TextEncoder().encode("after-fin-one"), 0)).toBe(13); + expect(client.send(7, new TextEncoder().encode("after-fin-two"), 0)).toBe(13); + client.close(7); + }); + + it("keeps the receive direction usable after SHUT_WR", () => { + const net = new LocalVirtualNetwork(); + const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); + const client = net.attachMachine({ id: "client", address: [10, 88, 0, 3] }); + let accepted: TcpConnectionPeer | null = null; + + expect(server.listenTcp!("srv:1", new Uint8Array([10, 88, 0, 2]), 8080, { + accept(peer) { + accepted = peer; + return 0; + }, + })).toBe(0); + + client.connect(7, new Uint8Array([10, 88, 0, 2]), 8080); + expect(client.connectStatus(7)).toBe(0); + accepted!.shutdown(1); + + expect(client.recv(7, 16, 0)).toHaveLength(0); + expect(client.send(7, new TextEncoder().encode("still-readable"), 0)).toBe(14); + expect(new TextDecoder().decode(accepted!.recv(16, 0))).toBe("still-readable"); + }); + it("routes UDP datagrams and preserves source metadata", () => { const net = new LocalVirtualNetwork(); const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); diff --git a/programs/virtual-tcp-echo-client.c b/programs/virtual-tcp-echo-client.c index 835a08cd7..e64e2fd83 100644 --- a/programs/virtual-tcp-echo-client.c +++ b/programs/virtual-tcp-echo-client.c @@ -12,12 +12,37 @@ static void die(const char* what) { exit(1); } +static void write_all(int fd, const void* data, size_t size) { + const unsigned char* bytes = data; + size_t offset = 0; + while (offset < size) { + ssize_t amount = write(fd, bytes + offset, size - offset); + if (amount < 0) die("write"); + if (amount == 0) { + fprintf(stderr, "write made no progress\n"); + exit(1); + } + offset += (size_t) amount; + } +} + int main(int argc, char** argv) { - if (argc != 4) { - fprintf(stderr, "usage: %s ADDRESS PORT MESSAGE\n", argv[0]); + if (argc != 4 && argc != 5) { + fprintf(stderr, "usage: %s ADDRESS PORT MESSAGE [POST_FIN_BYTES]\n", argv[0]); return 2; } + size_t post_fin_bytes = 0; + if (argc == 5) { + char* end; + unsigned long value = strtoul(argv[4], &end, 10); + if (!argv[4][0] || *end || value > 16 * 1024 * 1024UL) { + fprintf(stderr, "invalid post-FIN byte count: %s\n", argv[4]); + return 2; + } + post_fin_bytes = (size_t) value; + } + int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) die("socket"); @@ -30,14 +55,42 @@ int main(int argc, char** argv) { const char* msg = argv[3]; size_t msg_len = strlen(msg); - if (write(fd, msg, msg_len) != (ssize_t) msg_len) die("write"); + write_all(fd, msg, msg_len); char buf[320]; - ssize_t n = read(fd, buf, sizeof(buf) - 1); - if (n < 0) die("read"); - buf[n] = '\0'; + size_t received = 0; + for (;;) { + ssize_t n = read(fd, buf + received, sizeof(buf) - 1 - received); + if (n < 0) die("read"); + if (n == 0) break; + received += (size_t) n; + if (received == sizeof(buf) - 1) { + fprintf(stderr, "reply too large\n"); + return 1; + } + } + buf[received] = '\0'; printf("%s\n", buf); + /* + * A peer FIN closes only its write half. Sending this acknowledgement + * after EOF proves that the server's accepted socket can still receive + * after SHUT_WR and that the host bridge did not collapse the half-close + * into full connection teardown. + */ + if (post_fin_bytes > 0) { + char chunk[16 * 1024]; + memset(chunk, 'x', sizeof(chunk)); + while (post_fin_bytes > 0) { + size_t amount = post_fin_bytes < sizeof(chunk) ? post_fin_bytes : sizeof(chunk); + write_all(fd, chunk, amount); + post_fin_bytes -= amount; + } + } else { + const char ack[] = "ack"; + write_all(fd, ack, sizeof(ack) - 1); + } + close(fd); return 0; } diff --git a/programs/virtual-tcp-echo-server.c b/programs/virtual-tcp-echo-server.c index 2d60445f5..3fda4a538 100644 --- a/programs/virtual-tcp-echo-server.c +++ b/programs/virtual-tcp-echo-server.c @@ -12,9 +12,46 @@ static void die(const char* what) { exit(1); } +static void write_all(int fd, const void* data, size_t size) { + const unsigned char* bytes = data; + size_t offset = 0; + while (offset < size) { + ssize_t amount = write(fd, bytes + offset, size - offset); + if (amount < 0) die("write"); + if (amount == 0) { + fprintf(stderr, "write made no progress\n"); + exit(1); + } + offset += (size_t) amount; + } +} + int main(int argc, char** argv) { - if (argc != 2) { - fprintf(stderr, "usage: %s PORT\n", argv[0]); + if (argc < 2 || argc > 4) { + fprintf(stderr, "usage: %s PORT [fork | fork-bulk BYTES | half-close | half-close-bulk BYTES | bulk BYTES]\n", argv[0]); + return 2; + } + int test_fork = (argc == 3 && strcmp(argv[2], "fork") == 0) || + (argc == 4 && strcmp(argv[2], "fork-bulk") == 0); + int test_half_close = argc == 3 && strcmp(argv[2], "half-close") == 0; + size_t bulk_bytes = 0; + size_t post_fin_bytes = 0; + if (argc == 4 && + (strcmp(argv[2], "bulk") == 0 || + strcmp(argv[2], "fork-bulk") == 0 || + strcmp(argv[2], "half-close-bulk") == 0)) { + char* end; + unsigned long value = strtoul(argv[3], &end, 10); + if (!argv[3][0] || *end || value > 16 * 1024 * 1024UL) { + fprintf(stderr, "invalid byte count: %s\n", argv[3]); + return 2; + } + if (strcmp(argv[2], "bulk") == 0 || strcmp(argv[2], "fork-bulk") == 0) + bulk_bytes = (size_t) value; + else + post_fin_bytes = (size_t) value; + } else if (argc != 2 && !test_fork && !test_half_close) { + fprintf(stderr, "unknown mode: %s\n", argv[2]); return 2; } @@ -37,6 +74,18 @@ int main(int argc, char** argv) { int conn = accept(fd, (struct sockaddr*) &peer, &peer_len); if (conn < 0) die("accept"); + if (test_fork) { + pid_t child = fork(); + if (child < 0) die("fork"); + if (child > 0) { + close(conn); + close(fd); + return 0; + } + close(fd); + fd = -1; + } + char buf[256]; ssize_t n = read(conn, buf, sizeof(buf)); if (n < 0) die("read"); @@ -47,9 +96,48 @@ int main(int argc, char** argv) { fprintf(stderr, "reply too large\n"); return 1; } - if (write(conn, out, (size_t) out_len) != out_len) die("write"); + write_all(conn, out, (size_t) out_len); + + if (bulk_bytes > 0) { + char chunk[16 * 1024]; + memset(chunk, 'x', sizeof(chunk)); + while (bulk_bytes > 0) { + size_t amount = bulk_bytes < sizeof(chunk) ? bulk_bytes : sizeof(chunk); + write_all(conn, chunk, amount); + bulk_bytes -= amount; + } + } + + if (test_half_close || post_fin_bytes > 0) { + if (shutdown(conn, SHUT_WR) < 0) die("shutdown"); + + /* Let the host-side receive pipe fill before the guest starts reading. */ + if (post_fin_bytes > 0) usleep(200000); + + char ack[16 * 1024]; + size_t expected = post_fin_bytes > 0 ? post_fin_bytes : 3; + size_t received = 0; + while (received < expected) { + size_t remaining = expected - received; + size_t chunk_size = remaining < sizeof(ack) ? remaining : sizeof(ack); + ssize_t amount = read(conn, ack, chunk_size); + if (amount < 0) die("read ack"); + if (amount == 0) { + fprintf(stderr, "EOF before acknowledgement\n"); + return 1; + } + for (ssize_t i = 0; i < amount; i++) { + char expected_byte = post_fin_bytes > 0 ? 'x' : "ack"[received + (size_t) i]; + if (ack[i] != expected_byte) { + fprintf(stderr, "invalid acknowledgement\n"); + return 1; + } + } + received += (size_t) amount; + } + } close(conn); - close(fd); + if (fd >= 0) close(fd); return 0; } diff --git a/tests/sortix/os-test-local/basic/sys_socket/tcp-peer-close-eof-epipe.c b/tests/sortix/os-test-local/basic/sys_socket/tcp-peer-close-eof-epipe.c index e1119e74d..272891754 100644 --- a/tests/sortix/os-test-local/basic/sys_socket/tcp-peer-close-eof-epipe.c +++ b/tests/sortix/os-test-local/basic/sys_socket/tcp-peer-close-eof-epipe.c @@ -1,4 +1,4 @@ -/* Test TCP peer close EOF and MSG_NOSIGNAL EPIPE behavior. */ +/* Test queued TCP data, EOF, and valid post-FIN send outcomes. */ #include @@ -22,22 +22,42 @@ int main(void) int server_fd; tcp_connected_pair(&client_fd, &server_fd); + const char queued[] = "queued-before-close"; + if ( send(server_fd, queued, sizeof(queued), 0) != (ssize_t) sizeof(queued) ) + err(1, "send queued data"); if ( close(server_fd) < 0 ) err(1, "close server"); - char byte; - ssize_t amount = recv(client_fd, &byte, sizeof(byte), 0); + char buffer[sizeof(queued)]; + ssize_t amount = recv(client_fd, buffer, sizeof(buffer), MSG_WAITALL); if ( amount < 0 ) - err(1, "recv"); + err(1, "recv queued data"); + if ( amount != (ssize_t) sizeof(queued) || + memcmp(buffer, queued, sizeof(queued)) != 0 ) + errx(1, "queued data was not delivered before EOF"); + + amount = recv(client_fd, buffer, sizeof(buffer), 0); + if ( amount < 0 ) + err(1, "recv EOF"); if ( amount != 0 ) errx(1, "recv did not report EOF after peer close"); const char payload[] = "after-close"; - amount = send(client_fd, payload, sizeof(payload), MSG_NOSIGNAL); - if ( amount >= 0 ) - errx(1, "send after peer close unexpectedly succeeded"); - if ( errno != EPIPE && errno != ECONNRESET ) - err(1, "send after peer close"); + /* + * TCP does not define which send observes a later reset. Accept either a + * locally queued write or the transport's truthful EPIPE/ECONNRESET; this + * test must not impose an invented operation count. + */ + for ( int i = 0; i < 3; i++ ) { + amount = send(client_fd, payload, sizeof(payload), MSG_NOSIGNAL); + if ( amount < 0 ) { + if ( errno != EPIPE && errno != ECONNRESET ) + err(1, "send after peer close"); + break; + } + if ( amount != (ssize_t) sizeof(payload) ) + errx(1, "short send after peer close"); + } if ( close(client_fd) < 0 ) err(1, "close client");