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
- Raise the fd limit: `ulimit -n 65535`, `LimitNOFILE=` in systemd, `--ulimit nofile=` in Docker
- Find and fix leaked PTY readers - make sure old sessions' `SourceParts` are dropped
- 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
- Raise RLIMIT_NOFILE (ulimit -n, LimitNOFILE=, --ulimit nofile=) in service and container environments
- Drop PTY readers/writers promptly when sessions end; audit for leaks with ls /proc/self/fd
- Keep a small fd budget check before spawning subshells in long-running processes
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
- master lock
- take pty writer
- creating threadpool failed
- exceeded maximum allocation size
- exceeded maximum length
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)