'Why does reading from an exited PTY process return "Input/output error" in Rust?

I'm attempting to read from a process that's backed by a PTY in Rust, but once all bytes have been read from the process then reading from the process returns an Input/output error instead of the expected EOF. Is there an obvious reason for this behaviour, and how might it be resolved so that read returns Ok(0) instead of an error, as per the contract for read?

Here is a minimal working example:

use std::io;
use std::io::Read;
use std::io::Write;
use std::fs::File;
use std::os::unix::io::FromRawFd;
use std::process::Command;
use std::process::Stdio;

extern crate nix;

use crate::nix::pty;
use crate::nix::pty::OpenptyResult;

fn main() {
    let OpenptyResult{master: controller_fd, slave: follower_fd} =
        pty::openpty(None, None)
            .expect("couldn't open a new PTY");

    let new_follower_stdio = || unsafe { Stdio::from_raw_fd(follower_fd) };

    let mut child =
        Command::new("ls")
            .stdin(new_follower_stdio())
            .stdout(new_follower_stdio())
            .stderr(new_follower_stdio())
            .spawn()
            .expect("couldn't spawn the new PTY process");

    {
        let mut f = unsafe { File::from_raw_fd(controller_fd) };

        let mut buf = [0; 0x100];
        loop {
            let n = f.read(&mut buf[..])
                .expect("couldn't read");

            if n == 0 {
                break;
            }

            io::stdout().write_all(&buf[..n])
                .expect("couldn't write to STDOUT");
        }
    }

    child.kill()
        .expect("couldn't kill the PTY process");

    child.wait()
        .expect("couldn't wait for the PTY process");
}

This gives the following output:

Cargo.lock  Cargo.toml  build.Dockerfile  scripts  src  target
thread 'main' panicked at 'couldn't read: Os { code: 5, kind: Uncategorized, message: "Input/output error" }', src/main.rs:35:18
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

I've also tried using nix::unistd::dup to duplicate the follower_fd for stdin, stdout and stderr, but this didn't change the observed behaviour.

For reference, I'm using Rust 1.60.0 with the following Cargo.toml for this MWE:

[package]
name = "mwe"
version = "0.0.0"

[dependencies]
nix = "=0.24.1"


Solution 1:[1]

It seems that this error is expected behaviour for PTYs on Linux, and essentially signals EOF. This information is supported by a number of non-authoritative sources, but a good summary is provided by mosvy on the Unix StackExchange:

On Linux, a read() on the master side of a pseudo-tty will return -1 and set ERRNO to EIO when all the handles to its slave side have been closed, but will either block or return EAGAIN before the slave has been first opened.

I don't know if there's any standard spec or rationale for this, but it allows to (crudely) detect when the other side was closed, and simplifies the logic of programs like script which are just creating a pty and running another program inside it.

It is presumed that the EIO described here corresponds to the "Input/output error" returned above.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Sean Kelleher