@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.
- package/dist/assets/{runtime-worker-DCIwIWHJ.js → runtime-worker-C-l9pCp3.js} +622 -126
- package/dist/index.mjs +568 -91
- package/dist/node-lib/internals/amaro.d.ts +31 -0
- package/dist/node-lib/internals/runtime.d.ts +1 -0
- package/dist/node-lib/node-versions.d.ts +1 -0
- package/dist/node-lib/typescript-module.d.ts +11 -0
- package/dist/process-tokens.d.ts +2 -0
- package/dist/shims/process.d.ts +3 -0
- package/dist/shims/shell-jobs.d.ts +12 -0
- package/dist/shims/streamed-output.d.ts +14 -0
- package/package.json +2 -1
- package/src/node-lib/internal/modules/typescript.js +183 -0
- package/src/node-lib/internals/amaro.ts +51 -0
- package/src/node-lib/internals/index.ts +2 -0
- package/src/node-lib/internals/runtime.ts +16 -1
- package/src/node-lib/node-versions.ts +4 -1
- package/src/node-lib/sources.ts +2 -0
- package/src/node-lib/typescript-module.ts +50 -0
- package/src/process-tokens.ts +6 -0
- package/src/runtime.ts +38 -15
- package/src/shims/child_process.ts +15 -7
- package/src/shims/process.ts +2 -2
- package/src/shims/shell-jobs.ts +451 -0
- package/src/shims/streamed-output.ts +25 -0
|
@@ -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
|
-
|
|
1809
|
-
|
|
1813
|
+
const streamedOut: string[] = [];
|
|
1814
|
+
const streamedErr: string[] = [];
|
|
1810
1815
|
const streams: RunStreams = {
|
|
1811
|
-
onStdout: (data: string) => { streamedOut
|
|
1812
|
-
onStderr: (data: string) => { streamedErr
|
|
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
|
-
|
|
1904
|
-
|
|
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
|
};
|
package/src/shims/process.ts
CHANGED
|
@@ -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
|
+
}
|