Skip to content

Commit 4735b26

Browse files
authored
[nextest-runner] restore terminal state during SIGTSTP (#2025)
Ensure that canonical state is restored while nextest is stopped. Noticed this cause an issue on macOS.
1 parent 61a61c2 commit 4735b26

File tree

2 files changed

+108
-28
lines changed

2 files changed

+108
-28
lines changed

nextest-runner/src/input.rs

Lines changed: 103 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,45 @@ impl InputHandler {
115115
}
116116
}
117117
}
118+
119+
/// Suspends the input handler temporarily, restoring the original terminal
120+
/// state.
121+
///
122+
/// Used by the stop signal handler.
123+
#[cfg(unix)]
124+
pub(crate) fn suspend(&mut self) {
125+
let Some((handler, _)) = self.imp.as_mut() else {
126+
return;
127+
};
128+
129+
if let Err(error) = handler.restore() {
130+
warn!("failed to suspend terminal non-canonical mode: {}", error);
131+
// Don't set imp to None -- we want to try to reinit() on resume.
132+
}
133+
}
134+
135+
/// Resumes the input handler after a suspension.
136+
///
137+
/// Used by the continue signal handler.
138+
#[cfg(unix)]
139+
pub(crate) fn resume(&mut self) {
140+
let Some((handler, _)) = self.imp.as_mut() else {
141+
// None means that the input handler is disabled, so there is
142+
// nothing to resume.
143+
return;
144+
};
145+
146+
if let Err(error) = handler.reinit() {
147+
warn!(
148+
"failed to resume terminal non-canonical mode, \
149+
cannot read input events: {}",
150+
error
151+
);
152+
// Do set self.imp to None in this case -- we want to indicate to
153+
// callers (e.g. via status()) that the input handler is disabled.
154+
self.imp = None;
155+
}
156+
}
118157
}
119158

120159
/// The status of the input handler, returned by
@@ -151,7 +190,7 @@ impl InputHandlerImpl {
151190
let panic_hook = std::panic::take_hook();
152191
std::panic::set_hook(Box::new(move |info| {
153192
// Ignore errors to avoid double-panicking.
154-
if let Err(error) = ret2.finish() {
193+
if let Err(error) = ret2.restore() {
155194
eprintln!(
156195
"failed to restore terminal state: {}",
157196
DisplayErrorChain::new(error)
@@ -163,22 +202,44 @@ impl InputHandlerImpl {
163202
Ok(ret)
164203
}
165204

166-
fn finish(&self) -> Result<(), InputHandlerFinishError> {
205+
#[cfg(unix)]
206+
fn reinit(&self) -> Result<(), InputHandlerCreateError> {
207+
// Make a new input guard and replace the old one. Don't set a new panic
208+
// hook.
209+
//
210+
// The mutex is shared by the panic hook and self/the drop handler, so
211+
// the change below will also be visible to the panic hook. But we
212+
// acquire the mutex first to avoid a potential race where multiple
213+
// calls to reinit() can happen concurrently.
214+
//
215+
// Also note that if this fails, the old InputGuard will be visible to
216+
// the panic hook, which is fine -- since we called restore() first, the
217+
// terminal state is already restored and guard is None.
218+
let mut locked = self
219+
.guard
220+
.lock()
221+
.map_err(|_| InputHandlerCreateError::Poisoned)?;
222+
let guard = imp::InputGuard::new().map_err(InputHandlerCreateError::EnableNonCanonical)?;
223+
*locked = guard;
224+
Ok(())
225+
}
226+
227+
fn restore(&self) -> Result<(), InputHandlerFinishError> {
167228
// Do not panic here, in case a panic happened while the thread was
168229
// locked. Instead, ignore the error.
169230
let mut locked = self
170231
.guard
171232
.lock()
172233
.map_err(|_| InputHandlerFinishError::Poisoned)?;
173-
locked.finish().map_err(InputHandlerFinishError::Restore)
234+
locked.restore().map_err(InputHandlerFinishError::Restore)
174235
}
175236
}
176237

177238
// Defense in depth -- use both the Drop impl (for regular drops and
178239
// panic=unwind) and a panic hook (for panic=abort).
179240
impl Drop for InputHandlerImpl {
180241
fn drop(&mut self) {
181-
if let Err(error) = self.finish() {
242+
if let Err(error) = self.restore() {
182243
eprintln!(
183244
"failed to restore terminal state: {}",
184245
DisplayErrorChain::new(error)
@@ -191,6 +252,10 @@ impl Drop for InputHandlerImpl {
191252
enum InputHandlerCreateError {
192253
#[error("failed to enable terminal non-canonical mode")]
193254
EnableNonCanonical(#[source] imp::Error),
255+
256+
#[cfg(unix)]
257+
#[error("mutex was poisoned while reinitializing terminal state")]
258+
Poisoned,
194259
}
195260

196261
#[derive(Debug, Error)]
@@ -228,35 +293,15 @@ mod imp {
228293

229294
impl InputGuard {
230295
pub(super) fn new() -> io::Result<Self> {
231-
let mut termios = mem::MaybeUninit::uninit();
232-
let res = unsafe { tcgetattr(std::io::stdin().as_raw_fd(), termios.as_mut_ptr()) };
233-
if res == -1 {
234-
return Err(io::Error::last_os_error());
235-
}
236-
237-
// SAFETY: if res is 0, then termios has been initialized.
238-
let original = unsafe { termios.assume_init() };
239-
240-
let mut updated = original;
241-
242-
// Disable echoing inputs and canonical mode. We don't disable things like ISIG -- we
243-
// handle that via the signal handler.
244-
updated.c_lflag &= !(ECHO | ICANON);
245-
// VMIN is 1 and VTIME is 0: this enables blocking reads of 1 byte
246-
// at a time with no timeout. See
247-
// https://linux.die.net/man/3/tcgetattr's "Canonical and
248-
// noncanonical mode" section.
249-
updated.c_cc[VMIN] = 1;
250-
updated.c_cc[VTIME] = 0;
251-
296+
let TermiosPair { original, updated } = compute_termios()?;
252297
stdin_tcsetattr(TCSAFLUSH, &updated)?;
253298

254299
Ok(Self {
255300
original: Some(original),
256301
})
257302
}
258303

259-
pub(super) fn finish(&mut self) -> io::Result<()> {
304+
pub(super) fn restore(&mut self) -> io::Result<()> {
260305
if let Some(original) = self.original.take() {
261306
stdin_tcsetattr(TCSANOW, &original)
262307
} else {
@@ -265,6 +310,37 @@ mod imp {
265310
}
266311
}
267312

313+
fn compute_termios() -> io::Result<TermiosPair> {
314+
let mut termios = mem::MaybeUninit::uninit();
315+
let res = unsafe { tcgetattr(std::io::stdin().as_raw_fd(), termios.as_mut_ptr()) };
316+
if res == -1 {
317+
return Err(io::Error::last_os_error());
318+
}
319+
320+
// SAFETY: if res is 0, then termios has been initialized.
321+
let original = unsafe { termios.assume_init() };
322+
323+
let mut updated = original;
324+
325+
// Disable echoing inputs and canonical mode. We don't disable things like ISIG -- we
326+
// handle that via the signal handler.
327+
updated.c_lflag &= !(ECHO | ICANON);
328+
// VMIN is 1 and VTIME is 0: this enables blocking reads of 1 byte
329+
// at a time with no timeout. See
330+
// https://linux.die.net/man/3/tcgetattr's "Canonical and
331+
// noncanonical mode" section.
332+
updated.c_cc[VMIN] = 1;
333+
updated.c_cc[VTIME] = 0;
334+
335+
Ok(TermiosPair { original, updated })
336+
}
337+
338+
#[derive(Clone, Debug)]
339+
struct TermiosPair {
340+
original: libc::termios,
341+
updated: libc::termios,
342+
}
343+
268344
fn stdin_tcsetattr(optional_actions: c_int, updated: &libc::termios) -> io::Result<()> {
269345
let res = unsafe { tcsetattr(std::io::stdin().as_raw_fd(), optional_actions, updated) };
270346
if res == -1 {
@@ -321,7 +397,7 @@ mod imp {
321397
})
322398
}
323399

324-
pub(super) fn finish(&mut self) -> io::Result<()> {
400+
pub(super) fn restore(&mut self) -> io::Result<()> {
325401
if let Some(original) = self.original.take() {
326402
let handle = std::io::stdin().as_raw_handle();
327403
let res = unsafe { SetConsoleMode(handle, original) };

nextest-runner/src/runner/dispatcher.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,16 @@ where
198198
};
199199
}
200200

201+
// Restore the terminal state.
202+
input_handler.suspend();
203+
201204
// Now stop nextest itself.
202205
super::os::raise_stop();
203206
}
204207
#[cfg(unix)]
205208
HandleEventResponse::JobControl(JobControlEvent::Continue) => {
206-
// Nextest has been resumed. Resume all the tests as well.
209+
// Nextest has been resumed. Resume the input handler, as well as all the tests.
210+
input_handler.resume();
207211
self.broadcast_request(RunUnitRequest::Signal(SignalRequest::Continue));
208212
}
209213
#[cfg(not(unix))]

0 commit comments

Comments
 (0)