atuinsh/atuin · error

clone pty reader

Error message

clone pty reader

What it means

`into_parts` clones the PTY master's reader with `try_clone_reader().expect("clone pty reader")` to build the subshell's event source. Cloning duplicates the underlying file descriptor, and it fails when the process has hit its descriptor limit (EMFILE/ENFILE) or the OS refuses the dup - the code's own doc comment states 'the process is out of file descriptors'.

Solutions

  1. Raise the fd limit: `ulimit -n 65535`, `LimitNOFILE=` in systemd, `--ulimit nofile=` in Docker
  2. Find and fix leaked PTY readers - make sure old sessions' `SourceParts` are dropped
  3. Convert the expect into a `crate::Result` error so callers can report the failure and back off instead of crashing

Example fix

// before
master.try_clone_reader().expect("clone pty reader"),

// after
let reader = master
    .try_clone_reader()
    .map_err(crate::Error::ClonePtyReader)?;
Defensive patterns

Strategy: validation

Validate before calling

// Check fd headroom before spawning another PTY-owning subshell
fn fd_headroom(min_free: usize) -> bool {
    let open = std::fs::read_dir("/proc/self/fd").map(|d| d.count()).unwrap_or(0);
    let hard = 1024; // or read via rlimit crate: rlimit::getrlimit(rlimit::Resource::NOFILE)
    (hard.saturating_sub(open)) >= min_free
}

if !fd_headroom(8) {
    return Err(SessionError::FdExhaustion);
}

Prevention

When it happens

Trigger: Spawning and tearing down many subshells (each PTY consumes several fds) until RLIMIT_NOFILE is exhausted; leaking readers from prior sessions; a low `ulimit -n` in containerized or daemonized environments.

Common situations: Docker/systemd services with a low `LimitNOFILE` default (often 1024); stress tests spawning hundreds of PTYs; long-running sessions that never drop `SourceParts.reader`.

Related errors


AI-assisted analysis of atuinsh/atuin@15fe1318f1 (2026-08-19). Data as JSON: /api/errors/9895a7cdb18a195f. Report an issue: GitHub.

Appendix: source

Thrown at crates/atuin-lab-share/src/subshell.rs:121

    ///
    /// The subshell owns its child outright, so `stop` kills it, and `wait`
    /// applies the exit-code mapping the session has always used: the child's
    /// own code when the wait succeeds (non-`i32` codes clamp to 1), 0 when
    /// it fails. Everything else is the subshell's defaults: no bootstrap (a
    /// fresh shell starts blank), synthetic query answers (the compositor
    /// swallows its output, so nothing else would reply), and hub resizes
    /// applied to the child PTY.
    ///
    /// # Panics
    ///
    /// Panics if the reader cannot be cloned (the process is out of file
    /// descriptors) or the writer was already taken — impossible on a freshly
    /// spawned subshell, which is the only caller.
    fn into_parts(self) -> crate::Result<SourceParts> {
        let (reader, writer) = {
            let master = self.master.lock().expect("master lock");
            (
                master.try_clone_reader().expect("clone pty reader"),
                master.take_writer().expect("take pty writer"),
            )
        };
        let resizer = PtyResizer(self.master);
        // Terminates the child without owning it, so the session can stop the
        // child while `wait` runs on the blocking pool.
        let mut killer = self.child.clone_killer();
        let mut child = self.child;
        Ok(SourceParts {
            reader: Box::new(ByteReader(reader)),
            writer,
            resizer: Box::new(move |size| resizer.resize(size)),
            stop: Box::new(move || {
                // Best-effort, exactly as the session's kill switch always
                // treated it: a failed kill still reaches `wait`'s mapping.
                let _ = killer.kill();
            }),
            wait: Box::new(move || match child.wait() {

View on GitHub (pinned to 15fe1318f1)