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
6.9 KiB218 linesraw
1import {
2  assertEquals,
3  assertMatch,
4  assertNotEquals,
5  assertNotMatch,
6  assertRejects,
7} from "@std/assert";
8import { existsSync } from "node:fs";
9import { tempDir, until as eventually } from "@berry/util";
10import { BACKGROUND_CONTEXT as context, withCancel } from "@earendil-works/chord/context";
11import { type Environment, localEnvironment, type Pty } from "@berry/api/environment.ts";
12import { BOOTSTRAP, Connection, RemoteEnvironment } from "./remote/connection.ts";
13
14/** What the program has written so far, read until `pattern` turns up in it. */
15async function until(
16  reader: ReadableStreamDefaultReader<Uint8Array>,
17  seen: string[],
18  pattern: RegExp,
19) {
20  const decoder = new TextDecoder();
21  while (!pattern.test(seen.join(""))) {
22    const { value, done } = await reader.read();
23    if (done) throw new Error(`ended before ${pattern}: ${JSON.stringify(seen.join(""))}`);
24    seen.push(decoder.decode(value, { stream: true }));
25  }
26  return seen.join("");
27}
28
29const SCRIPT = [
30  "tty",
31  "stty size",
32  "read line",
33  'echo "got $line"',
34  "stty size",
35  'trap "echo interrupted; exit 5" INT',
36  "echo trapped",
37  "sleep 30 & wait",
38].join("; ");
39
40async function converse(env: Environment) {
41  const pty: Pty = await env.pty(["sh", "-c", SCRIPT], { cols: 80, rows: 24 }, context);
42  const reader = pty.readable.getReader();
43  const writer = pty.writable.getWriter();
44  const seen: string[] = [];
45  assertMatch(await until(reader, seen, /24 80/), /^\/dev\/pts\/\d+\r\n/);
46  pty.resize(100, 30);
47  // the terminal turns a typed return into the newline `read` waits for
48  await writer.write(new TextEncoder().encode("hello\r"));
49  await until(reader, seen, /got hello\r\n30 100\r\ntrapped\r\n/);
50  // ctrl-c reaches the program as a signal, which only a terminal of its own makes so
51  await writer.write(new TextEncoder().encode("\x03"));
52  await until(reader, seen, /interrupted/);
53  assertEquals(await pty.exited, 5);
54  for (let next = await reader.read(); !next.done; next = await reader.read());
55}
56
57/** A program that leaves something holding its terminal, to write there after it has exited. */
58async function leaves(env: Environment) {
59  await using dir = await tempDir();
60  const status = `${dir.path}/status`;
61  const pty = await env.pty(
62    [
63      "sh",
64      "-c",
65      // quoted apart, so the leftover's words are not the command's
66      `trap "" HUP; (sleep 1; echo left""over; echo $? > ${status}) & echo started`,
67    ],
68    { cols: 80, rows: 24 },
69    context,
70  );
71  const output = await new Response(pty.readable).text();
72  assertMatch(output, /started/);
73  assertNotMatch(output, /leftover/);
74  assertEquals(await pty.exited, 0);
75  // the terminal hung up under it
76  await eventually(() => existsSync(status));
77  assertNotEquals((await Deno.readTextFile(status)).trim(), "0");
78}
79
80Deno.test({
81  name: "a program on a pty has a terminal of its own: sized, resized, typed into and interrupted",
82  ignore: Deno.build.os !== "linux",
83  fn: () => converse(localEnvironment(Deno.cwd())),
84});
85
86Deno.test({
87  name: "a pty hangs up once its program exits, whatever that left holding it",
88  ignore: Deno.build.os !== "linux",
89  fn: () => leaves(localEnvironment(Deno.cwd())),
90});
91
92Deno.test({
93  name: "a pty hangs up when it is killed",
94  ignore: Deno.build.os !== "linux",
95  async fn() {
96    const pty = await localEnvironment(Deno.cwd()).pty(["sh", "-c", "sleep 30"], {
97      cols: 80,
98      rows: 24,
99    }, context);
100    pty.kill();
101    assertEquals(await pty.exited, null);
102    await pty.readable.pipeTo(new WritableStream());
103  },
104});
105
106Deno.test({
107  name: "aborting a pty wakes blocked readers and writers",
108  ignore: Deno.build.os !== "linux",
109  async fn() {
110    const cancelled = withCancel(context);
111    const pty = await localEnvironment(Deno.cwd()).pty(
112      ["sh", "-c", "stty raw -echo; echo ready; exec sleep 30"],
113      { cols: 80, rows: 24 },
114      cancelled.context,
115    );
116    const reader = pty.readable.getReader();
117    const writer = pty.writable.getWriter();
118    try {
119      await until(reader, [], /ready\n/);
120      let readEnded = false;
121      let writeEnded = false;
122      const reading = reader.read().then((next) => {
123        readEnded = true;
124        return next;
125      });
126      const writing = writer.write(new Uint8Array(1024 * 1024).fill(97));
127      const rejected = assertRejects(() => writing, Error).then(() => writeEnded = true);
128      await new Promise((resolve) => setTimeout(resolve, 20));
129      assertEquals([readEnded, writeEnded], [false, false]);
130      cancelled.cancel();
131      await eventually(() => readEnded && writeEnded);
132      assertEquals((await reading).done, true);
133      await rejected;
134      assertEquals(await pty.exited, null);
135    } finally {
136      pty.kill();
137      await reader.cancel();
138      await pty.exited;
139    }
140  },
141});
142
143Deno.test({
144  name: "a pty transfers input larger than its buffer without losing bytes",
145  ignore: Deno.build.os !== "linux",
146  async fn() {
147    const pty = await localEnvironment(Deno.cwd()).pty(
148      ["sh", "-c", "stty raw -echo; echo ready; exec cat"],
149      { cols: 80, rows: 24 },
150      context,
151    );
152    const reader = pty.readable.getReader();
153    const writer = pty.writable.getWriter();
154    try {
155      await until(reader, [], /ready\n/);
156      const input = new Uint8Array(256 * 1024);
157      for (let i = 0; i < input.length; i++) input[i] = i % 256;
158      const output = new Uint8Array(input.length);
159      await Promise.all([
160        writer.write(input),
161        (async () => {
162          for (let at = 0; at < output.length;) {
163            const { value, done } = await reader.read();
164            if (done) throw new Error("Terminal ended before echoing its input");
165            output.set(value, at);
166            at += value.length;
167          }
168        })(),
169      ]);
170      assertEquals(output, input);
171    } finally {
172      pty.kill();
173      await reader.cancel();
174      await pty.exited;
175    }
176  },
177});
178
179Deno.test({
180  name:
181    "a pty starts its program where it is asked, with the variables it is given, or says why not",
182  ignore: Deno.build.os !== "linux",
183  async fn() {
184    const env = localEnvironment("/");
185    const pty = await env.pty(["sh", "-c", 'echo "$PWD $THERE"'], {
186      cwd: "/tmp",
187      env: { THERE: "here" },
188      cols: 80,
189      rows: 24,
190    }, context);
191    assertEquals(await new Response(pty.readable).text(), "/tmp here\r\n");
192    assertEquals(await pty.exited, 0);
193    await assertRejects(
194      () => env.pty(["berry-no-such-program"], { cols: 80, rows: 24 }, context),
195      Error,
196      "No such program",
197    );
198  },
199});
200
201Deno.test({
202  name: "a pty on another machine speaks, resizes, and hangs up through its worker",
203  ignore: Deno.build.os !== "linux",
204  async fn() {
205    const connection = await Connection.open("far", [
206      Deno.execPath(),
207      "eval",
208      "--quiet",
209      BOOTSTRAP,
210    ]);
211    try {
212      await converse(new RemoteEnvironment("far", connection, "/"));
213      await leaves(new RemoteEnvironment("far", connection, "/"));
214    } finally {
215      await connection.close();
216    }
217  },
218});