@procedures/shell 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,12 +7,14 @@
7
7
  * - a record (id, pid, status, exit code), which `list` and `get` give;
8
8
  * - a `stop` that kills the whole process tree;
9
9
  * - the end of its output (the last 64 KiB), which also keeps its pipes from filling;
10
+ * - the output after a time, for a follower: `subscribe` and `follow`;
10
11
  * - an end with the host: the host does not wait for the process, and kills it when it ends.
11
12
  *
12
13
  * The registry keeps the records of ended processes, up to `keepExited` of them.
13
14
  */
14
15
 
15
16
  import type { ChildProcess } from "node:child_process";
17
+ import { StringDecoder } from "node:string_decoder";
16
18
  import { killTree } from "./tree-kill.js";
17
19
  import { OutputCollector } from "./output.js";
18
20
  import { startChild, startError, type SpawnOptions } from "./spawn.js";
@@ -48,6 +50,50 @@ export interface StopResult {
48
50
  signal?: string;
49
51
  }
50
52
 
53
+ /** One piece of the output of a process, as `subscribe` gives it. */
54
+ export interface ProcessOutputChunk {
55
+ /** The stream of the piece. */
56
+ stream: "stdout" | "stderr";
57
+ /** The bytes of the piece. A piece can end in a line or in a UTF-8 character. */
58
+ data: Buffer;
59
+ }
60
+
61
+ /** The functions that `subscribe` calls for one subscriber. */
62
+ export interface ProcessOutputListener {
63
+ /** This function gets each piece of output that the process writes after the subscription. */
64
+ output?(chunk: ProcessOutputChunk): void;
65
+ /** This function gets a copy of the record when the process ends. After it, no call comes. */
66
+ end?(info: ManagedProcessInfo): void;
67
+ }
68
+
69
+ /** One item of `follow`. */
70
+ export type ProcessFollowItem =
71
+ /** The output that the registry kept before the subscription: the first item with `backlog`. */
72
+ | { type: "backlog"; text: string }
73
+ /** A piece of new output, decoded as UTF-8. A piece can end in the middle of a line. */
74
+ | { type: "output"; stream: "stdout" | "stderr"; text: string }
75
+ /** The reader was slow, so the follower dropped the oldest pieces: this many bytes. */
76
+ | { type: "dropped"; bytes: number }
77
+ /** The end of the process: the last item. */
78
+ | { type: "end"; info: ManagedProcessInfo };
79
+
80
+ /** The options of `follow`. */
81
+ export interface FollowOptions {
82
+ /** True: the first item is the output that the registry kept before the subscription. */
83
+ backlog?: boolean | undefined;
84
+ /** An abort ends the stream. The process continues. */
85
+ signal?: AbortSignal | undefined;
86
+ /**
87
+ * The most bytes of output that wait for the reader. Above it, the follower drops the oldest
88
+ * pieces and gives a `dropped` item, because the registry cannot slow the process.
89
+ * @default 1 MiB
90
+ */
91
+ maxBufferedBytes?: number | undefined;
92
+ }
93
+
94
+ /** The default of `maxBufferedBytes` of `follow`. */
95
+ export const FOLLOW_BUFFER_BYTES: number = 1024 * 1024;
96
+
51
97
  /** The end of the output that the registry keeps for each process. */
52
98
  const OUTPUT_TAIL_BYTES = 64 * 1024;
53
99
  /** How long `stop` waits after the first kill, before it kills again with SIGKILL. */
@@ -67,9 +113,20 @@ interface Entry {
67
113
  output: OutputCollector;
68
114
  /** Called after each output chunk and at the end. */
69
115
  watchers: Set<() => void>;
116
+ /** The subscribers of `subscribe`. The end of the process removes them. */
117
+ listeners: Set<ProcessOutputListener>;
70
118
  done: Promise<void>;
71
119
  }
72
120
 
121
+ /** This function calls a function of a subscriber. An error of a subscriber does not reach the registry. */
122
+ function notify(run: () => void): void {
123
+ try {
124
+ run();
125
+ } catch (error) {
126
+ console.error("A subscriber of the process registry failed:", error);
127
+ }
128
+ }
129
+
73
130
  export class ProcessRegistry {
74
131
  private readonly entries = new Map<string, Entry>();
75
132
  private readonly keepExited: number;
@@ -97,17 +154,17 @@ export class ProcessRegistry {
97
154
 
98
155
  const output = new OutputCollector(OUTPUT_TAIL_BYTES, "tail");
99
156
  const watchers = new Set<() => void>();
157
+ const listeners = new Set<ProcessOutputListener>();
100
158
  const changed = (): void => {
101
159
  for (const watcher of [...watchers]) watcher();
102
160
  };
103
- child.stdout!.on("data", (chunk: Buffer) => {
104
- output.add(chunk);
105
- changed();
106
- });
107
- child.stderr!.on("data", (chunk: Buffer) => {
161
+ const received = (stream: "stdout" | "stderr", chunk: Buffer): void => {
108
162
  output.add(chunk);
163
+ for (const listener of [...listeners]) notify(() => listener.output?.({ stream, data: chunk }));
109
164
  changed();
110
- });
165
+ };
166
+ child.stdout!.on("data", (chunk: Buffer) => received("stdout", chunk));
167
+ child.stderr!.on("data", (chunk: Buffer) => received("stderr", chunk));
111
168
 
112
169
  const done = new Promise<void>((resolve) => {
113
170
  let finished = false;
@@ -117,6 +174,10 @@ export class ProcessRegistry {
117
174
  info.exitedAt = new Date().toISOString();
118
175
  resolve();
119
176
  changed();
177
+ // The end is the last call to a subscriber: the registry then forgets each subscriber
178
+ const ending = [...listeners];
179
+ listeners.clear();
180
+ for (const listener of ending) notify(() => listener.end?.({ ...info, args: [...info.args] }));
120
181
  this.reap();
121
182
  };
122
183
  child.on("error", (error) => {
@@ -140,7 +201,7 @@ export class ProcessRegistry {
140
201
  (stream as unknown as { unref?: () => void } | null)?.unref?.();
141
202
  }
142
203
 
143
- this.entries.set(id, { info, child, output, watchers, done });
204
+ this.entries.set(id, { info, child, output, watchers, listeners, done });
144
205
  this.reap();
145
206
  return { ...info };
146
207
  }
@@ -166,6 +227,105 @@ export class ProcessRegistry {
166
227
  return this.entries.get(id)?.output.text() ?? "";
167
228
  }
168
229
 
230
+ /**
231
+ * This function subscribes to the output of a process. `listener.output` gets each piece of
232
+ * output after this call, with its stream. `listener.end` gets the record at the end of the
233
+ * process, and then the registry forgets the subscriber. For a process that ended already,
234
+ * `listener.end` runs at once. The result removes the subscriber. It is undefined when the
235
+ * registry has no process of this id.
236
+ */
237
+ subscribe(id: string, listener: ProcessOutputListener): (() => void) | undefined {
238
+ const entry = this.entries.get(id);
239
+ if (!entry) return undefined;
240
+ if (entry.info.exitedAt) {
241
+ notify(() => listener.end?.({ ...entry.info, args: [...entry.info.args] }));
242
+ return () => undefined;
243
+ }
244
+ entry.listeners.add(listener);
245
+ return () => {
246
+ entry.listeners.delete(listener);
247
+ };
248
+ }
249
+
250
+ /**
251
+ * This function gives the output of a process as a stream, until the process ends or the
252
+ * signal aborts. The subscription starts when the reader asks for the first item, and the
253
+ * end of the stream removes it: an end of the process, an abort, or a reader that stops. With
254
+ * `backlog`, the first item is the output that the registry kept until then, without a gap.
255
+ * The last item of an ended process is `end`. An unknown id throws at the first item.
256
+ */
257
+ async *follow(id: string, options: FollowOptions = {}): AsyncGenerator<ProcessFollowItem, void, undefined> {
258
+ const limit = Math.max(1, options.maxBufferedBytes ?? FOLLOW_BUFFER_BYTES);
259
+ const signal = options.signal;
260
+ const queue: Array<{ item: ProcessFollowItem; bytes: number }> = [];
261
+ let queued = 0;
262
+ let dropped = 0;
263
+ let ended: ManagedProcessInfo | undefined;
264
+ let wake: (() => void) | undefined;
265
+ const wakeUp = (): void => {
266
+ const resolve = wake;
267
+ wake = undefined;
268
+ resolve?.();
269
+ };
270
+ const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
271
+ const add = (stream: "stdout" | "stderr", text: string, bytes: number): void => {
272
+ if (text === "") return;
273
+ queue.push({ item: { type: "output", stream, text }, bytes });
274
+ queued += bytes;
275
+ // The registry cannot slow the process: above the limit, the oldest pieces go
276
+ while (queued > limit && queue.length > 1) {
277
+ const oldest = queue.shift()!;
278
+ queued -= oldest.bytes;
279
+ dropped += oldest.bytes;
280
+ }
281
+ };
282
+
283
+ if (signal?.aborted) return;
284
+ const backlog = options.backlog ? this.output(id) : "";
285
+ const unsubscribe = this.subscribe(id, {
286
+ output: ({ stream, data }) => {
287
+ add(stream, decoders[stream].write(data), data.length);
288
+ wakeUp();
289
+ },
290
+ end: (info) => {
291
+ for (const stream of ["stdout", "stderr"] as const) {
292
+ const rest = decoders[stream].end();
293
+ add(stream, rest, Buffer.byteLength(rest));
294
+ }
295
+ ended = info;
296
+ wakeUp();
297
+ },
298
+ });
299
+ if (!unsubscribe) throw new Error(`No process ${id}`);
300
+ signal?.addEventListener("abort", wakeUp, { once: true });
301
+ try {
302
+ if (options.backlog) yield { type: "backlog", text: backlog };
303
+ for (;;) {
304
+ if (signal?.aborted) return;
305
+ if (dropped > 0) {
306
+ const bytes = dropped;
307
+ dropped = 0;
308
+ yield { type: "dropped", bytes };
309
+ continue;
310
+ }
311
+ const next = queue.shift();
312
+ if (next) {
313
+ queued -= next.bytes;
314
+ yield next.item;
315
+ continue;
316
+ }
317
+ if (ended) {
318
+ yield { type: "end", info: ended };
319
+ return;
320
+ }
321
+ await new Promise<void>((resolve) => (wake = resolve));
322
+ }
323
+ } finally {
324
+ unsubscribe();
325
+ signal?.removeEventListener("abort", wakeUp);
326
+ }
327
+ }
328
+
169
329
  /** This function waits until the process ends, then gives its record. */
170
330
  async exited(id: string): Promise<ManagedProcessInfo | undefined> {
171
331
  const entry = this.entries.get(id);
package/src/module.ts CHANGED
@@ -95,6 +95,8 @@ const shellStreamProcedure = createProcedure()
95
95
  shorts: { cwd: "C", timeout: "t" },
96
96
  output: "json",
97
97
  streaming: true,
98
+ // A program can print without an end (tail -f, a server): roadmap R15
99
+ stream: { unbounded: true },
98
100
  effects: RUNS_PROGRAM,
99
101
  })
100
102
  .handler(shellStream)