@volter/tabnode 0.5.17 → 0.5.18

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.
@@ -70,6 +70,8 @@ import { Runtime, pendingGuestTimers, stopGuestTimers, __substratePendingOf } fr
70
70
  import { __nodeResolverFor } from '../node-resolver';
71
71
  import { resolve as __resolvePath } from './path';
72
72
  import { setSyncChildVfs, warmSyncChild } from './sync-child';
73
+ import { installShellJobs } from './shell-jobs';
74
+ import { unstreamedOutput } from './streamed-output';
73
75
  import type { PackageJson } from '../types/package-json';
74
76
 
75
77
  // Capture host capabilities before the guest's authority is restricted. These
@@ -1036,6 +1038,9 @@ export function initChildProcess(vfs: VirtualFS): void {
1036
1038
  },
1037
1039
  customCommands: [nodeCommand, npmCommand],
1038
1040
  });
1041
+ // A statement ending in `&` runs in the background, with `$!`, `wait` and
1042
+ // `kill` over the engine's process table (`shell-jobs.ts` says how).
1043
+ installShellJobs(bashInstance, { runTokenOf, runStreamsFor, registerRunStreams, releaseRunStreams });
1039
1044
  shells.set(tree, { bash: bashInstance, adapter: vfsAdapter });
1040
1045
 
1041
1046
  if (processRunnerInstalled) return;
@@ -1805,11 +1810,11 @@ function startChildRun(request: RunRequest): StartedRun {
1805
1810
  }
1806
1811
  };
1807
1812
 
1808
- let streamedOut = 0;
1809
- let streamedErr = 0;
1813
+ const streamedOut: string[] = [];
1814
+ const streamedErr: string[] = [];
1810
1815
  const streams: RunStreams = {
1811
- onStdout: (data: string) => { streamedOut += data.length; request.stdout?.(data); },
1812
- onStderr: (data: string) => { streamedErr += data.length; request.stderr?.(data); },
1816
+ onStdout: (data: string) => { streamedOut.push(data); request.stdout?.(data); },
1817
+ onStderr: (data: string) => { streamedErr.push(data); request.stderr?.(data); },
1813
1818
  signal: controller.signal,
1814
1819
  held: false,
1815
1820
  stdinOpen: request.stdinIsPipe,
@@ -1899,9 +1904,12 @@ function startChildRun(request: RunRequest): StartedRun {
1899
1904
  }
1900
1905
  // A command the engine's shell ran to the end and did not stream --
1901
1906
  // every builtin -- delivers its output here; the `node` command streamed
1902
- // its own as it went, and only what it has not already sent is left.
1903
- if (outcome.stdout.length > streamedOut) request.stdout?.(outcome.stdout.slice(streamedOut));
1904
- if (outcome.stderr.length > streamedErr) request.stderr?.(outcome.stderr.slice(streamedErr));
1907
+ // its own as it went, and only what it has not already sent is left
1908
+ // (`streamed-output.ts` says why that is not a cut at a length).
1909
+ const restOut = unstreamedOutput(outcome.stdout, streamedOut);
1910
+ const restErr = unstreamedOutput(outcome.stderr, streamedErr);
1911
+ if (restOut) request.stdout?.(restOut);
1912
+ if (restErr) request.stderr?.(restErr);
1905
1913
  end(outcome.exitCode, outcome.signal ?? null);
1906
1914
  })();
1907
1915
  };
@@ -363,8 +363,8 @@ function trailingCallback(args: readonly unknown[]): (() => void) | undefined {
363
363
  }
364
364
 
365
365
  /** The signal numbers a guest sees, and the signals a tab must ignore. */
366
- const __substrateSignals: Record<string, number> = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGILL: 4, SIGTRAP: 5, SIGABRT: 6, SIGBUS: 7, SIGFPE: 8, SIGKILL: 9, SIGUSR1: 10, SIGSEGV: 11, SIGUSR2: 12, SIGPIPE: 13, SIGALRM: 14, SIGTERM: 15, SIGCHLD: 17, SIGCONT: 18, SIGSTOP: 19, SIGTSTP: 20, SIGTTIN: 21, SIGTTOU: 22, SIGURG: 23, SIGWINCH: 28 };
367
- const __substrateSignalNames: Record<number, string> = Object.fromEntries(Object.entries(__substrateSignals).map(([name, number]) => [number, name]));
366
+ export const __substrateSignals: Record<string, number> = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGILL: 4, SIGTRAP: 5, SIGABRT: 6, SIGBUS: 7, SIGFPE: 8, SIGKILL: 9, SIGUSR1: 10, SIGSEGV: 11, SIGUSR2: 12, SIGPIPE: 13, SIGALRM: 14, SIGTERM: 15, SIGCHLD: 17, SIGCONT: 18, SIGSTOP: 19, SIGTSTP: 20, SIGTTIN: 21, SIGTTOU: 22, SIGURG: 23, SIGWINCH: 28 };
367
+ export const __substrateSignalNames: Record<number, string> = Object.fromEntries(Object.entries(__substrateSignals).map(([name, number]) => [number, name]));
368
368
  // Node prints the `process.binding` deprecation once per process; the engine
369
369
  // runs many guests in one realm, so the record is per guest, not per load.
370
370
  const __substrateBindingWarned = new WeakSet<object>();
@@ -0,0 +1,451 @@
1
+ /**
2
+ * The engine's shell runs a statement that ends in `&` in the background, as
3
+ * a POSIX shell does.
4
+ *
5
+ * just-bash, the engine's shell, parses `&` (a statement's `background` flag)
6
+ * and its interpreter never reads it: `node -e '…setTimeout…' & c=$!; echo
7
+ * "bg pid=$c"; wait "$c"` ran the child to its end first, printed its output
8
+ * before `bg pid=0`, `$!` was always 0, `wait` returned 0 at once, and `kill`
9
+ * was "command not found". On a machine the job starts and the script goes on,
10
+ * `$!` is the job's pid, `wait` waits for it and returns its status, and
11
+ * `kill` reaches it.
12
+ *
13
+ * This is built on the doors just-bash offers rather than on a patched copy of
14
+ * it: a transform plugin, which runs on every script the shell executes (the
15
+ * top-level line, `sh -c`, `bash -c`, a script file), and custom commands.
16
+ *
17
+ * - The plugin replaces each background statement with a call of
18
+ * `__tabnode_bg`, carrying the statement's syntax tree (and the functions the
19
+ * script defines, which a job may call) as JSON; it renames `$!` to the
20
+ * variable `__tabnode_bg` sets, and the `wait` builtin (which answers 0 at
21
+ * once and cannot be overridden) to `__tabnode_wait`.
22
+ * - `__tabnode_bg` starts the statement as a run of the engine's own: a pid
23
+ * minted in the engine's process table (the one `child_process`, `kill -0`
24
+ * and `process.kill` read), a run name that a `node` inside the job takes as
25
+ * its own, so its `process.pid` is `$!`, and a copy of the shell's variables,
26
+ * as a forked subshell has. It returns at once.
27
+ * - `__tabnode_wait` waits for the jobs of the shell it runs in (all, `-n` the
28
+ * next, or the pids named) and answers the status of the last one: an exit
29
+ * code, or 128 plus the signal that ended it. `kill` delivers a signal to any
30
+ * process of the engine by pid; `disown` forgets a job.
31
+ *
32
+ * A job's output reaches the host's streams as it is written, and the text the
33
+ * shell's own run collects at the next `wait`, since the shell's result is the
34
+ * concatenation of its commands' results. What the plugin cannot see, it leaves
35
+ * as it was: `eval`'s text is not a script the plugin is shown.
36
+ */
37
+ import { defineCommand } from 'just-bash';
38
+ import type { Bash, CommandContext } from 'just-bash';
39
+ import {
40
+ PROCESS_TOKEN_ENV, enterRun, mintPid, setRunPid, runPid, forgetRunPid, pidIsLive, signalPid,
41
+ tokenOfPid, __signalOwnedProcess, __stopOwnedProcess, type ProcessToken,
42
+ } from '../process-tokens';
43
+ import { __substrateChildren, __substrateSignals, __substrateSignalNames } from './process';
44
+ import { __releaseOwnedServers, __releaseOwnedHandles } from '../node-lib/net-module';
45
+ import type { RunStreams } from './child_process';
46
+ import { unstreamedOutput } from './streamed-output';
47
+
48
+ /** What the engine's process model gives this module. */
49
+ export interface ShellJobsHost {
50
+ runTokenOf(ctx: CommandContext): ProcessToken | null;
51
+ runStreamsFor(token: ProcessToken): RunStreams | undefined;
52
+ registerRunStreams(token: ProcessToken, streams: RunStreams): void;
53
+ releaseRunStreams(token: ProcessToken): void;
54
+ }
55
+
56
+ /** The variable `$!` is read from once the plugin has renamed it. */
57
+ const LAST_JOB_VAR = '__TABNODE_LAST_JOB_PID';
58
+ /** The variable naming the shell a job belongs to; `sh -c` does not inherit it (it is not exported), a subshell copy does. */
59
+ const SHELL_VAR = '__TABNODE_SHELL';
60
+ const BG_COMMAND = '__tabnode_bg';
61
+ const WAIT_COMMAND = '__tabnode_wait';
62
+ const JOB_PLACEHOLDER = '__tabnode_job';
63
+ /** How much of its jobs' output a shell holds for its next `wait`; the rest still reached the host's streams. */
64
+ const PENDING_LIMIT = 1 << 20;
65
+ /** Signals whose default action is to do nothing. */
66
+ const IGNORED_BY_DEFAULT = new Set(['SIGCHLD', 'SIGCONT', 'SIGURG', 'SIGWINCH']);
67
+
68
+ interface Job {
69
+ pid: number;
70
+ token: ProcessToken;
71
+ status: number | null;
72
+ done: Promise<number>;
73
+ kill(signal: string): boolean;
74
+ }
75
+ interface Shell { jobs: Map<number, Job>; order: number[]; pendingOut: string; pendingErr: string }
76
+
77
+ type Node = Record<string, unknown>;
78
+
79
+ const shells = new Map<string, Shell>();
80
+ /** A job's script, from `__tabnode_bg` to the plugin, taken once. */
81
+ const handoff = new Map<string, Node>();
82
+ let nextShell = 1;
83
+ let nextJob = 1;
84
+
85
+ function isNode(value: unknown): value is Node {
86
+ return typeof value === 'object' && value !== null;
87
+ }
88
+
89
+ function literalWord(value: string): Node {
90
+ return { type: 'Word', parts: [{ type: 'Literal', value }] };
91
+ }
92
+
93
+ function wordLiteral(word: unknown): string | null {
94
+ if (!isNode(word) || !Array.isArray(word.parts) || word.parts.length !== 1) return null;
95
+ const part = word.parts[0];
96
+ return isNode(part) && part.type === 'Literal' && typeof part.value === 'string' ? part.value : null;
97
+ }
98
+
99
+ /** A statement whose one command defines a function. */
100
+ function definesFunction(statement: Node): boolean {
101
+ const pipelines = statement.pipelines;
102
+ return Array.isArray(pipelines) && pipelines.some((pipeline) => isNode(pipeline) && Array.isArray(pipeline.commands)
103
+ && pipeline.commands.some((command) => isNode(command) && command.type === 'FunctionDef'));
104
+ }
105
+
106
+ /** Every function definition in a script, outside the background statements (a job carries its own). */
107
+ function functionDefinitions(node: unknown, found: Node[] = []): Node[] {
108
+ if (Array.isArray(node)) { for (const child of node) functionDefinitions(child, found); return found; }
109
+ if (!isNode(node)) return found;
110
+ if (node.type === 'Statement' && node.background === true) return found;
111
+ if (node.type === 'Statement' && definesFunction(node)) found.push(node);
112
+ for (const key of Object.keys(node)) functionDefinitions(node[key], found);
113
+ return found;
114
+ }
115
+
116
+ /**
117
+ * The rewrite, in place: a background statement becomes a call of
118
+ * `__tabnode_bg` (its own subtree is not descended into; the job's script is
119
+ * rewritten when it runs), `$!` reads the job variable, `wait` is the job
120
+ * table's.
121
+ */
122
+ function rewrite(node: unknown, functions: string): void {
123
+ if (Array.isArray(node)) {
124
+ for (let index = 0; index < node.length; index += 1) {
125
+ const child = node[index];
126
+ if (isNode(child) && child.type === 'Statement' && child.background === true) {
127
+ node[index] = backgroundCall(child, functions);
128
+ } else rewrite(child, functions);
129
+ }
130
+ return;
131
+ }
132
+ if (!isNode(node)) return;
133
+ if (node.type === 'ParameterExpansion' && node.parameter === '!') node.parameter = LAST_JOB_VAR;
134
+ if (node.type === 'SimpleCommand' && wordLiteral(node.name) === 'wait') node.name = literalWord(WAIT_COMMAND);
135
+ for (const key of Object.keys(node)) {
136
+ const child = node[key];
137
+ if (isNode(child) && child.type === 'Statement' && child.background === true) node[key] = backgroundCall(child, functions);
138
+ else rewrite(child, functions);
139
+ }
140
+ }
141
+
142
+ function backgroundCall(statement: Node, functions: string): Node {
143
+ const job = JSON.stringify({ ...statement, background: false });
144
+ return {
145
+ type: 'Statement',
146
+ pipelines: [{
147
+ type: 'Pipeline',
148
+ commands: [{
149
+ type: 'SimpleCommand',
150
+ name: literalWord(BG_COMMAND),
151
+ args: [{ type: 'Word', parts: [{ type: 'SingleQuoted', value: job }] }, { type: 'Word', parts: [{ type: 'SingleQuoted', value: functions }] }],
152
+ assignments: [],
153
+ redirections: [],
154
+ }],
155
+ negated: false,
156
+ timed: false,
157
+ timePosix: false,
158
+ }],
159
+ operators: [],
160
+ background: false,
161
+ ...(typeof statement.sourceText === 'string' ? { sourceText: statement.sourceText } : {}),
162
+ };
163
+ }
164
+
165
+ /** The plugin: a job's own script is taken from the handoff, and every script is rewritten. */
166
+ function transform({ ast }: { ast: Node }): { ast: Node } {
167
+ let script = ast;
168
+ const statements = Array.isArray(ast.statements) ? ast.statements : [];
169
+ if (statements.length === 1 && isNode(statements[0])) {
170
+ const pipelines = statements[0].pipelines;
171
+ const command = Array.isArray(pipelines) && isNode(pipelines[0]) && Array.isArray(pipelines[0].commands) ? pipelines[0].commands[0] : undefined;
172
+ if (isNode(command) && command.type === 'SimpleCommand' && wordLiteral(command.name) === ':' && Array.isArray(command.args)
173
+ && command.args.length === 2 && wordLiteral(command.args[0]) === JOB_PLACEHOLDER) {
174
+ const token = wordLiteral(command.args[1]);
175
+ const own = token === null ? undefined : handoff.get(token);
176
+ if (own && token !== null) { handoff.delete(token); script = own; }
177
+ }
178
+ }
179
+ if (!Array.isArray(script.statements) || !JSON.stringify(script).match(/"background":true|"parameter":"!"|"value":"wait"/u)) return { ast: script };
180
+ const functions = JSON.stringify(functionDefinitions(script.statements));
181
+ rewrite(script.statements, functions);
182
+ return { ast: script };
183
+ }
184
+
185
+ function shellOf(ctx: CommandContext): { id: string; shell: Shell } {
186
+ let id = ctx.env.get(SHELL_VAR);
187
+ if (!id) { id = `shell-${nextShell++}`; ctx.env.set(SHELL_VAR, id); }
188
+ let shell = shells.get(id);
189
+ if (!shell) { shell = { jobs: new Map(), order: [], pendingOut: '', pendingErr: '' }; shells.set(id, shell); }
190
+ return { id, shell };
191
+ }
192
+
193
+ function signalNumber(name: string): number {
194
+ return __substrateSignals[name] ?? 15;
195
+ }
196
+
197
+ /** `-TERM`, `-SIGTERM`, `TERM`, `15`, `-15` to Node's name, or null. */
198
+ function signalName(spec: string): string | null {
199
+ const bare = spec.startsWith('-') ? spec.slice(1) : spec;
200
+ if (/^\d+$/u.test(bare)) {
201
+ const number = Number(bare);
202
+ if (number === 0) return '0';
203
+ return __substrateSignalNames[number > 128 ? number - 128 : number] ?? null;
204
+ }
205
+ const upper = bare.toUpperCase();
206
+ const name = upper.startsWith('SIG') ? upper : `SIG${upper}`;
207
+ return __substrateSignals[name] !== undefined ? name : null;
208
+ }
209
+
210
+ function startJob(host: ShellJobsHost, ctx: CommandContext, shell: Shell, script: Node, sourceText: string): Job {
211
+ const token: ProcessToken = `job-${nextJob++}`;
212
+ const parent = host.runTokenOf(ctx);
213
+ const parentPid = runPid(parent)?.pid;
214
+ const pid = mintPid();
215
+ setRunPid(token, pid, parentPid !== undefined && pidIsLive(parentPid) ? parentPid : 0, { argv: ['sh', '-c', sourceText], cwd: ctx.cwd });
216
+ const controller = new AbortController();
217
+ let streamedOut: string[] = [];
218
+ let streamedErr: string[] = [];
219
+ const live = (): RunStreams | undefined => (parent === null ? undefined : host.runStreamsFor(parent));
220
+ host.registerRunStreams(token, {
221
+ onStdout: (data: string) => { streamedOut.push(data); live()?.onStdout?.(data); },
222
+ onStderr: (data: string) => { streamedErr.push(data); live()?.onStderr?.(data); },
223
+ signal: controller.signal,
224
+ held: false,
225
+ });
226
+ let settle: (status: number) => void = () => {};
227
+ const job: Job = {
228
+ pid,
229
+ token,
230
+ status: null,
231
+ done: new Promise<number>((resolve) => { settle = resolve; }),
232
+ kill(signal: string): boolean {
233
+ if (job.status !== null) return false;
234
+ // A `node` of the job, here or in a realm of its own, takes the signal
235
+ // as a process does: its listeners, else its default action, which ends
236
+ // the job with it.
237
+ if (__signalOwnedProcess(token, signal) || signalPid(pid, signal)) return true;
238
+ if (IGNORED_BY_DEFAULT.has(signal)) return true;
239
+ // Nothing of the job's receives signals (a builtin, `sleep`): it ends
240
+ // now, as a killed child's run ends, and reports the signal.
241
+ controller.abort(signal);
242
+ __releaseOwnedServers(token, false);
243
+ __releaseOwnedHandles(token);
244
+ __stopOwnedProcess(token);
245
+ end(128 + signalNumber(signal));
246
+ return true;
247
+ },
248
+ };
249
+ const end = (status: number): void => {
250
+ if (job.status !== null) return;
251
+ job.status = status;
252
+ host.releaseRunStreams(token);
253
+ forgetRunPid(token);
254
+ __substrateChildren.delete(pid);
255
+ settle(status);
256
+ };
257
+ const hold = (text: string, key: 'pendingOut' | 'pendingErr'): void => {
258
+ if (!text) return;
259
+ const room = PENDING_LIMIT - shell[key].length;
260
+ if (room > 0) shell[key] += text.slice(0, room);
261
+ };
262
+ const env: Record<string, string> = Object.fromEntries(ctx.env);
263
+ delete env[SHELL_VAR];
264
+ env[PROCESS_TOKEN_ENV] = token;
265
+ handoff.set(token, script);
266
+ __substrateChildren.set(pid, {
267
+ get exitCode() { return job.status; },
268
+ signalCode: null,
269
+ kill: (signal = 'SIGTERM') => job.kill(signal),
270
+ } as { exitCode: number | null; signalCode: string | null; kill(signal?: string): boolean });
271
+ const run = ctx.exec
272
+ ? enterRun(token, () => ctx.exec!(`: ${JOB_PLACEHOLDER} ${token}`, { env, cwd: ctx.cwd, replaceEnv: true, signal: controller.signal }))
273
+ : Promise.resolve({ stdout: '', stderr: 'bash: background jobs need a shell that can run a script\n', exitCode: 1 });
274
+ void Promise.resolve(run).then(
275
+ (result) => {
276
+ handoff.delete(token);
277
+ const stdout = result.stdout ?? '';
278
+ const stderr = result.stderr ?? '';
279
+ const restOut = unstreamedOutput(stdout, streamedOut);
280
+ const restErr = unstreamedOutput(stderr, streamedErr);
281
+ if (restOut) live()?.onStdout?.(restOut);
282
+ if (restErr) live()?.onStderr?.(restErr);
283
+ hold(stdout, 'pendingOut');
284
+ hold(stderr, 'pendingErr');
285
+ streamedOut = [];
286
+ streamedErr = [];
287
+ end(result.exitCode ?? 0);
288
+ },
289
+ (error: unknown) => {
290
+ handoff.delete(token);
291
+ const text = `${error instanceof Error ? error.message : String(error)}\n`;
292
+ live()?.onStderr?.(text);
293
+ hold(text, 'pendingErr');
294
+ end(1);
295
+ },
296
+ );
297
+ return job;
298
+ }
299
+
300
+ /** The job's output held for the shell's next `wait`, taken. */
301
+ function flush(shell: Shell): { stdout: string; stderr: string } {
302
+ const taken = { stdout: shell.pendingOut, stderr: shell.pendingErr };
303
+ shell.pendingOut = '';
304
+ shell.pendingErr = '';
305
+ return taken;
306
+ }
307
+
308
+ /** The engine's shell, given background jobs. */
309
+ export function installShellJobs(bash: Bash, host: ShellJobsHost): void {
310
+ (bash as unknown as { registerTransformPlugin(plugin: { name: string; transform: typeof transform }): void })
311
+ .registerTransformPlugin({ name: 'tabnode-background-jobs', transform });
312
+
313
+ bash.registerCommand(defineCommand(BG_COMMAND, async (args, ctx) => {
314
+ let statement: Node;
315
+ let functions: Node[];
316
+ try {
317
+ statement = JSON.parse(args[0] ?? '') as Node;
318
+ functions = JSON.parse(args[1] ?? '[]') as Node[];
319
+ } catch {
320
+ return { stdout: '', stderr: `bash: ${BG_COMMAND}: not a job\n`, exitCode: 2 };
321
+ }
322
+ const { shell } = shellOf(ctx);
323
+ const script: Node = { type: 'Script', statements: [...functions, statement] };
324
+ const job = startJob(host, ctx, shell, script, typeof statement.sourceText === 'string' ? statement.sourceText : '');
325
+ shell.jobs.set(job.pid, job);
326
+ shell.order.push(job.pid);
327
+ ctx.env.set(LAST_JOB_VAR, String(job.pid));
328
+ return { stdout: '', stderr: '', exitCode: 0 };
329
+ }));
330
+
331
+ bash.registerCommand(defineCommand(WAIT_COMMAND, async (args, ctx) => {
332
+ const { id, shell } = shellOf(ctx);
333
+ let stderr = '';
334
+ let status = 0;
335
+ let next = false;
336
+ const targets: string[] = [];
337
+ for (const arg of args) {
338
+ if (arg === '-n') next = true;
339
+ else if (arg === '-f' || arg === '--') continue;
340
+ else targets.push(arg);
341
+ }
342
+ const aborted = new Promise<'aborted'>((resolve) => {
343
+ if (ctx.signal?.aborted) resolve('aborted');
344
+ ctx.signal?.addEventListener('abort', () => resolve('aborted'), { once: true });
345
+ });
346
+ const running = (): Job[] => shell.order.map((pid) => shell.jobs.get(pid)).filter((job): job is Job => job !== undefined && job.status === null);
347
+ const reap = (job: Job): void => {
348
+ shell.jobs.delete(job.pid);
349
+ shell.order = shell.order.filter((pid) => pid !== job.pid);
350
+ };
351
+ if (next) {
352
+ const pending = targets.length > 0
353
+ ? targets.map((target) => shell.jobs.get(Number(target))).filter((job): job is Job => job !== undefined)
354
+ // A job that has ended and is not yet reaped is the next one, as bash's is.
355
+ : shell.order.map((pid) => shell.jobs.get(pid)).filter((job): job is Job => job !== undefined);
356
+ if (pending.length === 0) status = 127;
357
+ else {
358
+ const first = await Promise.race([...pending.map((job) => job.done.then(() => job)), aborted]);
359
+ if (first === 'aborted') status = 130;
360
+ else { status = first.status ?? 0; reap(first); }
361
+ }
362
+ } else if (targets.length === 0) {
363
+ // Every job the shell has, including one started while waiting on the others.
364
+ for (let jobs = running(); jobs.length > 0; jobs = running()) {
365
+ if (await Promise.race([Promise.all(jobs.map((job) => job.done)), aborted]) === 'aborted') { status = 130; break; }
366
+ }
367
+ for (const pid of [...shell.order]) { const job = shell.jobs.get(pid); if (job && job.status !== null) reap(job); }
368
+ } else {
369
+ for (const target of targets) {
370
+ if (target.startsWith('%')) { stderr += `bash: wait: ${target}: no such job\n`; status = 127; continue; }
371
+ const pid = Number(target);
372
+ const job = Number.isInteger(pid) ? shell.jobs.get(pid) : undefined;
373
+ if (!job) {
374
+ stderr += Number.isInteger(pid) ? `bash: wait: pid ${target} is not a child of this shell\n` : `bash: wait: \`${target}': not a pid or valid job spec\n`;
375
+ status = 127;
376
+ continue;
377
+ }
378
+ const outcome = await Promise.race([job.done, aborted]);
379
+ if (outcome === 'aborted') { status = 130; break; }
380
+ status = outcome;
381
+ reap(job);
382
+ }
383
+ }
384
+ const held = flush(shell);
385
+ if (shell.jobs.size === 0) shells.delete(id);
386
+ return { stdout: held.stdout, stderr: held.stderr + stderr, exitCode: status };
387
+ }));
388
+
389
+ bash.registerCommand(defineCommand('kill', async (args) => {
390
+ let signal = 'SIGTERM';
391
+ let index = 0;
392
+ if (args[0] === '-l' || args[0] === '-L') {
393
+ const names = Object.entries(__substrateSignals).sort((a, b) => a[1] - b[1]).map(([name]) => name.slice(3));
394
+ if (args[1] !== undefined) {
395
+ const number = Number(args[1]);
396
+ const name = Number.isInteger(number) ? __substrateSignalNames[number > 128 ? number - 128 : number] : signalName(args[1]);
397
+ if (!name) return { stdout: '', stderr: `bash: kill: ${args[1]}: invalid signal specification\n`, exitCode: 1 };
398
+ return { stdout: `${Number.isInteger(number) ? name.slice(3) : __substrateSignals[name]}\n`, stderr: '', exitCode: 0 };
399
+ }
400
+ return { stdout: `${names.join(' ')}\n`, stderr: '', exitCode: 0 };
401
+ }
402
+ if (args[0] === '-s' || args[0] === '-n') {
403
+ const name = args[1] === undefined ? null : signalName(args[1]);
404
+ if (!name) return { stdout: '', stderr: `bash: kill: ${args[1] ?? ''}: invalid signal specification\n`, exitCode: 1 };
405
+ signal = name;
406
+ index = 2;
407
+ } else if (args[0] !== undefined && args[0].startsWith('-') && args[0] !== '--') {
408
+ const name = signalName(args[0]);
409
+ if (!name) return { stdout: '', stderr: `bash: kill: ${args[0].slice(1)}: invalid signal specification\n`, exitCode: 1 };
410
+ signal = name;
411
+ index = 1;
412
+ }
413
+ if (args[index] === '--') index += 1;
414
+ const targets = args.slice(index);
415
+ if (targets.length === 0) return { stdout: '', stderr: 'bash: kill: usage: kill [-s sigspec | -n signum | -sigspec] pid | jobspec ... or kill -l [sigspec]\n', exitCode: 2 };
416
+ let stderr = '';
417
+ let status = 0;
418
+ for (const target of targets) {
419
+ if (target.startsWith('%')) { stderr += `bash: kill: ${target}: no such job\n`; status = 1; continue; }
420
+ if (!/^-?\d+$/u.test(target)) { stderr += `bash: kill: ${target}: arguments must be process or job IDs\n`; status = 1; continue; }
421
+ const pid = Math.abs(Number(target));
422
+ if (!deliver(pid, signal)) { stderr += `bash: kill: (${target}) - No such process\n`; status = 1; }
423
+ }
424
+ return { stdout: '', stderr, exitCode: status };
425
+ }));
426
+
427
+ bash.registerCommand(defineCommand('disown', async (args, ctx) => {
428
+ const { shell } = shellOf(ctx);
429
+ const pids = args.filter((arg) => !arg.startsWith('-')).map(Number);
430
+ const all = args.includes('-a');
431
+ const chosen = all ? [...shell.order] : pids.length > 0 ? pids : shell.order.slice(-1);
432
+ for (const pid of chosen) { shell.jobs.delete(pid); shell.order = shell.order.filter((own) => own !== pid); }
433
+ return { stdout: '', stderr: '', exitCode: 0 };
434
+ }));
435
+ }
436
+
437
+ /**
438
+ * A signal to a process of the engine by pid, as `kill(2)` delivers one:
439
+ * signal 0 asks whether it is there; a child a guest spawned and a shell's job
440
+ * take it through their handle; any other run of the engine through its
441
+ * process, or through the realm that holds it.
442
+ */
443
+ function deliver(pid: number, signal: string): boolean {
444
+ const child = __substrateChildren.get(pid);
445
+ const childLive = child !== undefined && child.exitCode === null && child.signalCode === null;
446
+ if (signal === '0') return childLive || pidIsLive(pid);
447
+ if (childLive) return child.kill(signal);
448
+ const token = tokenOfPid(pid);
449
+ if (token !== null && __signalOwnedProcess(token, signal)) return true;
450
+ return signalPid(pid, signal);
451
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What of a command's collected output was not already streamed.
3
+ *
4
+ * The engine's shell collects a command line's output as one text at its end;
5
+ * a `node` in it (or a background job) also streams its own as it writes. A
6
+ * caller that forwarded the streamed pieces sends the rest at the end. Cutting
7
+ * the collected text at the streamed length assumed the streamed pieces came
8
+ * first: `echo "pid=1"; node -e "console.log('fg out')"; echo end`, spawned by
9
+ * a guest, reached it as `fg out\ng out\nend\n` -- the builtin's line lost and
10
+ * the node's half repeated. Each streamed piece is taken out where it stands in
11
+ * the collected text, in order, and what is left is the rest; a piece the text
12
+ * does not hold is passed over.
13
+ */
14
+ export function unstreamedOutput(total: string, streamed: readonly string[]): string {
15
+ let rest = total;
16
+ let cursor = 0;
17
+ for (const piece of streamed) {
18
+ if (!piece) continue;
19
+ const at = rest.indexOf(piece, cursor);
20
+ if (at < 0) continue;
21
+ rest = rest.slice(0, at) + rest.slice(at + piece.length);
22
+ cursor = at;
23
+ }
24
+ return rest;
25
+ }