withruntime 0.7.2 → 0.8.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.
@@ -0,0 +1,526 @@
1
+ import { checkEnvNames, httpDate, parseProcessLine, processLine, randomName, RUNTIME_HOME, toBlaxelPath, toRuntimePath, } from "./context.js";
2
+ import { codeOf, guard, ResponseError, responseError, translate } from "./errors.js";
3
+ /** Commands without an end of their own run as long as Runtime allows. */
4
+ const LONGEST_MS = 86_400_000;
5
+ /** A keepAlive process is killed after this long unless `timeout` says. */
6
+ const KEEP_ALIVE_SECONDS = 600;
7
+ const SIGKILL = 9;
8
+ /** Per sandbox, shared by every object this module makes for it. A fresh
9
+ * client starts empty and finds processes by their marker. */
10
+ const registries = new Map();
11
+ function registryOf(sandboxId) {
12
+ let registry = registries.get(sandboxId);
13
+ if (!registry) {
14
+ registry = { byName: new Map(), meta: new Map(), processes: new Map() };
15
+ registries.set(sandboxId, registry);
16
+ }
17
+ return registry;
18
+ }
19
+ /** The loopback addresses in /proc/net/tcp and tcp6: a server bound only to
20
+ * one cannot be reached from outside, so Blaxel does not count it. */
21
+ const LOOPBACK = [
22
+ "0100007F",
23
+ "00000000000000000000000001000000",
24
+ "0000000000000000FFFF00000100007F",
25
+ ];
26
+ /** A command that waits until every port listens on an address other than
27
+ * loopback, up to `seconds`, reading whichever of the kernel's socket tables
28
+ * exist (a guest without IPv6 has no tcp6). Exits 0 when they all listen. */
29
+ export function portWaitScript(ports, seconds, tables = ["/proc/net/tcp", "/proc/net/tcp6"]) {
30
+ const hex = ports.map((port) => port.toString(16).toUpperCase().padStart(4, "0"));
31
+ const skip = LOOPBACK.map((address) => `a[1] != "${address}"`).join(" && ");
32
+ return (`t=""; for f in ${tables.join(" ")}; do [ -r "$f" ] && t="$t $f"; done; ` +
33
+ `end=$((SECONDS+${seconds})); while :; do ok=1; for h in ${hex.join(" ")}; do ` +
34
+ `awk -v h="$h" '$4 == "0A" { split($2, a, ":"); if (a[2] == h && ${skip}) f = 1 } END { exit !f }' ` +
35
+ `$t </dev/null || ok=0; done; [ $ok = 1 ] && exit 0; ` +
36
+ `[ $SECONDS -ge $end ] && exit 1; sleep 0.2; done`);
37
+ }
38
+ /** Test hook: forget every process this module has seen, as a fresh client
39
+ * would. */
40
+ export function resetProcesses() {
41
+ registries.clear();
42
+ }
43
+ /** Blaxel's status from Runtime's record. A process ended by a signal has a
44
+ * negative exit code on Runtime (-15 after SIGTERM, -9 after SIGKILL or its
45
+ * time limit): SIGKILL is killed, any other signal stopped, from any client. */
46
+ function statusOf(state, exitCode) {
47
+ if (state === "running" && exitCode === null)
48
+ return "running";
49
+ if (exitCode === null)
50
+ return "killed";
51
+ if (exitCode < 0)
52
+ return exitCode === -SIGKILL ? "killed" : "stopped";
53
+ return exitCode === 0 ? "completed" : "failed";
54
+ }
55
+ /** Blaxel's restart notes in a process's output: the last is its restart count. */
56
+ function restartsIn(logs) {
57
+ let count = 0;
58
+ for (const [, n] of logs.matchAll(/\n\[Process failed with exit code -?\d+\. Attempting restart (\d+)\//g))
59
+ count = Math.max(count, Number(n));
60
+ return count;
61
+ }
62
+ /** `sandbox.process`: run, follow and stop processes, by pid or by name. */
63
+ export class SandboxProcess {
64
+ #ctx;
65
+ constructor(ctx) {
66
+ this.#ctx = ctx;
67
+ }
68
+ get #registry() {
69
+ return registryOf(this.#ctx.id);
70
+ }
71
+ #remember(id, meta, process) {
72
+ const registry = this.#registry;
73
+ registry.byName.set(meta.name, id);
74
+ registry.meta.set(id, meta);
75
+ if (process)
76
+ registry.processes.set(id, process);
77
+ }
78
+ /** Starts a command. Without waitForCompletion it answers at once, the
79
+ * process running; with it, once the process ends, with its output. */
80
+ async exec(request) {
81
+ const { onLog, onStdout, onStderr, ...req } = request;
82
+ if (typeof req.command !== "string" || req.command === "")
83
+ throw responseError(400, "command is required");
84
+ const env = req.env ?? {};
85
+ checkEnvNames(Object.keys(env));
86
+ const meta = {
87
+ name: req.name || randomName(),
88
+ command: req.command,
89
+ workingDir: req.workingDir ?? "",
90
+ ...(req.keepAlive ? { keepAlive: true } : {}),
91
+ ...(req.restartOnFailure
92
+ ? { restartOnFailure: true, maxRestarts: req.maxRestarts ?? 0 }
93
+ : {}),
94
+ ...(req.stdin ? { stdin: true } : {}),
95
+ };
96
+ const line = processLine(req.command, {
97
+ name: meta.name,
98
+ ...(req.restartOnFailure ? { maxRestarts: req.maxRestarts ?? 0 } : {}),
99
+ linkHome: /\/blaxel(\/|\s|$|["'`;:])/.test(`${req.command} ${Object.values(env).join(" ")}`),
100
+ ...(req.keepAlive ? { keepAlive: true } : {}),
101
+ });
102
+ const names = Object.keys(env);
103
+ const options = {
104
+ cwd: req.workingDir ? toRuntimePath(req.workingDir) : RUNTIME_HOME,
105
+ ...(names.length ? { env: { ...env, RUNTIME_BLAXEL_KEEP: `:${names.join(":")}:` } } : {}),
106
+ timeoutMs: this.#killAfterMs(req),
107
+ };
108
+ // Awake first: a sandbox that cannot be kept awake refuses the process
109
+ // rather than pausing under it.
110
+ if (req.keepAlive)
111
+ await this.#ctx.keepAwake(this.#killAfterMs(req) / 1000);
112
+ const callbacks = onLog || onStdout || onStderr;
113
+ if (req.waitForCompletion && !req.stdin && !req.waitForPorts?.length)
114
+ return await this.#execWaiting(line, options, meta, req.timeout, {
115
+ ...(onLog ? { onLog } : {}),
116
+ ...(onStdout ? { onStdout } : {}),
117
+ ...(onStderr ? { onStderr } : {}),
118
+ });
119
+ const runtime = await this.#ctx.live();
120
+ const process = await this.#retryPaused(() => runtime.spawn(line, { ...options, ...(req.stdin ? { stdin: "pipe" } : {}) }));
121
+ this.#remember(process.id, meta, process);
122
+ if (req.waitForPorts?.length)
123
+ await this.#waitForPorts(req.waitForPorts, req.timeout);
124
+ if (req.waitForCompletion) {
125
+ const bounded = req.timeout !== undefined && req.timeout > 0;
126
+ const done = await this.wait(process.id, {
127
+ maxWait: bounded ? req.timeout * 1000 : -1,
128
+ }).catch((error) => {
129
+ throw bounded && !(error instanceof ResponseError)
130
+ ? responseError(422, `process timed out after ${req.timeout} seconds`)
131
+ : error;
132
+ });
133
+ if (callbacks)
134
+ replay(done, { onLog, onStdout, onStderr });
135
+ return { ...done, close: () => undefined };
136
+ }
137
+ const running = this.#response(process.info, meta, { stdout: "", stderr: "", logs: "" });
138
+ if (!callbacks)
139
+ return running;
140
+ const stream = this.streamLogs(process.id, {
141
+ ...(onLog ? { onLog } : {}),
142
+ ...(onStdout ? { onStdout } : {}),
143
+ ...(onStderr ? { onStderr } : {}),
144
+ });
145
+ return { ...running, close: () => stream.close() };
146
+ }
147
+ #killAfterMs(req) {
148
+ if (!req.keepAlive)
149
+ return LONGEST_MS;
150
+ const seconds = req.timeout === undefined || req.timeout < 0 ? KEEP_ALIVE_SECONDS : req.timeout;
151
+ return seconds === 0 ? LONGEST_MS : Math.min(LONGEST_MS, seconds * 1000);
152
+ }
153
+ /** A call that found the sandbox paused (autoWake off) is made again once,
154
+ * after a wake: it never started, so it cannot run twice. */
155
+ async #retryPaused(work) {
156
+ try {
157
+ return await work();
158
+ }
159
+ catch (error) {
160
+ if (codeOf(error) !== "sandbox_paused")
161
+ throw translate(error);
162
+ await this.#ctx.wake();
163
+ return guard(work);
164
+ }
165
+ }
166
+ /** One streamed exec: the process's id arrives first, then its output, then
167
+ * its end. Past `timeout` seconds the wait gives up and the process keeps
168
+ * running, as in Blaxel. */
169
+ async #execWaiting(line, options, meta, timeout, callbacks) {
170
+ const runtime = await this.#ctx.live();
171
+ const abort = new AbortController();
172
+ let timedOut = false;
173
+ const timer = timeout && timeout > 0
174
+ ? setTimeout(() => {
175
+ timedOut = true;
176
+ abort.abort();
177
+ }, timeout * 1000)
178
+ : undefined;
179
+ const out = { stdout: "", stderr: "", logs: "" };
180
+ let id;
181
+ let exit;
182
+ const startedAt = new Date().toISOString();
183
+ const run = async () => {
184
+ for await (const event of runtime.execStream(line, { ...options, signal: abort.signal })) {
185
+ if (event.type === "start") {
186
+ id = event.processId;
187
+ this.#remember(id, meta);
188
+ }
189
+ else if (event.type === "stdout" || event.type === "stderr") {
190
+ out[event.type] += event.data;
191
+ out.logs += event.data;
192
+ (event.type === "stdout" ? callbacks.onStdout : callbacks.onStderr)?.(event.data);
193
+ callbacks.onLog?.(event.data);
194
+ }
195
+ else if (event.type === "exit")
196
+ exit = event;
197
+ }
198
+ };
199
+ try {
200
+ try {
201
+ await run();
202
+ }
203
+ catch (error) {
204
+ if (timedOut)
205
+ throw error;
206
+ if (id !== undefined || codeOf(error) !== "sandbox_paused")
207
+ throw error;
208
+ await this.#ctx.wake();
209
+ await run();
210
+ }
211
+ }
212
+ catch (error) {
213
+ if (timedOut)
214
+ throw responseError(422, `process timed out after ${timeout} seconds`);
215
+ throw translate(error);
216
+ }
217
+ finally {
218
+ clearTimeout(timer);
219
+ }
220
+ const info = {
221
+ id: id ?? "",
222
+ kind: "process",
223
+ state: exit?.state ?? "exited",
224
+ exitCode: exit?.exitCode ?? null,
225
+ command: line,
226
+ cwd: options.cwd,
227
+ pty: false,
228
+ stdinOpen: false,
229
+ stdinOffset: 0,
230
+ startedAt,
231
+ endedAt: new Date().toISOString(),
232
+ timeoutMs: options.timeoutMs,
233
+ outputBytes: 0,
234
+ firstOffset: 0,
235
+ };
236
+ const done = await this.#settled(this.#response(info, meta, out));
237
+ return { ...done, close: () => abort.abort() };
238
+ }
239
+ /** Waits until every port listens on an address other than loopback, as
240
+ * Blaxel's waitForPorts, in one command inside the sandbox. */
241
+ async #waitForPorts(ports, timeout) {
242
+ const seconds = timeout && timeout > 0 ? timeout : 60;
243
+ for (const port of ports)
244
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
245
+ throw responseError(400, `invalid port ${port}`);
246
+ const script = portWaitScript(ports, seconds);
247
+ const runtime = await this.#ctx.live();
248
+ const result = await guard(() => runtime.exec(["bash", "-c", script], { timeoutMs: (seconds + 5) * 1000 }));
249
+ if (result.exitCode !== 0)
250
+ throw responseError(422, `process timed out waiting for ports after ${seconds} seconds`);
251
+ }
252
+ /** A keepAlive process seen to end gives the sandbox its idle pause back
253
+ * (when no other runs) before the answer. */
254
+ async #settled(response) {
255
+ if (response.keepAlive && response.status !== "running")
256
+ await this.#ctx.keepAliveEnded();
257
+ return response;
258
+ }
259
+ #response(info, meta, out) {
260
+ const parsed = meta ?? metaOf(info);
261
+ const status = statusOf(info.state, info.exitCode);
262
+ return {
263
+ command: parsed.command,
264
+ completedAt: status === "running" ? "" : httpDate(info.endedAt ?? Date.now()),
265
+ exitCode: info.exitCode === null ? (status === "running" ? 0 : -1) : Math.max(-1, info.exitCode),
266
+ logs: out.logs,
267
+ name: parsed.name,
268
+ pid: info.id,
269
+ startedAt: httpDate(info.startedAt),
270
+ status,
271
+ stderr: out.stderr,
272
+ stdout: out.stdout,
273
+ workingDir: parsed.workingDir,
274
+ keepAlive: parsed.keepAlive ?? false,
275
+ restartOnFailure: parsed.restartOnFailure ?? false,
276
+ maxRestarts: parsed.maxRestarts ?? 0,
277
+ restartCount: restartsIn(out.logs),
278
+ stdin: parsed.stdin ?? false,
279
+ };
280
+ }
281
+ /** A process by pid or name: the registry first, then Runtime's list. */
282
+ async #find(identifier) {
283
+ const runtime = await this.#ctx.live();
284
+ const registry = this.#registry;
285
+ const known = registry.byName.get(identifier) ?? (registry.meta.has(identifier) ? identifier : undefined);
286
+ if (known !== undefined) {
287
+ const process = await this.#retryPaused(() => runtime.processes.get(known));
288
+ registry.processes.set(known, process);
289
+ return { id: known, info: process.info, process };
290
+ }
291
+ const all = await this.#retryPaused(() => runtime.processes.list());
292
+ let found;
293
+ for (const info of all) {
294
+ const parsed = parseProcessLine(info.command);
295
+ const matches = parsed?.name === identifier || info.id === identifier;
296
+ if (matches && (!found || Date.parse(info.startedAt) >= Date.parse(found.startedAt)))
297
+ found = info;
298
+ }
299
+ if (!found)
300
+ throw responseError(404, "process not found");
301
+ const parsed = parseProcessLine(found.command);
302
+ if (parsed)
303
+ this.#remember(found.id, { ...metaOf(found), name: parsed.name });
304
+ return { id: found.id, info: found };
305
+ }
306
+ async #process(found) {
307
+ if (found.process)
308
+ return found.process;
309
+ const cached = this.#registry.processes.get(found.id);
310
+ if (cached)
311
+ return cached;
312
+ const runtime = await this.#ctx.live();
313
+ const process = await guard(() => runtime.processes.get(found.id));
314
+ this.#registry.processes.set(found.id, process);
315
+ return process;
316
+ }
317
+ /** The output so far: to the end for a process that has ended, to what it
318
+ * had written when read for one still running. */
319
+ async #output(found, untilExit, signal) {
320
+ const out = { stdout: "", stderr: "", logs: "" };
321
+ const running = found.info.state === "running";
322
+ if (running && !untilExit && found.info.outputBytes <= found.info.firstOffset)
323
+ return out;
324
+ const runtime = await this.#ctx.live();
325
+ const abort = new AbortController();
326
+ const forward = () => abort.abort();
327
+ signal?.addEventListener("abort", forward, { once: true });
328
+ try {
329
+ for await (const event of runtime.processes.follow(found.id, {
330
+ cursor: 0,
331
+ signal: abort.signal,
332
+ })) {
333
+ if (event.type === "stdout" || event.type === "stderr") {
334
+ out[event.type] += event.data;
335
+ out.logs += event.data;
336
+ const reached = event.offset + Buffer.byteLength(event.data);
337
+ if (running && !untilExit && reached >= found.info.outputBytes)
338
+ break;
339
+ }
340
+ else if (event.type === "exit") {
341
+ found.info = {
342
+ ...found.info,
343
+ state: event.state,
344
+ exitCode: event.exitCode,
345
+ endedAt: found.info.endedAt ?? new Date().toISOString(),
346
+ };
347
+ break;
348
+ }
349
+ }
350
+ // A follow cut short by the caller's signal ends without its exit.
351
+ signal?.throwIfAborted();
352
+ }
353
+ catch (error) {
354
+ if (signal?.aborted)
355
+ throw signal.reason ?? error;
356
+ throw translate(error);
357
+ }
358
+ finally {
359
+ abort.abort();
360
+ signal?.removeEventListener("abort", forward);
361
+ }
362
+ return out;
363
+ }
364
+ /** The process, with the output it has written so far. */
365
+ async get(identifier, _options = {}) {
366
+ const found = await this.#find(identifier);
367
+ const out = await this.#output(found, false);
368
+ return this.#settled(this.#response(found.info, this.#registry.meta.get(found.id), out));
369
+ }
370
+ /** Waits for the process to end (at most `maxWait` ms, -1 for no limit) and
371
+ * answers it with its output. The process is never stopped by the wait. */
372
+ async wait(identifier, { maxWait = 60_000, interval = 1000, signal, } = {}) {
373
+ if (!Number.isFinite(maxWait) ||
374
+ (maxWait < 0 && maxWait !== -1) ||
375
+ !Number.isFinite(interval) ||
376
+ interval <= 0)
377
+ throw new RangeError("maxWait must be -1 or finite and non-negative; interval must be finite and positive");
378
+ signal?.throwIfAborted();
379
+ const late = () => new Error(`Process did not finish in time (${identifier}); it may still be running`);
380
+ if (maxWait === 0)
381
+ throw late();
382
+ const abort = new AbortController();
383
+ const forward = () => abort.abort(signal?.reason);
384
+ signal?.addEventListener("abort", forward, { once: true });
385
+ const timer = maxWait === -1 ? undefined : setTimeout(() => abort.abort(late()), maxWait);
386
+ try {
387
+ const found = await this.#find(identifier);
388
+ const out = await this.#output(found, true, abort.signal);
389
+ return await this.#settled(this.#response(found.info, this.#registry.meta.get(found.id), out));
390
+ }
391
+ catch (error) {
392
+ if (abort.signal.aborted)
393
+ throw abort.signal.reason ?? error;
394
+ throw error;
395
+ }
396
+ finally {
397
+ clearTimeout(timer);
398
+ signal?.removeEventListener("abort", forward);
399
+ }
400
+ }
401
+ /** Every process started through this adapter, running or ended. Their
402
+ * output is left empty: read one with get() or logs(). */
403
+ async list() {
404
+ const runtime = await this.#ctx.live();
405
+ const all = await this.#retryPaused(() => runtime.processes.list());
406
+ const registry = this.#registry;
407
+ return all
408
+ .filter((info) => registry.meta.has(info.id) || parseProcessLine(info.command))
409
+ .map((info) => this.#response(info, registry.meta.get(info.id), { stdout: "", stderr: "", logs: "" }));
410
+ }
411
+ /** SIGTERM. */
412
+ async stop(identifier) {
413
+ await this.#signal(identifier, "SIGTERM");
414
+ return { message: "Process stop requested" };
415
+ }
416
+ /** SIGKILL. */
417
+ async kill(identifier) {
418
+ await this.#signal(identifier, "SIGKILL");
419
+ return { message: "Process kill requested" };
420
+ }
421
+ /** Signals the process. A keepAlive one is given up to 2 s to end, and then
422
+ * the sandbox gets its idle pause back when no other runs. */
423
+ async #signal(identifier, signal) {
424
+ const found = await this.#find(identifier);
425
+ const process = await this.#process(found);
426
+ await guard(() => process.kill(signal));
427
+ const meta = this.#registry.meta.get(found.id) ?? metaOf(found.info);
428
+ if (!meta.keepAlive)
429
+ return;
430
+ const abort = new AbortController();
431
+ const timer = setTimeout(() => abort.abort(), 2000);
432
+ try {
433
+ await this.#output({ ...found, info: { ...found.info, state: "running" } }, true, abort.signal);
434
+ }
435
+ catch {
436
+ // Still ending after 2 s: the next check gives the idle pause back.
437
+ }
438
+ finally {
439
+ clearTimeout(timer);
440
+ }
441
+ await this.#ctx.keepAliveEnded();
442
+ }
443
+ /** Bytes to the standard input of a process started with `stdin: true`,
444
+ * sent as they are. */
445
+ async writeStdin(identifier, data) {
446
+ const process = await this.#process(await this.#find(identifier));
447
+ await guard(() => process.write(data));
448
+ }
449
+ /** Closes the process's standard input (EOF). */
450
+ async closeStdin(identifier) {
451
+ const process = await this.#process(await this.#find(identifier));
452
+ await guard(() => process.write("", { eof: true }));
453
+ }
454
+ /** The output so far: both streams in the order written ("all"), or one. */
455
+ async logs(identifier, type = "all") {
456
+ if (type !== "all" && type !== "stdout" && type !== "stderr")
457
+ throw new Error("Unsupported log type");
458
+ const found = await this.#find(identifier);
459
+ const out = await this.#output(found, false);
460
+ return type === "all" ? out.logs : out[type];
461
+ }
462
+ /** Follows the output from the start until the process ends, a line at a
463
+ * time, as Blaxel's streamLogs. `close()` stops following. */
464
+ streamLogs(identifier, options = {}) {
465
+ const abort = new AbortController();
466
+ const partial = { stdout: "", stderr: "" };
467
+ const emit = (stream, line) => {
468
+ (stream === "stdout" ? options.onStdout : options.onStderr)?.(line);
469
+ options.onLog?.(line);
470
+ };
471
+ const done = (async () => {
472
+ try {
473
+ const found = await this.#find(identifier);
474
+ const runtime = await this.#ctx.live();
475
+ for await (const event of runtime.processes.follow(found.id, {
476
+ cursor: 0,
477
+ signal: abort.signal,
478
+ })) {
479
+ if (event.type === "stdout" || event.type === "stderr") {
480
+ const lines = (partial[event.type] + event.data).split(/\r?\n/);
481
+ partial[event.type] = lines.pop();
482
+ for (const line of lines)
483
+ emit(event.type, line);
484
+ }
485
+ else if (event.type === "exit")
486
+ break;
487
+ }
488
+ }
489
+ catch (error) {
490
+ if (abort.signal.aborted)
491
+ return;
492
+ const failure = translate(error);
493
+ const asError = failure instanceof Error ? failure : new Error(String(failure));
494
+ options.onError?.(asError);
495
+ throw asError;
496
+ }
497
+ finally {
498
+ for (const stream of ["stdout", "stderr"])
499
+ if (partial[stream].trim())
500
+ emit(stream, partial[stream]);
501
+ }
502
+ })();
503
+ void done.catch(() => undefined);
504
+ return { close: () => abort.abort(), wait: () => done };
505
+ }
506
+ }
507
+ /** What a process's recorded command says about it, when this adapter did
508
+ * not start it in this client. */
509
+ function metaOf(info) {
510
+ const parsed = parseProcessLine(info.command);
511
+ return {
512
+ name: parsed?.name ?? info.id,
513
+ command: parsed?.command ?? info.command,
514
+ workingDir: info.cwd === RUNTIME_HOME ? "" : toBlaxelPath(info.cwd),
515
+ ...(parsed?.keepAlive ? { keepAlive: true } : {}),
516
+ };
517
+ }
518
+ /** Output a waited process already wrote, given to the callbacks line by line. */
519
+ function replay(done, callbacks) {
520
+ for (const line of done.stdout.split("\n").filter(Boolean))
521
+ callbacks.onStdout?.(line);
522
+ for (const line of done.stderr.split("\n").filter(Boolean))
523
+ callbacks.onStderr?.(line);
524
+ for (const line of done.logs.split("\n").filter(Boolean))
525
+ callbacks.onLog?.(line);
526
+ }