@prized/sdk 0.0.0-stage → 0.1.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.
- package/LICENSE +202 -0
- package/README.md +625 -2
- package/dist/cjs/commands.d.ts +168 -0
- package/dist/cjs/commands.js +567 -0
- package/dist/cjs/errors.d.ts +73 -0
- package/dist/cjs/errors.js +258 -0
- package/dist/cjs/files.d.ts +86 -0
- package/dist/cjs/files.js +366 -0
- package/dist/cjs/http.d.ts +210 -0
- package/dist/cjs/http.js +570 -0
- package/dist/cjs/index.d.ts +12 -0
- package/dist/cjs/index.js +22 -0
- package/dist/cjs/outcome.d.ts +75 -0
- package/dist/cjs/outcome.js +167 -0
- package/dist/cjs/output.d.ts +59 -0
- package/dist/cjs/output.js +288 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/sandbox.d.ts +218 -0
- package/dist/cjs/sandbox.js +810 -0
- package/dist/cjs/shell.d.ts +152 -0
- package/dist/cjs/shell.js +393 -0
- package/dist/cjs/toolbox.d.ts +48 -0
- package/dist/cjs/toolbox.js +86 -0
- package/dist/cjs/types.d.ts +99 -0
- package/dist/cjs/types.js +102 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/esm/commands.d.ts +168 -0
- package/dist/esm/commands.js +560 -0
- package/dist/esm/errors.d.ts +73 -0
- package/dist/esm/errors.js +250 -0
- package/dist/esm/files.d.ts +86 -0
- package/dist/esm/files.js +360 -0
- package/dist/esm/http.d.ts +210 -0
- package/dist/esm/http.js +553 -0
- package/dist/esm/index.d.ts +12 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/outcome.d.ts +75 -0
- package/dist/esm/outcome.js +152 -0
- package/dist/esm/output.d.ts +59 -0
- package/dist/esm/output.js +280 -0
- package/dist/esm/sandbox.d.ts +218 -0
- package/dist/esm/sandbox.js +806 -0
- package/dist/esm/shell.d.ts +152 -0
- package/dist/esm/shell.js +379 -0
- package/dist/esm/toolbox.d.ts +48 -0
- package/dist/esm/toolbox.js +81 -0
- package/dist/esm/types.d.ts +99 -0
- package/dist/esm/types.js +95 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/package.json +53 -4
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// What an error says about a command that may have run. A command, a file
|
|
2
|
+
// write, a remove or a signal (`exec` retry mode) is never sent again once it
|
|
3
|
+
// may have reached the sandbox, so its error has to say what the caller cannot
|
|
4
|
+
// see: whether it happened. The Python SDK words these the same (_core.py).
|
|
5
|
+
import { defaultMessage, PrizedError } from './errors.js';
|
|
6
|
+
const NOT_SENT_AGAIN = 'It was not sent again';
|
|
7
|
+
export function commandEffect() {
|
|
8
|
+
return {
|
|
9
|
+
maybe: 'the command may or may not have run',
|
|
10
|
+
did: 'The command ran',
|
|
11
|
+
check: `${NOT_SENT_AGAIN}: check for its effects (the files it writes, the processes it starts) before you run it again.`,
|
|
12
|
+
// The sandbox runs a command under its request, in a process group of
|
|
13
|
+
// its own that it KILLs when the request ends, and the edge forwards the
|
|
14
|
+
// request under the caller's connection: a closed connection kills it.
|
|
15
|
+
onClose: 'The sandbox stops a command (and its process group) once its connection closes, so it may have run in part.',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function writeEffect(path) {
|
|
19
|
+
return {
|
|
20
|
+
maybe: `${path} may or may not have been written`,
|
|
21
|
+
did: `${path} was written`,
|
|
22
|
+
check: `${NOT_SENT_AGAIN}: read it back with files.read() to see which.`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function removeEffect(path) {
|
|
26
|
+
return {
|
|
27
|
+
maybe: `${path} may or may not have been removed`,
|
|
28
|
+
did: `The remove of ${path} ran`,
|
|
29
|
+
check: `${NOT_SENT_AGAIN}: files.exists() tells you which.`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** For CommandHandle.kill (`signal` without its SIG prefix). */
|
|
33
|
+
export function signalEffect(runId, signal) {
|
|
34
|
+
return {
|
|
35
|
+
maybe: `SIG${signal} may or may not have reached ${runId}`,
|
|
36
|
+
did: `The kill of ${runId} ran`,
|
|
37
|
+
check: `${NOT_SENT_AGAIN}: status() tells you whether the command is still running.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** For a background start (or a streamed or long exec, which runs as one): said before what the SDK did about it (startAbandoned). A 2xx proves only that the start ran. */
|
|
41
|
+
export function startEffect() {
|
|
42
|
+
return { maybe: 'the command may or may not have started', did: 'The sandbox ran the start', check: '' };
|
|
43
|
+
}
|
|
44
|
+
/** For an SDK helper sent in `exec` mode that names no effect of its own. */
|
|
45
|
+
export function helperEffect() {
|
|
46
|
+
return { maybe: 'it may or may not have taken effect', did: 'It ran', check: `${NOT_SENT_AGAIN}.` };
|
|
47
|
+
}
|
|
48
|
+
const acted = new WeakSet();
|
|
49
|
+
/** Whether withOutcome said the sandbox may have acted on the request. */
|
|
50
|
+
export function mayHaveActed(error) {
|
|
51
|
+
return error instanceof PrizedError && acted.has(error);
|
|
52
|
+
}
|
|
53
|
+
/** `error` with another message (and, given, another status, code, sandbox or cause), every other field kept. */
|
|
54
|
+
export function restate(error, message, init = {}) {
|
|
55
|
+
return new PrizedError(init.code ?? error.code, message, {
|
|
56
|
+
status: init.status ?? error.status,
|
|
57
|
+
reason: error.reason,
|
|
58
|
+
sandboxId: error.sandboxId ?? init.sandboxId ?? null,
|
|
59
|
+
body: error.body,
|
|
60
|
+
retryAfter: error.retryAfter,
|
|
61
|
+
cause: 'cause' in init ? init.cause : error.cause,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** `error` restated with `message` (and, given, another sandbox or cause), as one after which the sandbox may have acted. */
|
|
65
|
+
function acting(error, message, init = {}) {
|
|
66
|
+
const restated = restate(error, message, init);
|
|
67
|
+
acted.add(restated);
|
|
68
|
+
return restated;
|
|
69
|
+
}
|
|
70
|
+
function trimmed(text) {
|
|
71
|
+
return text.replace(/[.\s]+$/, '');
|
|
72
|
+
}
|
|
73
|
+
/** The failure in a few words, for inside a sentence. */
|
|
74
|
+
function detail(error) {
|
|
75
|
+
const message = trimmed(error.message);
|
|
76
|
+
if (error.status === null || error.code === 'http_error' || error.code === 'bad_response')
|
|
77
|
+
return message;
|
|
78
|
+
const head = `HTTP ${error.status} ${error.code}`;
|
|
79
|
+
return message === trimmed(defaultMessage(error.code)) ? head : `${head}: ${message}`;
|
|
80
|
+
}
|
|
81
|
+
/** What follows "so" when no clear answer came: what may have happened, then (`closed`: the caller aborted) what a closed connection does, then how to check. */
|
|
82
|
+
function maybeTail(effect, closed) {
|
|
83
|
+
let tail = `${effect.maybe}.`;
|
|
84
|
+
if (closed && effect.onClose)
|
|
85
|
+
tail += ` ${effect.onClose}`;
|
|
86
|
+
if (effect.check)
|
|
87
|
+
tail += ` ${effect.check}`;
|
|
88
|
+
return tail;
|
|
89
|
+
}
|
|
90
|
+
/** `error` (same code, status and body), its message saying what it means for `effect`. */
|
|
91
|
+
export function withOutcome(error, effect, outcome) {
|
|
92
|
+
if (outcome === null)
|
|
93
|
+
return error;
|
|
94
|
+
if (outcome === 'never')
|
|
95
|
+
return restate(error, `${trimmed(error.message)}. It never reached the sandbox, so it had no effect.`);
|
|
96
|
+
if (outcome === 'did')
|
|
97
|
+
return acting(error, `${effect.did}, but the answer could not be read (${detail(error)}).`);
|
|
98
|
+
return acting(error, `No clear answer came back (${detail(error)}), so ${maybeTail(effect, false)}`);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `error` (code `aborted`) for a request with an `effect`: `never` when
|
|
102
|
+
* nothing that may have reached the sandbox was sent (the signal was
|
|
103
|
+
* aborted before an attempt, or while the SDK waited to send one again
|
|
104
|
+
* after attempts that never arrived), `did` when a 2xx status came before
|
|
105
|
+
* the abort cut its body, else `maybe`, with what the sandbox does once a
|
|
106
|
+
* connection closes. TypeScript only: the Python SDK takes no signal.
|
|
107
|
+
*/
|
|
108
|
+
export function withAbort(error, effect, outcome) {
|
|
109
|
+
if (outcome !== 'maybe')
|
|
110
|
+
return withOutcome(error, effect, outcome);
|
|
111
|
+
return acting(error, `Aborted before a clear answer came, so ${maybeTail(effect, true)}`);
|
|
112
|
+
}
|
|
113
|
+
/** What the SDK did about a run it stopped by its id (`stopped`), or could not confirm it did. */
|
|
114
|
+
function stopTail(runId, stopped) {
|
|
115
|
+
return stopped
|
|
116
|
+
? ` The SDK stopped ${runId}, or it had already ended.`
|
|
117
|
+
: ` The SDK could not confirm it stopped: it may still be running as ${runId} (exec('ps -ef | grep ${runId}') finds it).`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* A background start that may have started (`error`, from withOutcome or
|
|
121
|
+
* withAbort) and that the SDK then stopped by its run id (`stopped`: the
|
|
122
|
+
* abandon script answered exit 0, so the run can no longer start, was
|
|
123
|
+
* killed with its group, or had already ended), or could not confirm it did
|
|
124
|
+
* (no answer, or exit 3: no pid came, so the command may yet run). The
|
|
125
|
+
* Python SDK's start_abandoned.
|
|
126
|
+
*/
|
|
127
|
+
export function startAbandoned(error, runId, stopped) {
|
|
128
|
+
const said = error.message.trimEnd();
|
|
129
|
+
return acting(error, (/[.!?]$/.test(said) ? said : `${said}.`) + stopTail(runId, stopped));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* A streamed or long exec the caller aborted after its run started
|
|
133
|
+
* (`error`, code `aborted`, from `sandboxId`), saying whether the SDK then
|
|
134
|
+
* stopped the run by its id (`stopped`: its KILL was sent, or the run had
|
|
135
|
+
* already ended), or could not confirm it did.
|
|
136
|
+
*/
|
|
137
|
+
export function runAborted(error, runId, stopped, sandboxId) {
|
|
138
|
+
return acting(error, `${trimmed(error.message)}.${stopTail(runId, stopped)}`, { sandboxId });
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* A streamed or long exec whose poll failed after its run started (`error`:
|
|
142
|
+
* a lost connection, a 5xx, an answer that could not be read, a refused
|
|
143
|
+
* key), saying whether the SDK then stopped the run by its id (`stopped`),
|
|
144
|
+
* or could not confirm it did: with the network down, the command may still
|
|
145
|
+
* be running. Same code, status and body (and `sandboxId` when it names
|
|
146
|
+
* none); `error` itself is the cause. The Python SDK's poll_failed.
|
|
147
|
+
*/
|
|
148
|
+
export function pollFailed(error, runId, stopped, sandboxId) {
|
|
149
|
+
// One sentence, whatever lines the poll's own error carries (a script's stderr).
|
|
150
|
+
const said = detail(error).replace(/\s+/g, ' ');
|
|
151
|
+
return acting(error, `Polling the command failed (${said}).${stopTail(runId, stopped)}`, { sandboxId, cause: error });
|
|
152
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type Toolbox } from './toolbox.js';
|
|
2
|
+
import type { ExecResult } from './types.js';
|
|
3
|
+
export declare const OUTPUT_DIR = "/home/prized/.prized/exec";
|
|
4
|
+
export declare const OUTPUT_ID_RE: RegExp;
|
|
5
|
+
/** Each stream of a fullOutput command keeps at most this many bytes: one download. */
|
|
6
|
+
export declare const FULL_OUTPUT_BYTES: number;
|
|
7
|
+
/**
|
|
8
|
+
* The variables that carry the script and the caller's command, so neither
|
|
9
|
+
* is in the argv of any process but the command's own shell: `pkill -f`
|
|
10
|
+
* finds what it finds in a plain exec. The script unsets both first.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SCRIPT_VAR = "PRIZED_EXEC_SCRIPT";
|
|
13
|
+
export declare const COMMAND_VAR = "PRIZED_EXEC_COMMAND";
|
|
14
|
+
/** What a fullOutput exec sends as its command: the script, from SCRIPT_VAR. */
|
|
15
|
+
export declare const RUN_SCRIPT = "eval \"${PRIZED_EXEC_SCRIPT:?}\"";
|
|
16
|
+
/**
|
|
17
|
+
* The script a fullOutput exec runs (RUN_SCRIPT, from SCRIPT_VAR). The
|
|
18
|
+
* caller's command, from COMMAND_VAR, runs as a plain exec runs it (`bash
|
|
19
|
+
* -c`, or `sh -c` where there is no bash, stdin from /dev/null), with the
|
|
20
|
+
* request's cwd and env.
|
|
21
|
+
*
|
|
22
|
+
* Nothing is assigned before it runs but the positional parameters, so every
|
|
23
|
+
* variable the caller's env sets reaches it unchanged, PATH included; and
|
|
24
|
+
* every step holds under an errexit the env may switch on (SHELLOPTS). As in
|
|
25
|
+
* a plain exec, the readers stop 5 s after the command ends if a process it
|
|
26
|
+
* left running still holds its output, keeping what came through. A command
|
|
27
|
+
* a signal ends exits 128+n, as a background command does. Without tee,
|
|
28
|
+
* setsid or a directory to write to, the command runs as a plain exec.
|
|
29
|
+
*/
|
|
30
|
+
export declare function captureScript(id: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* What a fullOutput command left when the script could not print it (its
|
|
33
|
+
* time limit, or a signal, ended the script): the marker line, or `gone`,
|
|
34
|
+
* with the directory removed, when there is nothing to read (it was never
|
|
35
|
+
* made, or its output was already printed back).
|
|
36
|
+
*/
|
|
37
|
+
export declare function collectScript(id: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Remove a fullOutput command's directory (keyed by the SDK's own id, safe to
|
|
40
|
+
* send again), and any an earlier one left over an hour ago, when it got no
|
|
41
|
+
* answer to remove its own.
|
|
42
|
+
*/
|
|
43
|
+
export declare function removeOutputScript(id: string): string;
|
|
44
|
+
/** The marker's byte counts (a stream cut at FULL_OUTPUT_BYTES reads more), `gone`, or null for no marker. */
|
|
45
|
+
export declare function parseMarker(stdout: string, id: string): {
|
|
46
|
+
out: number;
|
|
47
|
+
err: number;
|
|
48
|
+
} | 'gone' | null;
|
|
49
|
+
/**
|
|
50
|
+
* A blocking exec that keeps the whole output (up to FULL_OUTPUT_BYTES of
|
|
51
|
+
* each stream). The result is a plain exec's, timeouts included: the caller
|
|
52
|
+
* (execCommand) turns a timed-out one into CommandTimeoutError.
|
|
53
|
+
*/
|
|
54
|
+
export declare function execFullOutput(toolbox: Toolbox, id: string, req: {
|
|
55
|
+
command: string;
|
|
56
|
+
cwd?: string | undefined;
|
|
57
|
+
env?: Record<string, string> | undefined;
|
|
58
|
+
timeoutMs: number;
|
|
59
|
+
}, signal: AbortSignal | undefined): Promise<ExecResult>;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// exec(..., { fullOutput: true }): one call that keeps a command's whole
|
|
2
|
+
// output, not just the first 4 MiB of each stream the sandbox answers with.
|
|
3
|
+
//
|
|
4
|
+
// The command's stdout and stderr stay pipes, as in a plain exec. A reader on
|
|
5
|
+
// each copies what comes through to a file in the exec's own directory, at a
|
|
6
|
+
// fixed path under the sandbox user's home, and drops what passes 100 MiB, so
|
|
7
|
+
// a runaway command cannot fill the disk:
|
|
8
|
+
//
|
|
9
|
+
// ~/.prized/exec/<exec id>/stdout
|
|
10
|
+
// ~/.prized/exec/<exec id>/stderr
|
|
11
|
+
//
|
|
12
|
+
// Both under 4 MiB: the script prints them back and removes the directory,
|
|
13
|
+
// so the answer carries the same output a plain exec's does. Bigger: it
|
|
14
|
+
// prints one marker line instead, and the SDK downloads both files through
|
|
15
|
+
// the files API and removes the directory. The scripts are the Python SDK's
|
|
16
|
+
// (sdk/python/src/prized/_output.py), rendered the same; both SDKs check
|
|
17
|
+
// them against sdk/output-scripts/.
|
|
18
|
+
import { PrizedError } from './errors.js';
|
|
19
|
+
import { request } from './http.js';
|
|
20
|
+
import { restate } from './outcome.js';
|
|
21
|
+
import { LAUNCH_PATH } from './shell.js';
|
|
22
|
+
import { helperExec, MAX_FILE_BYTES, MAX_OUTPUT_BYTES, rawExec } from './toolbox.js';
|
|
23
|
+
export const OUTPUT_DIR = '/home/prized/.prized/exec';
|
|
24
|
+
export const OUTPUT_ID_RE = /^exec_[0-9a-f]{16}$/;
|
|
25
|
+
/** Each stream of a fullOutput command keeps at most this many bytes: one download. */
|
|
26
|
+
export const FULL_OUTPUT_BYTES = MAX_FILE_BYTES;
|
|
27
|
+
/**
|
|
28
|
+
* The variables that carry the script and the caller's command, so neither
|
|
29
|
+
* is in the argv of any process but the command's own shell: `pkill -f`
|
|
30
|
+
* finds what it finds in a plain exec. The script unsets both first.
|
|
31
|
+
*/
|
|
32
|
+
export const SCRIPT_VAR = 'PRIZED_EXEC_SCRIPT';
|
|
33
|
+
export const COMMAND_VAR = 'PRIZED_EXEC_COMMAND';
|
|
34
|
+
/** What a fullOutput exec sends as its command: the script, from SCRIPT_VAR. */
|
|
35
|
+
export const RUN_SCRIPT = `eval "\${${SCRIPT_VAR}:?}"`;
|
|
36
|
+
/** How long the readers may run on after the command (the sandbox's own wait for a plain exec's pipes). */
|
|
37
|
+
const GRACE_S = 5;
|
|
38
|
+
/**
|
|
39
|
+
* A directory older than this is one an earlier exec could not remove: no
|
|
40
|
+
* exec runs it that long (MAX_BLOCKING_EXEC_MS, then GRACE_S and READ_MS).
|
|
41
|
+
*/
|
|
42
|
+
const STALE_MIN = 60;
|
|
43
|
+
/** The bound on each request the SDK makes on its own to collect or remove the files. */
|
|
44
|
+
const TEARDOWN_MS = 10_000;
|
|
45
|
+
/** The bound on reading the files back, retries included (files.ts FILE_TIMEOUT_MS). */
|
|
46
|
+
const READ_MS = 10 * 60_000;
|
|
47
|
+
function checkId(id) {
|
|
48
|
+
if (!OUTPUT_ID_RE.test(id))
|
|
49
|
+
throw new Error(`unreachable: bad exec id ${id}`);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The reader on one stream: tee copies it to the file as it comes (each read
|
|
53
|
+
* written at once) until the file reaches FULL_OUTPUT_BYTES and one 512-byte
|
|
54
|
+
* block more, which tells a cut stream from a full one. The file size limit
|
|
55
|
+
* (ulimit -f, in 512-byte blocks in POSIX mode) stops the file there with
|
|
56
|
+
* EFBIG, not SIGXFSZ, and tee goes on draining into /dev/null, so the command
|
|
57
|
+
* never meets a closed pipe and a runaway one cannot fill the disk. One
|
|
58
|
+
* process per stream (exec), in a session of its own (setsid, same pid): a
|
|
59
|
+
* time limit or a `kill 0` that ends the command's process group leaves it to
|
|
60
|
+
* drain what the pipe still holds.
|
|
61
|
+
*/
|
|
62
|
+
function reader(d, name, fifo) {
|
|
63
|
+
const blocks = FULL_OUTPUT_BYTES / 512 + 1;
|
|
64
|
+
return (`( exec > /dev/null 2>&1; PATH=${LAUNCH_PATH}; trap '' XFSZ; ( set -o posix ) && set -o posix; ` +
|
|
65
|
+
`ulimit -f ${blocks} || :; exec setsid tee ${d}/${name} < ${d}/${fifo} ) &`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The bound on waiting for the readers once the command has ended: GRACE_S
|
|
69
|
+
* seconds, as the sandbox waits for a plain exec's pipes, then the readers
|
|
70
|
+
* still running stop (a pid is checked to be this exec's tee first, never
|
|
71
|
+
* one reused since), and a process the command left holding its output
|
|
72
|
+
* meets a closed pipe, as it would there. Stopped early (TERM, once the
|
|
73
|
+
* readers are done), it takes its sleep with it.
|
|
74
|
+
*/
|
|
75
|
+
function watchdog(d) {
|
|
76
|
+
return (`( s=; trap '[ -z "$s" ] || kill "$s" 2> /dev/null; exit 0' TERM; sleep ${GRACE_S} & s=$!; wait "$s" || :; ` +
|
|
77
|
+
`for p in "$2" "$3"; do if tr "\\0" " " < "/proc/$p/cmdline" 2> /dev/null | grep -qF -- ${d}/; then kill "$p" 2> /dev/null || :; fi; done ) > /dev/null 2>&1 &`);
|
|
78
|
+
}
|
|
79
|
+
/** Sizes, the cut past FULL_OUTPUT_BYTES and the marker line, with `d` set to the directory. */
|
|
80
|
+
function reportLines(id) {
|
|
81
|
+
const max = FULL_OUTPUT_BYTES;
|
|
82
|
+
return {
|
|
83
|
+
sizes: ['o=$(stat -c %s "$d/stdout" 2> /dev/null || echo 0)', 'e=$(stat -c %s "$d/stderr" 2> /dev/null || echo 0)'],
|
|
84
|
+
cut: [
|
|
85
|
+
`if [ "$o" -gt ${max} ]; then truncate -s ${max} "$d/stdout" || :; fi`,
|
|
86
|
+
`if [ "$e" -gt ${max} ]; then truncate -s ${max} "$d/stderr" || :; fi`,
|
|
87
|
+
`echo "prized-output ${id} $o $e"`,
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The script a fullOutput exec runs (RUN_SCRIPT, from SCRIPT_VAR). The
|
|
93
|
+
* caller's command, from COMMAND_VAR, runs as a plain exec runs it (`bash
|
|
94
|
+
* -c`, or `sh -c` where there is no bash, stdin from /dev/null), with the
|
|
95
|
+
* request's cwd and env.
|
|
96
|
+
*
|
|
97
|
+
* Nothing is assigned before it runs but the positional parameters, so every
|
|
98
|
+
* variable the caller's env sets reaches it unchanged, PATH included; and
|
|
99
|
+
* every step holds under an errexit the env may switch on (SHELLOPTS). As in
|
|
100
|
+
* a plain exec, the readers stop 5 s after the command ends if a process it
|
|
101
|
+
* left running still holds its output, keeping what came through. A command
|
|
102
|
+
* a signal ends exits 128+n, as a background command does. Without tee,
|
|
103
|
+
* setsid or a directory to write to, the command runs as a plain exec.
|
|
104
|
+
*/
|
|
105
|
+
export function captureScript(id) {
|
|
106
|
+
checkId(id);
|
|
107
|
+
const d = `${OUTPUT_DIR}/${id}`;
|
|
108
|
+
const { sizes, cut } = reportLines(id);
|
|
109
|
+
return [
|
|
110
|
+
`set -- "\${${COMMAND_VAR}:?}" /bin/bash; unset ${COMMAND_VAR} ${SCRIPT_VAR}; [ -x "$2" ] || set -- "$1" /bin/sh`,
|
|
111
|
+
`if ( PATH=${LAUNCH_PATH}; mkdir -p ${OUTPUT_DIR} && mkdir ${d} ) 2> /dev/null; then`,
|
|
112
|
+
`if ( PATH=${LAUNCH_PATH}; command -v tee && command -v setsid && mkfifo ${d}/o ${d}/e ) > /dev/null 2>&1; then`,
|
|
113
|
+
reader(d, 'stdout', 'o'),
|
|
114
|
+
'set -- "$1" "$2" "$!"',
|
|
115
|
+
reader(d, 'stderr', 'e'),
|
|
116
|
+
'set -- "$1" "$2" "$3" "$!"',
|
|
117
|
+
// bash's "Killed" line for a command a signal ended goes to the braces' /dev/null.
|
|
118
|
+
`if { "$2" -c "$1" > ${d}/o 2> ${d}/e < /dev/null; } 2> /dev/null; then set -- 0 "$3" "$4"; else set -- "$?" "$3" "$4"; fi`,
|
|
119
|
+
`PATH=${LAUNCH_PATH}`,
|
|
120
|
+
`c=$1; d=${d}`,
|
|
121
|
+
watchdog(d),
|
|
122
|
+
'w=$!',
|
|
123
|
+
'wait "$2" "$3" 2> /dev/null || :',
|
|
124
|
+
'kill "$w" 2> /dev/null || :',
|
|
125
|
+
...sizes,
|
|
126
|
+
`if [ "$o" -lt ${MAX_OUTPUT_BYTES} ] && [ "$e" -lt ${MAX_OUTPUT_BYTES} ]; then ` +
|
|
127
|
+
'cat "$d/stdout" 2> /dev/null || :; cat "$d/stderr" >&2 2> /dev/null || :; rm -rf "$d" || :; exit "$c"; fi',
|
|
128
|
+
...cut,
|
|
129
|
+
'exit "$c"',
|
|
130
|
+
'fi',
|
|
131
|
+
`( PATH=${LAUNCH_PATH}; rm -rf ${d} ) 2> /dev/null || :`,
|
|
132
|
+
'fi',
|
|
133
|
+
'exec "$2" -c "$1" < /dev/null',
|
|
134
|
+
].join('\n');
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* What a fullOutput command left when the script could not print it (its
|
|
138
|
+
* time limit, or a signal, ended the script): the marker line, or `gone`,
|
|
139
|
+
* with the directory removed, when there is nothing to read (it was never
|
|
140
|
+
* made, or its output was already printed back).
|
|
141
|
+
*/
|
|
142
|
+
export function collectScript(id) {
|
|
143
|
+
checkId(id);
|
|
144
|
+
const { sizes, cut } = reportLines(id);
|
|
145
|
+
return [
|
|
146
|
+
`d=${OUTPUT_DIR}/${id}`,
|
|
147
|
+
`PATH=${LAUNCH_PATH}`,
|
|
148
|
+
`if [ ! -f "$d/stdout" ] || [ ! -f "$d/stderr" ]; then rm -rf "$d" || :; echo "prized-output ${id} gone"; exit 0; fi`,
|
|
149
|
+
...sizes,
|
|
150
|
+
...cut,
|
|
151
|
+
].join('\n');
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Remove a fullOutput command's directory (keyed by the SDK's own id, safe to
|
|
155
|
+
* send again), and any an earlier one left over an hour ago, when it got no
|
|
156
|
+
* answer to remove its own.
|
|
157
|
+
*/
|
|
158
|
+
export function removeOutputScript(id) {
|
|
159
|
+
checkId(id);
|
|
160
|
+
return (`PATH=${LAUNCH_PATH}; rm -rf ${OUTPUT_DIR}/${id}; ` +
|
|
161
|
+
`find ${OUTPUT_DIR} -mindepth 1 -maxdepth 1 -name 'exec_*' -mmin +${STALE_MIN} -exec rm -rf -- {} + 2> /dev/null || :`);
|
|
162
|
+
}
|
|
163
|
+
/** The marker's byte counts (a stream cut at FULL_OUTPUT_BYTES reads more), `gone`, or null for no marker. */
|
|
164
|
+
export function parseMarker(stdout, id) {
|
|
165
|
+
const m = new RegExp(`(?:^|\\n)prized-output ${id} (?:([0-9]+) ([0-9]+)|(gone))\\n$`).exec(stdout);
|
|
166
|
+
if (m === null)
|
|
167
|
+
return null;
|
|
168
|
+
if (m[3] === 'gone')
|
|
169
|
+
return 'gone';
|
|
170
|
+
return { out: Number(m[1]), err: Number(m[2]) };
|
|
171
|
+
}
|
|
172
|
+
/** A request the SDK sends on its own to collect or remove the files: best effort, within TEARDOWN_MS. */
|
|
173
|
+
async function teardown(toolbox, script) {
|
|
174
|
+
try {
|
|
175
|
+
return await helperExec(toolbox, script, { deadline: toolbox.config.now() + TEARDOWN_MS });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function download(toolbox, path, signal) {
|
|
182
|
+
const result = await request(toolbox.config, {
|
|
183
|
+
method: 'GET',
|
|
184
|
+
url: `${toolbox.base()}/files/download?path=${encodeURIComponent(path)}`,
|
|
185
|
+
retry: 'safe',
|
|
186
|
+
read: 'bytes',
|
|
187
|
+
timeoutMs: READ_MS,
|
|
188
|
+
deadline: toolbox.config.now() + READ_MS,
|
|
189
|
+
signal,
|
|
190
|
+
sandboxId: toolbox.sandboxId,
|
|
191
|
+
});
|
|
192
|
+
// A leading BOM is the command's own output, as in the answer.
|
|
193
|
+
return new TextDecoder('utf-8', { ignoreBOM: true }).decode(result.bytes ?? new Uint8Array(0));
|
|
194
|
+
}
|
|
195
|
+
/** The files' contents in place of the answer's output. */
|
|
196
|
+
async function readBack(toolbox, id, sizes, result, signal) {
|
|
197
|
+
const dir = `${OUTPUT_DIR}/${id}`;
|
|
198
|
+
// Both at once; one that fails stops the other, as the caller's abort stops both.
|
|
199
|
+
const both = new AbortController();
|
|
200
|
+
const stop = () => both.abort();
|
|
201
|
+
if (signal?.aborted)
|
|
202
|
+
stop();
|
|
203
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
204
|
+
const read = (name, size) => size > 0
|
|
205
|
+
? download(toolbox, `${dir}/${name}`, both.signal).catch((error) => {
|
|
206
|
+
stop();
|
|
207
|
+
throw error;
|
|
208
|
+
})
|
|
209
|
+
: Promise.resolve('');
|
|
210
|
+
let stdout;
|
|
211
|
+
let stderr;
|
|
212
|
+
try {
|
|
213
|
+
[stdout, stderr] = await Promise.all([read('stdout', sizes.out), read('stderr', sizes.err)]);
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
signal?.removeEventListener('abort', stop);
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
...result,
|
|
220
|
+
stdout,
|
|
221
|
+
stderr,
|
|
222
|
+
truncated: sizes.out > FULL_OUTPUT_BYTES || sizes.err > FULL_OUTPUT_BYTES,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* A blocking exec that keeps the whole output (up to FULL_OUTPUT_BYTES of
|
|
227
|
+
* each stream). The result is a plain exec's, timeouts included: the caller
|
|
228
|
+
* (execCommand) turns a timed-out one into CommandTimeoutError.
|
|
229
|
+
*/
|
|
230
|
+
export async function execFullOutput(toolbox, id, req, signal) {
|
|
231
|
+
const raw = {
|
|
232
|
+
command: RUN_SCRIPT,
|
|
233
|
+
env: { ...req.env, [SCRIPT_VAR]: captureScript(id), [COMMAND_VAR]: req.command },
|
|
234
|
+
timeoutMs: req.timeoutMs,
|
|
235
|
+
};
|
|
236
|
+
if (req.cwd !== undefined)
|
|
237
|
+
raw.cwd = req.cwd;
|
|
238
|
+
let result;
|
|
239
|
+
try {
|
|
240
|
+
// A plain exec's retry rules: sent again only if it provably never arrived.
|
|
241
|
+
result = await rawExec(toolbox, raw, { retry: 'exec', signal });
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
// The caller has no way to read what it printed: its files go.
|
|
245
|
+
await teardown(toolbox, removeOutputScript(id));
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
let sizes = parseMarker(result.stdout, id);
|
|
249
|
+
if (sizes === null && (result.timedOut || result.exitCode === -1)) {
|
|
250
|
+
// Its time limit (or a signal) ended the script with the command, before
|
|
251
|
+
// it printed anything back: what the command printed is in its files.
|
|
252
|
+
const collected = await teardown(toolbox, collectScript(id));
|
|
253
|
+
sizes = collected === null ? null : parseMarker(collected.stdout, id);
|
|
254
|
+
if (sizes === null)
|
|
255
|
+
return result; // no answer: nothing more to be done
|
|
256
|
+
}
|
|
257
|
+
// No marker: the output came back in the answer (or the command ran as a
|
|
258
|
+
// plain exec), and the directory is already gone.
|
|
259
|
+
if (sizes === null || sizes === 'gone')
|
|
260
|
+
return result;
|
|
261
|
+
let full;
|
|
262
|
+
try {
|
|
263
|
+
full = await readBack(toolbox, id, sizes, result, signal);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
const aborted = !(error instanceof PrizedError) || error.code === 'aborted';
|
|
267
|
+
if (aborted || result.timedOut) {
|
|
268
|
+
// Leaving, or the timeout is the caller's error: the files go.
|
|
269
|
+
await teardown(toolbox, removeOutputScript(id));
|
|
270
|
+
if (aborted)
|
|
271
|
+
throw error;
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
const dir = `${OUTPUT_DIR}/${id}`;
|
|
275
|
+
throw restate(error, `The command ran (exit ${result.exitCode}), but its output could not be read back (${error.message.replace(/[.\s]+$/, '')}). ` +
|
|
276
|
+
`It is kept in ${dir}/stdout and ${dir}/stderr for an hour: read them with files.read(), then remove ${dir}.`);
|
|
277
|
+
}
|
|
278
|
+
await teardown(toolbox, removeOutputScript(id));
|
|
279
|
+
return full;
|
|
280
|
+
}
|