T04: own Unix pty so a failed chdir fails the start; closing logs and reaps
This commit is contained in:
8 files changed
+423
-33
No files matched your search
@@ -19,7 +19,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `src/main.rs`: start, window. `src/lib.rs`: the modules, public so tests reach them.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY.
|
||||
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||
|
||||
## Rules
|
||||
@@ -37,4 +37,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-14: the working directory comes from OSC 7, read by a tap on the pty reader, sent by injected shell integration. vte drops OSC 7.
|
||||
- 2026-10-14: Git through the `git` command line, not libgit2.
|
||||
- 2026-10-14: license MIT, as Ely.
|
||||
- 2026-10-14: on Unix the pty is ELYTerminal's own: alacritty 0.26 ignores `chdir`'s result, so a session could start in the wrong folder. Its traits feed alacritty's event loop, keyed 0 and 1 as alacritty's private constants are; alacritty is pinned to =0.26.0 for that.
|
||||
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
||||
Generated
+3
-1
@@ -1757,12 +1757,14 @@ dependencies = [
|
||||
"gethostname",
|
||||
"gpui-pre",
|
||||
"gpui-pre-platform",
|
||||
"libc",
|
||||
"log",
|
||||
"polling",
|
||||
"rustix",
|
||||
"rustix-openpty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+4
-2
@@ -9,7 +9,7 @@ repository = "https://git.zacharyzhang.com/ZacharyZhang-NY/ELYTerminal"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
alacritty_terminal = { version = "0.26", default-features = false }
|
||||
alacritty_terminal = { version = "=0.26.0", default-features = false }
|
||||
anyhow = "1"
|
||||
dirs = "7"
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "12512217bd9bc652ed6bbf10461b00cc26c01c21" }
|
||||
@@ -25,7 +25,9 @@ serde_json = "1"
|
||||
serde_path_to_error = "0.1.20"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1", default-features = false, features = ["std", "fs"] }
|
||||
libc = "0.2"
|
||||
rustix-openpty = "0.2"
|
||||
signal-hook = "0.4"
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod settings;
|
||||
pub mod term;
|
||||
#[cfg(test)]
|
||||
mod testlog;
|
||||
pub mod themes;
|
||||
@@ -1,4 +1,6 @@
|
||||
mod pty;
|
||||
mod tap;
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
|
||||
pub use pty::{Launch, Listener, Pty, PtyEvent, fit, spawn};
|
||||
+82
-29
@@ -1,11 +1,12 @@
|
||||
use std::{borrow::Cow, collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
#[cfg(windows)]
|
||||
use alacritty_terminal::tty;
|
||||
use alacritty_terminal::{
|
||||
event::{Event, EventListener, Notify, OnResize, WindowSize},
|
||||
event_loop::{EventLoop, Msg, Notifier},
|
||||
sync::FairMutex,
|
||||
term::{Config, Term, test::TermSize},
|
||||
tty::{self, Options, Shell},
|
||||
};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||
|
||||
@@ -53,6 +54,11 @@ pub struct Pty {
|
||||
notifier: Notifier,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
type Raw = super::unix::Pty;
|
||||
#[cfg(windows)]
|
||||
type Raw = tty::Pty;
|
||||
|
||||
/// Starts `launch` on a pty of `columns` by `lines` cells, each `cell` pixels, keeping `scrollback` lines.
|
||||
pub fn spawn(
|
||||
launch: &Launch,
|
||||
@@ -60,14 +66,6 @@ pub fn spawn(
|
||||
cell: (u16, u16),
|
||||
scrollback: usize,
|
||||
) -> anyhow::Result<Pty> {
|
||||
anyhow::ensure!(
|
||||
launch.cwd.is_dir(),
|
||||
"pty: cannot start in {}: no such folder",
|
||||
launch.cwd.display()
|
||||
);
|
||||
#[cfg(unix)]
|
||||
rustix::fs::access(&launch.cwd, rustix::fs::Access::EXEC_OK)
|
||||
.map_err(|error| anyhow::anyhow!("pty: cannot enter {}: {error}", launch.cwd.display()))?;
|
||||
let mut env = HashMap::from([
|
||||
("TERM".to_string(), "xterm-256color".to_string()),
|
||||
("COLORTERM".to_string(), "truecolor".to_string()),
|
||||
@@ -76,20 +74,15 @@ pub fn spawn(
|
||||
env.insert("LANG".into(), "en_US.UTF-8".into());
|
||||
}
|
||||
env.extend(launch.env.iter().cloned());
|
||||
let options = Options {
|
||||
shell: launch
|
||||
.program
|
||||
.clone()
|
||||
.map(|(program, args)| Shell::new(program, args)),
|
||||
working_directory: Some(launch.cwd.clone()),
|
||||
drain_on_exit: true,
|
||||
env,
|
||||
#[cfg(windows)]
|
||||
escape_args: true,
|
||||
};
|
||||
let size = fit(columns, lines, cell);
|
||||
let pty = tty::new(&options, size, 0)?;
|
||||
let pid = child_id(&pty)?;
|
||||
let raw = start(launch, env, size).map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"pty: cannot start {:?} in {}: {error}",
|
||||
launch.program,
|
||||
launch.cwd.display()
|
||||
)
|
||||
})?;
|
||||
let pid = child_id(&raw)?;
|
||||
log::info!(
|
||||
"pty {pid}: started {:?} in {}",
|
||||
launch.program,
|
||||
@@ -110,7 +103,7 @@ pub fn spawn(
|
||||
let event_loop = EventLoop::new(
|
||||
term.clone(),
|
||||
listener,
|
||||
Tapped::new(pty, sender, pid),
|
||||
Tapped::new(raw, sender, pid),
|
||||
true,
|
||||
false,
|
||||
)?;
|
||||
@@ -124,6 +117,26 @@ pub fn spawn(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn start(launch: &Launch, env: HashMap<String, String>, size: WindowSize) -> std::io::Result<Raw> {
|
||||
super::unix::spawn(launch.program.as_ref(), &launch.cwd, &env, size)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn start(launch: &Launch, env: HashMap<String, String>, size: WindowSize) -> std::io::Result<Raw> {
|
||||
let options = tty::Options {
|
||||
shell: launch
|
||||
.program
|
||||
.clone()
|
||||
.map(|(program, args)| tty::Shell::new(program, args)),
|
||||
working_directory: Some(launch.cwd.clone()),
|
||||
drain_on_exit: true,
|
||||
env,
|
||||
escape_args: true,
|
||||
};
|
||||
tty::new(&options, size, 0)
|
||||
}
|
||||
|
||||
impl Pty {
|
||||
/// Sends bytes to the program, as typing does.
|
||||
pub fn write(&self, bytes: impl Into<Cow<'static, [u8]>>) {
|
||||
@@ -144,17 +157,19 @@ impl Drop for Pty {
|
||||
fn drop(&mut self) {
|
||||
if self.notifier.0.send(Msg::Shutdown).is_err() {
|
||||
log::debug!("pty {}: the event loop had already stopped", self.pid);
|
||||
} else {
|
||||
log::info!("pty {}: closing", self.pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn child_id(pty: &tty::Pty) -> anyhow::Result<u32> {
|
||||
Ok(pty.child().id())
|
||||
fn child_id(pty: &Raw) -> anyhow::Result<u32> {
|
||||
Ok(pty.id())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn child_id(pty: &tty::Pty) -> anyhow::Result<u32> {
|
||||
fn child_id(pty: &Raw) -> anyhow::Result<u32> {
|
||||
pty.child_watcher()
|
||||
.pid()
|
||||
.map(u32::from)
|
||||
@@ -198,8 +213,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn run(script: &str, size: (usize, usize)) -> Ran {
|
||||
// ponytail: a second init fails by design
|
||||
env_logger::builder().is_test(true).try_init().ok();
|
||||
crate::testlog::init();
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
@@ -255,6 +269,43 @@ mod tests {
|
||||
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_session_hangs_up_its_program() {
|
||||
crate::testlog::init();
|
||||
let launch = Launch {
|
||||
program: Some((
|
||||
"/bin/sh".into(),
|
||||
vec!["-c".into(), "printf READY; exec cat".into()],
|
||||
)),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
};
|
||||
let mut pty = spawn(&launch, (20, 4), (8, 16), 100).expect("sh starts");
|
||||
futures::executor::block_on(async {
|
||||
while let Some(event) = pty.events.next().await {
|
||||
let term = pty.term.lock();
|
||||
let text: String = (0..5)
|
||||
.map(|column| term.grid()[Line(0)][Column(column)].c)
|
||||
.collect();
|
||||
drop(term);
|
||||
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let pid = pty.pid;
|
||||
drop(pty);
|
||||
let hung_up = format!("pty {pid}: hung up, signal: 1 (SIGHUP)");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while !crate::testlog::logged(&hung_up) {
|
||||
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
|
||||
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
|
||||
assert!(!alive, "the program was reaped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_folder_stops_the_start() {
|
||||
let launch = Launch {
|
||||
@@ -283,7 +334,9 @@ mod tests {
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
|
||||
std::fs::remove_dir(&folder).expect("the temp folder goes");
|
||||
let error = started.err().expect("a folder it cannot enter is an error");
|
||||
assert!(error.to_string().contains("cannot enter"), "{error}");
|
||||
let error = error.to_string();
|
||||
assert!(error.contains("Permission denied"), "{error}");
|
||||
assert!(error.contains(&folder.display().to_string()), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{CStr, CString},
|
||||
fs::File,
|
||||
io::{self, ErrorKind, Read},
|
||||
mem::MaybeUninit,
|
||||
os::{
|
||||
fd::AsRawFd,
|
||||
unix::{ffi::OsStrExt, net::UnixStream, process::CommandExt},
|
||||
},
|
||||
path::Path,
|
||||
process::{Child, Command},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use alacritty_terminal::{
|
||||
event::{OnResize, WindowSize},
|
||||
tty::{ChildEvent, EventedPty, EventedReadWrite, ToWinsize},
|
||||
};
|
||||
use polling::{Event, PollMode, Poller};
|
||||
use rustix_openpty::{
|
||||
openpty,
|
||||
rustix::termios::{self, InputModes, OptionalActions},
|
||||
};
|
||||
use signal_hook::{
|
||||
SigId,
|
||||
consts::SIGCHLD,
|
||||
low_level::{pipe, unregister},
|
||||
};
|
||||
|
||||
/// The keys alacritty's event loop reads pty readiness by.
|
||||
const READ_WRITE: usize = 0;
|
||||
const CHILD: usize = 1;
|
||||
|
||||
/// A program on a pseudo-terminal, started here so a failed `chdir` fails the start.
|
||||
pub(crate) struct Pty {
|
||||
child: Child,
|
||||
file: File,
|
||||
signals: UnixStream,
|
||||
sig: SigId,
|
||||
}
|
||||
|
||||
impl Pty {
|
||||
pub fn id(&self) -> u32 {
|
||||
self.child.id()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts `program`, or the login shell, in `cwd` on a new pty of `size`.
|
||||
pub(crate) fn spawn(
|
||||
program: Option<&(String, Vec<String>)>,
|
||||
cwd: &Path,
|
||||
env: &HashMap<String, String>,
|
||||
size: WindowSize,
|
||||
) -> io::Result<Pty> {
|
||||
let pair = openpty(None, Some(&size.to_winsize()))?;
|
||||
let (master, slave) = (pair.controller, pair.user);
|
||||
let mut modes = termios::tcgetattr(&master)?;
|
||||
modes.input_modes.set(InputModes::IUTF8, true);
|
||||
termios::tcsetattr(&master, OptionalActions::Now, &modes)?;
|
||||
let user = User::find()?;
|
||||
let mut command = match program {
|
||||
Some((program, args)) => {
|
||||
let mut command = Command::new(program);
|
||||
command.args(args);
|
||||
command
|
||||
}
|
||||
None => login_shell(&user),
|
||||
};
|
||||
command.stdin(slave.try_clone()?);
|
||||
command.stderr(slave.try_clone()?);
|
||||
command.stdout(slave.try_clone()?);
|
||||
command.env("USER", &user.name).env("HOME", &user.home);
|
||||
command.envs(env);
|
||||
command.env_remove("XDG_ACTIVATION_TOKEN");
|
||||
command.env_remove("DESKTOP_STARTUP_ID");
|
||||
let folder = CString::new(cwd.as_os_str().as_bytes())?;
|
||||
let (master_fd, slave_fd) = (master.as_raw_fd(), slave.as_raw_fd());
|
||||
// SAFETY: the hook calls only async-signal-safe libc functions.
|
||||
unsafe {
|
||||
command.pre_exec(move || {
|
||||
if libc::setsid() == -1 || libc::chdir(folder.as_ptr()) == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if libc::ioctl(slave_fd, libc::TIOCSCTTY as _, 0) == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
libc::close(slave_fd);
|
||||
libc::close(master_fd);
|
||||
for signal in [
|
||||
libc::SIGCHLD,
|
||||
libc::SIGHUP,
|
||||
libc::SIGINT,
|
||||
libc::SIGQUIT,
|
||||
libc::SIGTERM,
|
||||
libc::SIGALRM,
|
||||
] {
|
||||
libc::signal(signal, libc::SIG_DFL);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let (sender, signals) = UnixStream::pair()?;
|
||||
let sig = pipe::register(SIGCHLD, sender)?;
|
||||
signals.set_nonblocking(true)?;
|
||||
let child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
unregister(sig);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
drop(slave);
|
||||
let flags = unsafe { libc::fcntl(master_fd, libc::F_GETFL, 0) };
|
||||
if flags == -1
|
||||
|| unsafe { libc::fcntl(master_fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(Pty {
|
||||
child,
|
||||
file: File::from(master),
|
||||
signals,
|
||||
sig,
|
||||
})
|
||||
}
|
||||
|
||||
/// macOS runs the shell through `login` so it is a login shell on a tty, as Terminal does.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn login_shell(user: &User) -> Command {
|
||||
let name = user.shell.rsplit('/').next().unwrap_or(&user.shell);
|
||||
let hush = Path::new(&user.home).join(".hushlogin").exists();
|
||||
let mut command = Command::new("/usr/bin/login");
|
||||
command.args([
|
||||
if hush { "-qflp" } else { "-flp" },
|
||||
&user.name,
|
||||
"/bin/zsh",
|
||||
"-fc",
|
||||
&format!("exec -a -{name} {}", user.shell),
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn login_shell(user: &User) -> Command {
|
||||
Command::new(&user.shell)
|
||||
}
|
||||
|
||||
struct User {
|
||||
name: String,
|
||||
home: String,
|
||||
shell: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// USER, HOME and SHELL, each from the password entry when the variable is unset.
|
||||
fn find() -> io::Result<Self> {
|
||||
let entry = || -> io::Result<(String, String, String)> {
|
||||
let mut buffer = vec![0 as libc::c_char; 4096];
|
||||
let mut entry = MaybeUninit::<libc::passwd>::uninit();
|
||||
let mut found = std::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
libc::getpwuid_r(
|
||||
libc::getuid(),
|
||||
entry.as_mut_ptr(),
|
||||
buffer.as_mut_ptr(),
|
||||
buffer.len(),
|
||||
&mut found,
|
||||
)
|
||||
};
|
||||
if status != 0 || found.is_null() {
|
||||
return Err(io::Error::other("pty: no password entry for this user"));
|
||||
}
|
||||
let entry = unsafe { entry.assume_init() };
|
||||
let text = |field: *const libc::c_char| {
|
||||
unsafe { CStr::from_ptr(field) }
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
Ok((
|
||||
text(entry.pw_name),
|
||||
text(entry.pw_dir),
|
||||
text(entry.pw_shell),
|
||||
))
|
||||
};
|
||||
let (name, home, shell) = match (
|
||||
std::env::var("USER"),
|
||||
std::env::var("HOME"),
|
||||
std::env::var("SHELL"),
|
||||
) {
|
||||
(Ok(name), Ok(home), Ok(shell)) => (name, home, shell),
|
||||
(name, home, shell) => {
|
||||
let (pw_name, pw_home, pw_shell) = entry()?;
|
||||
(
|
||||
name.unwrap_or(pw_name),
|
||||
home.unwrap_or(pw_home),
|
||||
shell.unwrap_or(pw_shell),
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Self { name, home, shell })
|
||||
}
|
||||
}
|
||||
|
||||
impl EventedReadWrite for Pty {
|
||||
type Reader = File;
|
||||
type Writer = File;
|
||||
|
||||
unsafe fn register(
|
||||
&mut self,
|
||||
poll: &Arc<Poller>,
|
||||
mut interest: Event,
|
||||
mode: PollMode,
|
||||
) -> io::Result<()> {
|
||||
interest.key = READ_WRITE;
|
||||
unsafe {
|
||||
poll.add_with_mode(&self.file, interest, mode)?;
|
||||
poll.add_with_mode(&self.signals, Event::readable(CHILD), PollMode::Level)
|
||||
}
|
||||
}
|
||||
|
||||
fn reregister(
|
||||
&mut self,
|
||||
poll: &Arc<Poller>,
|
||||
mut interest: Event,
|
||||
mode: PollMode,
|
||||
) -> io::Result<()> {
|
||||
interest.key = READ_WRITE;
|
||||
poll.modify_with_mode(&self.file, interest, mode)?;
|
||||
poll.modify_with_mode(&self.signals, Event::readable(CHILD), PollMode::Level)
|
||||
}
|
||||
|
||||
fn deregister(&mut self, poll: &Arc<Poller>) -> io::Result<()> {
|
||||
poll.delete(&self.file)?;
|
||||
poll.delete(&self.signals)
|
||||
}
|
||||
|
||||
fn reader(&mut self) -> &mut File {
|
||||
&mut self.file
|
||||
}
|
||||
|
||||
fn writer(&mut self) -> &mut File {
|
||||
&mut self.file
|
||||
}
|
||||
}
|
||||
|
||||
impl EventedPty for Pty {
|
||||
fn next_child_event(&mut self) -> Option<ChildEvent> {
|
||||
let mut byte = [0u8; 1];
|
||||
if let Err(error) = self.signals.read(&mut byte) {
|
||||
if error.kind() != ErrorKind::WouldBlock {
|
||||
log::error!("pty {}: the SIGCHLD pipe failed: {error}", self.id());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
match self.child.try_wait() {
|
||||
Ok(None) => None,
|
||||
Ok(status) => Some(ChildEvent::Exited(status)),
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"pty {}: cannot read the program's state: {error}",
|
||||
self.id()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OnResize for Pty {
|
||||
fn on_resize(&mut self, size: WindowSize) {
|
||||
let winsize = size.to_winsize();
|
||||
if unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TIOCSWINSZ, &winsize) } == -1 {
|
||||
log::error!(
|
||||
"pty {}: resize failed: {}",
|
||||
self.id(),
|
||||
io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pty {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::kill(self.child.id() as libc::pid_t, libc::SIGHUP) };
|
||||
unregister(self.sig);
|
||||
match self.child.wait() {
|
||||
Ok(status) => log::info!("pty {}: hung up, {status}", self.id()),
|
||||
Err(error) => log::error!("pty {}: cannot reap the program: {error}", self.id()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use std::sync::{Mutex, Once};
|
||||
|
||||
static LINES: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Keeps every log line so tests can assert what was logged.
|
||||
struct Capture;
|
||||
|
||||
impl log::Log for Capture {
|
||||
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||
metadata.level() <= log::Level::Debug
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
let line = record.args().to_string();
|
||||
eprintln!("{} {line}", record.level());
|
||||
LINES.lock().expect("the log lines").push(line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
pub fn init() {
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| {
|
||||
log::set_logger(&Capture).expect("the only logger in tests");
|
||||
log::set_max_level(log::LevelFilter::Debug);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn logged(line: &str) -> bool {
|
||||
LINES
|
||||
.lock()
|
||||
.expect("the log lines")
|
||||
.iter()
|
||||
.any(|kept| kept == line)
|
||||
}
|
||||
Reference in new issue
Block a user