herdrdev/herdr · error

poll encountered invalid PTY actor fd

Error message

poll encountered invalid PTY actor fd

What it means

poll_pty_and_wake polls the PTY fd and the actor's wake fd with libc::poll; if either returns POLLNVAL, the fd is invalid (closed or never opened) and the function fails fast with ErrorKind::BrokenPipe instead of spinning on a dead fd. POLLNVAL means the fd does not refer to an open file.

Source

Thrown at src/pty/fd.rs:199

            let err = std::io::Error::last_os_error();
            if err.kind() == std::io::ErrorKind::Interrupted {
                let Some(deadline) = deadline else {
                    continue;
                };
                let remaining = deadline.saturating_duration_since(Instant::now());
                if remaining.is_zero() {
                    return Ok(PtyWakeReadiness::default());
                }
                remaining_timeout_ms = remaining.as_millis().clamp(1, i32::MAX as u128) as i32;
                continue;
            }
            return Err(err);
        }

        let pty_revents = if poll_pty { poll_fds[0].revents } else { 0 };
        let wake_revents = poll_fds[1].revents;
        if (pty_revents | wake_revents) & libc::POLLNVAL != 0 {
            return Err(std::io::Error::new(
                std::io::ErrorKind::BrokenPipe,
                "poll encountered invalid PTY actor fd",
            ));
        }
        if pty_revents & libc::POLLERR != 0 {
            return Err(std::io::Error::new(
                std::io::ErrorKind::BrokenPipe,
                "poll encountered PTY fd error",
            ));
        }

        return Ok(PtyWakeReadiness {
            pty_read_ready: pty_revents & (libc::POLLIN | libc::POLLHUP) != 0,
            pty_write_ready: pty_revents & (libc::POLLOUT | libc::POLLHUP) != 0,
            wake_ready: wake_revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0,
        });
    }
}

View on GitHub (pinned to f457cff4f2)

Solutions

  1. Audit fd ownership: ensure poll_pty_and_wake is never called after the PTY or wake fd is closed (check actor state before polling)
  2. Fix the ordering so the actor loop exits before the fds are closed, or use a guard/flag marking fds invalid
  3. Reproduce under a race detector or with shutdown logging to find who closes the fd early
  4. If using owned fds, switch to a type that clears the RawFd on close so stale fds can't be polled

Example fix

// before
let readiness = fd::poll_pty_and_wake(pty_fd, wake_fd, timeout)?; // may poll closed fd

// after
if actor.is_released() { return Ok(Default::default()); }
let readiness = fd::poll_pty_and_wake(actor.pty_fd()?, actor.wake_fd()?, timeout)?;
Defensive patterns

Strategy: try-catch

Validate before calling

if !actor.has_valid_fds() { return Ok(()); } // skip poll after close

Try / catch

match fd::poll_pty_and_wake(pty, wake, ms) {
    Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => { actor.shutdown_from_fd_error(); Ok(()) }
    other => other,
}

Prevention

When it happens

Trigger: Calling poll_pty_and_wake with a PTY fd or wake fd that was already closed, dup'd incorrectly, or never initialized — e.g. polling after release/close, or using a stale RawFd stored past the fd's lifetime.

Common situations: Use-after-close races in the PTY actor loop (release during shutdown), a child exiting and the master being closed by another thread, or fd lifecycle bugs after handoff/detach rework.

Related errors


AI-assisted analysis of herdrdev/herdr@f457cff4f2 (2026-08-28). Data as JSON: /api/errors/29d4a96c9d7eb541. Report an issue: GitHub.