mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
367b2bcf59
commit
74613de659
6 changed files with 130 additions and 14 deletions
17
packages/env/daemon/src/main.rs
vendored
17
packages/env/daemon/src/main.rs
vendored
|
|
@ -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),
|
||||
|
|
|
|||
62
packages/env/daemon/src/pty.rs
vendored
62
packages/env/daemon/src/pty.rs
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
17
packages/env/docs/protocol.md
vendored
17
packages/env/docs/protocol.md
vendored
|
|
@ -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.
|
||||
|
|
|
|||
2
packages/env/src/index.ts
vendored
2
packages/env/src/index.ts
vendored
|
|
@ -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";
|
||||
|
|
|
|||
17
packages/env/src/terminal.ts
vendored
17
packages/env/src/terminal.ts
vendored
|
|
@ -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 } : {}) })
|
||||
|
|
|
|||
29
packages/env/test/terminal.test.ts
vendored
29
packages/env/test/terminal.test.ts
vendored
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue