herdrdev/herdr · error

PTY actor was released before handoff quiesce

Error message

PTY actor was released before handoff quiesce

What it means

begin_handoff drains pending control commands and applies pending controls before quiescing the PTY for handoff; if the actor's state is already ActorState::Released at that point, it refuses to continue with ErrorKind::BrokenPipe. This indicates the PTY was released (peer gone/closed) before the handoff could start. It prevents handing off a dead actor.

Source

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

                };
                let _ = reply.send(result);
            }
            PtyIoControlCommand::ReleaseAfterCommit(reply) => {
                self.state = ActorState::Released;
                self.pending_writes.clear();
                let _ = reply.send(Ok(()));
                return true;
            }
            PtyIoControlCommand::Shutdown => return true,
        }
        false
    }

    fn begin_handoff(&mut self) -> std::io::Result<()> {
        self.drain_pre_quiesce_commands();
        self.apply_pending_controls();
        if self.state == ActorState::Released {
            return Err(std::io::Error::new(
                std::io::ErrorKind::BrokenPipe,
                "PTY actor was released before handoff quiesce",
            ));
        }
        let deadline = Instant::now() + HANDOFF_DRAIN_TIMEOUT;
        self.flush_pending_writes_once();
        while !self.pending_writes.is_empty() {
            let remaining = deadline.saturating_duration_since(Instant::now());
            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(),

View on GitHub (pinned to f457cff4f2)

Solutions

  1. Treat this as a benign terminal-close race: check whether the pane/process already exited and handle Released as 'nothing to hand off'
  2. Ensure handoff is initiated before the actor receives a Release control command (ordering of detach vs close)
  3. Reproduce with logs of control-command ordering to find who sent Release first, and fix the caller sequencing
  4. If the PTY legitimately died, propagate shutdown instead of retrying handoff

Example fix

// before
match actor.begin_handoff() {
    Ok(()) => {},
    Err(e) => return Err(e), // crashes detach flow on released actor
}

// after
match actor.begin_handoff() {
    Ok(()) => {},
    Err(e) if e.kind() == io::ErrorKind::BrokenPipe && actor.state() == ActorState::Released => {
        // PTY already released; nothing to hand off
    }
    Err(e) => return Err(e),
}
Defensive patterns

Strategy: try-catch

Validate before calling

if actor.state() == ActorState::Released { /* skip handoff; session already closed */ }

Try / catch

match actor.begin_handoff() {
    Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe && actor.state() == ActorState::Released => { /* benign race; finalize as closed */ }
    other => other?,
}

Prevention

When it happens

Trigger: Calling begin_handoff (via handle_control_command or the user-write drain path) after the actor has already transitioned to ActorState::Released, e.g. the PTY peer closed or a prior Release command was processed in the pre-quiesce drain.

Common situations: Detaching or migrating a session where the child process exited first, a race between pane close and detach/handoff, or replayed control commands where a release was already queued.

Related errors


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