herdrdev/herdr · error

PTY closed while draining writes before handoff

Error message

PTY closed while draining writes before handoff

What it means

While draining pending writes before handoff, begin_handoff polls the PTY; when the PTY read side becomes ready and read_once() returns false (EOF — the PTY closed), it aborts with ErrorKind::BrokenPipe. Continuing to hand off a closed PTY would lose the fact that the session ended.

Source

Thrown at src/pty/actor/unix.rs:630

            if remaining.is_zero() {
                return Err(std::io::Error::new(
                    std::io::ErrorKind::TimedOut,
                    "timed out draining PTY writes before handoff",
                ));
            }
            let timeout_ms = remaining.as_millis().min(i32::MAX as u128) as i32;
            let readiness = fd::poll_pty_and_wake(
                self.file.as_raw_fd(),
                self.wake_read_fd.as_raw_fd(),
                true,
                true,
                timeout_ms,
            )?;
            if readiness.wake_ready {
                fd::drain_wake_fd(self.wake_read_fd.as_raw_fd())?;
            }
            if readiness.pty_read_ready && !self.read_once() {
                return Err(std::io::Error::new(
                    std::io::ErrorKind::BrokenPipe,
                    "PTY closed while draining writes before handoff",
                ));
            }
            if readiness.pty_write_ready {
                self.flush_pending_writes_once();
            }
        }
        self.state = ActorState::Quiesced;
        Ok(())
    }

    fn drain_pre_quiesce_commands(&mut self) {
        while let Ok(PtyIoDataCommand::WriteUserInput(bytes)) = self.data_rx.try_recv() {
            if self.state != ActorState::Released {
                self.enqueue_write(bytes);
            }
        }

View on GitHub (pinned to f457cff4f2)

Solutions

  1. Handle it as a normal session-end race: complete the handoff path as 'session closed' rather than as an error
  2. Verify the child's exit status to confirm the PTY closed intentionally
  3. Ensure close/exit handling is idempotent so a handoff racing an exit is resolved deterministically
  4. Add a small grace poll or subscribe to child-exit notification before starting handoff

Example fix

// before
let res = actor.begin_handoff();
if res.is_err() { panic!("handoff failed"); }

// after
match actor.begin_handoff() {
    Ok(()) => {}
    Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {
        // PTY closed mid-drain; treat as session end, finalize exit state
        session.mark_pty_closed();
    }
    Err(e) => return Err(e),
}
Defensive patterns

Strategy: try-catch

Validate before calling

if !actor.is_pty_open() { skip handoff and finalize session exit; }

Try / catch

match actor.begin_handoff() {
    Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => { reap_child(); session.mark_closed(); }
    other => other?,
}

Prevention

When it happens

Trigger: The child process exits (or the PTY master is closed elsewhere) while begin_handoff is still flushing pending writes; the poll reports pty_read_ready and read_once returns false, triggering the error.

Common situations: User pastes input and detaches immediately, but the child (e.g. a short-lived command) exits during the drain; races between process exit and detach/handoff.

Related errors


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