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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
//! Owned pseudo-terminal session facade.
#[cfg(not(windows))]
use crate::platform::terminal::PtyChild;
use crate::{
platform::terminal::{PtyBackend, PtyMaster, PtySize, PtySlave},
Backend,
};
use std::{
ffi::OsString,
io::{self, Read, Write},
path::PathBuf,
time::Duration,
};
/// Caller-selected process program and arguments for a native PTY session.
#[derive(Debug, Clone)]
pub struct PtyCommand {
pub program: OsString,
pub arguments: Vec<OsString>,
pub cwd: Option<PathBuf>,
pub environment: Option<Vec<(OsString, OsString)>>,
}
impl PtyCommand {
pub fn new(program: impl Into<OsString>) -> Self {
Self {
program: program.into(),
arguments: Vec::new(),
cwd: None,
environment: None,
}
}
}
/// An owned child process and its pseudo-terminal.
pub struct PtySession {
master: <Backend as PtyBackend>::Master,
child: <<Backend as PtyBackend>::Slave as PtySlave>::Child,
writer: Box<dyn Write + Send>,
}
impl PtySession {
pub fn spawn(command: PtyCommand, size: PtySize) -> io::Result<(Self, Box<dyn Read + Send>)> {
let (mut master, slave) = Backend::openpty(size)?;
let reader = master.try_clone_reader()?;
let writer = master.take_writer()?;
let mut argv = Vec::with_capacity(command.arguments.len() + 1);
argv.push(command.program);
argv.extend(command.arguments);
let child = slave.spawn(
&argv,
command.cwd.as_deref(),
command.environment.as_deref(),
)?;
Ok((
Self {
master,
child,
writer,
},
reader,
))
}
pub fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
self.writer
.write_all(bytes)
.and_then(|_| self.writer.flush())
}
/// Write as much of `bytes` as the terminal accepts before `timeout`.
///
/// Returns the number of bytes written; `0` means the input queue stayed
/// full for the whole timeout, so the caller regains control and can watch
/// for something else — a disconnected transport, a shutdown — and stop.
///
/// Prefer this over [`PtySession::write`] wherever the caller has anything
/// to observe. `write` parks in the kernel until the queue drains, which is
/// unbounded once the foreground program stops reading, and that state
/// cannot be interrupted: not by cancelling the thread, and not by ending
/// the process holding the other end of the terminal.
///
/// Backends without a bounded write fall back to the blocking write, so the
/// method is always usable; only the interruption guarantee is Unix-only.
pub fn write_available(&mut self, bytes: &[u8], timeout: Duration) -> io::Result<usize> {
match self.master.write_available(bytes, timeout) {
Ok(written) => Ok(written),
Err(error) if error.kind() == io::ErrorKind::Unsupported => {
self.write(bytes)?;
Ok(bytes.len())
}
Err(error) => Err(error),
}
}
pub fn resize(&self, size: PtySize) -> io::Result<()> {
self.master.resize(size)
}
/// The externally meaningful process identifier for this session's child,
/// suitable for [`crate::platform::terminal::signal_pty_tree`].
///
/// This is informational and control-oriented, not an escape from a blocked
/// write. Signalling the process tree does **not** release a write parked on
/// a full terminal input queue: the kernel keeps that write blocked even
/// after every process holding the slave has been killed, so the parked
/// thread returns only when something finally drains the queue. Escaping
/// that state needs a non-blocking write on the master, not a signal.
pub fn pid(&self) -> Option<u32> {
self.master.preferred_pid(&self.child)
}
pub fn try_wait(&mut self) -> io::Result<Option<u32>> {
self.child.try_wait()
}
}
impl Drop for PtySession {
fn drop(&mut self) {
let _ = self.master.kill_process_group();
let _ = self.child.kill();
let _ = self.child.wait();
// The writer's own drop sends a newline and the terminal's EOF character
// through the master. That write blocks while the input queue is full,
// which hangs teardown for exactly the session that filled the queue —
// the one whose client gave up mid-write and most needs releasing.
// Fields drop after this body, so this is the last moment the master is
// still ours to prepare.
let _ = self.master.prepare_for_teardown();
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::time::{Duration, Instant};
/// Spawn a child that has stopped reading its terminal, and wait until it has.
///
/// The queue only stops accepting input once the child reaches its
/// non-reading phase in raw mode. Before `stty` runs, the line discipline is
/// still cooked, where input is consumed rather than queued and the queue
/// never fills — so filling on a timer races the child's startup and
/// silently leaves room. Waiting for the marker removes the race.
fn spawn_non_reading_session() -> PtySession {
let mut command = PtyCommand::new("/bin/sh");
command.arguments = vec![
"-c".into(),
"stty raw -echo; printf READY; exec sleep 30".into(),
];
let (session, reader) = PtySession::spawn(
command,
PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
},
)
.expect("spawn a child that stops reading its terminal");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut reader = reader;
let mut seen: Vec<u8> = Vec::new();
let mut buffer = [0_u8; 64];
loop {
match reader.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(count) => {
seen.extend_from_slice(&buffer[..count]);
if seen.windows(5).any(|window| window == b"READY") {
break;
}
}
}
}
let _ = tx.send(());
});
rx.recv_timeout(Duration::from_secs(10))
.expect("the child never reported terminal readiness");
session
}
/// Fill the terminal's input queue until it refuses more.
fn fill_terminal_queue(session: &mut PtySession) {
let chunk = vec![b'x'; 4096];
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
match session.write_available(&chunk, Duration::from_millis(200)) {
Ok(0) => return,
Ok(_) => {}
Err(error) => panic!("bounded write while filling: {error}"),
}
}
panic!("the terminal input queue never filled");
}
#[test]
fn session_owns_a_shell_command_and_its_pty_io() {
let mut command = PtyCommand::new("/bin/sh");
command.arguments = vec!["-c".into(), "printf kernal-pty-session".into()];
let (mut session, mut reader) = PtySession::spawn(
command,
PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
},
)
.expect("spawn shell in PTY");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 64];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(count) => bytes.extend_from_slice(&buffer[..count]),
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
// Linux PTYs report EIO when the slave closes normally.
Err(error) if error.raw_os_error() == Some(libc::EIO) => break,
Err(error) => panic!("read PTY output: {error}"),
}
}
assert!(String::from_utf8_lossy(&bytes).contains("kernal-pty-session"));
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if let Some(status) = session.try_wait().expect("reap shell") {
assert_eq!(status, 0);
break;
}
assert!(Instant::now() < deadline, "shell did not exit promptly");
std::thread::sleep(Duration::from_millis(10));
}
}
/// Dropping a session whose input queue is full must return.
///
/// The writer's own drop sends a newline and the terminal's EOF character
/// through the master. That write blocks while the queue is full, so
/// teardown hangs for exactly the session that filled it — the one whose
/// client gave up mid-write and most needs releasing. The drop runs on its
/// own thread so a regression fails the test instead of hanging it.
#[cfg(unix)]
#[test]
fn teardown_does_not_hang_on_a_full_input_queue() {
let mut session = spawn_non_reading_session();
fill_terminal_queue(&mut session);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
drop(session);
let _ = tx.send(());
});
assert!(
rx.recv_timeout(Duration::from_secs(5)).is_ok(),
"dropping a session with a full input queue did not return"
);
}
/// A bounded write gives up rather than parking, which is the whole reason
/// it exists: it is the only way a caller with something else to watch ever
/// regains control from a terminal nobody is reading.
#[cfg(unix)]
#[test]
fn bounded_write_returns_instead_of_parking_on_a_full_queue() {
let mut session = spawn_non_reading_session();
// The queue is full once a write is refused, which is the state under
// test. A blocking write would park here forever; this one returns.
fill_terminal_queue(&mut session);
let chunk = vec![b'x'; 4096];
let started = Instant::now();
let written = session
.write_available(&chunk, Duration::from_millis(200))
.expect("bounded write against a full queue");
let elapsed = started.elapsed();
assert_eq!(written, 0, "a full queue should accept nothing");
assert!(
elapsed < Duration::from_secs(2),
"bounded write did not respect its timeout: {elapsed:?}"
);
}
/// Signalling the reported pid must end the child, not merely identify it.
///
/// This covers what the pid accessor actually promises. It deliberately does
/// not cover releasing a parked write: killing the tree does not do that,
/// and asserting otherwise would encode a false guarantee.
#[cfg(unix)]
#[test]
fn reported_pid_terminates_the_session() {
let mut command = PtyCommand::new("/bin/sh");
command.arguments = vec!["-c".into(), "sleep 30".into()];
let (mut session, _reader) = PtySession::spawn(
command,
PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
},
)
.expect("spawn a long-lived child in a PTY");
let pid = session
.pid()
.expect("a spawned session reports its child pid");
assert!(pid > 0, "pid must be a real identifier, got {pid}");
assert!(
session.try_wait().expect("poll child").is_none(),
"the child should still be running before it is signalled"
);
crate::platform::terminal::signal_pty_tree(pid, true).expect("signal the session tree");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if session.try_wait().expect("reap signalled child").is_some() {
break;
}
assert!(
Instant::now() < deadline,
"signalling the reported pid did not end the session"
);
std::thread::sleep(Duration::from_millis(20));
}
}
}