# Non-blocking stdin support in WASI runtimes πŸ€” > This topic is a proceeding from [Agda in WebAssembly & Experiments on Language Servers](https://hackmd.io/bdhi0GLxRKO-Nn2CfuNpuA). I aim to study deeper on replicating POSIX I/O mechanism. tl;dr, `poll_oneoff` for WASI preview 1 is difficult to get right. Will making stdin non-blocking help? Let's see the following sample code: ```cpp= #include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <errno.h> #include <string.h> int main() { printf("nonblock stdin before? %d\n", fcntl(0, F_GETFL) & O_NONBLOCK); if (fcntl(0, F_SETFL, O_NONBLOCK) < 0) { perror("fcntl"); } printf("nonblock stdin after ? %d\n", fcntl(0, F_GETFL) & O_NONBLOCK); char buf[16]; int n = read(0, buf, 1); printf("read ret=%d\n", n); if (n < 0) perror("read"); } ``` In WASI environment, we expect `fcntl` calls `fd_fdstat_set_flags` system call and (hopefully) turns on the non-blocking mode for stdin (fd=0). How will every environment handle this then? # Expected output In a typical POSIX-compat system, the output will be: ``` $ gcc nonblock.c -o nonblock $ ./nonblock nonblock stdin before? 0 nonblock stdin after ? 4 read ret=-1 read: Resource temporarily unavailable ``` When stdin is set to non-blocking mode, the read sould immediately return with `EAGAIN`! p.s. the return of `fcntl(0, F_SETFL)` is a mix of fd status flags and access mode flag, as explained in [fcntl(3p)](https://man7.org/linux/man-pages/man3/fcntl.3p.html). It might not be zero. ## The pseudocode in my head Here is the flow that I think a complete implementation could be: ``` # scan for ready fds and the nearest timeout (might be undefined) # and, if shouldBlock, block until at least one fd is ready fn scan(subs, shouldBlock?) -> ( events, timeout ) # block until the system clock goes >= t fn sleep(t) -> () fn poll_oneoff(subs) -> events: let (es, t) <- scan(subs, false) # case 1: some fd is ready if len(es) > 0: return es # case 2: no fd is ready, but has clock if t is NOT undefined: sleep(t) let ets = [evt for events if evt.timeout === t] # rescan for ready fds let (es', _) <- scan(subs without clock evts, false) return [*ets, *es'] # case 3: no fd is ready and has no clock let (es'', _) <- scan(subs, true) # es'' must be nonempty return es'' ``` See [the real code](https://gitlab.com/qbane/wasi-monorepo/-/blob/e03d4bcf8911ed2e028fb35282a1fe789906d48c/wasi-js/src/wasi.ts#L1619) in my fork of wasi-js for a realization (or kludge). # The survey The most hassle-free way to get a working WASM module: ``` zig cc -target wasm32-wasi nonblock.c -o nonblock.wasm ``` * Node.js 20 πŸ™† > But no direct CLI. You need to [write some code](https://nodejs.org/api/wasi.html) to pull it off... <details style="color: #666; margin: 1em 0 1em 20px; border: 1px solid currentColor; padding: 8px"> <summary>Grab this!</summary> ```javascript import { readFile } from 'node:fs/promises' import { WASI } from 'node:wasi' const wasi = new WASI({ version: 'preview1', args: ['nonblock.wasm'], env: {}, returnOnExit: true, }) const wasm = await WebAssembly.compile( await readFile('nonblock.wasm'), ) const instance = await WebAssembly.instantiate(wasm, wasi.getImportObject()) process.exit(await wasi.start(instance)) ``` </details> * wasm3 πŸ™† > Build: Nov 10 2022 09:50:46, Apple LLVM 14.0.0 (clang-1400.0.29.202) * wazero πŸ™† > 1.7.2 ``` $ wazero run nonblock.wasm nonblock stdin before? 0 nonblock stdin after ? 4 read ret=-1 read: Resource temporarily unavailable ``` ## Runtimes that do not pass this... ⚠️ means the read call blocks there, and you need to press Ctrl-D to escape. ### wasmtime πŸ™… > wasmtime-cli 21.0.1 (cedf9aa0f 2024-05-22) ``` $ wasmtime run nonblock.wasm nonblock stdin before? 0 fcntl: Bad file descriptor nonblock stdin after ? 0 ⚠️ read ret=0 ``` Trace: (From my vague understanding of Rust code) See `crates/wasi/src/preview1.rs` for the function `fd_fdstat_set_flags`. A file object is some member of enum `File`. As you can see that `stdin` is defined as a different member, the operation to "get a `File` from fd" (`get_file_mut`) will lead to a `BADF` error: ```rust match self.descriptors.get_mut(&fd) { Some(Descriptor::File(file)) => Ok(file), _ => Err(types::Errno::Badf.into()), } ``` ### wasmer πŸ™… > wasmer 4.3.1 ``` $ wasmer run nonblock.wasm nonblock stdin before? 0 fcntl: Permission denied nonblock stdin after ? 0 ⚠️ read ret=0 ``` Trace: "Permission denied" is more interesting. See function `fd_fdstat_set_flags_internal`. `STDIN_DEFAULT_RIGHTS` is hardcoded in `lib/wasix/src/fs/mod.rs` and it does not include `FD_FDSTAT_SET_FLAGS`. But adding that flag is not effective either: ``` $ target/release/wasmer nonblock.wasm nonblock stdin before? 0 nonblock stdin after ? 0 ⚠️ read ret=0 ``` Problems observed: 1. The first `fcntl` succeeds, but the flag is not changed. 2. It is still blocking. ### VS Code (WebAssembly Execution Engine) πŸ™… ``` nonblock stdin before? 0 fcntl: Function not implemented nonblock stdin after ? 0 ⚠️ ``` Cannot proceed. Sidenote: There is an EOT (Ctrl-D) support [added in 2024/10](https://github.com/microsoft/vscode-wasm/commit/6af74e0f4137dd00ef41f10da793b884d28566d1), but the engine on the extension marketplace has yet updated (last updated 2024/9). Hopeless? Good news is that the runtime is under your control. It seems that you can provide a facade stdin that throws an error if it would block. ```js const stdinPipe = wasm.createWritable() ;(stdinPipe as any).read = function(mode?: 'max', size?: number) { logger.appendLine(`STDIN READ mode=${mode} size=${size}`) if ((pty as any).lines.length <= 0) { // FIXME: Come up with a way to throw a WasiError, which is not exported throw FileSystemError.Unavailable('This read to stdin would block') } return pty.read(size ?? 0) } ``` The error message would become "Resource busy". This is less than ideal because the program normally would abort in this case, while in `EAGAIN`'s case the program is expected to retry. Sadly even [duck typing `FileSystemError` is futile](https://github.com/microsoft/vscode-wasm/blob/829b358896d07dbe35bec89d8dd9c9cad2ab04b8/wasm-wasi-core/src/common/service.ts#L304). Our fix, along with other enhancements, is materialized as a [fork](https://github.com/agda-web/vscode-wasm). ## [jswasi](https://github.com/antmicro/jswasi) (tested 2025/8/6) πŸ™… Allow switching to non-blocking read, the read does not block, but the errno is NOT set. ``` nonblock stdin before? 0 nonblock stdin after ? 4 read ret=0 ``` Interestingly, after the program exits, any read from stdin will not block anymore. Try e.g., `python`. Completely mysterious... ## Runno ((un)tested 2025/8/22) πŸ™… The polling implementation is [obviously incorrect](https://github.com/taybenlor/runno/blob/f9314e2c4537ccecf4d11b52932229ce1dc7e395/packages/wasi/lib/wasi/wasi.ts#L1325). I made a partial implementation: https://gist.github.com/andy0130tw/9c6bf71ae3bc613e543a1650f9ccb33c#file-wrap-poll-oneoff-js. --- --- --- Technical content below --- --- --- # Appendix: The attempt to fix Wasmer ## Step 1: Make it possible to call `fcntl` For 1., in the same file, the `fdstat` function always return flag `0`. ```rust! pub fn fdstat(&self, fd: WasiFd) -> Result<Fdstat, Errno> { match fd { __WASI_STDIN_FILENO => { return Ok(Fdstat { fs_filetype: Filetype::CharacterDevice, fs_flags: Fdflags::empty(), // ⬅️ fs_rights_base: STDIN_DEFAULT_RIGHTS, fs_rights_inheriting: Rights::empty(), }) } ... ``` For 2., the write only saves the flag in memory, and does not acknowledge the system. Let's patch them. 1. ```rust= // wasmer/lib/wasix/src/fs/mod.rs pub fn fdstat(&self, fd: WasiFd) -> Result<Fdstat, Errno> { match fd { __WASI_STDIN_FILENO => { let fd = self.get_fd(fd)?; // βœ… return Ok(Fdstat { fs_filetype: Filetype::CharacterDevice, fs_flags: fd.flags, // βœ… fs_rights_base: STDIN_DEFAULT_RIGHTS, fs_rights_inheriting: Rights::empty(), }) ``` 2. ```rust= // wasmer/lib/wasix/src/syscalls/wasi/fd_fdstat_set_flags.rs #[instrument(level = "debug", skip_all, fields(%fd), ret)] pub fn fd_fdstat_set_flags( // ... ) -> Result<Errno, WasiError> { // ... let guard = fd_entry.inode.read(); let maybe_sys_fd = match guard.deref() { Kind::File { handle, .. } => { if let Some(handle) = handle { let handle = handle.clone(); let handle = wasi_try_ok!(handle.read().map_err(|_| { Errno::Badf })); handle.get_special_fd() } else { None } }, _ => None, }; if let Some(sys_fd) = maybe_sys_fd { let sys_fd = wasi_try_ok!(i32::try_from(sys_fd).map_err(|_| { Errno::Badf })); let fcntl_flags = unsafe { libc::fcntl(sys_fd, libc::F_GETFL) }; wasi_try_ok!((fcntl_flags >= 0).then(|| ()).ok_or(Errno::Access)); let fcntl_flags = if flags.contains(Fdflags::NONBLOCK) { fcntl_flags | libc::O_NONBLOCK } else { fcntl_flags & (!libc::O_NONBLOCK) }; let ret = unsafe { libc::fcntl(sys_fd, libc::F_SETFL, fcntl_flags) }; wasi_try_ok!((ret == 0).then(|| ()).ok_or(Errno::Access)); }; // ... Ok(Errno::Success) } ``` ## Step 2: Fix `poll_oneoff` accordingly The above patch works well only for our example. It breaks apart as soon as our code involves `poll_oneoff`. The blocking read from stdin issue is described in Tokio's documentation, as per <https://docs.rs/tokio/latest/tokio/io/struct.Stdin.html>: > This handle is best used for non-interactive uses, such as [...] For interactive uses, it is recommended to **spawn a thread dedicated to user input and use blocking IO directly in that thread.** With some strace-ing, I can almost conclude that Wasmer (tokio, and in turn mio) **relies on the fact that stdin is blocking**, and does not take advantage of any technique of I/O multiplexing (select/poll/epoll/...) for `poll_oneoff`, and so behaves unexpectedly in non-blocking mode. For instance, ```c // #include <sys/select.h> int try_select() { fd_set readfds; FD_ZERO(&readfds); FD_SET(0, &readfds); // NOTE: passing NULL or a negative number in timeout // means waiting indefinitely! struct timeval timeout = { .tv_sec = 0, .tv_usec = 0, }; return select(1, &readfds, NULL, NULL, &timeout); } ``` It is surprising to see how Wasmer deviates from the spec... | Blocking? | Timeout | POSIX spec | Wasmer's behavior | Remark | | --------- | --------- | ---------- | ----------------- | ------ | | Yes | `NULL` | Blocks | Blocks | | Yes | `{0, 0}` | Returns 0 | Blocks (*1) | This is trivial to fix by skipping s/g buffers of zero length. | No | `NULL` | Blocks | Returns 1 (*2) | This is hard to fix on Wasmer's side. | No | `{0, 0}` | Returns 0 | Returns 1 (*2) | This corresponds to the demo code. The trace is provided below. (*1): Wasmtime's behavior *is* correct. I believe this is [another bug](https://github.com/wasmerio/wasmer/blob/v4.2.5/lib/wasix/src/syscalls/wasi/poll_oneoff.rs#L293-L301) in Wasmer. The only correct way to tell `poll_oneoff` to wait indefinitely is to not include a clock event. (*2): it errs out with `EAGAIN` (!); the `readiness` is set to `EPOLLERR`. <details style="margin: 2em 0; padding-left: 2em; padding-right: 1em"> <summary style="margin-left: -1em">Read this section only if you are curious about the inner working...</summary> Call `try_select` after setting `O_NONBLOCK` and compare the behavior in a UNIX system vs. in WASI runtime. UNIX returns `0` denoting that the read is not ready. On the other hand, the debug trace from Wasmer shows that `poll_oneoff` returns `1` when the underlying FD is set to non-blocking mode. A program seeing this will do the read and fail with `EAGAIN`. The `strace -ff` shows that the original polling implementation is actually a direct `read`! (See `Stdin`'s `poll_read_ready`) ``` [pid 95011] read(0, <unfinished ...> [pid 94997] <... futex resumed>) = -1 ETIMEDOUT (ι€£η·šθΆ…ιŽζ™‚ι–“) [pid 94997] sigaltstack({ss_sp=NULL, ss_flags=SS_DISABLE, ss_size=8192}, NULL) = 0 [pid 94997] munmap(0x79dfaa456000, 12288) = 0 [pid 94997] rt_sigprocmask(SIG_BLOCK, ~[RT_1], NULL, 8) = 0 [pid 94997] madvise(0x79dfa9e00000, 2076672, MADV_DONTNEED) = 0 [pid 94997] exit(0) = ? [pid 94997] +++ exited with 0 +++ 123 [pid 95011] <... read resumed>"123\n", 8192) = 4 ``` And the trace from Rust's side is ``` TRACE [...]::poll_oneoff: enter TRACE [...]::poll_oneoff: triggered fd=0 readiness=EPOLLERR userdata=0 ty=1 peb=1 fd_guards="[guard-file(fd=0, peb=1)]" TRACE [...]::poll_oneoff: return=Ok(Errno::success) fd_guards="[guard-file(fd=0, peb=1)]" seen="Event { userdata: 0, error: Errno::again, type: Eventtype::FdRead }" TRACE [...]::poll_oneoff: close time.busy=1.86ms time.idle=417ns fd_guards="[guard-file(fd=0, peb=1)]" seen="Event { userdata: 0, error: Errno::again, type: Eventtype::FdRead }" ``` We want polling without timeout blocks until stdin is ready, but non-blocking `read` must not block. The takeaway is that **you can never truly make a non-blocking, concurrent program without essential APIs from OS**. </details> After several failing attempts, I do not think the problem can be solved. But after a month of debugging, finally I found that ==the problem genuinely came from two instances of `Stdin`s ever constructed==. The solution can be broken up in two parts: 1. Carefully implement polling logic in `poll_read_ready`. Note that we do not need anything fancy in `poll_read`. It is the outer layer (i.e., `fd_read` that is responsible for the blocking/nonblocking mode. 2. Stick to that non-blocking IO from now on. Here is [my patch](https://github.com/agda-web/wasmer/commit/48b87d87679ebaa1f35d0b7f538ef79cb4232a53) against Wasmer v4.2.5. Note that this patch only works for UNIX-like system ~~, and programs relying on a blocking stdin will break.~~ The patch enables the nonblocking mode for `fd_fdstat_set_flags`, which a program can invoke through `fcntl`. It would be better to inherit that flag from the host. This way, the user can use a wrapper program (like [stdbuf](https://linux.die.net/man/1/stdbuf)) to specify the mode. This bug bothers me for nearly a month. I have to resort to talking with ChatGPT on my Rust-y code. ### Acknowledgements Thanks to osa1 and others for a discussion thread at [Rust user forum](https://users.rust-lang.org/t/how-do-i-debug-a-tokio-hang-tokio-0-3-3/51235), and the code from project [@osa1/tiny](https://github.com/osa1/tiny/blob/ee8615a55256b242b02e6f8a2350ca7f39aca517/crates/term_input/src/lib.rs). # TODO for Wasmtime. This section serves as a placeholder. ~~I expect Wasmtime easier to fix.~~