Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion crates/tui/src/runtime_api/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,21 @@ fn open_session(
.ok_or_else(|| ApiError::not_found(format!("no live terminal session named '{name}'")))
}

/// How many terminal route calls may occupy blocking threads at once. The
/// rest wait in `with_session` asynchronously, so a client that disconnects
/// while queued simply disappears instead of holding a pool thread, and one
/// stuck write (a child that stopped reading, lock held) can stall terminal
/// routes but never the runtime's other blocking work.
#[cfg(all(unix, not(target_env = "ohos")))]
static ROUTE_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(8);

/// Run one operation against the locked session on the blocking pool.
///
/// The session mutex and the PTY behind it are synchronous: the agent's own
/// tool holds the lock across a whole command, and a write to a child that
/// stopped reading blocks until the kernel buffer drains. Neither may park a
/// runtime worker (#6149), so a route never touches the session inline.
/// runtime worker (#6149), so a route never touches the session inline, and
/// `ROUTE_GATE` bounds how many such touches can be in flight.
#[cfg(all(unix, not(target_env = "ohos")))]
async fn with_session<T>(
session: terminal_session::SharedSession,
Expand All @@ -214,7 +223,12 @@ async fn with_session<T>(
where
T: Send + 'static,
{
let permit = ROUTE_GATE
.acquire()
.await
.map_err(|_| ApiError::internal("terminal route gate closed"))?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
let mut guard = session
.lock()
.map_err(|_| ApiError::internal("terminal session lock poisoned"))?;
Expand Down
48 changes: 44 additions & 4 deletions crates/tui/src/sleep_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
//! `kill_on_drop` sends the release signal, and the runtime reaps the child —
//! no `wait` runs inline on a worker (#6149). `hold` therefore has to be
//! called from within a Tokio runtime context.
//!
//! On Linux the lock is held by `systemd-inhibit` around a child of its own,
//! and a kill is never forwarded to that grandchild. The command is therefore
//! `cat` reading a pipe this guard holds: dropping the guard closes the pipe,
//! `cat` exits on EOF, and `systemd-inhibit` follows — nothing is left behind.

#[cfg(unix)]
use tokio::process::Child;
Expand Down Expand Up @@ -95,7 +100,9 @@ fn start_inhibitor() -> Option<Child> {
}

/// `--what=idle` only: an explicit suspend or a closed lid is still honoured.
/// `sleep infinity` is the command whose lifetime holds the block open.
/// `cat` on the guard's pipe is the command whose lifetime holds the block
/// open: it exits on EOF when the guard drops, which no signal sent to
/// `systemd-inhibit` could make a `sleep infinity` grandchild do.
#[cfg(target_os = "linux")]
fn start_inhibitor() -> Option<Child> {
spawn(
Expand All @@ -104,8 +111,7 @@ fn start_inhibitor() -> Option<Child> {
"--what=idle",
"--why=Codewhale turn in flight",
"--mode=block",
"sleep",
"infinity",
"cat",
Comment thread
Hmbown marked this conversation as resolved.
],
)
}
Expand All @@ -120,7 +126,9 @@ fn start_inhibitor() -> Option<Child> {
fn spawn(program: &str, args: &[&str]) -> Option<Child> {
Command::new(program)
.args(args)
.stdin(Stdio::null())
// The pipe is never written to: closing it when the guard drops is
// what ends an inhibitor's own child (see the Linux inhibitor).
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
// Killing the inhibitor is what releases the assertion; the runtime
Expand Down Expand Up @@ -173,6 +181,38 @@ mod tests {
);
}

/// Linux: `systemd-inhibit` holds the lock around a child of its own and a
/// kill never reaches that grandchild — the guard's pipe is what ends it.
/// Without logind the inhibitor exits at once and the list is empty, so
/// this proves something only where an inhibitor really runs.
#[tokio::test]
#[cfg(target_os = "linux")]
async fn a_released_guard_leaves_no_grandchild_behind() {
let guard = SleepGuard::hold();
let pid = guard
.inhibitor_pid()
.expect("this platform starts an inhibitor");
// Give the inhibitor a moment to fork its command.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let grandchildren: Vec<u32> =
tokio::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children"))
.await
.unwrap_or_default()
.split_whitespace()
.filter_map(|child| child.parse().ok())
.collect();

drop(guard);

assert!(released(pid).await, "the inhibitor itself must be gone");
for grandchild in grandchildren {
assert!(
released(grandchild).await,
"process {grandchild} outlived the guard: the inhibitor's command must end with the guard's pipe"
);
}
}

#[tokio::test]
#[cfg(any(target_os = "macos", target_os = "linux"))]
async fn holding_twice_holds_two_independent_inhibitors() {
Expand Down
Loading