eduardomourar opened issue #14061:
Test Case
No wasm file needed — reproducible with any wasip2 guest that writes past a closed pipe, or reads stdin redirected from a directory. Minimal repro source: [gist-equivalent below].
// write side use std::io::Write; fn main() { loop { if let Err(e) = std::io::stdout().write_all(b"0123456789\n") { eprintln!("write error: kind={:?} raw_os_error={:?}", e.kind(), e.raw_os_error()); return; } } }// read side use std::io::Read; fn main() { let mut buf = [0u8; 16]; match std::io::stdin().read(&mut buf) { Err(e) => eprintln!("stdin read error: kind={:?} raw_os_error={:?}", e.kind(), e.raw_os_error()), _ => {} } }Steps to Reproduce
- Compile the write-side repro for
wasm32-wasip2, run underwasmtime run pipe.wasm | head -c 30, so the reader closes early.- Compile the read-side repro for
wasm32-wasip2, run underwasmtime run --dir <dir> stdin.wasm < <dir>, redirecting stdin from a directory.- Compare against the same repros compiled for
wasm32-wasip1.Expected Results
wasip1 correctly reports
BrokenPipe/EPIPE(64) for the write case andIsADirectory/EISDIR(31) for the read case — matching native Unix.Actual Results
wasip2 reports
InputOutputError/EIO(29) for both cases. Confirmed onwasmtime45.0.0 and 47.0.3 (latest release, 2026-07-31) — unchanged between versions.Root cause (traced through wasi-libc + wasmtime source):
crates/wasi/src/cli/stdout.rs#StdioOutputStream::write/flushwraps any error fromstd::io::stdout().write_all()asStreamError::LastOperationFailed(anyhow::Error)viawasmtime::format_err!(e), even whene.kind() == BrokenPipe. It never returnsStreamError::Closed.crates/wasi/src/cli/worker_thread_stdin.rs#WasiStdin::readdoes the same forStdinState::Error(e) => Err(StreamError::LastOperationFailed(e.into())), discardinge.kind() == IsADirectory.- Downstream, wasi-libc's
wasip2_handle_write_error/wasip2_handle_read_erroronly distinguishStreamError::ClosedfromLastOperationFailed, mapping the latter to a genericEIO— so once the specific error kind is erased above, it can't be recovered here either.- wasip1 avoids this because
From<StreamError> for types::ErrordowncastsLastOperationFailed's inner error back tostd::io::Errorand runs it throughfilesystem::ErrorCode::from, which has realRustixErrno::PIPE/ISDIRarms. The p2 CLI-stdio path has no equivalent recovery step.This affects every wasip2 guest, not just Rust's — e.g. uutils/coreutils#13625 had to add a userland
wasi_is_broken_pipehelper checking for raw errno 29 as a workaround.Versions and Environment
Wasmtime version or commit: 45.0.0 and 47.0.3 (both exhibit the bug)
Operating system: macOS (Darwin), confirmed reproducible; likely platform-independent since the bug is in
wasmtime's stdio glue rather than any OS-specific pathArchitecture: aarch64 host, wasm32-wasip2 guest
Extra Info
The
wasi:io/streamsspec'sstream-errortype is designed for exactly this: it carries an opaqueerrorresource that other interfaces can "downcast" into more specific info (e.g.wasi:filesystem/types/filesystem-error-code). A fix could either (a) haveStdioOutputStream::write/WasiStdin::readchecke.kind()and returnStreamError::ClosedforBrokenPipe(matching what wasi-libc already expects), or (b) give p2's CLI stdio the same io::Error-recovery path p1 already has viafilesystem::ErrorCode::from.
eduardomourar added the bug label to Issue #14061.
alexcrichton closed issue #14061:
Test Case
No wasm file needed — reproducible with any wasip2 guest that writes past a closed pipe, or reads stdin redirected from a directory. Minimal repro source: [gist-equivalent below].
// write side use std::io::Write; fn main() { loop { if let Err(e) = std::io::stdout().write_all(b"0123456789\n") { eprintln!("write error: kind={:?} raw_os_error={:?}", e.kind(), e.raw_os_error()); return; } } }// read side use std::io::Read; fn main() { let mut buf = [0u8; 16]; match std::io::stdin().read(&mut buf) { Err(e) => eprintln!("stdin read error: kind={:?} raw_os_error={:?}", e.kind(), e.raw_os_error()), _ => {} } }Steps to Reproduce
- Compile the write-side repro for
wasm32-wasip2, run underwasmtime run pipe.wasm | head -c 30, so the reader closes early.- Compile the read-side repro for
wasm32-wasip2, run underwasmtime run --dir <dir> stdin.wasm < <dir>, redirecting stdin from a directory.- Compare against the same repros compiled for
wasm32-wasip1.Expected Results
wasip1 correctly reports
BrokenPipe/EPIPE(64) for the write case andIsADirectory/EISDIR(31) for the read case — matching native Unix.Actual Results
wasip2 reports
InputOutputError/EIO(29) for both cases. Confirmed onwasmtime45.0.0 and 47.0.3 (latest release, 2026-07-31) — unchanged between versions.Root cause (traced through wasi-libc + wasmtime source):
crates/wasi/src/cli/stdout.rs#StdioOutputStream::write/flushwraps any error fromstd::io::stdout().write_all()asStreamError::LastOperationFailed(anyhow::Error)viawasmtime::format_err!(e), even whene.kind() == BrokenPipe. It never returnsStreamError::Closed.crates/wasi/src/cli/worker_thread_stdin.rs#WasiStdin::readdoes the same forStdinState::Error(e) => Err(StreamError::LastOperationFailed(e.into())), discardinge.kind() == IsADirectory.- Downstream, wasi-libc's
wasip2_handle_write_error/wasip2_handle_read_erroronly distinguishStreamError::ClosedfromLastOperationFailed, mapping the latter to a genericEIO— so once the specific error kind is erased above, it can't be recovered here either.- wasip1 avoids this because
From<StreamError> for types::ErrordowncastsLastOperationFailed's inner error back tostd::io::Errorand runs it throughfilesystem::ErrorCode::from, which has realRustixErrno::PIPE/ISDIRarms. The p2 CLI-stdio path has no equivalent recovery step.This affects every wasip2 guest, not just Rust's — e.g. uutils/coreutils#13625 had to add a userland
wasi_is_broken_pipehelper checking for raw errno 29 as a workaround.Versions and Environment
Wasmtime version or commit: 45.0.0 and 47.0.3 (both exhibit the bug)
Operating system: macOS (Darwin), confirmed reproducible; likely platform-independent since the bug is in
wasmtime's stdio glue rather than any OS-specific pathArchitecture: aarch64 host, wasm32-wasip2 guest
Extra Info
The
wasi:io/streamsspec'sstream-errortype is designed for exactly this: it carries an opaqueerrorresource that other interfaces can "downcast" into more specific info (e.g.wasi:filesystem/types/filesystem-error-code). A fix could either (a) haveStdioOutputStream::write/WasiStdin::readchecke.kind()and returnStreamError::ClosedforBrokenPipe(matching what wasi-libc already expects), or (b) give p2's CLI stdio the same io::Error-recovery path p1 already has viafilesystem::ErrorCode::from.
alexcrichton commented on issue #14061:
The behavior you're describing here is an artifact of WASI's interfaces as opposed to Wasmtime's implementation. If you're interested in having this resolved I'd recommend filing an issue in the upstream WASI specification repository.
eduardomourar commented on issue #14061:
@alexcrichton, thanks for looking at this. I don't think this is a spec limitation, though. The
stream-errortype's two variants (last-operation-failed(error)/closed) are the full interface, sure, but wasmtime's own p1 path already handles this correctly:From<StreamError> for types::Errorin p1.rs downcasts the inner error back tostd::io::Errorand maps it throughfilesystem::ErrorCode::from(which has realPIPE/ISDIRarms). p2's CLI-stdio path just doesn't do any equivalent recovery. It wraps everything as opaqueLastOperationFailedin cli/stdout.rs and cli/worker_thread_stdin.rs, and wasi-libc maps that toEIOsince it can only distinguishClosedfromLastOperationFailed.So the spec already accommodates the fix; it's just that the p2 implementation doesn't use the same trick p1 does. A minimal fix could be as simple as returning
StreamError::Closedwhene.kind() == BrokenPipe(which is what wasi-libc already expects on that variant), or giving p2 the sameErrorCoderecovery p1 has.Happy to put a PR together if either approach sounds reasonable.
alexcrichton reopened issue #14061:
Test Case
No wasm file needed — reproducible with any wasip2 guest that writes past a closed pipe, or reads stdin redirected from a directory. Minimal repro source: [gist-equivalent below].
// write side use std::io::Write; fn main() { loop { if let Err(e) = std::io::stdout().write_all(b"0123456789\n") { eprintln!("write error: kind={:?} raw_os_error={:?}", e.kind(), e.raw_os_error()); return; } } }// read side use std::io::Read; fn main() { let mut buf = [0u8; 16]; match std::io::stdin().read(&mut buf) { Err(e) => eprintln!("stdin read error: kind={:?} raw_os_error={:?}", e.kind(), e.raw_os_error()), _ => {} } }Steps to Reproduce
- Compile the write-side repro for
wasm32-wasip2, run underwasmtime run pipe.wasm | head -c 30, so the reader closes early.- Compile the read-side repro for
wasm32-wasip2, run underwasmtime run --dir <dir> stdin.wasm < <dir>, redirecting stdin from a directory.- Compare against the same repros compiled for
wasm32-wasip1.Expected Results
wasip1 correctly reports
BrokenPipe/EPIPE(64) for the write case andIsADirectory/EISDIR(31) for the read case — matching native Unix.Actual Results
wasip2 reports
InputOutputError/EIO(29) for both cases. Confirmed onwasmtime45.0.0 and 47.0.3 (latest release, 2026-07-31) — unchanged between versions.Root cause (traced through wasi-libc + wasmtime source):
crates/wasi/src/cli/stdout.rs#StdioOutputStream::write/flushwraps any error fromstd::io::stdout().write_all()asStreamError::LastOperationFailed(anyhow::Error)viawasmtime::format_err!(e), even whene.kind() == BrokenPipe. It never returnsStreamError::Closed.crates/wasi/src/cli/worker_thread_stdin.rs#WasiStdin::readdoes the same forStdinState::Error(e) => Err(StreamError::LastOperationFailed(e.into())), discardinge.kind() == IsADirectory.- Downstream, wasi-libc's
wasip2_handle_write_error/wasip2_handle_read_erroronly distinguishStreamError::ClosedfromLastOperationFailed, mapping the latter to a genericEIO— so once the specific error kind is erased above, it can't be recovered here either.- wasip1 avoids this because
From<StreamError> for types::ErrordowncastsLastOperationFailed's inner error back tostd::io::Errorand runs it throughfilesystem::ErrorCode::from, which has realRustixErrno::PIPE/ISDIRarms. The p2 CLI-stdio path has no equivalent recovery step.This affects every wasip2 guest, not just Rust's — e.g. uutils/coreutils#13625 had to add a userland
wasi_is_broken_pipehelper checking for raw errno 29 as a workaround.Versions and Environment
Wasmtime version or commit: 45.0.0 and 47.0.3 (both exhibit the bug)
Operating system: macOS (Darwin), confirmed reproducible; likely platform-independent since the bug is in
wasmtime's stdio glue rather than any OS-specific pathArchitecture: aarch64 host, wasm32-wasip2 guest
Extra Info
The
wasi:io/streamsspec'sstream-errortype is designed for exactly this: it carries an opaqueerrorresource that other interfaces can "downcast" into more specific info (e.g.wasi:filesystem/types/filesystem-error-code). A fix could either (a) haveStdioOutputStream::write/WasiStdin::readchecke.kind()and returnStreamError::ClosedforBrokenPipe(matching what wasi-libc already expects), or (b) give p2's CLI stdio the same io::Error-recovery path p1 already has viafilesystem::ErrorCode::from.
alexcrichton commented on issue #14061:
Ah sorry yeah I can expand on my previous message a bit. You're correct that in wasip1 and wasip2 there are vectors by which these precise errors can be communicated. My comment is that for wasip3, the latest iteration of WASI, there is no means by which to communicate precise errors via stdio right now. You can see the current specification here and notably what I'm referring to is this error type definition which can represent
EPIPEbut cannot representEISDIR. I've tested locally for wasip3 and was able to getEPIPEcorrectly with the steps you outlined, butEISDIRshowed up asEIO.For wasip2, however, if you'd like then yeah having a PR to make the error type more precise I think would be fine to add to Wasmtime. I'll reopen for further discussion on that if needed, too.
dicej closed issue #14061:
Test Case
No wasm file needed — reproducible with any wasip2 guest that writes past a closed pipe, or reads stdin redirected from a directory. Minimal repro source: [gist-equivalent below].
// write side use std::io::Write; fn main() { loop { if let Err(e) = std::io::stdout().write_all(b"0123456789\n") { eprintln!("write error: kind={:?} raw_os_error={:?}", e.kind(), e.raw_os_error()); return; } } }// read side use std::io::Read; fn main() { let mut buf = [0u8; 16]; match std::io::stdin().read(&mut buf) { Err(e) => eprintln!("stdin read error: kind={:?} raw_os_error={:?}", e.kind(), e.raw_os_error()), _ => {} } }Steps to Reproduce
- Compile the write-side repro for
wasm32-wasip2, run underwasmtime run pipe.wasm | head -c 30, so the reader closes early.- Compile the read-side repro for
wasm32-wasip2, run underwasmtime run --dir <dir> stdin.wasm < <dir>, redirecting stdin from a directory.- Compare against the same repros compiled for
wasm32-wasip1.Expected Results
wasip1 correctly reports
BrokenPipe/EPIPE(64) for the write case andIsADirectory/EISDIR(31) for the read case — matching native Unix.Actual Results
wasip2 reports
InputOutputError/EIO(29) for both cases. Confirmed onwasmtime45.0.0 and 47.0.3 (latest release, 2026-07-31) — unchanged between versions.Root cause (traced through wasi-libc + wasmtime source):
crates/wasi/src/cli/stdout.rs#StdioOutputStream::write/flushwraps any error fromstd::io::stdout().write_all()asStreamError::LastOperationFailed(anyhow::Error)viawasmtime::format_err!(e), even whene.kind() == BrokenPipe. It never returnsStreamError::Closed.crates/wasi/src/cli/worker_thread_stdin.rs#WasiStdin::readdoes the same forStdinState::Error(e) => Err(StreamError::LastOperationFailed(e.into())), discardinge.kind() == IsADirectory.- Downstream, wasi-libc's
wasip2_handle_write_error/wasip2_handle_read_erroronly distinguishStreamError::ClosedfromLastOperationFailed, mapping the latter to a genericEIO— so once the specific error kind is erased above, it can't be recovered here either.- wasip1 avoids this because
From<StreamError> for types::ErrordowncastsLastOperationFailed's inner error back tostd::io::Errorand runs it throughfilesystem::ErrorCode::from, which has realRustixErrno::PIPE/ISDIRarms. The p2 CLI-stdio path has no equivalent recovery step.This affects every wasip2 guest, not just Rust's — e.g. uutils/coreutils#13625 had to add a userland
wasi_is_broken_pipehelper checking for raw errno 29 as a workaround.Versions and Environment
Wasmtime version or commit: 45.0.0 and 47.0.3 (both exhibit the bug)
Operating system: macOS (Darwin), confirmed reproducible; likely platform-independent since the bug is in
wasmtime's stdio glue rather than any OS-specific pathArchitecture: aarch64 host, wasm32-wasip2 guest
Extra Info
The
wasi:io/streamsspec'sstream-errortype is designed for exactly this: it carries an opaqueerrorresource that other interfaces can "downcast" into more specific info (e.g.wasi:filesystem/types/filesystem-error-code). A fix could either (a) haveStdioOutputStream::write/WasiStdin::readchecke.kind()and returnStreamError::ClosedforBrokenPipe(matching what wasi-libc already expects), or (b) give p2's CLI stdio the same io::Error-recovery path p1 already has viafilesystem::ErrorCode::from.
Last updated: Aug 30 2026 at 09:07 UTC