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
- Handle it as a normal session-end race: complete the handoff path as 'session closed' rather than as an error
- Verify the child's exit status to confirm the PTY closed intentionally
- Ensure close/exit handling is idempotent so a handoff racing an exit is resolved deterministically
- 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
- Check child liveness before starting handoff
- Make exit handling idempotent to tolerate exit/handoff races
- Propagate child exit status instead of retrying handoff
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
- PTY actor was released before handoff quiesce
- timed out draining PTY writes before handoff
- poll encountered PTY fd error
- poll encountered invalid PTY actor fd
- app response channel closed
AI-assisted analysis of herdrdev/herdr@f457cff4f2 (2026-08-28).
Data as JSON: /api/errors/2a8a3a4cc1993f45.
Report an issue: GitHub.