How to Fix: fd_write writes only one buffer

5 min read

WASI fd_write is a vectored I/O call, not a single-buffer write. If your WebAssembly binary writes two strings and only the first line appears, the runtime is almost certainly consuming only the first iovec instead of the full buffer list passed to fd_write.

Symptoms

This bug typically shows up when a program calls fd_write with multiple buffers, such as two separate lines for stdout or stderr. The expected behavior is that all buffers are written in order, and the returned byte count reflects the combined number of bytes successfully written.

With the broken implementation, only the first buffer is emitted. In practice that means:

  • The first line appears.
  • The second line never reaches the output stream.
  • The reported byte count may be wrong if it reflects only the first slice.

In Wasmtime or another WASI host, this is a strong sign that the fd_write adapter is not honoring vectored writes.

Understanding the Root Cause

The WASI Preview 1 API defines fd_write to accept an array of ciovec entries. Each entry points to a separate memory region inside the guest module. The host must treat those entries as a single logical write request.

Technically, the bug happens when the runtime does one of the following:

  • Reads only iovs[0] and ignores the remaining buffers.
  • Converts the vectored write into a scalar write without concatenating or iterating through every slice.
  • Calls a host write function once and never handles partial writes across multiple buffers.

That violates the expected fd_write behavior. A correct implementation must either:

  • Pass the entire slice array to a host write_vectored-style API, or
  • Loop over every iovec, writing each buffer in sequence and tracking the total number of bytes written.

The key detail is that the byte count returned by fd_write is cumulative across all buffers, not just the first one. If the host stops early, guest programs that rely on multiple buffers for a single logical write will produce truncated output.

Step-by-Step Solution

The fix is to update the WASI host implementation so fd_write consumes the full iovec array and correctly handles partial progress.

1. Read every ciovec from guest memory

struct Ciovec {
    buf: u32,
    buf_len: u32,
}

let iovs: Vec<Ciovec> = read_ciovecs_from_guest(memory, iovs_ptr, iovs_len)?;

2. Convert them into host-side byte slices

let slices: Vec<&[u8]> = iovs
    .iter()
    .map(|iov| read_guest_slice(memory, iov.buf, iov.buf_len))
    .collect::<Result<_, _>>()?;

3. Write all buffers, not just the first one

If your host type supports vectored I/O, prefer that path:

use std::io::{self, IoSlice, Write};

let ioslices: Vec<IoSlice<'_>> = slices.iter().map(|s| IoSlice::new(s)).collect();
let nwritten = writer.write_vectored(&ioslices)?;

If vectored I/O is not available or you need stricter control, loop manually and account for partial writes:

use std::io::{self, Write};

fn write_all_iovecs<W: Write>(writer: &mut W, slices: Vec<&[u8]>) -> io::Result<u32> {
    let mut total: usize = 0;

    for mut slice in slices {
        while !slice.is_empty() {
            let n = writer.write(slice)?;
            if n == 0 {
                return Err(io::Error::new(io::ErrorKind::WriteZero, "short write in fd_write"));
            }
            total += n;
            slice = &slice[n..];
        }
    }

    u32::try_from(total)
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "byte count overflow"))
}

4. Store the total byte count back into guest memory

write_u32_to_guest(memory, nwritten_ptr, nwritten)?;

5. Return the proper WASI error code

Ok(types::Errno::Success)

For many Wasmtime-style implementations, the practical patch is replacing code that effectively behaves like this:

let first = read_guest_slice(memory, iovs[0].buf, iovs[0].buf_len)?;
let nwritten = writer.write(first)?;

with code that processes all iovecs and returns the aggregate number of bytes written.

Recommended validation

  1. Run the original repro binary that prints two lines from separate buffers.
  2. Confirm both lines appear in stdout or stderr.
  3. Add a regression test that passes at least two ciovecs in one fd_write call.
  4. Verify that the reported nwritten equals the total bytes across both buffers.

A minimal regression-style test looks like this:

#[test]
fn fd_write_writes_all_iovecs() {
    let bufs = vec![b"first line\n".as_slice(), b"second line\n".as_slice()];
    let mut output = Vec::new();

    let nwritten = write_all_iovecs(&mut output, bufs).unwrap();

    assert_eq!(&output, b"first line\nsecond line\n");
    assert_eq!(nwritten as usize, output.len());
}

Common Edge Cases

Even after fixing the main bug, several related cases can still break fd_write if they are not handled carefully.

  • Partial writes: Host writers are allowed to write fewer bytes than requested. If you call write once per buffer and assume completion, output can still be truncated.
  • Empty buffers: Some ciovecs may have buf_len == 0. These should not fail the call and should simply contribute zero bytes.
  • Invalid guest memory: A bad pointer or length must return the correct WASI error rather than reading arbitrary memory.
  • Overflow in nwritten: The total written byte count must fit the ABI type written back to guest memory.
  • Non-blocking descriptors: If the target descriptor is non-blocking, the implementation must preserve the correct host error semantics instead of pretending the full write succeeded.
  • stdout vs stderr handling: If the host wraps these streams differently, make sure both paths honor multiple iovecs consistently.

FAQ

Why does this bug usually show up as “only one line was printed”?

Many programs build output using multiple buffers, for example one buffer for a prefix and another for the message body or newline. If fd_write consumes only the first iovec, the first chunk appears and the rest is silently lost.

Should fd_write always use write_vectored?

No. write_vectored is the cleanest mapping when available, but a manual loop is also correct as long as it processes every buffer, handles partial writes, and returns the total byte count accurately.

How do I know the fix is correct?

The fix is correct when a single fd_write call with multiple ciovecs writes all buffers in order, the guest-visible nwritten equals the total bytes emitted, and a regression test reproducing the original issue passes reliably.

For the original Wasmtime issue, the real takeaway is simple: fd_write is a vectored contract. Any implementation that treats it like a single-slice write will truncate guest output and break valid WASI binaries.

Leave a Reply

Your email address will not be published. Required fields are marked *