feat(env): report a terminal's foreground job with ptyForeground

The process group in front of the pty when it is not the program's own (a job its shell runs), with the leader's name on macOS and Linux. Exposed as RemoteTerminal.foreground().
This commit is contained in:
Armin Ronacher 2026-10-06 22:38:09 +02:00 • committed by Mario Zechner
parent 367b2bcf59
commit 74613de659
6 changed files with 130 additions and 14 deletions

View file

@ -400,12 +400,19 @@ fn serve_frames(server: &Arc<Server>, input: &mut impl Read) -> io::Result<()> {
let jobs = start_workers();
while let Some(request) = frame::read_frame(input)? {
match request.kind {
REQUEST if matches!(request.json["op"].as_str(), Some("ptyWrite" | "ptyResize")) => {
REQUEST
if matches!(
request.json["op"].as_str(),
Some("ptyWrite" | "ptyResize" | "ptyForeground")
) =>
{
// Answered here, in arrival order: through the worker pool, two writes could swap.
let reply = if request.json["op"].as_str() == Some("ptyWrite") {
pty::write(&server.terminals, &request.json, request.payload)
} else {
pty::resize(&server.terminals, &request.json)
let reply = match request.json["op"].as_str() {
Some("ptyWrite") => {
pty::write(&server.terminals, &request.json, request.payload)
}
Some("ptyResize") => pty::resize(&server.terminals, &request.json),
_ => pty::foreground(&server.terminals, &request.json),
};
server.output.control(match reply {
Ok(json) => Frame::new(RESULT, request.id, json),

View file

@ -1,5 +1,6 @@
//! `pty`: a program in a pseudo-terminal, for interactive terminals. Unlike `exec`, output is the terminal's raw
//! bytes, and the program reads input: `ptyWrite` and `ptyResize` address the running `pty` request by its id.
//! bytes, and the program reads input: `ptyWrite`, `ptyResize` and `ptyForeground` address the running `pty` request by
//! its id.
use crate::errors::Failure;
use crate::exec::{Control, Groups};
@ -19,11 +20,14 @@ const EXIT_GRACE: std::time::Duration = std::time::Duration::from_millis(100);
/// Largest terminal size accepted, in cells.
const MAX_CELLS: u64 = 4096;
/// What `ptyWrite` and `ptyResize` reach: the terminal of one running `pty` request.
/// What `ptyWrite`, `ptyResize` and `ptyForeground` reach: the terminal of one running `pty` request.
pub struct Terminal {
input: Sender<Vec<u8>>,
#[cfg(unix)]
master: Arc<std::fs::File>,
/// The program's process id, which is also its process group's (it leads the terminal's session).
#[cfg(unix)]
pid: u32,
}
/// Running terminals by the id of their `pty` request.
@ -116,6 +120,27 @@ pub fn resize(terminals: &Terminals, json: &Value) -> Result<Value, Failure> {
Ok(json!({}))
}
/// The process group in the terminal's foreground when it is not the program's own: a job a shell started, such as an
/// editor. `{ foreground: null }` while the program itself is in front (a shell at its prompt).
pub fn foreground(terminals: &Terminals, json: &Value) -> Result<Value, Failure> {
let terminal = find(terminals, json)?;
#[cfg(unix)]
{
let group = unix::foreground(&terminal.master)
.map_err(|error| Failure::io(&error, "tcgetpgrp", "pty"))?;
if group <= 0 || group as u32 == terminal.pid {
return Ok(json!({ "foreground": null }));
}
let name = unix::process_name(group);
Ok(json!({ "foreground": { "pid": group, "name": name } }))
}
#[cfg(not(unix))]
{
let _ = terminal;
Ok(json!({ "foreground": null }))
}
}
#[cfg(not(unix))]
pub fn run(
_id: u32,
@ -168,6 +193,7 @@ pub fn run(
Arc::new(Terminal {
input,
master: master.clone(),
pid,
}),
);
output.control(Frame::new(
@ -323,6 +349,38 @@ mod unix {
Ok(())
}
/// The terminal's foreground process group.
pub fn foreground(master: &File) -> io::Result<libc::pid_t> {
// SAFETY: tcgetpgrp on a descriptor the terminal owns.
let group = unsafe { libc::tcgetpgrp(master.as_raw_fd()) };
if group < 0 {
Err(io::Error::last_os_error())
} else {
Ok(group)
}
}
/// A process's name, if it still runs and the system tells.
#[cfg(target_vendor = "apple")]
pub fn process_name(pid: libc::pid_t) -> Option<String> {
let mut buffer = [0u8; 256];
// SAFETY: proc_name writes at most `buffer.len()` bytes and returns how many.
let length =
unsafe { libc::proc_name(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
(length > 0).then(|| String::from_utf8_lossy(&buffer[..length as usize]).into_owned())
}
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn process_name(pid: libc::pid_t) -> Option<String> {
let name = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
Some(name.trim_end().to_string()).filter(|name| !name.is_empty())
}
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
pub fn process_name(_pid: libc::pid_t) -> Option<String> {
None
}
fn cloexec(fd: libc::c_int) -> io::Result<()> {
// SAFETY: fcntl on a descriptor this function's caller owns.
unsafe {

View file

@ -51,12 +51,12 @@ between chunks with `aborted`. `watch` stops and finishes with `{}`. Other opera
## Scheduling
File operations run on a fixed pool of worker threads; `exec`, `pty` and `watch` run on their own threads. `ptyWrite`
and `ptyResize` are answered in arrival order by the thread that reads frames, so keystrokes never swap. Chunk writes to one
handle run in arrival order. Results, errors, pings and `watch` events are sent before queued command output, and
results with payloads above 64 KiB queue behind output too, so a slow link delays bulk data, not replies. Output of a
command without an output window waits while more than 4 MiB of output is unsent, which slows the command as a full
pipe would.
File operations run on a fixed pool of worker threads; `exec`, `pty` and `watch` run on their own threads. `ptyWrite`,
`ptyResize` and `ptyForeground` are answered in arrival order by the thread that reads frames, so keystrokes never
swap. Chunk writes to one handle run in arrival order. Results, errors, pings and `watch` events are sent before queued
command output, and results with payloads above 64 KiB queue behind output too, so a slow link delays bulk data, not
replies. Output of a command without an output window waits while more than 4 MiB of output is unsent, which slows the
command as a full pipe would.
## Liveness
@ -127,3 +127,8 @@ While a `pty` request runs, `ptyWrite { target }` writes its payload to the term
`ptyResize { target, cols, rows }` changes its size (the program gets `SIGWINCH`); `target` is the id of the `pty`
request. Both return `{}` at once; input is queued and written in order, waiting while the program does not read it.
Once the terminal has ended, they fail with `EBADF`.
`ptyForeground { target }` reports the terminal's foreground process group when it is not the program's own:
`{ foreground: { pid, name } }`, where `pid` is the group's id and `name` its leader's process name (`null` if unknown),
or `{ foreground: null }` while the program itself is in front. For a shell this tells a running job (an editor, a
build) from the prompt; background jobs are not reported. It fails with `EBADF` once the terminal has ended.

View file

@ -26,5 +26,5 @@ export {
sshArguments,
sshConnection,
} from "./ssh.ts";
export { openTerminal, RemoteTerminal, type TerminalOptions } from "./terminal.ts";
export { openTerminal, RemoteTerminal, type TerminalForeground, type TerminalOptions } from "./terminal.ts";
export { RemoteWatcher, type RemoteWatchOptions } from "./watch.ts";

View file

@ -17,6 +17,12 @@ export interface TerminalOptions {
onData: (bytes: Uint8Array) => void;
}
/** The foreground job of a terminal: its process group id, and its leader's name if the system tells. */
export interface TerminalForeground {
pid: number;
name?: string;
}
/**
* A program running in a pseudo-terminal of a pi-env daemon. It ends when the program exits, `close()` kills it, or
* the connection is lost (the daemon then kills it).
@ -64,6 +70,17 @@ export class RemoteTerminal {
this.#abort.abort();
}
/**
* The job in the terminal's foreground when it is not the program itself: what a shell runs, such as an editor.
* Undefined while the program is in front (a shell at its prompt). Rejects once the terminal has ended.
*/
async foreground(): Promise<TerminalForeground | undefined> {
const reply = await this.#connection.request("ptyForeground", { target: this.#id }, { session: this.#session });
const foreground = reply.json.foreground as { pid: number; name: string | null } | null;
if (!foreground) return undefined;
return { pid: foreground.pid, ...(foreground.name ? { name: foreground.name } : {}) };
}
#request(op: string, json: Json, payload?: Uint8Array): void {
this.#connection
.request(op, { ...json, target: this.#id }, { session: this.#session, ...(payload ? { payload } : {}) })

View file

@ -71,6 +71,35 @@ describe.skipIf(process.platform === "win32")("openTerminal", () => {
expect(await terminal.exited).toBe(3);
}, 20_000);
it("reports the job in the foreground, not the shell at its prompt", async () => {
const { connection, cwd } = connect();
const output = collector();
const terminal = await openTerminal(connection, {
cwd,
// Interactive, for job control: the shell puts each job in front of the terminal.
argv: ["/bin/sh", "-i"],
env: { PS1: "$ ", TERM: "xterm-256color" },
cols: 80,
rows: 24,
onData: output.onData,
});
terminal.write("echo READY\n");
await output.waitFor("READY");
expect(await terminal.foreground()).toBeUndefined();
terminal.write("sleep 100\n");
let foreground = await terminal.foreground();
for (let tries = 0; !foreground && tries < 100; tries++) {
await new Promise((resolve) => setTimeout(resolve, 20));
foreground = await terminal.foreground();
}
expect(foreground?.pid).toBeGreaterThan(0);
expect(foreground?.pid).not.toBe(terminal.pid);
if (process.platform === "darwin" || process.platform === "linux") expect(foreground?.name).toBe("sleep");
terminal.close();
await expect(terminal.exited).rejects.toMatchObject({ code: "aborted" });
await expect(terminal.foreground()).rejects.toMatchObject({ code: "EBADF" });
}, 20_000);
it("delivers raw bytes, not decoded text", async () => {
const { connection, cwd } = connect();
const output = collector();