Skip to content
Merged
33 changes: 32 additions & 1 deletion corcovado/src/sys/unix/epoll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,19 @@ impl Events {
}

pub fn push_event(&mut self, event: Event) {
// Unlike registration interests, injected events must retain HUP and
// error readiness. The kernel reports these flags automatically for
// file descriptors, but user-space registrations have no kernel event.
let readiness = UnixReady::from(event.readiness());
let mut events = ioevent_to_epoll(event.readiness(), PollOpt::empty());
if readiness.is_hup() {
events |= EPOLLHUP as u32;
}
if readiness.is_error() {
events |= EPOLLERR as u32;
}
self.events.push(libc::epoll_event {
events: ioevent_to_epoll(event.readiness(), PollOpt::empty()),
events,
u64: usize::from(event.token()) as u64,
});
}
Expand Down Expand Up @@ -297,3 +308,23 @@ pub fn millis(duration: Duration) -> u64 {
.saturating_mul(MILLIS_PER_SEC)
.saturating_add(millis as u64)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn injected_events_preserve_hangup_and_error_readiness() {
for readiness in [
Ready::from(UnixReady::hup()),
Ready::from(UnixReady::error()),
Ready::readable() | UnixReady::hup() | UnixReady::error(),
] {
let mut events = Events::with_capacity(1);
events.push_event(Event::new(readiness, Token(42)));
let event = events.get(0).unwrap();
assert_eq!(event.readiness(), readiness);
assert_eq!(event.token(), Token(42));
}
}
}
6 changes: 5 additions & 1 deletion frontends/rioterm/src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ impl ApplicationHandler<EventPayload> for Application<'_> {
if self.config.confirm_before_quit {
route.confirm_quit();
} else {
route.quit();
event_loop.exit();
}
}
}
Expand Down Expand Up @@ -2036,6 +2036,10 @@ impl ApplicationHandler<EventPayload> for Application<'_> {
..
} => {
if route.has_key_wait(&key_event, &mut self.router.clipboard) {
if route.quit_requested {
event_loop.exit();
return;
}
if route.path != RoutePath::Terminal
&& key_event.state == ElementState::Released
{
Expand Down
19 changes: 13 additions & 6 deletions frontends/rioterm/src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ pub struct Context<T: EventListener> {
pub main_fd: Arc<i32>,
#[cfg(not(target_os = "windows"))]
pub shell_pid: u32,
#[cfg(not(target_os = "windows"))]
child_terminator: teletypewriter::ChildTerminator,
pub rich_text_id: usize,
pub dimension: ContextDimension,
pub title: ContextTitle,
Expand All @@ -69,13 +71,12 @@ impl<T: rio_backend::event::EventListener> Drop for Context<T> {
fn drop(&mut self) {
// Shutdown the terminal's PTY.
let _ = self.messenger.channel.send(Msg::Shutdown);

// `create_dead_context` uses 1 as a placeholder PID, so guard against
// signalling init (1) or our own process group (0).
// Also hang up synchronously: quit paths call process::exit
// right after dropping routes, before the reader thread can run
// its shutdown escalation. The handle is a no-op once the child
// was reaped, so no stale PID is ever signaled.
#[cfg(not(target_os = "windows"))]
if self.shell_pid > 1 {
teletypewriter::kill_pid(self.shell_pid as i32);
}
let _ = self.child_terminator.hangup();
}
}

Expand Down Expand Up @@ -190,6 +191,8 @@ pub fn create_dead_context<T: rio_backend::event::EventListener>(
main_fd: Arc::new(-1),
#[cfg(not(target_os = "windows"))]
shell_pid: 1,
#[cfg(not(target_os = "windows"))]
child_terminator: teletypewriter::ChildTerminator::retired(),
messenger: Messenger::new(sender),
renderable_content: RenderableContent::new(Cursor::default()),
terminal,
Expand Down Expand Up @@ -341,6 +344,8 @@ impl<T: EventListener + Clone + std::marker::Send + 'static> ContextManager<T> {
let main_fd = pty.child.id.clone();
#[cfg(not(target_os = "windows"))]
let shell_pid = *pty.child.pid.clone() as u32;
#[cfg(not(target_os = "windows"))]
let child_terminator = pty.child.terminator();

#[cfg(target_os = "windows")]
{
Expand Down Expand Up @@ -382,6 +387,8 @@ impl<T: EventListener + Clone + std::marker::Send + 'static> ContextManager<T> {
main_fd,
#[cfg(not(target_os = "windows"))]
shell_pid,
#[cfg(not(target_os = "windows"))]
child_terminator,
messenger,
terminal,
rich_text_id,
Expand Down
13 changes: 12 additions & 1 deletion frontends/rioterm/src/router/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ pub struct Route<'a> {
pub assistant: assistant::Assistant,
pub path: RoutePath,
pub window: RouteWindow<'a>,
/// Set by `quit`; the application answers it with an event loop
/// exit so `exiting` drops every route (hanging up each PTY child)
/// before the process exits.
pub quit_requested: bool,
}

impl Route<'_> {
Expand All @@ -67,6 +71,7 @@ impl Route<'_> {
assistant,
path,
window,
quit_requested: false,
}
}
}
Expand Down Expand Up @@ -253,7 +258,10 @@ impl Route<'_> {

#[inline]
pub fn quit(&mut self) {
std::process::exit(0);
// A direct process::exit here would skip every destructor: no
// Msg::Shutdown, no hangup, and PTY children ignoring the
// kernel's HUP-on-master-close would be orphaned.
self.quit_requested = true;
}

#[inline]
Expand Down Expand Up @@ -659,6 +667,7 @@ impl Router<'_> {
window,
path: RoutePath::Terminal,
assistant: Assistant::new(),
quit_requested: false,
};

if let Some(err) = &self.propagated_report {
Expand Down Expand Up @@ -696,6 +705,7 @@ impl Router<'_> {
window,
path: RoutePath::Terminal,
assistant: Assistant::new(),
quit_requested: false,
},
);
self.quake_window_id = Some(id);
Expand Down Expand Up @@ -728,6 +738,7 @@ impl Router<'_> {
window,
path: RoutePath::Terminal,
assistant: Assistant::new(),
quit_requested: false,
},
);
}
Expand Down
23 changes: 17 additions & 6 deletions librio/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ pub struct Surface {
shell_pid: u32,
#[cfg(all(feature = "pty", not(target_os = "windows")))]
main_fd: std::os::fd::RawFd,
#[cfg(all(feature = "pty", not(target_os = "windows")))]
child_terminator: teletypewriter::ChildTerminator,
#[cfg(feature = "pty")]
_io_thread: std::thread::JoinHandle<(
Machine<teletypewriter::Pty, Listener>,
Expand Down Expand Up @@ -511,6 +513,8 @@ impl Surface {
let shell_pid = pty.child_watcher().pid().map(|pid| pid.get()).unwrap_or(0);
#[cfg(not(target_os = "windows"))]
let main_fd = *pty.child.id;
#[cfg(not(target_os = "windows"))]
let child_terminator = pty.child.terminator();

let machine = Machine::new(
Arc::clone(&terminal),
Expand All @@ -535,6 +539,8 @@ impl Surface {
shell_pid,
#[cfg(not(target_os = "windows"))]
main_fd,
#[cfg(not(target_os = "windows"))]
child_terminator,
_io_thread: io_thread,
})
}
Expand Down Expand Up @@ -1024,11 +1030,12 @@ impl Surface {
}

/// The pid of the program this surface spawned (the shell, or the
/// configured `shell` program). On unix it is a session leader, so a
/// host that must take the whole process tree down on teardown can
/// `killpg` it: dropping the surface only hangs up the pty and signals
/// this pid. On Windows it is the conpty child's process id (terminate
/// it with `TerminateProcess`/taskkill); 0 if the pid was unavailable.
/// configured `shell` program), for identity and diagnostics. Do not
/// signal it on teardown: dropping the surface already hangs up the
/// process group, and the reader thread escalates to SIGKILL and
/// reaps, so a host-side killpg would race that escalation. On
/// Windows it is the conpty child's process id; 0 if the pid was
/// unavailable.
#[cfg(feature = "pty")]
pub fn child_pid(&self) -> u32 {
self.shell_pid
Expand Down Expand Up @@ -1186,8 +1193,12 @@ impl Surface {
impl Drop for Surface {
fn drop(&mut self) {
let _ = self.channel.send(Msg::Shutdown);
// Also hang up synchronously: an embedder may exit the process
// right after dropping the surface, before the reader thread can
// run its shutdown escalation. The handle is a no-op once the
// child was reaped, so no stale PID is ever signaled.
#[cfg(not(target_os = "windows"))]
teletypewriter::kill_pid(self.shell_pid as i32);
let _ = self.child_terminator.hangup();
}
}

Expand Down
14 changes: 14 additions & 0 deletions rio-vt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ automatically. See [`librio/src/lib.rs`](../librio/src/lib.rs) for a
complete, working setup (`Crosswords::new` + `Machine::new` +
`teletypewriter`), which is also the reference consumer of this crate.

On child exit, the reader drains available output across parsing budgets and
flushes pending synchronized updates before publishing `ChildExited` and
`CloseTerminal`. It does not wait for descendants holding the slave open:
a read that would block ends the drain, and continuous descendant output is
limited to 100 ms between parsing batches. Terminal-lock waits and parsing
can extend that interval.

Send `Msg::Shutdown` to close a running PTY. On Unix, shutdown sends SIGHUP,
allows a 100 ms grace period, then uses SIGKILL if necessary and waits to
reap the child. Repeated shutdown preserves its exit status and does not
signal a reaped child. The final reap can take longer for a process stuck
in the kernel; shutdown is not a strict wall-clock deadline. Embedders
should let the PTY owner manage termination instead of signaling a saved PID.

## Pull-based rendering

`rio-vt` does not draw anything. A frontend reads terminal state on demand:
Expand Down
Loading
Loading