1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
//! `tm doctor` pseudo-terminal headroom probe (#6529).
//!
//! Why: every tmux pane holds one pseudo-terminal, and macOS caps the total at
//! `kern.tty.ptmx_max` — 511 by default. When the orphan-GC stopped reaping,
//! 456 leaked `tm-*` sessions pushed the host past that cap, and the next
//! session spawn failed with a bare `ENXIO` that names neither the limit nor
//! the leak. Nothing reported the pressure before it became a hard failure.
//!
//! What: [`check_pty_headroom`] folds a pty census into a [`DoctorCheck`] via
//! the pure [`build_pty_headroom_check`]. Only the two platform reads are
//! target-gated, behind [`read_pty_census`], which yields `None` off Darwin
//! because `/dev/ttys*` is a Darwin device layout; the check then reports `Ok`
//! with a skip reason. The threshold classification and its constants sit on
//! the non-gated path, so they compile and are tested on every target.
//! Read-only: it counts device nodes and reaps nothing itself.
//! Test: the `tests` module below.
use crate::core::doctor::{CheckStatus, DoctorCheck};
/// Stable check name.
pub(super) const CHECK_NAME: &str = "pty_headroom";
/// Fraction of the cap at which the probe warns.
const WARN_AT: f64 = 0.80;
/// Fraction of the cap at which the probe fails.
const FAIL_AT: f64 = 0.95;
/// Decide the check from an already-gathered count and cap.
///
/// Why: pure, so every threshold branch is testable without a Darwin host, a
/// `sysctl` binary, or a `/dev` walk.
/// What: `Unknown` when either number could not be read — a probe that learned
/// nothing must not read healthy. Otherwise `Fail` at or above [`FAIL_AT`] of
/// the cap, `Warn` at or above [`WARN_AT`], else `Ok`. Every non-`Ok` message
/// names both numbers and the command that reaps the leaked sessions holding
/// them, because the count alone does not tell an operator what to do.
/// Test: `pty_headroom_ok_below_the_warn_line`, `pty_headroom_warns_at_80pct`,
/// `pty_headroom_fails_at_95pct`, `pty_headroom_is_unknown_without_numbers`.
fn build_pty_headroom_check(allocated: Option<usize>, max: Option<usize>) -> DoctorCheck {
let (Some(allocated), Some(max)) = (allocated, max) else {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Unknown,
"could not read both `sysctl kern.tty.ptmx_max` and the /dev/ttys* \
inventory — pseudo-terminal headroom undetermined (#6529)",
);
};
if max == 0 {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Unknown,
"`kern.tty.ptmx_max` reported 0 — pseudo-terminal headroom undetermined (#6529)",
);
}
let used = allocated as f64 / max as f64;
let pct = (used * 100.0).round() as u64;
let detail = format!(
"{allocated} of {max} pseudo-terminals allocated ({pct}%); every tmux pane \
holds one, so a leaked session holds one until it is reaped — \
`tm doctor --fix` does not reap sessions; `tm session prune` and \
the daemon's orphan-GC do (#6529)"
);
if used >= FAIL_AT {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Fail,
format!("{detail} — a new session spawn will fail with ENXIO at this level"),
);
}
if used >= WARN_AT {
return DoctorCheck::new(CHECK_NAME, CheckStatus::Warn, detail);
}
DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
format!("{allocated} of {max} pseudo-terminals allocated ({pct}%)"),
)
}
/// Read `kern.tty.ptmx_max`.
///
/// Why: the cap is a runtime sysctl, not a constant — an operator can raise it,
/// and a probe that assumed 511 would misreport a host that did.
/// What: `sysctl -n kern.tty.ptmx_max`, parsed as a count. `None` on any
/// failure, which the caller reports as `Unknown` rather than a default.
/// Test: covered through `check_pty_headroom` on a Darwin host.
#[cfg(target_os = "macos")]
fn read_ptmx_max() -> Option<usize> {
let out = std::process::Command::new("sysctl")
.args(["-n", "kern.tty.ptmx_max"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
/// Count the allocated pseudo-terminal slaves.
///
/// Why: macOS creates `/dev/ttysNNN` lazily, one per allocated pty, so the node
/// count IS the in-use count. There is no sysctl reporting it.
/// What: counts `/dev` entries whose name starts with `ttys`. `None` when
/// `/dev` cannot be read.
/// Test: covered through `check_pty_headroom` on a Darwin host.
#[cfg(target_os = "macos")]
fn count_allocated_ptys() -> Option<usize> {
let entries = std::fs::read_dir("/dev").ok()?;
Some(
entries
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("ttys"))
})
.count(),
)
}
/// Gather the pty census on hosts that have one.
///
/// Why: #6529 — this is the file's ONLY target-gated decision. Confining the
/// platform split to the reads keeps [`build_pty_headroom_check`] and its
/// thresholds on the non-gated path, so they are neither dead code nor
/// untested off Darwin.
/// What: `Some((allocated, cap))` on macOS, each element still `None` when that
/// individual read failed; `None` where the `/dev/ttys*` inventory does not
/// exist as a concept.
/// Test: `pty_headroom_skips_cleanly_off_darwin`.
#[cfg(target_os = "macos")]
fn read_pty_census() -> Option<(Option<usize>, Option<usize>)> {
Some((count_allocated_ptys(), read_ptmx_max()))
}
/// No pty census off Darwin — see the macOS variant above.
#[cfg(not(target_os = "macos"))]
fn read_pty_census() -> Option<(Option<usize>, Option<usize>)> {
None
}
/// Report how much pseudo-terminal capacity the host has left (#6529).
///
/// Why: see the module doc — pty exhaustion presents as an unexplained `ENXIO`
/// on the next session spawn, long after the leak that caused it.
/// What: renders whatever [`read_pty_census`] gathered with
/// [`build_pty_headroom_check`]. A host with no census reports `Ok` naming the
/// skip, because a check that guessed on Linux would report a fiction.
/// Test: `pty_headroom_skips_cleanly_off_darwin` and the pure-verdict tests.
pub(super) fn check_pty_headroom() -> DoctorCheck {
let Some((allocated, max)) = read_pty_census() else {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
"pseudo-terminal headroom is a macOS-specific probe (`kern.tty.ptmx_max` \
against the /dev/ttys* inventory) — skipped on this platform",
);
};
build_pty_headroom_check(allocated, max)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pty_headroom_ok_below_the_warn_line() {
let check = build_pty_headroom_check(Some(100), Some(511));
assert_eq!(check.status, CheckStatus::Ok);
assert!(check.message.contains("100 of 511"), "{}", check.message);
}
#[test]
fn pty_headroom_warns_at_80pct() {
let check = build_pty_headroom_check(Some(409), Some(511));
assert_eq!(check.status, CheckStatus::Warn, "{}", check.message);
assert!(check.message.contains("409 of 511"), "{}", check.message);
assert!(
check.message.contains("orphan-GC"),
"the message must name what reaps the sessions holding them: {}",
check.message
);
}
#[test]
fn pty_headroom_fails_at_95pct() {
// The state this probe was written for: 527 allocated against a cap of
// 511, from 456 leaked tmux sessions the orphan-GC never reaped.
let check = build_pty_headroom_check(Some(527), Some(511));
assert_eq!(check.status, CheckStatus::Fail, "{}", check.message);
assert!(check.message.contains("ENXIO"), "{}", check.message);
}
#[test]
fn pty_headroom_is_unknown_without_numbers() {
assert_eq!(
build_pty_headroom_check(None, Some(511)).status,
CheckStatus::Unknown
);
assert_eq!(
build_pty_headroom_check(Some(10), None).status,
CheckStatus::Unknown
);
// A zero cap is not a division to attempt.
assert_eq!(
build_pty_headroom_check(Some(10), Some(0)).status,
CheckStatus::Unknown
);
}
/// The probe always returns a named check, on every platform.
#[test]
fn pty_headroom_skips_cleanly_off_darwin() {
let check = check_pty_headroom();
assert_eq!(check.name, CHECK_NAME);
#[cfg(not(target_os = "macos"))]
assert_eq!(check.status, CheckStatus::Ok);
}
}