@prized/sdk 0.1.0 → 0.1.1
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/README.md +56 -15
- package/dist/cjs/commands.d.ts +44 -13
- package/dist/cjs/commands.js +49 -19
- package/dist/cjs/errors.d.ts +43 -3
- package/dist/cjs/errors.js +54 -6
- package/dist/cjs/files.d.ts +20 -5
- package/dist/cjs/files.js +35 -14
- package/dist/cjs/http.d.ts +29 -13
- package/dist/cjs/http.js +68 -20
- package/dist/cjs/index.d.ts +3 -3
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/outcome.d.ts +2 -1
- package/dist/cjs/outcome.js +7 -4
- package/dist/cjs/output.d.ts +19 -8
- package/dist/cjs/output.js +151 -56
- package/dist/cjs/sandbox.js +28 -2
- package/dist/cjs/toolbox.js +3 -1
- package/dist/cjs/types.d.ts +13 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/commands.d.ts +44 -13
- package/dist/esm/commands.js +49 -19
- package/dist/esm/errors.d.ts +43 -3
- package/dist/esm/errors.js +52 -5
- package/dist/esm/files.d.ts +20 -5
- package/dist/esm/files.js +35 -14
- package/dist/esm/http.d.ts +29 -13
- package/dist/esm/http.js +68 -20
- package/dist/esm/index.d.ts +3 -3
- package/dist/esm/index.js +1 -1
- package/dist/esm/outcome.d.ts +2 -1
- package/dist/esm/outcome.js +7 -4
- package/dist/esm/output.d.ts +19 -8
- package/dist/esm/output.js +151 -57
- package/dist/esm/sandbox.js +28 -2
- package/dist/esm/toolbox.js +3 -1
- package/dist/esm/types.d.ts +13 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/cjs/output.js
CHANGED
|
@@ -5,22 +5,25 @@ exports.captureScript = captureScript;
|
|
|
5
5
|
exports.collectScript = collectScript;
|
|
6
6
|
exports.removeOutputScript = removeOutputScript;
|
|
7
7
|
exports.parseMarker = parseMarker;
|
|
8
|
+
exports.markerTruncation = markerTruncation;
|
|
8
9
|
exports.execFullOutput = execFullOutput;
|
|
9
10
|
// exec(..., { fullOutput: true }): one call that keeps a command's whole
|
|
10
11
|
// output, not just the first 4 MiB of each stream the sandbox answers with.
|
|
11
12
|
//
|
|
12
|
-
// The command's stdout and stderr stay pipes, as in a plain exec. A
|
|
13
|
-
// each
|
|
14
|
-
// fixed path under the sandbox user's home, and
|
|
15
|
-
// a runaway command cannot fill the disk:
|
|
13
|
+
// The command's stdout and stderr stay pipes, as in a plain exec. A relay on
|
|
14
|
+
// each passes what comes through to a reader that copies it to a file in the
|
|
15
|
+
// exec's own directory, at a fixed path under the sandbox user's home, and
|
|
16
|
+
// drops what passes 100 MiB, so a runaway command cannot fill the disk:
|
|
16
17
|
//
|
|
17
18
|
// ~/.prized/exec/<exec id>/stdout
|
|
18
19
|
// ~/.prized/exec/<exec id>/stderr
|
|
19
20
|
//
|
|
20
21
|
// Both under 4 MiB: the script prints them back and removes the directory,
|
|
21
|
-
// so the answer carries the same output a plain exec's does. Bigger
|
|
22
|
-
//
|
|
23
|
-
//
|
|
22
|
+
// so the answer carries the same output a plain exec's does. Bigger, or a
|
|
23
|
+
// reader that could not write its file (a full disk): it prints one marker
|
|
24
|
+
// line instead, and the SDK downloads both files through the files API and
|
|
25
|
+
// removes the directory. A stream whose write failed comes back as far as it
|
|
26
|
+
// got, `truncated` with `truncatedReason: 'write_failed'`. The scripts are the Python SDK's
|
|
24
27
|
// (sdk/python/src/prized/_output.py), rendered the same; both SDKs check
|
|
25
28
|
// them against sdk/output-scripts/.
|
|
26
29
|
const errors_js_1 = require("./errors.js");
|
|
@@ -57,45 +60,102 @@ function checkId(id) {
|
|
|
57
60
|
throw new Error(`unreachable: bad exec id ${id}`);
|
|
58
61
|
}
|
|
59
62
|
/**
|
|
60
|
-
* The reader
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
* The reader's own shell (`$1` the file, `$2` its flag): cat writes the file
|
|
64
|
+
* until a write fails; then the flag, an empty file (it takes no free block),
|
|
65
|
+
* then the rest drained into /dev/null, and exit 1. `true`, not `:`: a
|
|
66
|
+
* redirection that fails on a special builtin ends the shell, and with it
|
|
67
|
+
* the draining.
|
|
68
|
+
*/
|
|
69
|
+
const READER_SHELL = `'cat > "$1" || { true > "$2" || :; cat > /dev/null; exit 1; }'`;
|
|
70
|
+
/**
|
|
71
|
+
* The reader on one stream: cat copies what the relay passes it to the file
|
|
72
|
+
* as it comes (each read written at once) until the file reaches
|
|
73
|
+
* FULL_OUTPUT_BYTES and one 512-byte block more, which tells a cut stream
|
|
74
|
+
* from a full one. The file size limit (ulimit -f, in 512-byte blocks in
|
|
75
|
+
* POSIX mode) stops the file there with EFBIG, not SIGXFSZ, and the reader
|
|
76
|
+
* goes on draining into /dev/null, so the command never meets a closed pipe
|
|
77
|
+
* and a runaway one cannot fill the disk. A write that fails short of that (a
|
|
78
|
+
* full disk) stops the file the same way. Either leaves `<name>.failed` at
|
|
79
|
+
* once, which collect reads when a time limit ended the script before it
|
|
80
|
+
* could wait for the reader, and the reader exits 1 once its input ends. A
|
|
81
|
+
* shell per stream (exec), in a session of its own (setsid, same pid): a time
|
|
82
|
+
* limit or a `kill 0` that ends the command's process group leaves it to
|
|
68
83
|
* drain what the pipe still holds.
|
|
69
84
|
*/
|
|
70
85
|
function reader(d, name, fifo) {
|
|
71
86
|
const blocks = exports.FULL_OUTPUT_BYTES / 512 + 1;
|
|
72
87
|
return (`( exec > /dev/null 2>&1; PATH=${shell_js_1.LAUNCH_PATH}; trap '' XFSZ; ( set -o posix ) && set -o posix; ` +
|
|
73
|
-
`ulimit -f ${blocks} || :; exec setsid
|
|
88
|
+
`ulimit -f ${blocks} || :; exec setsid /bin/sh -c ${READER_SHELL} sh ${d}/${name} ${d}/${name}.failed < ${d}/${fifo} ) &`);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The relay on one stream: cat reads the command's pipe and passes it on to
|
|
92
|
+
* the reader's. It is what the watchdog stops, never the reader, so the
|
|
93
|
+
* reader always ends on the end of its input, and its exit status says
|
|
94
|
+
* whether every write to its file went through. In a session of its own
|
|
95
|
+
* (setsid, same pid), as the reader is.
|
|
96
|
+
*/
|
|
97
|
+
function relay(d, from, to) {
|
|
98
|
+
return `( exec > ${d}/${to} 2> /dev/null < /dev/null; PATH=${shell_js_1.LAUNCH_PATH}; exec setsid cat ${d}/${from} ) &`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A shell test: process `pid` (shell text) is this exec's relay on `fifo`,
|
|
102
|
+
* not another process that reused its pid. Its arguments must be exactly
|
|
103
|
+
* `cat <fifo>` (as setsid execs it), not merely name the exec's directory
|
|
104
|
+
* somewhere in the command line, as a process the command started may
|
|
105
|
+
* (`tail -f ~/.prized/exec/<id>/stdout`). /proc ends each argument with a
|
|
106
|
+
* NUL; here each is one line (a NUL becomes a newline, a newline a space),
|
|
107
|
+
* as the run scripts' isRun (shell.ts) reads them.
|
|
108
|
+
*/
|
|
109
|
+
function isRelay(pid, fifo) {
|
|
110
|
+
return `[ "$(tr "\\0\\n" "\\n " < "/proc/${pid}/cmdline" 2> /dev/null | tr "\\n" " ")" = "cat ${fifo} " ]`;
|
|
74
111
|
}
|
|
75
112
|
/**
|
|
76
113
|
* The bound on waiting for the readers once the command has ended: GRACE_S
|
|
77
|
-
* seconds, as the sandbox waits for a plain exec's pipes, then the
|
|
78
|
-
* still running stop (
|
|
79
|
-
* one reused since)
|
|
80
|
-
*
|
|
81
|
-
*
|
|
114
|
+
* seconds, as the sandbox waits for a plain exec's pipes, then the relays
|
|
115
|
+
* still running stop (each pid checked to be this exec's relay on its own
|
|
116
|
+
* pipe first, never one reused since): its reader then ends with what came
|
|
117
|
+
* through, and a process the command left holding its output meets a closed
|
|
118
|
+
* pipe, as it would there. Stopped early (TERM, once the readers are done),
|
|
119
|
+
* it takes its sleep with it.
|
|
82
120
|
*/
|
|
83
121
|
function watchdog(d) {
|
|
84
122
|
return (`( s=; trap '[ -z "$s" ] || kill "$s" 2> /dev/null; exit 0' TERM; sleep ${GRACE_S} & s=$!; wait "$s" || :; ` +
|
|
85
|
-
`
|
|
123
|
+
`if ${isRelay('$4', `${d}/o`)}; then kill "$4" 2> /dev/null || :; fi; ` +
|
|
124
|
+
`if ${isRelay('$5', `${d}/e`)}; then kill "$5" 2> /dev/null || :; fi ) > /dev/null 2>&1 &`);
|
|
86
125
|
}
|
|
87
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Sizes, the cut past FULL_OUTPUT_BYTES and the marker line, with `d` set to
|
|
128
|
+
* the directory and each reader's write failure in `x` and `y` (1 or 0,
|
|
129
|
+
* failedLines). A cut leaves `<name>.cut`, so collect sent again still reads
|
|
130
|
+
* the stream as past the bound (its flag is still there, its size no longer
|
|
131
|
+
* says so). `true`, not `:`, for the same reason as in READER_SHELL.
|
|
132
|
+
*/
|
|
88
133
|
function reportLines(id) {
|
|
89
134
|
const max = exports.FULL_OUTPUT_BYTES;
|
|
90
135
|
return {
|
|
91
136
|
sizes: ['o=$(stat -c %s "$d/stdout" 2> /dev/null || echo 0)', 'e=$(stat -c %s "$d/stderr" 2> /dev/null || echo 0)'],
|
|
92
137
|
cut: [
|
|
93
|
-
`if [ "$o" -gt ${max} ]; then truncate -s ${max} "$d/stdout" || :; fi`,
|
|
94
|
-
`if [ "$e" -gt ${max} ]; then truncate -s ${max} "$d/stderr" || :; fi`,
|
|
95
|
-
`echo "prized-output ${id} $o $e"`,
|
|
138
|
+
`if [ "$o" -gt ${max} ]; then truncate -s ${max} "$d/stdout" && true > "$d/stdout.cut" || :; fi`,
|
|
139
|
+
`if [ "$e" -gt ${max} ]; then truncate -s ${max} "$d/stderr" && true > "$d/stderr.cut" || :; fi`,
|
|
140
|
+
`echo "prized-output ${id} $o $e $x $y"`,
|
|
96
141
|
],
|
|
97
142
|
};
|
|
98
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Whether each reader failed to write its file (`x` for stdout, `y` for
|
|
146
|
+
* stderr: nonzero from its exit status, or its flag, after `sizes`): 1 when
|
|
147
|
+
* it ended in error short of the bound (a full disk or a quota, a file it
|
|
148
|
+
* could not create, a reader killed), else 0. Past the bound its error is the
|
|
149
|
+
* cut (EFBIG). The watchdog stops relays, never readers, so a reader's status
|
|
150
|
+
* is its own.
|
|
151
|
+
*/
|
|
152
|
+
function failedLines() {
|
|
153
|
+
const max = exports.FULL_OUTPUT_BYTES;
|
|
154
|
+
return [
|
|
155
|
+
`if [ "$x" -eq 0 ] || [ "$o" -gt ${max} ]; then x=0; else x=1; fi`,
|
|
156
|
+
`if [ "$y" -eq 0 ] || [ "$e" -gt ${max} ]; then y=0; else y=1; fi`,
|
|
157
|
+
];
|
|
158
|
+
}
|
|
99
159
|
/**
|
|
100
160
|
* The script a fullOutput exec runs (RUN_SCRIPT, from SCRIPT_VAR). The
|
|
101
161
|
* caller's command, from COMMAND_VAR, runs as a plain exec runs it (`bash
|
|
@@ -105,10 +165,12 @@ function reportLines(id) {
|
|
|
105
165
|
* Nothing is assigned before it runs but the positional parameters, so every
|
|
106
166
|
* variable the caller's env sets reaches it unchanged, PATH included; and
|
|
107
167
|
* every step holds under an errexit the env may switch on (SHELLOPTS). As in
|
|
108
|
-
* a plain exec, the
|
|
168
|
+
* a plain exec, the relays stop 5 s after the command ends if a process it
|
|
109
169
|
* left running still holds its output, keeping what came through. A command
|
|
110
|
-
* a signal ends exits 128+n, as a background command does.
|
|
111
|
-
*
|
|
170
|
+
* a signal ends exits 128+n, as a background command does. A reader that
|
|
171
|
+
* could not write its file (failedLines) sends the output to the files API
|
|
172
|
+
* whatever its size, with the marker saying which. Without /bin/sh, setsid,
|
|
173
|
+
* cat or a directory to write to, the command runs as a plain exec.
|
|
112
174
|
*/
|
|
113
175
|
function captureScript(id) {
|
|
114
176
|
checkId(id);
|
|
@@ -117,21 +179,28 @@ function captureScript(id) {
|
|
|
117
179
|
return [
|
|
118
180
|
`set -- "\${${exports.COMMAND_VAR}:?}" /bin/bash; unset ${exports.COMMAND_VAR} ${exports.SCRIPT_VAR}; [ -x "$2" ] || set -- "$1" /bin/sh`,
|
|
119
181
|
`if ( PATH=${shell_js_1.LAUNCH_PATH}; mkdir -p ${exports.OUTPUT_DIR} && mkdir ${d} ) 2> /dev/null; then`,
|
|
120
|
-
`if ( PATH=${shell_js_1.LAUNCH_PATH}; command -v
|
|
121
|
-
|
|
182
|
+
`if ( PATH=${shell_js_1.LAUNCH_PATH}; [ -x /bin/sh ] && command -v setsid && command -v cat && mkfifo ${d}/o ${d}/e ${d}/p ${d}/q ) > /dev/null 2>&1; then`,
|
|
183
|
+
// The command writes to o and e; a relay passes each on to p and q, where its reader is.
|
|
184
|
+
reader(d, 'stdout', 'p'),
|
|
122
185
|
'set -- "$1" "$2" "$!"',
|
|
123
|
-
reader(d, 'stderr', '
|
|
186
|
+
reader(d, 'stderr', 'q'),
|
|
124
187
|
'set -- "$1" "$2" "$3" "$!"',
|
|
188
|
+
relay(d, 'o', 'p'),
|
|
189
|
+
'set -- "$1" "$2" "$3" "$4" "$!"',
|
|
190
|
+
relay(d, 'e', 'q'),
|
|
191
|
+
'set -- "$1" "$2" "$3" "$4" "$5" "$!"',
|
|
125
192
|
// bash's "Killed" line for a command a signal ended goes to the braces' /dev/null.
|
|
126
|
-
`if { "$2" -c "$1" > ${d}/o 2> ${d}/e < /dev/null; } 2> /dev/null; then set -- 0 "$3" "$4"; else set -- "$?" "$3" "$4"; fi`,
|
|
193
|
+
`if { "$2" -c "$1" > ${d}/o 2> ${d}/e < /dev/null; } 2> /dev/null; then set -- 0 "$3" "$4" "$5" "$6"; else set -- "$?" "$3" "$4" "$5" "$6"; fi`,
|
|
127
194
|
`PATH=${shell_js_1.LAUNCH_PATH}`,
|
|
128
195
|
`c=$1; d=${d}`,
|
|
129
196
|
watchdog(d),
|
|
130
197
|
'w=$!',
|
|
131
|
-
'wait "$2"
|
|
198
|
+
'if wait "$2" 2> /dev/null; then x=0; else x=$?; fi',
|
|
199
|
+
'if wait "$3" 2> /dev/null; then y=0; else y=$?; fi',
|
|
132
200
|
'kill "$w" 2> /dev/null || :',
|
|
133
201
|
...sizes,
|
|
134
|
-
|
|
202
|
+
...failedLines(),
|
|
203
|
+
`if [ "$o" -lt ${toolbox_js_1.MAX_OUTPUT_BYTES} ] && [ "$e" -lt ${toolbox_js_1.MAX_OUTPUT_BYTES} ] && [ "$x$y" = 00 ]; then ` +
|
|
135
204
|
'cat "$d/stdout" 2> /dev/null || :; cat "$d/stderr" >&2 2> /dev/null || :; rm -rf "$d" || :; exit "$c"; fi',
|
|
136
205
|
...cut,
|
|
137
206
|
'exit "$c"',
|
|
@@ -145,7 +214,10 @@ function captureScript(id) {
|
|
|
145
214
|
* What a fullOutput command left when the script could not print it (its
|
|
146
215
|
* time limit, or a signal, ended the script): the marker line, or `gone`,
|
|
147
216
|
* with the directory removed, when there is nothing to read (it was never
|
|
148
|
-
* made, or its output was already printed back).
|
|
217
|
+
* made, or its output was already printed back). A failed write is read from
|
|
218
|
+
* the reader's flag, before the size, which is final once the flag is there
|
|
219
|
+
* (the script's wait for the reader's exit status went with the script); a
|
|
220
|
+
* reader killed, or one that could not make its flag, is not seen here.
|
|
149
221
|
*/
|
|
150
222
|
function collectScript(id) {
|
|
151
223
|
checkId(id);
|
|
@@ -154,7 +226,13 @@ function collectScript(id) {
|
|
|
154
226
|
`d=${exports.OUTPUT_DIR}/${id}`,
|
|
155
227
|
`PATH=${shell_js_1.LAUNCH_PATH}`,
|
|
156
228
|
`if [ ! -f "$d/stdout" ] || [ ! -f "$d/stderr" ]; then rm -rf "$d" || :; echo "prized-output ${id} gone"; exit 0; fi`,
|
|
229
|
+
'if [ -e "$d/stdout.failed" ]; then x=1; else x=0; fi',
|
|
230
|
+
'if [ -e "$d/stderr.failed" ]; then y=1; else y=0; fi',
|
|
157
231
|
...sizes,
|
|
232
|
+
// Cut by an earlier collect whose answer was lost: as long as a cut stream (the bound and a block).
|
|
233
|
+
`if [ -e "$d/stdout.cut" ]; then o=$((${exports.FULL_OUTPUT_BYTES} + 512)); fi`,
|
|
234
|
+
`if [ -e "$d/stderr.cut" ]; then e=$((${exports.FULL_OUTPUT_BYTES} + 512)); fi`,
|
|
235
|
+
...failedLines(),
|
|
158
236
|
...cut,
|
|
159
237
|
].join('\n');
|
|
160
238
|
}
|
|
@@ -168,14 +246,22 @@ function removeOutputScript(id) {
|
|
|
168
246
|
return (`PATH=${shell_js_1.LAUNCH_PATH}; rm -rf ${exports.OUTPUT_DIR}/${id}; ` +
|
|
169
247
|
`find ${exports.OUTPUT_DIR} -mindepth 1 -maxdepth 1 -name 'exec_*' -mmin +${STALE_MIN} -exec rm -rf -- {} + 2> /dev/null || :`);
|
|
170
248
|
}
|
|
171
|
-
/** The marker
|
|
249
|
+
/** The marker (one without failure flags, as an earlier SDK's collect printed, has none), `gone`, or null for no marker. */
|
|
172
250
|
function parseMarker(stdout, id) {
|
|
173
|
-
const m = new RegExp(`(?:^|\\n)prized-output ${id} (?:([0-9]+) ([0-9]+)
|
|
251
|
+
const m = new RegExp(`(?:^|\\n)prized-output ${id} (?:([0-9]+) ([0-9]+)(?: ([01]) ([01]))?|(gone))\\n$`).exec(stdout);
|
|
174
252
|
if (m === null)
|
|
175
253
|
return null;
|
|
176
|
-
if (m[
|
|
254
|
+
if (m[5] === 'gone')
|
|
177
255
|
return 'gone';
|
|
178
|
-
return { out: Number(m[1]), err: Number(m[2]) };
|
|
256
|
+
return { out: Number(m[1]), err: Number(m[2]), outFailed: m[3] === '1', errFailed: m[4] === '1' };
|
|
257
|
+
}
|
|
258
|
+
/** Why the read-back output is cut, if it is: a failed write first, since it is the one the caller cannot foresee. */
|
|
259
|
+
function markerTruncation(marker) {
|
|
260
|
+
if (marker.outFailed || marker.errFailed)
|
|
261
|
+
return 'write_failed';
|
|
262
|
+
if (marker.out > exports.FULL_OUTPUT_BYTES || marker.err > exports.FULL_OUTPUT_BYTES)
|
|
263
|
+
return 'output_limit';
|
|
264
|
+
return null;
|
|
179
265
|
}
|
|
180
266
|
/** A request the SDK sends on its own to collect or remove the files: best effort, within TEARDOWN_MS. */
|
|
181
267
|
async function teardown(toolbox, script) {
|
|
@@ -201,7 +287,7 @@ async function download(toolbox, path, signal) {
|
|
|
201
287
|
return new TextDecoder('utf-8', { ignoreBOM: true }).decode(result.bytes ?? new Uint8Array(0));
|
|
202
288
|
}
|
|
203
289
|
/** The files' contents in place of the answer's output. */
|
|
204
|
-
async function readBack(toolbox, id,
|
|
290
|
+
async function readBack(toolbox, id, marker, result, signal) {
|
|
205
291
|
const dir = `${exports.OUTPUT_DIR}/${id}`;
|
|
206
292
|
// Both at once; one that fails stops the other, as the caller's abort stops both.
|
|
207
293
|
const both = new AbortController();
|
|
@@ -218,17 +304,17 @@ async function readBack(toolbox, id, sizes, result, signal) {
|
|
|
218
304
|
let stdout;
|
|
219
305
|
let stderr;
|
|
220
306
|
try {
|
|
221
|
-
[stdout, stderr] = await Promise.all([read('stdout',
|
|
307
|
+
[stdout, stderr] = await Promise.all([read('stdout', marker.out), read('stderr', marker.err)]);
|
|
222
308
|
}
|
|
223
309
|
finally {
|
|
224
310
|
signal?.removeEventListener('abort', stop);
|
|
225
311
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
312
|
+
const reason = markerTruncation(marker);
|
|
313
|
+
const { exitCode, durationMs, timedOut } = result;
|
|
314
|
+
const full = { stdout, stderr, exitCode, durationMs, timedOut, truncated: reason !== null };
|
|
315
|
+
if (reason !== null)
|
|
316
|
+
full.truncatedReason = reason;
|
|
317
|
+
return full;
|
|
232
318
|
}
|
|
233
319
|
/**
|
|
234
320
|
* A blocking exec that keeps the whole output (up to FULL_OUTPUT_BYTES of
|
|
@@ -253,22 +339,22 @@ async function execFullOutput(toolbox, id, req, signal) {
|
|
|
253
339
|
await teardown(toolbox, removeOutputScript(id));
|
|
254
340
|
throw error;
|
|
255
341
|
}
|
|
256
|
-
let
|
|
257
|
-
if (
|
|
342
|
+
let marker = parseMarker(result.stdout, id);
|
|
343
|
+
if (marker === null && (result.timedOut || result.exitCode === -1)) {
|
|
258
344
|
// Its time limit (or a signal) ended the script with the command, before
|
|
259
345
|
// it printed anything back: what the command printed is in its files.
|
|
260
346
|
const collected = await teardown(toolbox, collectScript(id));
|
|
261
|
-
|
|
262
|
-
if (
|
|
347
|
+
marker = collected === null ? null : parseMarker(collected.stdout, id);
|
|
348
|
+
if (marker === null)
|
|
263
349
|
return result; // no answer: nothing more to be done
|
|
264
350
|
}
|
|
265
351
|
// No marker: the output came back in the answer (or the command ran as a
|
|
266
352
|
// plain exec), and the directory is already gone.
|
|
267
|
-
if (
|
|
353
|
+
if (marker === null || marker === 'gone')
|
|
268
354
|
return result;
|
|
269
355
|
let full;
|
|
270
356
|
try {
|
|
271
|
-
full = await readBack(toolbox, id,
|
|
357
|
+
full = await readBack(toolbox, id, marker, result, signal);
|
|
272
358
|
}
|
|
273
359
|
catch (error) {
|
|
274
360
|
const aborted = !(error instanceof errors_js_1.PrizedError) || error.code === 'aborted';
|
|
@@ -279,9 +365,18 @@ async function execFullOutput(toolbox, id, req, signal) {
|
|
|
279
365
|
throw error;
|
|
280
366
|
return result;
|
|
281
367
|
}
|
|
368
|
+
if (error.code === 'sandbox_deleted') {
|
|
369
|
+
// Nothing is kept: the output went with the sandbox, as its code says for any call on it.
|
|
370
|
+
// The command ran: its exit code goes on the error too, so a caller
|
|
371
|
+
// that replaces the sandbox can tell not to run it again.
|
|
372
|
+
throw (0, outcome_js_1.restate)(error, `The command ran (exit ${result.exitCode}), but the sandbox was deleted before its output could be read back.`, {
|
|
373
|
+
cause: error,
|
|
374
|
+
exitCode: result.exitCode,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
282
377
|
const dir = `${exports.OUTPUT_DIR}/${id}`;
|
|
283
|
-
throw
|
|
284
|
-
`It is kept in ${dir}/stdout and ${dir}/stderr for an hour: read them with files.read(), then remove ${dir}
|
|
378
|
+
throw new errors_js_1.OutputReadError(`The command ran (exit ${result.exitCode}), but its output could not be read back (${error.message.replace(/[.\s]+$/, '')}). ` +
|
|
379
|
+
`It is kept in ${dir}/stdout and ${dir}/stderr for an hour: read them with files.read(), then remove ${dir}.`, { cause: error, exitCode: result.exitCode, stdoutPath: `${dir}/stdout`, stderrPath: `${dir}/stderr` });
|
|
285
380
|
}
|
|
286
381
|
await teardown(toolbox, removeOutputScript(id));
|
|
287
382
|
return full;
|
package/dist/cjs/sandbox.js
CHANGED
|
@@ -228,6 +228,32 @@ function failedState(info) {
|
|
|
228
228
|
}
|
|
229
229
|
return null;
|
|
230
230
|
}
|
|
231
|
+
/** How the API's errorReason starts on each copy it deletes because another
|
|
232
|
+
* copy of the same fork could not start. */
|
|
233
|
+
const FORK_UNDONE_PREFIX = 'fork undone';
|
|
234
|
+
/**
|
|
235
|
+
* The error a fork throws when not every copy runs, or null. The API undoes
|
|
236
|
+
* a fork one of whose copies could not start, deleting every copy, so the
|
|
237
|
+
* copy that failed may already read `deleting`, with its errorReason, by the
|
|
238
|
+
* time it is read: the error is that copy's reason (one that is not the
|
|
239
|
+
* undo's own), else a copy in error, else any non-running copy's state.
|
|
240
|
+
*/
|
|
241
|
+
function forkFailure(copies) {
|
|
242
|
+
const failed = copies.filter((copy) => copy.state !== 'running');
|
|
243
|
+
if (failed.length === 0)
|
|
244
|
+
return null;
|
|
245
|
+
const cause = failed.find((copy) => copy.errorReason && !copy.errorReason.startsWith(FORK_UNDONE_PREFIX)) ??
|
|
246
|
+
failed.find((copy) => copy.state === 'error') ??
|
|
247
|
+
failed.find((copy) => copy.errorReason) ??
|
|
248
|
+
failed[0];
|
|
249
|
+
if (cause.errorReason) {
|
|
250
|
+
return new errors_js_1.PrizedError('sandbox_error', `The sandbox failed: ${cause.errorReason}`, {
|
|
251
|
+
sandboxId: cause.id,
|
|
252
|
+
reason: cause.errorReason,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return failedState(cause) ?? new errors_js_1.PrizedError('sandbox_error', null, { sandboxId: cause.id });
|
|
256
|
+
}
|
|
231
257
|
/**
|
|
232
258
|
* A wait's timeout. One ended by a read its deadline cut short (the retry it
|
|
233
259
|
* was due would have ended past it: a 503's Retry-After, say) may come
|
|
@@ -563,9 +589,9 @@ class Sandbox {
|
|
|
563
589
|
return children;
|
|
564
590
|
try {
|
|
565
591
|
await Sandbox.settle(this.config, children, deadline, timeoutMs, options.signal, this.id);
|
|
566
|
-
const failed = children.
|
|
592
|
+
const failed = forkFailure(children.map((child) => child.current));
|
|
567
593
|
if (failed)
|
|
568
|
-
throw
|
|
594
|
+
throw failed;
|
|
569
595
|
}
|
|
570
596
|
catch (error) {
|
|
571
597
|
// All or nothing: copies the caller will never get are not left billing.
|
package/dist/cjs/toolbox.js
CHANGED
|
@@ -63,13 +63,15 @@ async function rawExec(toolbox, req, options) {
|
|
|
63
63
|
}
|
|
64
64
|
const stdout = typeof doc.stdout === 'string' ? doc.stdout : '';
|
|
65
65
|
const stderr = typeof doc.stderr === 'string' ? doc.stderr : '';
|
|
66
|
+
const truncated = keptBytes(stdout) >= exports.MAX_OUTPUT_BYTES || keptBytes(stderr) >= exports.MAX_OUTPUT_BYTES;
|
|
66
67
|
return {
|
|
67
68
|
stdout,
|
|
68
69
|
stderr,
|
|
69
70
|
exitCode: doc.exitCode,
|
|
70
71
|
durationMs: typeof doc.durationMs === 'number' ? doc.durationMs : 0,
|
|
71
72
|
timedOut: doc.timedOut === true,
|
|
72
|
-
truncated
|
|
73
|
+
truncated,
|
|
74
|
+
...(truncated ? { truncatedReason: 'output_limit' } : {}),
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
77
|
/** A helper command the SDK sends itself; a non-zero exit is the caller's to judge. */
|
package/dist/cjs/types.d.ts
CHANGED
|
@@ -70,10 +70,22 @@ export interface ExecResult {
|
|
|
70
70
|
* True when stdout or stderr reached the 4 MiB per-stream cap of a single
|
|
71
71
|
* call: the command kept running, the rest of that stream was dropped.
|
|
72
72
|
* Pass `fullOutput: true` for commands that may print more (it keeps up
|
|
73
|
-
* to 100 MiB of each stream, and sets this past that).
|
|
73
|
+
* to 100 MiB of each stream, and sets this past that). `truncatedReason`
|
|
74
|
+
* says why.
|
|
74
75
|
*/
|
|
75
76
|
truncated: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Why `truncated` is true; absent when it is false. `output_limit`: a
|
|
79
|
+
* stream reached its cap (4 MiB in one call, 100 MiB with `fullOutput`).
|
|
80
|
+
* `write_failed` (`fullOutput` only): the sandbox could not save a stream
|
|
81
|
+
* as the command printed it, most often because its disk is full, so that
|
|
82
|
+
* stream stops where the write failed, however short; free some space and
|
|
83
|
+
* run it again.
|
|
84
|
+
*/
|
|
85
|
+
truncatedReason?: TruncatedReason;
|
|
76
86
|
}
|
|
87
|
+
/** Why a command's output was cut: ExecResult.truncatedReason. */
|
|
88
|
+
export type TruncatedReason = 'output_limit' | 'write_failed';
|
|
77
89
|
/** One directory entry, as `sandbox.files.list()` returns it. */
|
|
78
90
|
export interface FileInfo {
|
|
79
91
|
name: string;
|
package/dist/cjs/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This package's version; test/version.test.ts holds it equal to package.json. */
|
|
2
|
-
export declare const VERSION = "0.1.
|
|
2
|
+
export declare const VERSION = "0.1.1";
|
package/dist/cjs/version.js
CHANGED
package/dist/esm/commands.d.ts
CHANGED
|
@@ -19,20 +19,34 @@ export declare const MAX_BLOCKING_EXEC_MS = 240000;
|
|
|
19
19
|
export declare function ridesOut(error: PrizedError): boolean;
|
|
20
20
|
/**
|
|
21
21
|
* The failed polls of a wait that rides out an outage of up to `windowMs`
|
|
22
|
-
* (
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* (pollOutageMs), from when the first poll's retries ran out, and, while the
|
|
23
|
+
* command runs, never past the wait's own deadline (`waitDeadline`, epoch
|
|
24
|
+
* ms; null for none): polled again with backoff, each poll and its retries
|
|
25
|
+
* ending before the last look, which goes out LAST_LOOK_MAX_MS before the
|
|
26
|
+
* window's end (half the window, for one shorter than twice that) with room
|
|
27
|
+
* for its own retries. So an outage that ends inside the window is always
|
|
28
|
+
* seen to end. The Python SDK's Outage.
|
|
28
29
|
*/
|
|
29
30
|
export declare class Outage {
|
|
30
31
|
private readonly windowMs;
|
|
32
|
+
private readonly waitDeadline;
|
|
31
33
|
private end;
|
|
32
34
|
private last;
|
|
33
35
|
private tries;
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
+
private room;
|
|
37
|
+
private finished;
|
|
38
|
+
constructor(windowMs: number, waitDeadline?: number | null);
|
|
39
|
+
/** The command has ended: draining its output, and an outage meanwhile, are not held to the deadline. */
|
|
40
|
+
finish(): void;
|
|
41
|
+
/**
|
|
42
|
+
* Whether a wait's failed poll (`error`, at `now`; `cut`: its bound cut it
|
|
43
|
+
* short) means the wait's own time ran out: its deadline cut the poll
|
|
44
|
+
* short while the command ran, with no outage being ridden out, so the
|
|
45
|
+
* command was still running when last seen. With no window, any timeout
|
|
46
|
+
* while it runs says so, as it always has. Never once the command has
|
|
47
|
+
* ended. The Python SDK's Outage.ran_out.
|
|
48
|
+
*/
|
|
49
|
+
ranOut(error: PrizedError, now: number, cut: boolean): boolean;
|
|
36
50
|
/** The bound on a poll sent `now`: undefined unless polls are failing; else the last look's start, or the window's end for the last look. */
|
|
37
51
|
deadline(now: number): number | undefined;
|
|
38
52
|
/** A poll was answered: the outage, if any, is over. */
|
|
@@ -95,11 +109,26 @@ export interface ExecOptions {
|
|
|
95
109
|
pollOutageMs?: number;
|
|
96
110
|
}
|
|
97
111
|
export interface WaitOptions {
|
|
98
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Stop waiting after this many ms (the command keeps running). Default: no
|
|
114
|
+
* limit. Once the command has ended, reading the rest of its output is not
|
|
115
|
+
* held to it.
|
|
116
|
+
*/
|
|
99
117
|
timeoutMs?: number;
|
|
100
118
|
onStdout?: (chunk: string) => void;
|
|
101
119
|
onStderr?: (chunk: string) => void;
|
|
102
120
|
signal?: AbortSignal;
|
|
121
|
+
/**
|
|
122
|
+
* How long the wait keeps polling while its polls fail (no answer, a 5xx,
|
|
123
|
+
* a rate limit), in ms, before it gives up and throws what the polls met.
|
|
124
|
+
* While the command runs, never past `timeoutMs`. Default: the client's
|
|
125
|
+
* `pollOutageMs` (120 000).
|
|
126
|
+
* `0` gives up once one poll's retries run out. A refused key, or an
|
|
127
|
+
* answer that settles the run (the sandbox deleted or not running, the
|
|
128
|
+
* run gone), ends the wait at once. Giving up stops nothing: the command
|
|
129
|
+
* keeps running, and wait() can be called again.
|
|
130
|
+
*/
|
|
131
|
+
pollOutageMs?: number;
|
|
103
132
|
}
|
|
104
133
|
export interface CommandStatus {
|
|
105
134
|
running: boolean;
|
|
@@ -136,15 +165,17 @@ export declare class CommandHandle {
|
|
|
136
165
|
* `onStdout`/`onStderr` receive the output as it arrives, from the start.
|
|
137
166
|
* A `timeoutMs` here bounds the wait only; the command keeps running. If
|
|
138
167
|
* the command ran past its own timeout (the one given to exec), it was
|
|
139
|
-
* killed and this throws CommandTimeoutError with what it printed.
|
|
168
|
+
* killed and this throws CommandTimeoutError with what it printed. While
|
|
169
|
+
* polls fail (a dropped connection, a 5xx), it keeps polling for up to
|
|
170
|
+
* `pollOutageMs` (the client's, 120 000 by default) before it throws.
|
|
140
171
|
*/
|
|
141
172
|
wait(options?: WaitOptions): Promise<ExecResult>;
|
|
142
173
|
/**
|
|
143
174
|
* @internal wait() without the timeout check: exec cleans up before it
|
|
144
|
-
* throws. `outageMs` (
|
|
145
|
-
*
|
|
175
|
+
* throws. `outageMs` (pollOutageMs): how long polls may fail before the
|
|
176
|
+
* wait gives up (Outage), never past the wait's own deadline.
|
|
146
177
|
*/
|
|
147
|
-
waitRaw(options
|
|
178
|
+
waitRaw(options: WaitOptions, outageMs: number): Promise<ExecResult>;
|
|
148
179
|
private waitTimeout;
|
|
149
180
|
private result;
|
|
150
181
|
/**
|