char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somcleanup: hang up PTYs when their process exits0575637

main
9.6 KiB240 linesraw
1// Programs on pseudo-terminals of this machine, through libc, since Deno has no terminals of its own. Linux only.
2
3import type { Context } from "@earendil-works/chord";
4import type { Pty, PtyOptions } from "./environment.ts";
5import { windowsPty } from "./pty-windows.ts";
6
7const O_RDWR = 0o2;
8const O_NOCTTY = 0o400;
9const O_NONBLOCK = 0o4000;
10// or every program started later would hold this terminal open too
11const O_CLOEXEC = 0o2000000;
12const TIOCSWINSZ = 0x5414n;
13const POSIX_SPAWN_SETSID = 0x80;
14const ENOENT = 2;
15const EINTR = 4;
16const EIO = 5;
17const EAGAIN = 11;
18const POLLIN = 0x1;
19const POLLOUT = 0x4;
20/** How long a terminal stays open once its program has exited, for what it wrote last. */
21const GRACE_MS = 250;
22/** Room for glibc's and musl's posix_spawnattr_t and posix_spawn_file_actions_t, which are smaller. */
23const OPAQUE = 1024;
24
25let libc: ReturnType<typeof openLibc> | undefined;
26
27function openLibc() {
28  return Deno.dlopen("libc.so.6", {
29    posix_openpt: { parameters: ["i32"], result: "i32" },
30    grantpt: { parameters: ["i32"], result: "i32" },
31    unlockpt: { parameters: ["i32"], result: "i32" },
32    ptsname_r: { parameters: ["i32", "buffer", "usize"], result: "i32" },
33    ioctl: { parameters: ["i32", "u64", "buffer"], result: "i32" },
34    posix_spawnattr_init: { parameters: ["buffer"], result: "i32" },
35    posix_spawnattr_setflags: { parameters: ["buffer", "i16"], result: "i32" },
36    posix_spawnattr_destroy: { parameters: ["buffer"], result: "i32" },
37    posix_spawn_file_actions_init: { parameters: ["buffer"], result: "i32" },
38    posix_spawn_file_actions_addopen: {
39      parameters: ["buffer", "i32", "buffer", "i32", "u32"],
40      result: "i32",
41    },
42    posix_spawn_file_actions_adddup2: { parameters: ["buffer", "i32", "i32"], result: "i32" },
43    posix_spawn_file_actions_addchdir_np: { parameters: ["buffer", "buffer"], result: "i32" },
44    posix_spawn_file_actions_destroy: { parameters: ["buffer"], result: "i32" },
45    posix_spawnp: {
46      parameters: ["buffer", "buffer", "buffer", "buffer", "buffer", "buffer"],
47      result: "i32",
48    },
49    __errno_location: { parameters: [], result: "pointer" },
50    eventfd: { parameters: ["u32", "i32"], result: "i32" },
51    close: { parameters: ["i32"], result: "i32" },
52    read: { parameters: ["i32", "buffer", "usize"], result: "isize" },
53    write: { parameters: ["i32", "buffer", "usize"], result: "isize" },
54    // these wait, on the program or for the terminal: each on a thread of its own
55    waitpid: { parameters: ["i32", "buffer", "i32"], result: "i32", nonblocking: true },
56    poll: { parameters: ["buffer", "u64", "i32"], result: "i32", nonblocking: true },
57  }).symbols;
58}
59
60const encoder = new TextEncoder();
61const cString = (text: string) => encoder.encode(`${text}\0`);
62
63/** A NULL-terminated array of pointers to C strings, as argv and envp are; the strings live as long as it does. */
64function cStrings(texts: readonly string[]) {
65  const strings = texts.map(cString);
66  const pointers = new BigUint64Array(strings.length + 1);
67  for (const [i, string] of strings.entries()) {
68    pointers[i] = BigInt(Deno.UnsafePointer.value(Deno.UnsafePointer.of(string)));
69  }
70  return Object.assign(new Uint8Array(pointers.buffer), { strings });
71}
72
73/** A program on a new pseudo-terminal on this machine, leading a session of its own, `cwd` as given. */
74export async function ptyHere(
75  argv: readonly string[],
76  { cwd, env, cols, rows }: PtyOptions,
77  context: Context,
78): Promise<Pty> {
79  context.abortSignal?.throwIfAborted();
80  if (Deno.build.os === "windows") return await windowsPty(argv, { cwd, env, cols, rows }, context);
81  if (Deno.build.os !== "linux") throw new Error(`No terminals on ${Deno.build.os} yet`);
82  const c = libc ??= openLibc();
83  // errno belongs to this thread, so only inspect it after synchronous calls
84  const errno = new Deno.UnsafePointerView(c.__errno_location()!);
85  // it never blocks: waiting is poll's, which a hang-up can cut short
86  const master = c.posix_openpt(O_RDWR | O_NOCTTY | O_NONBLOCK | O_CLOEXEC);
87  if (master < 0) throw new Error("Could not open a pseudo-terminal");
88  const hangingUp = c.eventfd(0, O_NONBLOCK | O_CLOEXEC);
89  const path = new Uint8Array(256);
90  if (
91    hangingUp < 0 || c.grantpt(master) || c.unlockpt(master) ||
92    c.ptsname_r(master, path, BigInt(path.length))
93  ) {
94    c.close(master);
95    if (hangingUp >= 0) c.close(hangingUp);
96    throw new Error("Could not set up a pseudo-terminal");
97  }
98  const resize = (cols: number, rows: number) => {
99    const size = new Uint16Array([rows, cols, 0, 0]);
100    c.ioctl(master, TIOCSWINSZ, new Uint8Array(size.buffer));
101  };
102  resize(cols, rows);
103
104  // in a session of its own, the terminal it opens becomes its controlling terminal, which gives it job control
105  const attributes = new Uint8Array(OPAQUE);
106  const actions = new Uint8Array(OPAQUE);
107  c.posix_spawnattr_init(attributes);
108  c.posix_spawnattr_setflags(attributes, POSIX_SPAWN_SETSID);
109  c.posix_spawn_file_actions_init(actions);
110  c.posix_spawn_file_actions_addopen(actions, 0, path, O_RDWR, 0);
111  c.posix_spawn_file_actions_adddup2(actions, 0, 1);
112  c.posix_spawn_file_actions_adddup2(actions, 0, 2);
113  if (cwd !== undefined) c.posix_spawn_file_actions_addchdir_np(actions, cString(cwd));
114  const variables = Object.entries({ ...Deno.env.toObject(), ...env });
115  const pid = new Int32Array(1);
116  // it returns once the program has opened the terminal, or failed to
117  const failed = c.posix_spawnp(
118    new Uint8Array(pid.buffer),
119    cString(argv[0]),
120    actions,
121    attributes,
122    cStrings(argv),
123    cStrings(variables.map(([key, value]) => `${key}=${value}`)),
124  );
125  c.posix_spawn_file_actions_destroy(actions);
126  c.posix_spawnattr_destroy(attributes);
127  if (failed) {
128    c.close(master);
129    c.close(hangingUp);
130    throw new Error(
131      failed === ENOENT ? `No such program: ${argv[0]}` : `Could not start ${argv[0]} (${failed})`,
132    );
133  }
134
135  let hungUp = false;
136  let closed = false;
137  /** Polls under way, which would see another descriptor if the terminal's number were reused. */
138  let polling = 0;
139  let grace: ReturnType<typeof setTimeout> | undefined;
140  const release = () => {
141    if (!hungUp || polling > 0 || closed) return;
142    closed = true;
143    c.close(master);
144    c.close(hangingUp);
145  };
146  // closing the terminal's side hangs it up for everything that holds the other, and SIGHUPs the program
147  const hangUp = () => {
148    if (hungUp) return;
149    hungUp = true;
150    clearTimeout(grace);
151    const wake = new Uint8Array(new BigUint64Array([1n]).buffer);
152    while (c.write(hangingUp, wake, 8n) < 0 && errno.getInt32() === EINTR) {
153      // a lost wakeup would leave pending polls holding the terminal open
154    }
155    release();
156  };
157  /** Wait until the terminal might be ready for `events`, or `false` once it hangs up. */
158  const ready = async (events: number) => {
159    if (hungUp) return false;
160    const fds = new Int32Array([master, events, hangingUp, POLLIN]);
161    polling++;
162    try {
163      // interrupted, it is ready for nothing
164      for (let tries = 0; tries < 3; tries++) {
165        const result = await c.poll(new Uint8Array(fds.buffer), 2n, -1);
166        if (hungUp) return false;
167        if (result >= 0) return result > 0 && (fds[1] >>> 16) !== 0;
168      }
169      hangUp();
170      throw new Error("Could not wait for the terminal");
171    } finally {
172      polling--;
173      release();
174    }
175  };
176
177  context.abortSignal?.addEventListener("abort", hangUp, { once: true });
178  const exited = (async () => {
179    const status = new Int32Array(1);
180    // a wait cut short by a signal is tried again; one that keeps failing has nothing left to wait for
181    for (let tries = 0; tries < 3; tries++) {
182      if (await c.waitpid(pid[0], new Uint8Array(status.buffer), 0) !== pid[0]) continue;
183      // exited, with a code, or ended by a signal
184      return (status[0] & 0x7f) === 0 ? (status[0] >> 8) & 0xff : null;
185    }
186    return null;
187  })().finally(() => {
188    context.abortSignal?.removeEventListener("abort", hangUp);
189    // what it started may hold the terminal open, but as with a window, it goes with the program
190    if (!hungUp) grace = setTimeout(hangUp, GRACE_MS);
191  });
192
193  const readable = new ReadableStream<Uint8Array>({
194    async pull(controller) {
195      const buffer = new Uint8Array(65536);
196      while (!hungUp) {
197        const n = Number(c.read(master, buffer, BigInt(buffer.length)));
198        if (n > 0) return controller.enqueue(buffer.subarray(0, n));
199        const error = n < 0 ? errno.getInt32() : 0;
200        // EIO once nothing holds the terminal's other side any more
201        if (n === 0 || error === EIO) break;
202        if (error === EINTR) continue;
203        if (error !== EAGAIN) {
204          hangUp();
205          throw new Error(`Could not read from the terminal (${error})`);
206        }
207        if (!await ready(POLLIN)) break;
208      }
209      hangUp();
210      controller.close();
211    },
212    cancel: hangUp,
213  }, { highWaterMark: 0 });
214  const writable = new WritableStream<Uint8Array>({
215    async write(chunk) {
216      for (let at = 0; at < chunk.length;) {
217        if (hungUp) throw new Error("The terminal is closed");
218        const n = Number(c.write(master, chunk.subarray(at), BigInt(chunk.length - at)));
219        if (n > 0) {
220          at += n;
221          continue;
222        }
223        const error = n < 0 ? errno.getInt32() : 0;
224        if (error === EINTR) continue;
225        if (error !== EAGAIN) {
226          hangUp();
227          throw new Error(`Could not write to the terminal (${error})`);
228        }
229        if (!await ready(POLLOUT)) throw new Error("The terminal is closed");
230      }
231    },
232  });
233  return {
234    readable,
235    writable,
236    resize: (cols, rows) => void (!hungUp && resize(cols, rows)),
237    exited,
238    kill: hangUp,
239  };
240}