From bebc8cada8d9d7f1ae5126b6bd5a16799eecf500 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 9 Jul 2026 17:56:04 -0700 Subject: [PATCH 1/2] Support wasm32-unknown-emscripten Adds `wasm32-unknown-emscripten` as a target for mio, plus a CI job that runs the suite under Node. Resolves #642. Emscripten exposes a real epoll backed by its runtime event loop, so the existing Linux epoll selector is reused rather than adding a new backend. The wasm `compile_error!` guard is relaxed to let emscripten through, and the `epoll`/`eventfd`/pipe-waker cfg lists gain emscripten. Because emscripten cannot block in `epoll_wait` without JSPI/ASYNCIFY, the epoll fd is exposed via `AsRawFd` on `Poll`/`Registry` so the runtime driving mio can arm emscripten's `emscripten_epoll_set_callback` on it directly, delivering ready events on a fresh host tick whenever the set makes progress. A zero-timeout `Poll::poll` remains available as a synchronous readiness probe where a blocking wait is impossible. AF_UNIX support is stream-only: emscripten's node-backed sockets have no datagram primitive, so `UnixDatagram` and the `socketpair`-based helpers are not compiled there. Sockets set `O_NONBLOCK` via `fcntl` since emscripten's `socket(2)` silently strips `SOCK_NONBLOCK`/`SOCK_CLOEXEC`. The suite spawns OS threads (socket-peer test harnesses), so std is rebuilt with atomics via -Zbuild-std and linked -pthread with -sPROXY_TO_PTHREAD so the main thread can block; JSPI (-sJSPI) provides the return-to-host suspension for blocking reads/writes, and NODERAWFS/NODERAWSOCKETS back the filesystem and sockets with node's. This needs nightly + rust-src and a JSPI-capable Node (26+, or 22 with --experimental-wasm-jspi). No custom target spec is required: nightly now emits the __main_argc_argv entry point (rust-lang/rust#158937). Doctests are skipped on this target: rustdoc does not apply the emcc link args, so the examples cannot be linked with the socket/thread runtime. Temporary, until the dependencies land upstream: * Cargo.toml patches libc to guybedford/libc#emscripten for the emscripten epoll/pthread externs (rust-lang/libc#5270). * the CI job builds against the guybedford/emscripten `cf` fork, which carries AF_UNIX pathname stream sockets and multicast getsockopt patches this target depends on. The two `reconnect_udp_socket_*` tests are ignored on emscripten: libuv does not re-associate a connected UDP socket when it is reconnected to a new peer, so no readiness is delivered for the new peer. Suite result: 144 passed, 0 failed, 5 ignored under Node - green on CI. --- .github/workflows/ci.yml | 39 ++++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- README.md | 1 + src/lib.rs | 5 ++++- src/net/mod.rs | 5 ++++- src/net/uds/mod.rs | 2 ++ src/net/uds/stream.rs | 2 ++ src/sys/shell/uds.rs | 4 ++++ src/sys/unix/mod.rs | 3 +++ src/sys/unix/net.rs | 3 +++ src/sys/unix/pipe.rs | 1 + src/sys/unix/selector/epoll.rs | 3 +++ src/sys/unix/tcp.rs | 1 + src/sys/unix/uds/mod.rs | 7 +++++- src/sys/unix/uds/stream.rs | 1 + tests/regressions.rs | 2 +- tests/tcp_stream.rs | 5 +++++ tests/udp_socket.rs | 10 +++++++++ tests/unix_datagram.rs | 8 ++++++- tests/unix_pipe.rs | 8 ++++++- tests/unix_stream.rs | 3 +++ tests/util/mod.rs | 5 +++++ 22 files changed, 113 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49e212bac..3c68abab4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,44 @@ jobs: run: rustup target add wasm32-wasip2 - name: Tests run: cargo test --target wasm32-wasip2 --all-features + TestEmscripten: + runs-on: ubuntu-latest + timeout-minutes: 20 + # TEMPORARY: this job builds against the guybedford/emscripten `cf` fork, + # which carries the epoll callback, AF_UNIX pathname sockets, and multicast + # getsockopt patches this branch depends on. Once those land upstream the + # fork checkout can be dropped and this can move to a released emsdk. + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + # JSPI is the return-to-host bridge; it needs a Node with JSPI on by + # default (V8 13.x+). Recorded here so the cargo runner uses it rather than + # the Node bundled with emsdk. + - uses: actions/setup-node@v4 + with: + node-version: 26 + - name: Record JSPI-capable node + run: echo "JSPI_NODE=$(which node)" >> "$GITHUB_ENV" + - name: Install emsdk (LLVM + binaryen toolchain) + run: | + git clone --depth 1 https://github.com/emscripten-core/emsdk.git + ./emsdk/emsdk install latest + ./emsdk/emsdk activate latest + - name: Clone guybedford/emscripten (cf) fork over the emsdk tree + run: | + git clone --depth 1 --branch cf https://github.com/guybedford/emscripten.git em-fork + python3 em-fork/bootstrap.py + rm -rf ./emsdk/upstream/emscripten + ln -s "$(pwd)/em-fork" ./emsdk/upstream/emscripten + - name: Tests + run: | + source ./emsdk/emsdk_env.sh + export EM_CONFIG="$EMSDK/.emscripten" + CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_RUNNER="$JSPI_NODE" \ + RUSTFLAGS="-A linker_messages -C target-feature=+atomics,+bulk-memory,+mutable-globals -C link-arg=-pthread -C link-arg=-sPROXY_TO_PTHREAD -C link-arg=-sNODERAWSOCKETS -C link-arg=-sNODERAWFS -C link-arg=-sALLOW_MEMORY_GROWTH=1 -C link-arg=-sEXIT_RUNTIME=1 -C link-arg=-sJSPI" \ + cargo +nightly test --target wasm32-unknown-emscripten -Zbuild-std --all-features --tests --examples --lib Nightly: runs-on: ubuntu-latest timeout-minutes: 10 @@ -170,6 +208,7 @@ jobs: #- powerpc64-ibm-aix - riscv32imc-esp-espidf - sparcv9-sun-solaris + - wasm32-unknown-emscripten - wasm32-wasip1 - x86_64-apple-darwin - x86_64-apple-ios diff --git a/Cargo.toml b/Cargo.toml index 13978e989..9d4c68391 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ net = [] log = { version = "0.4.8", optional = true } [target.'cfg(any(unix, target_os = "hermit", target_os = "wasi"))'.dependencies] -libc = "0.2.183" +libc = { git = "https://github.com/guybedford/libc", branch = "emscripten" } [target.'cfg(windows)'.dependencies.windows-sys] version = "0.61" diff --git a/README.md b/README.md index 3a5329420..b1b6fe0f8 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ Currently supported platforms: * iOS * macOS * Solaris +* Emscripten Mio can handle interfacing with each of the event systems of the aforementioned platforms. The details of their implementation are further discussed in the diff --git a/src/lib.rs b/src/lib.rs index 7a84cc013..54b4c201d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,7 +40,10 @@ //! //! The available features are described in the [`features`] module. -#[cfg(all(target_family = "wasm", not(target_os = "wasi")))] +#[cfg(all( + target_family = "wasm", + not(any(target_os = "wasi", target_os = "emscripten")) +))] compile_error!("This wasm target is unsupported by mio. If using Tokio, disable the net feature."); // macros used internally diff --git a/src/net/mod.rs b/src/net/mod.rs index d941d8010..bc26ceb12 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -36,4 +36,7 @@ pub use self::udp::UdpSocket; #[cfg(unix)] mod uds; #[cfg(unix)] -pub use self::uds::{UnixDatagram, UnixListener, UnixStream}; +pub use self::uds::{UnixListener, UnixStream}; +// Emscripten's node-backed AF_UNIX is stream-only (no datagram primitive). +#[cfg(all(unix, not(target_os = "emscripten")))] +pub use self::uds::UnixDatagram; diff --git a/src/net/uds/mod.rs b/src/net/uds/mod.rs index e02fd80dc..55b81bf4e 100644 --- a/src/net/uds/mod.rs +++ b/src/net/uds/mod.rs @@ -1,4 +1,6 @@ +#[cfg(not(target_os = "emscripten"))] mod datagram; +#[cfg(not(target_os = "emscripten"))] pub use self::datagram::UnixDatagram; mod listener; diff --git a/src/net/uds/stream.rs b/src/net/uds/stream.rs index 244f40455..549658a76 100644 --- a/src/net/uds/stream.rs +++ b/src/net/uds/stream.rs @@ -52,6 +52,8 @@ impl UnixStream { /// Creates an unnamed pair of connected sockets. /// /// Returns two `UnixStream`s which are connected to each other. + // Emscripten has no `socketpair(2)` (no `uv_socketpair` exposed by node). + #[cfg(not(target_os = "emscripten"))] pub fn pair() -> io::Result<(UnixStream, UnixStream)> { sys::uds::stream::pair().map(|(stream1, stream2)| { (UnixStream::from_std(stream1), UnixStream::from_std(stream2)) diff --git a/src/sys/shell/uds.rs b/src/sys/shell/uds.rs index 446781aed..73f7b548f 100644 --- a/src/sys/shell/uds.rs +++ b/src/sys/shell/uds.rs @@ -1,3 +1,5 @@ +// Emscripten's node-backed AF_UNIX is stream-only (no datagram primitive). +#[cfg(not(target_os = "emscripten"))] pub(crate) mod datagram { use std::io; use std::os::unix::net::{self, SocketAddr}; @@ -38,6 +40,8 @@ pub(crate) mod stream { os_required!() } + // Emscripten has no `socketpair(2)` (no `uv_socketpair` exposed by node). + #[cfg(not(target_os = "emscripten"))] pub(crate) fn pair() -> io::Result<(net::UnixStream, net::UnixStream)> { os_required!() } diff --git a/src/sys/unix/mod.rs b/src/sys/unix/mod.rs index 0f7f87a1a..97f1e991a 100644 --- a/src/sys/unix/mod.rs +++ b/src/sys/unix/mod.rs @@ -19,6 +19,7 @@ cfg_os_poll! { not(mio_unsupported_force_poll_poll), any( target_os = "android", + target_os = "emscripten", target_os = "illumos", target_os = "linux", target_os = "redox", @@ -105,6 +106,7 @@ cfg_os_poll! { ), target_os = "aix", target_os = "dragonfly", + target_os = "emscripten", target_os = "haiku", target_os = "hurd", target_os = "netbsd", @@ -158,6 +160,7 @@ cfg_os_poll! { // NOTE: also add to the list for the `pipe` module below. target_os = "aix", target_os = "dragonfly", + target_os = "emscripten", target_os = "haiku", target_os = "hurd", target_os = "netbsd", diff --git a/src/sys/unix/net.rs b/src/sys/unix/net.rs index 68626746a..cd1d221ec 100644 --- a/src/sys/unix/net.rs +++ b/src/sys/unix/net.rs @@ -56,8 +56,11 @@ pub(crate) fn new_socket(domain: libc::c_int, socket_type: libc::c_int) -> io::R } // Darwin (and others) doesn't have SOCK_NONBLOCK or SOCK_CLOEXEC. + // Emscripten's `socket(2)` silently strips both flags, so set `O_NONBLOCK` + // via `fcntl(2)` instead (`FD_CLOEXEC` is a no-op there). #[cfg(any( target_os = "aix", + target_os = "emscripten", target_os = "ios", target_os = "macos", target_os = "tvos", diff --git a/src/sys/unix/pipe.rs b/src/sys/unix/pipe.rs index 5dfe7501b..cf49955a0 100644 --- a/src/sys/unix/pipe.rs +++ b/src/sys/unix/pipe.rs @@ -11,6 +11,7 @@ pub(crate) fn new_raw() -> io::Result<[RawFd; 2]> { #[cfg(any( target_os = "android", target_os = "dragonfly", + target_os = "emscripten", target_os = "freebsd", target_os = "fuchsia", target_os = "hurd", diff --git a/src/sys/unix/selector/epoll.rs b/src/sys/unix/selector/epoll.rs index 29e36b7e0..134941ef5 100644 --- a/src/sys/unix/selector/epoll.rs +++ b/src/sys/unix/selector/epoll.rs @@ -238,6 +238,9 @@ pub mod event { } // No special requirement from the implementation around waking. +// On emscripten the public `Waker` is not compiled (the runtime wakes through +// its own event loop), so this re-export is unused there. +#[cfg_attr(target_os = "emscripten", allow(unused_imports))] pub(crate) use crate::sys::unix::waker::Waker; cfg_io_source! { diff --git a/src/sys/unix/tcp.rs b/src/sys/unix/tcp.rs index 5b9f078b5..918021bbe 100644 --- a/src/sys/unix/tcp.rs +++ b/src/sys/unix/tcp.rs @@ -65,6 +65,7 @@ pub(crate) fn accept(listener: &net::TcpListener) -> io::Result<(net::TcpStream, // See https://github.com/tokio-rs/mio/issues/1445 for details all(not(target_arch="x86"), target_os = "android"), target_os = "dragonfly", + target_os = "emscripten", target_os = "freebsd", target_os = "fuchsia", target_os = "hurd", diff --git a/src/sys/unix/uds/mod.rs b/src/sys/unix/uds/mod.rs index a4585e461..309bafe54 100644 --- a/src/sys/unix/uds/mod.rs +++ b/src/sys/unix/uds/mod.rs @@ -1,12 +1,16 @@ +#[cfg(not(target_os = "emscripten"))] +use std::io; #[cfg(target_os = "android")] use std::os::android::net::SocketAddrExt; #[cfg(target_os = "linux")] use std::os::linux::net::SocketAddrExt; use std::os::unix::ffi::OsStrExt; +#[cfg(not(target_os = "emscripten"))] use std::os::unix::io::FromRawFd; use std::os::unix::net::SocketAddr; -use std::{io, mem, ptr}; +use std::{mem, ptr}; +#[cfg(not(target_os = "emscripten"))] pub(crate) mod datagram; pub(crate) mod listener; pub(crate) mod stream; @@ -81,6 +85,7 @@ fn unix_addr(address: &SocketAddr) -> (libc::sockaddr_un, libc::socklen_t) { (sockaddr, addrlen as _) } +#[cfg(not(target_os = "emscripten"))] fn pair(flags: libc::c_int) -> io::Result<(T, T)> where T: FromRawFd, diff --git a/src/sys/unix/uds/stream.rs b/src/sys/unix/uds/stream.rs index dd2b2cab3..60410a1f8 100644 --- a/src/sys/unix/uds/stream.rs +++ b/src/sys/unix/uds/stream.rs @@ -20,6 +20,7 @@ pub(crate) fn connect_addr(address: &SocketAddr) -> io::Result Ok(socket) } +#[cfg(not(target_os = "emscripten"))] pub(crate) fn pair() -> io::Result<(net::UnixStream, net::UnixStream)> { super::pair(libc::SOCK_STREAM) } diff --git a/tests/regressions.rs b/tests/regressions.rs index c41a23b02..2e8f296da 100644 --- a/tests/regressions.rs +++ b/tests/regressions.rs @@ -109,7 +109,7 @@ fn issue_1205() { } #[test] -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "emscripten")))] fn issue_1403() { use mio::net::UnixDatagram; use util::temp_file; diff --git a/tests/tcp_stream.rs b/tests/tcp_stream.rs index 7967f1fe3..07d18afd7 100644 --- a/tests/tcp_stream.rs +++ b/tests/tcp_stream.rs @@ -464,6 +464,10 @@ fn shutdown_both() { } #[cfg(unix)] +#[cfg_attr( + target_os = "emscripten", + ignore = "local_addr not preserved across from_raw_fd on emscripten" +)] #[test] fn raw_fd() { init(); @@ -636,6 +640,7 @@ fn tcp_shutdown_client_read_close_event() { #[cfg_attr( any( target_os = "android", + target_os = "emscripten", target_os = "hurd", target_os = "illumos", target_os = "solaris", diff --git a/tests/udp_socket.rs b/tests/udp_socket.rs index 9c98efcbf..8c938812c 100644 --- a/tests/udp_socket.rs +++ b/tests/udp_socket.rs @@ -407,6 +407,10 @@ fn smoke_test_connected_udp_socket(mut socket1: UdpSocket, mut socket2: UdpSocke assert!(socket2.take_error().unwrap().is_none()); } +#[cfg_attr( + target_os = "emscripten", + ignore = "libuv does not re-associate a connected UDP socket on reconnect" +)] #[test] fn reconnect_udp_socket_sending() { let (mut poll, mut events) = init_with_poll(); @@ -470,6 +474,10 @@ fn reconnect_udp_socket_sending() { assert!(socket3.take_error().unwrap().is_none()); } +#[cfg_attr( + target_os = "emscripten", + ignore = "libuv does not re-associate a connected UDP socket on reconnect" +)] #[test] fn reconnect_udp_socket_receiving() { let (mut poll, mut events) = init_with_poll(); @@ -652,6 +660,7 @@ fn connected_udp_socket_unconnected_methods() { target_os = "android", target_os = "hurd", target_os = "linux", + target_os = "emscripten", target_os = "windows", target_os = "cygwin", target_os = "wasi", @@ -664,6 +673,7 @@ fn connected_udp_socket_unconnected_methods() { target_os = "android", target_os = "hurd", target_os = "linux", + target_os = "emscripten", target_os = "windows", target_os = "cygwin", target_os = "wasi", diff --git a/tests/unix_datagram.rs b/tests/unix_datagram.rs index f94ea7624..a4fa17757 100644 --- a/tests/unix_datagram.rs +++ b/tests/unix_datagram.rs @@ -1,4 +1,10 @@ -#![cfg(all(unix, feature = "os-poll", feature = "net"))] +// Emscripten's node-backed AF_UNIX is stream-only (no datagram sockets). +#![cfg(all( + unix, + not(target_os = "emscripten"), + feature = "os-poll", + feature = "net" +))] use mio::net::UnixDatagram; use mio::{Interest, Token}; diff --git a/tests/unix_pipe.rs b/tests/unix_pipe.rs index 73d5b7fc8..8736c285b 100644 --- a/tests/unix_pipe.rs +++ b/tests/unix_pipe.rs @@ -1,12 +1,15 @@ #![cfg(all(unix, feature = "os-poll", feature = "os-ext", feature = "net"))] use std::io::{Read, Write}; +#[cfg(not(target_os = "emscripten"))] use std::process::{Command, Stdio}; use std::sync::{Arc, Barrier}; use std::thread; use std::time::Duration; -use mio::unix::pipe::{self, Receiver, Sender}; +use mio::unix::pipe; +#[cfg(not(target_os = "emscripten"))] +use mio::unix::pipe::{Receiver, Sender}; use mio::{Events, Interest, Poll, Token}; mod util; @@ -132,6 +135,8 @@ fn event_when_receiver_is_dropped() { } #[test] +// Emscripten/wasm has no fork/exec, so no subprocesses. +#[cfg(not(target_os = "emscripten"))] #[cfg_attr( any(target_os = "hurd", target_os = "nto", target_os = "cygwin"), ignore = "Writer fd close events do not trigger POLLHUP on nto and GNU/Hurd targets" @@ -183,6 +188,7 @@ fn from_child_process_io() { } #[test] +#[cfg(not(target_os = "emscripten"))] fn nonblocking_child_process_io() { // `cat` simply echo everything that we write via standard in. let mut child = Command::new("cat") diff --git a/tests/unix_stream.rs b/tests/unix_stream.rs index 770baddd7..e4c4161fa 100644 --- a/tests/unix_stream.rs +++ b/tests/unix_stream.rs @@ -24,6 +24,7 @@ const DATA1_LEN: usize = 16; const DATA2_LEN: usize = 14; const DEFAULT_BUF_SIZE: usize = 64; const TOKEN_1: Token = Token(0); +#[cfg(not(target_os = "emscripten"))] const TOKEN_2: Token = Token(1); #[test] @@ -145,6 +146,8 @@ fn unix_stream_from_std() { ) } +// Emscripten has no `socketpair(2)`. +#[cfg(not(target_os = "emscripten"))] #[test] fn unix_stream_pair() { let (mut poll, mut events) = init_with_poll(); diff --git a/tests/util/mod.rs b/tests/util/mod.rs index e451009f0..80d74d9fa 100644 --- a/tests/util/mod.rs +++ b/tests/util/mod.rs @@ -242,6 +242,11 @@ where } } +// Emscripten is a single process with no `exec(2)`, so `FD_CLOEXEC` is a no-op +// (`F_GETFD` always returns 0); the concept doesn't apply. +#[cfg(target_os = "emscripten")] +pub fn assert_socket_close_on_exec(_: &S) {} + #[cfg(windows)] pub fn assert_socket_close_on_exec(_: &S) { // Windows doesn't have this concept. From 0ac8183559a901a401f3dadec016d6df9bf6b2c6 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 21 Jul 2026 10:59:55 -0700 Subject: [PATCH 2/2] remove Emscripten from README while experimental --- README.md | 1 - tests/util/mod.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index b1b6fe0f8..3a5329420 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,6 @@ Currently supported platforms: * iOS * macOS * Solaris -* Emscripten Mio can handle interfacing with each of the event systems of the aforementioned platforms. The details of their implementation are further discussed in the diff --git a/tests/util/mod.rs b/tests/util/mod.rs index 80d74d9fa..167f742d1 100644 --- a/tests/util/mod.rs +++ b/tests/util/mod.rs @@ -224,7 +224,7 @@ pub fn assert_socket_non_blocking(_: &S) { } /// Assert that `CLOEXEC` is set on `socket`. -#[cfg(any(unix, target_os = "wasi"))] +#[cfg(any(all(unix, not(target_os = "emscripten")), target_os = "wasi"))] pub fn assert_socket_close_on_exec(socket: &S) where S: AsRawFd,