diff --git a/packages/env/daemon/src/main.rs b/packages/env/daemon/src/main.rs index 07ce957a6..4a9fb730a 100644 --- a/packages/env/daemon/src/main.rs +++ b/packages/env/daemon/src/main.rs @@ -400,12 +400,19 @@ fn serve_frames(server: &Arc, 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), diff --git a/packages/env/daemon/src/pty.rs b/packages/env/daemon/src/pty.rs index 70dcd32b7..00cc566aa 100644 --- a/packages/env/daemon/src/pty.rs +++ b/packages/env/daemon/src/pty.rs @@ -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>, #[cfg(unix)] master: Arc, + /// 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 { 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 { + 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 { + // 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 { + 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 { + 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 { + None + } + fn cloexec(fd: libc::c_int) -> io::Result<()> { // SAFETY: fcntl on a descriptor this function's caller owns. unsafe { diff --git a/packages/env/docs/protocol.md b/packages/env/docs/protocol.md index c309cec98..e5d95c0bb 100644 --- a/packages/env/docs/protocol.md +++ b/packages/env/docs/protocol.md @@ -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. diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index cf08b0bc5..4c4b72026 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -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"; diff --git a/packages/env/src/terminal.ts b/packages/env/src/terminal.ts index 77e7c8fe5..6a89abf21 100644 --- a/packages/env/src/terminal.ts +++ b/packages/env/src/terminal.ts @@ -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 { + 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 } : {}) }) diff --git a/packages/env/test/terminal.test.ts b/packages/env/test/terminal.test.ts index d329a3875..3c0ab1d8c 100644 --- a/packages/env/test/terminal.test.ts +++ b/packages/env/test/terminal.test.ts @@ -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();