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
- Raise the open-file limit before starting atuin: ulimit -n 8192, systemd LimitNOFILE=8192, docker --ulimit nofile=8192
- Watch /proc/<pid>/fd counts during lab/share sessions and fix any descriptor leak
- 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
- Set generous NOFILE limits for any process hosting PTY sessions
- Call into_parts() exactly once per subshell
- Monitor /proc/<pid>/fd growth in long-lived daemons
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
- creating threadpool failed
- frame exceeds maximum length
- eof in the middle of a frame
- bug in list query. please report
- bug in search query. please report
AI-assisted analysis of atuinsh/atuin@202f6ad98e (2026-08-16).
Data as JSON: /api/errors/9895a7cdb18a195f.
Report an issue: GitHub.