From 4c785d144c1e3b4296c2d67a133bb93c7d96b85e Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 9 Jul 2026 00:34:11 -0400 Subject: [PATCH] Report EINPROGRESS/EALREADY for in-flight non-blocking TCP connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POSIX/Linux require a non-blocking `connect()` that cannot complete synchronously to fail with `EINPROGRESS` on the first call and `EALREADY` while the handshake is still pending. Kandelo returned `EAGAIN` instead, so the standard async-connect idiom — `O_NONBLOCK` → `connect()` → `poll(POLLOUT)` → `getsockopt(SO_ERROR)` — treated the connection as a hard failure. This affects external and virtual-network TCP clients (curl, redis-cli, event-loop libraries); loopback demos were unaffected because loopback connect completes synchronously via `cross_process_loopback_connect`. Kernel (`sys_connect`): the external two-phase path now maps the in-flight status to `EINPROGRESS` on the first call (transition into `Connecting`) and `EALREADY` on subsequent calls, instead of `EAGAIN`. The kernel owns connect state, so it is the correct source of these errnos. Host (`kernel-worker.ts`): the syscall-completion dispatch routes a connect that returns `EINPROGRESS`/`EALREADY` — for a non-blocking fd it returns the errno to the guest as-is; for a blocking fd it feeds the same retry loop previously keyed on `EAGAIN`, so blocking connect still waits for the handshake. No ABI change: no new exports/imports or repr(C) types; snapshot in sync. Validation: `cargo test -p kandelo --lib` (964 pass, incl. new `test_nonblocking_connect_reports_einprogress_then_ealready`, which drives `sys_connect` against a host reporting an in-flight handshake and asserts EINPROGRESS then EALREADY); host `npm run typecheck` clean; `scripts/check-abi-version.sh` reports snapshot in sync. End-to-end guest behavior follows from the kernel errno + the host routing (no prebuilt kernel wasm is available in this environment to drive a full two-process integration run). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kernel/src/syscalls.rs | 70 +++++++++++++++++++++++++++++++++-- docs/posix-status.md | 2 +- host/src/kernel-worker.ts | 23 ++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 175960c9a..0d26df516 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -7425,7 +7425,8 @@ pub fn sys_connect( // until the TCP handshake either completes or errors. EAGAIN // surfaces while still in flight. let net_handle = sock_idx as i32; - if sock.state != SocketState::Connecting { + let was_connecting = sock.state == SocketState::Connecting; + if !was_connecting { host.host_net_connect(net_handle, &ip, port)?; let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; sock.state = SocketState::Connecting; @@ -7437,7 +7438,18 @@ pub fn sys_connect( sock.state = SocketState::Connected; Ok(()) } - Err(Errno::EAGAIN) => Err(Errno::EAGAIN), + // Handshake still in flight. POSIX reports EINPROGRESS on the + // first (non-completing) call and EALREADY on subsequent + // calls while connecting — never EAGAIN. The host layer maps + // these to a blocking retry for blocking sockets and returns + // them to userspace for non-blocking sockets. + Err(Errno::EAGAIN) => { + if was_connecting { + Err(Errno::EALREADY) + } else { + Err(Errno::EINPROGRESS) + } + } Err(e) => { let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; sock.state = SocketState::Closed; @@ -11086,6 +11098,11 @@ mod tests { /// Override for `gl_submit`'s return value (0 = success, negative /// = errno). Defaults to 0. gl_submit_rc: i32, + /// When true, model an external TCP connect whose handshake is still in + /// flight: `host_net_connect` succeeds (kickoff) and + /// `host_net_connect_status` reports EAGAIN. Defaults to false, which + /// keeps the historical "connect refused" behavior. + net_connect_in_flight: bool, } impl MockHostIO { @@ -11109,6 +11126,7 @@ mod tests { gl_unbind_calls: Vec::new(), gbm_bo_bind_rc: 0, gl_submit_rc: 0, + net_connect_in_flight: false, } } @@ -11469,10 +11487,18 @@ mod tests { _addr: &[u8], _port: u16, ) -> Result<(), Errno> { - Err(Errno::ECONNREFUSED) + if self.net_connect_in_flight { + Ok(()) + } else { + Err(Errno::ECONNREFUSED) + } } fn host_net_connect_status(&mut self, _handle: i32) -> Result<(), Errno> { - Err(Errno::ECONNREFUSED) + if self.net_connect_in_flight { + Err(Errno::EAGAIN) + } else { + Err(Errno::ECONNREFUSED) + } } fn host_net_send( &mut self, @@ -17657,6 +17683,42 @@ mod tests { assert_eq!(from_addr[0], 2); // AF_INET } + #[test] + fn test_nonblocking_connect_reports_einprogress_then_ealready() { + // A non-blocking external TCP connect whose handshake cannot complete + // synchronously must report EINPROGRESS on the first call and EALREADY + // while still in flight — never EAGAIN (which POSIX/Linux do not use for + // connect). Async clients gate on `errno == EINPROGRESS`. + use wasm_posix_shared::socket::*; + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.net_connect_in_flight = true; // handshake never completes + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + + // sockaddr_in for a non-loopback, non-virtual destination (203.0.113.5:80) + // so connect takes the external host-backed path. + let mut addr = [0u8; 16]; + addr[0] = 2; // AF_INET + let port_be = 80u16.to_be_bytes(); + addr[2] = port_be[0]; + addr[3] = port_be[1]; + addr[4] = 203; + addr[5] = 0; + addr[6] = 113; + addr[7] = 5; + + let first = sys_connect(&mut proc, &mut host, fd, &addr).unwrap_err(); + assert_eq!(first, Errno::EINPROGRESS, "first connect should be EINPROGRESS"); + + let second = sys_connect(&mut proc, &mut host, fd, &addr).unwrap_err(); + assert_eq!( + second, + Errno::EALREADY, + "in-flight connect should be EALREADY, not EAGAIN" + ); + } + // ── Threading tests ────────────────────────────────────────────── #[test] diff --git a/docs/posix-status.md b/docs/posix-status.md index dbec136ee..6d1d7339b 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -232,7 +232,7 @@ shortcuts. | `bind()` | Partial | AF_UNIX paths, AF_INET TCP host-backed bind/listen, and AF_INET UDP in-kernel bind for INADDR_ANY, loopback, and broadcast addresses. The browser local virtual-network backend supports AF_INET TCP/UDP binds between attached Kandelo machines. UDP ephemeral ports, getsockname, bind conflicts, and SO_REUSEADDR conflict cases are covered by Sortix UDP tests. | | `listen()` | Partial | AF_INET TCP delegates to the active HostIO networking backend, including Node `net` and the browser local virtual-network backend. Datagram listen rejects as unsupported. AF_UNIX stream listen remains EOPNOTSUPP. | | `accept()` / `accept4()` | Partial | AF_INET TCP delegates to the active HostIO networking backend and returns connected sockets. Datagram accept rejects as unsupported. SOCK_NONBLOCK and SOCK_CLOEXEC flags on accept4. | -| `connect()` | Partial | AF_UNIX SOCK_DGRAM connects to a bit-bucket pattern. 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. UDP loopback and local virtual routes are supported; external raw UDP routes return ENETUNREACH without a HostIO UDP proxy/backend. | +| `connect()` | Partial | AF_UNIX SOCK_DGRAM connects to a bit-bucket pattern. AF_INET TCP is host-backed and works over Node external TCP or the browser local virtual-network backend. A non-blocking TCP connect whose handshake cannot complete synchronously reports `EINPROGRESS` on the first call and `EALREADY` while still in flight (blocking connects wait for completion). 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. UDP loopback and local virtual routes are supported; external raw UDP routes return ENETUNREACH without a HostIO UDP proxy/backend. | | `send()` / `recv()` | Partial | Unix domain streams, AF_INET TCP, and connected AF_INET UDP. TCP send/recv works over Node external TCP and the local virtual-network backend. UDP preserves datagram boundaries and supports MSG_PEEK/MSG_DONTWAIT through recvfrom. MSG_NOSIGNAL is honored for stream/broken-pipe paths. | | `sendto()` / `recvfrom()` | Partial | AF_INET UDP loopback and local virtual-network send/receive, connected and unconnected sendto, source address reporting, and connected receive filtering are implemented. External raw UDP routes return ENETUNREACH and are narrowly xfailed in the Sortix UDP suite. | | `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET: SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, SO_SNDBUF readable; SO_REUSEADDR affects UDP bind conflicts; SO_KEEPALIVE, SO_LINGER, SO_RCVTIMEO, SO_SNDTIMEO, SO_BROADCAST accepted/stored. IPPROTO_TCP: TCP_NODELAY stored. | diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index e2878a4b6..d8bcd5dfa 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -97,6 +97,8 @@ const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; /** Errno values */ const EAGAIN = 11; +const EALREADY = 114; +const EINPROGRESS = 115; const ETIMEDOUT = 110; const EINTR_ERRNO = 4; @@ -2560,6 +2562,27 @@ export class CentralizedKernelWorker { return; } + // In-flight non-blocking connect: the kernel reports EINPROGRESS on the + // first call and EALREADY while the handshake is still pending. For a + // non-blocking socket these are the POSIX-correct results and are returned + // to the guest as-is; for a blocking socket they mean "keep waiting", so we + // route them into the same retry loop as EAGAIN. + if ( + retVal === -1 && + syscallNr === SYS_CONNECT && + (errVal === EINPROGRESS || errVal === EALREADY) + ) { + const isFdNonblock = this.kernelInstance!.exports.kernel_is_fd_nonblock as + ((pid: number, fd: number) => number) | undefined; + const nonblock = isFdNonblock ? isFdNonblock(channel.pid, origArgs[0]) === 1 : false; + if (nonblock) { + this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, errVal); + } else { + this.handleBlockingRetry(channel, syscallNr, origArgs); + } + return; + } + // 2. Sleep syscalls: kernel returned success immediately, but we need // to delay the response to simulate the sleep duration. if (this.handleSleepDelay(channel, syscallNr, origArgs, retVal, errVal)) {