// Programs on pseudo-terminals of this machine, through libc, since Deno has no terminals of its own. Linux only. import type { Context } from "@earendil-works/chord"; import type { Pty, PtyOptions } from "./environment.ts"; import { windowsPty } from "./pty-windows.ts"; const O_RDWR = 0o2; const O_NOCTTY = 0o400; const O_NONBLOCK = 0o4000; // or every program started later would hold this terminal open too const O_CLOEXEC = 0o2000000; const TIOCSWINSZ = 0x5414n; const POSIX_SPAWN_SETSID = 0x80; const ENOENT = 2; const EINTR = 4; const EIO = 5; const EAGAIN = 11; const POLLIN = 0x1; const POLLOUT = 0x4; /** How long a terminal stays open once its program has exited, for what it wrote last. */ const GRACE_MS = 250; /** Room for glibc's and musl's posix_spawnattr_t and posix_spawn_file_actions_t, which are smaller. */ const OPAQUE = 1024; let libc: ReturnType | undefined; function openLibc() { return Deno.dlopen("libc.so.6", { posix_openpt: { parameters: ["i32"], result: "i32" }, grantpt: { parameters: ["i32"], result: "i32" }, unlockpt: { parameters: ["i32"], result: "i32" }, ptsname_r: { parameters: ["i32", "buffer", "usize"], result: "i32" }, ioctl: { parameters: ["i32", "u64", "buffer"], result: "i32" }, posix_spawnattr_init: { parameters: ["buffer"], result: "i32" }, posix_spawnattr_setflags: { parameters: ["buffer", "i16"], result: "i32" }, posix_spawnattr_destroy: { parameters: ["buffer"], result: "i32" }, posix_spawn_file_actions_init: { parameters: ["buffer"], result: "i32" }, posix_spawn_file_actions_addopen: { parameters: ["buffer", "i32", "buffer", "i32", "u32"], result: "i32", }, posix_spawn_file_actions_adddup2: { parameters: ["buffer", "i32", "i32"], result: "i32" }, posix_spawn_file_actions_addchdir_np: { parameters: ["buffer", "buffer"], result: "i32" }, posix_spawn_file_actions_destroy: { parameters: ["buffer"], result: "i32" }, posix_spawnp: { parameters: ["buffer", "buffer", "buffer", "buffer", "buffer", "buffer"], result: "i32", }, __errno_location: { parameters: [], result: "pointer" }, eventfd: { parameters: ["u32", "i32"], result: "i32" }, close: { parameters: ["i32"], result: "i32" }, read: { parameters: ["i32", "buffer", "usize"], result: "isize" }, write: { parameters: ["i32", "buffer", "usize"], result: "isize" }, // these wait, on the program or for the terminal: each on a thread of its own waitpid: { parameters: ["i32", "buffer", "i32"], result: "i32", nonblocking: true }, poll: { parameters: ["buffer", "u64", "i32"], result: "i32", nonblocking: true }, }).symbols; } const encoder = new TextEncoder(); const cString = (text: string) => encoder.encode(`${text}\0`); /** A NULL-terminated array of pointers to C strings, as argv and envp are; the strings live as long as it does. */ function cStrings(texts: readonly string[]) { const strings = texts.map(cString); const pointers = new BigUint64Array(strings.length + 1); for (const [i, string] of strings.entries()) { pointers[i] = BigInt(Deno.UnsafePointer.value(Deno.UnsafePointer.of(string))); } return Object.assign(new Uint8Array(pointers.buffer), { strings }); } /** A program on a new pseudo-terminal on this machine, leading a session of its own, `cwd` as given. */ export async function ptyHere( argv: readonly string[], { cwd, env, cols, rows }: PtyOptions, context: Context, ): Promise { context.abortSignal?.throwIfAborted(); if (Deno.build.os === "windows") return await windowsPty(argv, { cwd, env, cols, rows }, context); if (Deno.build.os !== "linux") throw new Error(`No terminals on ${Deno.build.os} yet`); const c = libc ??= openLibc(); // errno belongs to this thread, so only inspect it after synchronous calls const errno = new Deno.UnsafePointerView(c.__errno_location()!); // it never blocks: waiting is poll's, which a hang-up can cut short const master = c.posix_openpt(O_RDWR | O_NOCTTY | O_NONBLOCK | O_CLOEXEC); if (master < 0) throw new Error("Could not open a pseudo-terminal"); const hangingUp = c.eventfd(0, O_NONBLOCK | O_CLOEXEC); const path = new Uint8Array(256); if ( hangingUp < 0 || c.grantpt(master) || c.unlockpt(master) || c.ptsname_r(master, path, BigInt(path.length)) ) { c.close(master); if (hangingUp >= 0) c.close(hangingUp); throw new Error("Could not set up a pseudo-terminal"); } const resize = (cols: number, rows: number) => { const size = new Uint16Array([rows, cols, 0, 0]); c.ioctl(master, TIOCSWINSZ, new Uint8Array(size.buffer)); }; resize(cols, rows); // in a session of its own, the terminal it opens becomes its controlling terminal, which gives it job control const attributes = new Uint8Array(OPAQUE); const actions = new Uint8Array(OPAQUE); c.posix_spawnattr_init(attributes); c.posix_spawnattr_setflags(attributes, POSIX_SPAWN_SETSID); c.posix_spawn_file_actions_init(actions); c.posix_spawn_file_actions_addopen(actions, 0, path, O_RDWR, 0); c.posix_spawn_file_actions_adddup2(actions, 0, 1); c.posix_spawn_file_actions_adddup2(actions, 0, 2); if (cwd !== undefined) c.posix_spawn_file_actions_addchdir_np(actions, cString(cwd)); const variables = Object.entries({ ...Deno.env.toObject(), ...env }); const pid = new Int32Array(1); // it returns once the program has opened the terminal, or failed to const failed = c.posix_spawnp( new Uint8Array(pid.buffer), cString(argv[0]), actions, attributes, cStrings(argv), cStrings(variables.map(([key, value]) => `${key}=${value}`)), ); c.posix_spawn_file_actions_destroy(actions); c.posix_spawnattr_destroy(attributes); if (failed) { c.close(master); c.close(hangingUp); throw new Error( failed === ENOENT ? `No such program: ${argv[0]}` : `Could not start ${argv[0]} (${failed})`, ); } let hungUp = false; let closed = false; /** Polls under way, which would see another descriptor if the terminal's number were reused. */ let polling = 0; let grace: ReturnType | undefined; const release = () => { if (!hungUp || polling > 0 || closed) return; closed = true; c.close(master); c.close(hangingUp); }; // closing the terminal's side hangs it up for everything that holds the other, and SIGHUPs the program const hangUp = () => { if (hungUp) return; hungUp = true; clearTimeout(grace); const wake = new Uint8Array(new BigUint64Array([1n]).buffer); while (c.write(hangingUp, wake, 8n) < 0 && errno.getInt32() === EINTR) { // a lost wakeup would leave pending polls holding the terminal open } release(); }; /** Wait until the terminal might be ready for `events`, or `false` once it hangs up. */ const ready = async (events: number) => { if (hungUp) return false; const fds = new Int32Array([master, events, hangingUp, POLLIN]); polling++; try { // interrupted, it is ready for nothing for (let tries = 0; tries < 3; tries++) { const result = await c.poll(new Uint8Array(fds.buffer), 2n, -1); if (hungUp) return false; if (result >= 0) return result > 0 && (fds[1] >>> 16) !== 0; } hangUp(); throw new Error("Could not wait for the terminal"); } finally { polling--; release(); } }; context.abortSignal?.addEventListener("abort", hangUp, { once: true }); const exited = (async () => { const status = new Int32Array(1); // a wait cut short by a signal is tried again; one that keeps failing has nothing left to wait for for (let tries = 0; tries < 3; tries++) { if (await c.waitpid(pid[0], new Uint8Array(status.buffer), 0) !== pid[0]) continue; // exited, with a code, or ended by a signal return (status[0] & 0x7f) === 0 ? (status[0] >> 8) & 0xff : null; } return null; })().finally(() => { context.abortSignal?.removeEventListener("abort", hangUp); // what it started may hold the terminal open, but as with a window, it goes with the program if (!hungUp) grace = setTimeout(hangUp, GRACE_MS); }); const readable = new ReadableStream({ async pull(controller) { const buffer = new Uint8Array(65536); while (!hungUp) { const n = Number(c.read(master, buffer, BigInt(buffer.length))); if (n > 0) return controller.enqueue(buffer.subarray(0, n)); const error = n < 0 ? errno.getInt32() : 0; // EIO once nothing holds the terminal's other side any more if (n === 0 || error === EIO) break; if (error === EINTR) continue; if (error !== EAGAIN) { hangUp(); throw new Error(`Could not read from the terminal (${error})`); } if (!await ready(POLLIN)) break; } hangUp(); controller.close(); }, cancel: hangUp, }, { highWaterMark: 0 }); const writable = new WritableStream({ async write(chunk) { for (let at = 0; at < chunk.length;) { if (hungUp) throw new Error("The terminal is closed"); const n = Number(c.write(master, chunk.subarray(at), BigInt(chunk.length - at))); if (n > 0) { at += n; continue; } const error = n < 0 ? errno.getInt32() : 0; if (error === EINTR) continue; if (error !== EAGAIN) { hangUp(); throw new Error(`Could not write to the terminal (${error})`); } if (!await ready(POLLOUT)) throw new Error("The terminal is closed"); } }, }); return { readable, writable, resize: (cols, rows) => void (!hungUp && resize(cols, rows)), exited, kill: hangUp, }; }