atuinsh/atuin · error

clone pty reader

Error message

clone pty reader

What it means

In atuin-lab-share, Subshell::into_parts() splits a freshly spawned PTY subshell into reader/writer/resizer. It expects portable-pty's master.try_clone_reader() and master.take_writer() to succeed; the doc comment states a clone failure means the process is out of file descriptors. Duplicating the PTY master fd fails with EMFILE/ENFILE and the expect panics with 'clone pty reader'.

Source

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

    ///
    /// 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 202f6ad98e)

Solutions

  1. Raise the open-file limit before starting atuin: ulimit -n 8192, systemd LimitNOFILE=8192, docker --ulimit nofile=8192
  2. Watch /proc/<pid>/fd counts during lab/share sessions and fix any descriptor leak
  3. Reduce the number of concurrent subshell sessions

Example fix

# before
[Service]
LimitNOFILE=1024

# after
[Service]
LimitNOFILE=8192
Defensive patterns

Strategy: validation

Validate before calling

fn fd_headroom(headroom: usize) -> bool {
    let Ok(limits) = std::fs::read_to_string("/proc/self/limits") else { return true };
    let limit = limits
        .lines()
        .find(|l| l.starts_with("Max open files"))
        .and_then(|l| l.split_whitespace().nth(3))
        .and_then(|v| v.parse::<usize>().ok())
        .unwrap_or(1024);
    let used = std::fs::read_dir("/proc/self/fd").map(|d| d.count()).unwrap_or(0);
    used + headroom < limit
}

assert!(fd_headroom(64), "raise RLIMIT_NOFILE before starting PTY subshells");

Try / catch

let parts = std::panic::catch_unwind(AssertUnwindSafe(subshell.into_parts));
match parts {
    Ok(source) => source,
    Err(_) => { /* terminate the session cleanly; treat fd exhaustion as fatal */ }
}

Prevention

When it happens

Trigger: Calling into_parts() when the process is at its open-file limit (RLIMIT_NOFILE / ulimit -n) or the system file table is full; take_writer can only fail if into_parts is called twice on the same subshell, which the API contract forbids.

Common situations: Long-running hub/share sessions that leak descriptors; containers or systemd units with low LimitNOFILE; many concurrent PTY subshells exhausting the fd budget.

Related errors


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