@prized/sdk 0.1.0 → 0.1.2
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 +76 -21
- package/dist/cjs/commands.d.ts +47 -14
- package/dist/cjs/commands.js +94 -26
- package/dist/cjs/errors.d.ts +43 -3
- package/dist/cjs/errors.js +54 -6
- package/dist/cjs/files.d.ts +21 -5
- package/dist/cjs/files.js +38 -14
- package/dist/cjs/http.d.ts +74 -15
- package/dist/cjs/http.js +208 -32
- package/dist/cjs/index.d.ts +3 -3
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/outcome.d.ts +11 -1
- package/dist/cjs/outcome.js +28 -5
- package/dist/cjs/output.d.ts +19 -8
- package/dist/cjs/output.js +152 -56
- package/dist/cjs/sandbox.d.ts +3 -2
- package/dist/cjs/sandbox.js +40 -5
- package/dist/cjs/shell.d.ts +2 -1
- package/dist/cjs/shell.js +2 -1
- package/dist/cjs/toolbox.d.ts +2 -0
- package/dist/cjs/toolbox.js +4 -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 +47 -14
- package/dist/esm/commands.js +95 -27
- package/dist/esm/errors.d.ts +43 -3
- package/dist/esm/errors.js +52 -5
- package/dist/esm/files.d.ts +21 -5
- package/dist/esm/files.js +39 -15
- package/dist/esm/http.d.ts +74 -15
- package/dist/esm/http.js +207 -31
- package/dist/esm/index.d.ts +3 -3
- package/dist/esm/index.js +1 -1
- package/dist/esm/outcome.d.ts +11 -1
- package/dist/esm/outcome.js +27 -5
- package/dist/esm/output.d.ts +19 -8
- package/dist/esm/output.js +153 -58
- package/dist/esm/sandbox.d.ts +3 -2
- package/dist/esm/sandbox.js +40 -5
- package/dist/esm/shell.d.ts +2 -1
- package/dist/esm/shell.js +2 -1
- package/dist/esm/toolbox.d.ts +2 -0
- package/dist/esm/toolbox.js +4 -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/esm/output.js
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
// exec(..., { fullOutput: true }): one call that keeps a command's whole
|
|
2
2
|
// output, not just the first 4 MiB of each stream the sandbox answers with.
|
|
3
3
|
//
|
|
4
|
-
// The command's stdout and stderr stay pipes, as in a plain exec. A
|
|
5
|
-
// each
|
|
6
|
-
// fixed path under the sandbox user's home, and
|
|
7
|
-
// a runaway command cannot fill the disk:
|
|
4
|
+
// The command's stdout and stderr stay pipes, as in a plain exec. A relay on
|
|
5
|
+
// each passes what comes through to a reader that copies it to a file in the
|
|
6
|
+
// exec's own directory, at a fixed path under the sandbox user's home, and
|
|
7
|
+
// drops what passes 100 MiB, so a runaway command cannot fill the disk:
|
|
8
8
|
//
|
|
9
9
|
// ~/.prized/exec/<exec id>/stdout
|
|
10
10
|
// ~/.prized/exec/<exec id>/stderr
|
|
11
11
|
//
|
|
12
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// so the answer carries the same output a plain exec's does. Bigger, or a
|
|
14
|
+
// reader that could not write its file (a full disk): it prints one marker
|
|
15
|
+
// line instead, and the SDK downloads both files through the files API and
|
|
16
|
+
// removes the directory. A stream whose write failed comes back as far as it
|
|
17
|
+
// got, `truncated` with `truncatedReason: 'write_failed'`. The scripts are the Python SDK's
|
|
16
18
|
// (sdk/python/src/prized/_output.py), rendered the same; both SDKs check
|
|
17
19
|
// them against sdk/output-scripts/.
|
|
18
|
-
import { PrizedError } from './errors.js';
|
|
19
|
-
import { request } from './http.js';
|
|
20
|
+
import { OutputReadError, PrizedError } from './errors.js';
|
|
21
|
+
import { request, timing } from './http.js';
|
|
20
22
|
import { restate } from './outcome.js';
|
|
21
23
|
import { LAUNCH_PATH } from './shell.js';
|
|
22
24
|
import { helperExec, MAX_FILE_BYTES, MAX_OUTPUT_BYTES, rawExec } from './toolbox.js';
|
|
@@ -49,45 +51,102 @@ function checkId(id) {
|
|
|
49
51
|
throw new Error(`unreachable: bad exec id ${id}`);
|
|
50
52
|
}
|
|
51
53
|
/**
|
|
52
|
-
* The reader
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
* The reader's own shell (`$1` the file, `$2` its flag): cat writes the file
|
|
55
|
+
* until a write fails; then the flag, an empty file (it takes no free block),
|
|
56
|
+
* then the rest drained into /dev/null, and exit 1. `true`, not `:`: a
|
|
57
|
+
* redirection that fails on a special builtin ends the shell, and with it
|
|
58
|
+
* the draining.
|
|
59
|
+
*/
|
|
60
|
+
const READER_SHELL = `'cat > "$1" || { true > "$2" || :; cat > /dev/null; exit 1; }'`;
|
|
61
|
+
/**
|
|
62
|
+
* The reader on one stream: cat copies what the relay passes it to the file
|
|
63
|
+
* as it comes (each read written at once) until the file reaches
|
|
64
|
+
* FULL_OUTPUT_BYTES and one 512-byte block more, which tells a cut stream
|
|
65
|
+
* from a full one. The file size limit (ulimit -f, in 512-byte blocks in
|
|
66
|
+
* POSIX mode) stops the file there with EFBIG, not SIGXFSZ, and the reader
|
|
67
|
+
* goes on draining into /dev/null, so the command never meets a closed pipe
|
|
68
|
+
* and a runaway one cannot fill the disk. A write that fails short of that (a
|
|
69
|
+
* full disk) stops the file the same way. Either leaves `<name>.failed` at
|
|
70
|
+
* once, which collect reads when a time limit ended the script before it
|
|
71
|
+
* could wait for the reader, and the reader exits 1 once its input ends. A
|
|
72
|
+
* shell per stream (exec), in a session of its own (setsid, same pid): a time
|
|
73
|
+
* limit or a `kill 0` that ends the command's process group leaves it to
|
|
60
74
|
* drain what the pipe still holds.
|
|
61
75
|
*/
|
|
62
76
|
function reader(d, name, fifo) {
|
|
63
77
|
const blocks = FULL_OUTPUT_BYTES / 512 + 1;
|
|
64
78
|
return (`( exec > /dev/null 2>&1; PATH=${LAUNCH_PATH}; trap '' XFSZ; ( set -o posix ) && set -o posix; ` +
|
|
65
|
-
`ulimit -f ${blocks} || :; exec setsid
|
|
79
|
+
`ulimit -f ${blocks} || :; exec setsid /bin/sh -c ${READER_SHELL} sh ${d}/${name} ${d}/${name}.failed < ${d}/${fifo} ) &`);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The relay on one stream: cat reads the command's pipe and passes it on to
|
|
83
|
+
* the reader's. It is what the watchdog stops, never the reader, so the
|
|
84
|
+
* reader always ends on the end of its input, and its exit status says
|
|
85
|
+
* whether every write to its file went through. In a session of its own
|
|
86
|
+
* (setsid, same pid), as the reader is.
|
|
87
|
+
*/
|
|
88
|
+
function relay(d, from, to) {
|
|
89
|
+
return `( exec > ${d}/${to} 2> /dev/null < /dev/null; PATH=${LAUNCH_PATH}; exec setsid cat ${d}/${from} ) &`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A shell test: process `pid` (shell text) is this exec's relay on `fifo`,
|
|
93
|
+
* not another process that reused its pid. Its arguments must be exactly
|
|
94
|
+
* `cat <fifo>` (as setsid execs it), not merely name the exec's directory
|
|
95
|
+
* somewhere in the command line, as a process the command started may
|
|
96
|
+
* (`tail -f ~/.prized/exec/<id>/stdout`). /proc ends each argument with a
|
|
97
|
+
* NUL; here each is one line (a NUL becomes a newline, a newline a space),
|
|
98
|
+
* as the run scripts' isRun (shell.ts) reads them.
|
|
99
|
+
*/
|
|
100
|
+
function isRelay(pid, fifo) {
|
|
101
|
+
return `[ "$(tr "\\0\\n" "\\n " < "/proc/${pid}/cmdline" 2> /dev/null | tr "\\n" " ")" = "cat ${fifo} " ]`;
|
|
66
102
|
}
|
|
67
103
|
/**
|
|
68
104
|
* 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
|
|
70
|
-
* still running stop (
|
|
71
|
-
* one reused since)
|
|
72
|
-
*
|
|
73
|
-
*
|
|
105
|
+
* seconds, as the sandbox waits for a plain exec's pipes, then the relays
|
|
106
|
+
* still running stop (each pid checked to be this exec's relay on its own
|
|
107
|
+
* pipe first, never one reused since): its reader then ends with what came
|
|
108
|
+
* through, and a process the command left holding its output meets a closed
|
|
109
|
+
* pipe, as it would there. Stopped early (TERM, once the readers are done),
|
|
110
|
+
* it takes its sleep with it.
|
|
74
111
|
*/
|
|
75
112
|
function watchdog(d) {
|
|
76
113
|
return (`( s=; trap '[ -z "$s" ] || kill "$s" 2> /dev/null; exit 0' TERM; sleep ${GRACE_S} & s=$!; wait "$s" || :; ` +
|
|
77
|
-
`
|
|
114
|
+
`if ${isRelay('$4', `${d}/o`)}; then kill "$4" 2> /dev/null || :; fi; ` +
|
|
115
|
+
`if ${isRelay('$5', `${d}/e`)}; then kill "$5" 2> /dev/null || :; fi ) > /dev/null 2>&1 &`);
|
|
78
116
|
}
|
|
79
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Sizes, the cut past FULL_OUTPUT_BYTES and the marker line, with `d` set to
|
|
119
|
+
* the directory and each reader's write failure in `x` and `y` (1 or 0,
|
|
120
|
+
* failedLines). A cut leaves `<name>.cut`, so collect sent again still reads
|
|
121
|
+
* the stream as past the bound (its flag is still there, its size no longer
|
|
122
|
+
* says so). `true`, not `:`, for the same reason as in READER_SHELL.
|
|
123
|
+
*/
|
|
80
124
|
function reportLines(id) {
|
|
81
125
|
const max = FULL_OUTPUT_BYTES;
|
|
82
126
|
return {
|
|
83
127
|
sizes: ['o=$(stat -c %s "$d/stdout" 2> /dev/null || echo 0)', 'e=$(stat -c %s "$d/stderr" 2> /dev/null || echo 0)'],
|
|
84
128
|
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"`,
|
|
129
|
+
`if [ "$o" -gt ${max} ]; then truncate -s ${max} "$d/stdout" && true > "$d/stdout.cut" || :; fi`,
|
|
130
|
+
`if [ "$e" -gt ${max} ]; then truncate -s ${max} "$d/stderr" && true > "$d/stderr.cut" || :; fi`,
|
|
131
|
+
`echo "prized-output ${id} $o $e $x $y"`,
|
|
88
132
|
],
|
|
89
133
|
};
|
|
90
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Whether each reader failed to write its file (`x` for stdout, `y` for
|
|
137
|
+
* stderr: nonzero from its exit status, or its flag, after `sizes`): 1 when
|
|
138
|
+
* it ended in error short of the bound (a full disk or a quota, a file it
|
|
139
|
+
* could not create, a reader killed), else 0. Past the bound its error is the
|
|
140
|
+
* cut (EFBIG). The watchdog stops relays, never readers, so a reader's status
|
|
141
|
+
* is its own.
|
|
142
|
+
*/
|
|
143
|
+
function failedLines() {
|
|
144
|
+
const max = FULL_OUTPUT_BYTES;
|
|
145
|
+
return [
|
|
146
|
+
`if [ "$x" -eq 0 ] || [ "$o" -gt ${max} ]; then x=0; else x=1; fi`,
|
|
147
|
+
`if [ "$y" -eq 0 ] || [ "$e" -gt ${max} ]; then y=0; else y=1; fi`,
|
|
148
|
+
];
|
|
149
|
+
}
|
|
91
150
|
/**
|
|
92
151
|
* The script a fullOutput exec runs (RUN_SCRIPT, from SCRIPT_VAR). The
|
|
93
152
|
* caller's command, from COMMAND_VAR, runs as a plain exec runs it (`bash
|
|
@@ -97,10 +156,12 @@ function reportLines(id) {
|
|
|
97
156
|
* Nothing is assigned before it runs but the positional parameters, so every
|
|
98
157
|
* variable the caller's env sets reaches it unchanged, PATH included; and
|
|
99
158
|
* every step holds under an errexit the env may switch on (SHELLOPTS). As in
|
|
100
|
-
* a plain exec, the
|
|
159
|
+
* a plain exec, the relays stop 5 s after the command ends if a process it
|
|
101
160
|
* left running still holds its output, keeping what came through. A command
|
|
102
|
-
* a signal ends exits 128+n, as a background command does.
|
|
103
|
-
*
|
|
161
|
+
* a signal ends exits 128+n, as a background command does. A reader that
|
|
162
|
+
* could not write its file (failedLines) sends the output to the files API
|
|
163
|
+
* whatever its size, with the marker saying which. Without /bin/sh, setsid,
|
|
164
|
+
* cat or a directory to write to, the command runs as a plain exec.
|
|
104
165
|
*/
|
|
105
166
|
export function captureScript(id) {
|
|
106
167
|
checkId(id);
|
|
@@ -109,21 +170,28 @@ export function captureScript(id) {
|
|
|
109
170
|
return [
|
|
110
171
|
`set -- "\${${COMMAND_VAR}:?}" /bin/bash; unset ${COMMAND_VAR} ${SCRIPT_VAR}; [ -x "$2" ] || set -- "$1" /bin/sh`,
|
|
111
172
|
`if ( PATH=${LAUNCH_PATH}; mkdir -p ${OUTPUT_DIR} && mkdir ${d} ) 2> /dev/null; then`,
|
|
112
|
-
`if ( PATH=${LAUNCH_PATH}; command -v
|
|
113
|
-
|
|
173
|
+
`if ( PATH=${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`,
|
|
174
|
+
// The command writes to o and e; a relay passes each on to p and q, where its reader is.
|
|
175
|
+
reader(d, 'stdout', 'p'),
|
|
114
176
|
'set -- "$1" "$2" "$!"',
|
|
115
|
-
reader(d, 'stderr', '
|
|
177
|
+
reader(d, 'stderr', 'q'),
|
|
116
178
|
'set -- "$1" "$2" "$3" "$!"',
|
|
179
|
+
relay(d, 'o', 'p'),
|
|
180
|
+
'set -- "$1" "$2" "$3" "$4" "$!"',
|
|
181
|
+
relay(d, 'e', 'q'),
|
|
182
|
+
'set -- "$1" "$2" "$3" "$4" "$5" "$!"',
|
|
117
183
|
// 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`,
|
|
184
|
+
`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`,
|
|
119
185
|
`PATH=${LAUNCH_PATH}`,
|
|
120
186
|
`c=$1; d=${d}`,
|
|
121
187
|
watchdog(d),
|
|
122
188
|
'w=$!',
|
|
123
|
-
'wait "$2"
|
|
189
|
+
'if wait "$2" 2> /dev/null; then x=0; else x=$?; fi',
|
|
190
|
+
'if wait "$3" 2> /dev/null; then y=0; else y=$?; fi',
|
|
124
191
|
'kill "$w" 2> /dev/null || :',
|
|
125
192
|
...sizes,
|
|
126
|
-
|
|
193
|
+
...failedLines(),
|
|
194
|
+
`if [ "$o" -lt ${MAX_OUTPUT_BYTES} ] && [ "$e" -lt ${MAX_OUTPUT_BYTES} ] && [ "$x$y" = 00 ]; then ` +
|
|
127
195
|
'cat "$d/stdout" 2> /dev/null || :; cat "$d/stderr" >&2 2> /dev/null || :; rm -rf "$d" || :; exit "$c"; fi',
|
|
128
196
|
...cut,
|
|
129
197
|
'exit "$c"',
|
|
@@ -137,7 +205,10 @@ export function captureScript(id) {
|
|
|
137
205
|
* What a fullOutput command left when the script could not print it (its
|
|
138
206
|
* time limit, or a signal, ended the script): the marker line, or `gone`,
|
|
139
207
|
* with the directory removed, when there is nothing to read (it was never
|
|
140
|
-
* made, or its output was already printed back).
|
|
208
|
+
* made, or its output was already printed back). A failed write is read from
|
|
209
|
+
* the reader's flag, before the size, which is final once the flag is there
|
|
210
|
+
* (the script's wait for the reader's exit status went with the script); a
|
|
211
|
+
* reader killed, or one that could not make its flag, is not seen here.
|
|
141
212
|
*/
|
|
142
213
|
export function collectScript(id) {
|
|
143
214
|
checkId(id);
|
|
@@ -146,7 +217,13 @@ export function collectScript(id) {
|
|
|
146
217
|
`d=${OUTPUT_DIR}/${id}`,
|
|
147
218
|
`PATH=${LAUNCH_PATH}`,
|
|
148
219
|
`if [ ! -f "$d/stdout" ] || [ ! -f "$d/stderr" ]; then rm -rf "$d" || :; echo "prized-output ${id} gone"; exit 0; fi`,
|
|
220
|
+
'if [ -e "$d/stdout.failed" ]; then x=1; else x=0; fi',
|
|
221
|
+
'if [ -e "$d/stderr.failed" ]; then y=1; else y=0; fi',
|
|
149
222
|
...sizes,
|
|
223
|
+
// Cut by an earlier collect whose answer was lost: as long as a cut stream (the bound and a block).
|
|
224
|
+
`if [ -e "$d/stdout.cut" ]; then o=$((${FULL_OUTPUT_BYTES} + 512)); fi`,
|
|
225
|
+
`if [ -e "$d/stderr.cut" ]; then e=$((${FULL_OUTPUT_BYTES} + 512)); fi`,
|
|
226
|
+
...failedLines(),
|
|
150
227
|
...cut,
|
|
151
228
|
].join('\n');
|
|
152
229
|
}
|
|
@@ -160,14 +237,22 @@ export function removeOutputScript(id) {
|
|
|
160
237
|
return (`PATH=${LAUNCH_PATH}; rm -rf ${OUTPUT_DIR}/${id}; ` +
|
|
161
238
|
`find ${OUTPUT_DIR} -mindepth 1 -maxdepth 1 -name 'exec_*' -mmin +${STALE_MIN} -exec rm -rf -- {} + 2> /dev/null || :`);
|
|
162
239
|
}
|
|
163
|
-
/** The marker
|
|
240
|
+
/** The marker (one without failure flags, as an earlier SDK's collect printed, has none), `gone`, or null for no marker. */
|
|
164
241
|
export function parseMarker(stdout, id) {
|
|
165
|
-
const m = new RegExp(`(?:^|\\n)prized-output ${id} (?:([0-9]+) ([0-9]+)
|
|
242
|
+
const m = new RegExp(`(?:^|\\n)prized-output ${id} (?:([0-9]+) ([0-9]+)(?: ([01]) ([01]))?|(gone))\\n$`).exec(stdout);
|
|
166
243
|
if (m === null)
|
|
167
244
|
return null;
|
|
168
|
-
if (m[
|
|
245
|
+
if (m[5] === 'gone')
|
|
169
246
|
return 'gone';
|
|
170
|
-
return { out: Number(m[1]), err: Number(m[2]) };
|
|
247
|
+
return { out: Number(m[1]), err: Number(m[2]), outFailed: m[3] === '1', errFailed: m[4] === '1' };
|
|
248
|
+
}
|
|
249
|
+
/** Why the read-back output is cut, if it is: a failed write first, since it is the one the caller cannot foresee. */
|
|
250
|
+
export function markerTruncation(marker) {
|
|
251
|
+
if (marker.outFailed || marker.errFailed)
|
|
252
|
+
return 'write_failed';
|
|
253
|
+
if (marker.out > FULL_OUTPUT_BYTES || marker.err > FULL_OUTPUT_BYTES)
|
|
254
|
+
return 'output_limit';
|
|
255
|
+
return null;
|
|
171
256
|
}
|
|
172
257
|
/** A request the SDK sends on its own to collect or remove the files: best effort, within TEARDOWN_MS. */
|
|
173
258
|
async function teardown(toolbox, script) {
|
|
@@ -185,6 +270,7 @@ async function download(toolbox, path, signal) {
|
|
|
185
270
|
retry: 'safe',
|
|
186
271
|
read: 'bytes',
|
|
187
272
|
timeoutMs: READ_MS,
|
|
273
|
+
stallMs: timing.transferStallMs,
|
|
188
274
|
deadline: toolbox.config.now() + READ_MS,
|
|
189
275
|
signal,
|
|
190
276
|
sandboxId: toolbox.sandboxId,
|
|
@@ -193,7 +279,7 @@ async function download(toolbox, path, signal) {
|
|
|
193
279
|
return new TextDecoder('utf-8', { ignoreBOM: true }).decode(result.bytes ?? new Uint8Array(0));
|
|
194
280
|
}
|
|
195
281
|
/** The files' contents in place of the answer's output. */
|
|
196
|
-
async function readBack(toolbox, id,
|
|
282
|
+
async function readBack(toolbox, id, marker, result, signal) {
|
|
197
283
|
const dir = `${OUTPUT_DIR}/${id}`;
|
|
198
284
|
// Both at once; one that fails stops the other, as the caller's abort stops both.
|
|
199
285
|
const both = new AbortController();
|
|
@@ -210,17 +296,17 @@ async function readBack(toolbox, id, sizes, result, signal) {
|
|
|
210
296
|
let stdout;
|
|
211
297
|
let stderr;
|
|
212
298
|
try {
|
|
213
|
-
[stdout, stderr] = await Promise.all([read('stdout',
|
|
299
|
+
[stdout, stderr] = await Promise.all([read('stdout', marker.out), read('stderr', marker.err)]);
|
|
214
300
|
}
|
|
215
301
|
finally {
|
|
216
302
|
signal?.removeEventListener('abort', stop);
|
|
217
303
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
304
|
+
const reason = markerTruncation(marker);
|
|
305
|
+
const { exitCode, durationMs, timedOut } = result;
|
|
306
|
+
const full = { stdout, stderr, exitCode, durationMs, timedOut, truncated: reason !== null };
|
|
307
|
+
if (reason !== null)
|
|
308
|
+
full.truncatedReason = reason;
|
|
309
|
+
return full;
|
|
224
310
|
}
|
|
225
311
|
/**
|
|
226
312
|
* A blocking exec that keeps the whole output (up to FULL_OUTPUT_BYTES of
|
|
@@ -245,22 +331,22 @@ export async function execFullOutput(toolbox, id, req, signal) {
|
|
|
245
331
|
await teardown(toolbox, removeOutputScript(id));
|
|
246
332
|
throw error;
|
|
247
333
|
}
|
|
248
|
-
let
|
|
249
|
-
if (
|
|
334
|
+
let marker = parseMarker(result.stdout, id);
|
|
335
|
+
if (marker === null && (result.timedOut || result.exitCode === -1)) {
|
|
250
336
|
// Its time limit (or a signal) ended the script with the command, before
|
|
251
337
|
// it printed anything back: what the command printed is in its files.
|
|
252
338
|
const collected = await teardown(toolbox, collectScript(id));
|
|
253
|
-
|
|
254
|
-
if (
|
|
339
|
+
marker = collected === null ? null : parseMarker(collected.stdout, id);
|
|
340
|
+
if (marker === null)
|
|
255
341
|
return result; // no answer: nothing more to be done
|
|
256
342
|
}
|
|
257
343
|
// No marker: the output came back in the answer (or the command ran as a
|
|
258
344
|
// plain exec), and the directory is already gone.
|
|
259
|
-
if (
|
|
345
|
+
if (marker === null || marker === 'gone')
|
|
260
346
|
return result;
|
|
261
347
|
let full;
|
|
262
348
|
try {
|
|
263
|
-
full = await readBack(toolbox, id,
|
|
349
|
+
full = await readBack(toolbox, id, marker, result, signal);
|
|
264
350
|
}
|
|
265
351
|
catch (error) {
|
|
266
352
|
const aborted = !(error instanceof PrizedError) || error.code === 'aborted';
|
|
@@ -271,9 +357,18 @@ export async function execFullOutput(toolbox, id, req, signal) {
|
|
|
271
357
|
throw error;
|
|
272
358
|
return result;
|
|
273
359
|
}
|
|
360
|
+
if (error.code === 'sandbox_deleted') {
|
|
361
|
+
// Nothing is kept: the output went with the sandbox, as its code says for any call on it.
|
|
362
|
+
// The command ran: its exit code goes on the error too, so a caller
|
|
363
|
+
// that replaces the sandbox can tell not to run it again.
|
|
364
|
+
throw restate(error, `The command ran (exit ${result.exitCode}), but the sandbox was deleted before its output could be read back.`, {
|
|
365
|
+
cause: error,
|
|
366
|
+
exitCode: result.exitCode,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
274
369
|
const dir = `${OUTPUT_DIR}/${id}`;
|
|
275
|
-
throw
|
|
276
|
-
`It is kept in ${dir}/stdout and ${dir}/stderr for an hour: read them with files.read(), then remove ${dir}
|
|
370
|
+
throw new OutputReadError(`The command ran (exit ${result.exitCode}), but its output could not be read back (${error.message.replace(/[.\s]+$/, '')}). ` +
|
|
371
|
+
`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` });
|
|
277
372
|
}
|
|
278
373
|
await teardown(toolbox, removeOutputScript(id));
|
|
279
374
|
return full;
|
package/dist/esm/sandbox.d.ts
CHANGED
|
@@ -128,8 +128,9 @@ export declare class Sandbox {
|
|
|
128
128
|
/**
|
|
129
129
|
* Delete every sandbox carrying all of `labels` (at least one) and resolve
|
|
130
130
|
* with their ids (the API takes up to 250 a request; this repeats it until
|
|
131
|
-
* none are left).
|
|
132
|
-
*
|
|
131
|
+
* none are left). Each request selects the current matches, so a repeat
|
|
132
|
+
* can delete sandboxes created since the first request. An uncertain
|
|
133
|
+
* answer is not retried; list the matches before deleting again. Each
|
|
133
134
|
* counts toward the workspace's quotas until it is gone;
|
|
134
135
|
* `Sandbox.list({ labels })` coming back empty means they all are.
|
|
135
136
|
*/
|
package/dist/esm/sandbox.js
CHANGED
|
@@ -225,6 +225,32 @@ function failedState(info) {
|
|
|
225
225
|
}
|
|
226
226
|
return null;
|
|
227
227
|
}
|
|
228
|
+
/** How the API's errorReason starts on each copy it deletes because another
|
|
229
|
+
* copy of the same fork could not start. */
|
|
230
|
+
const FORK_UNDONE_PREFIX = 'fork undone';
|
|
231
|
+
/**
|
|
232
|
+
* The error a fork throws when not every copy runs, or null. The API undoes
|
|
233
|
+
* a fork one of whose copies could not start, deleting every copy, so the
|
|
234
|
+
* copy that failed may already read `deleting`, with its errorReason, by the
|
|
235
|
+
* time it is read: the error is that copy's reason (one that is not the
|
|
236
|
+
* undo's own), else a copy in error, else any non-running copy's state.
|
|
237
|
+
*/
|
|
238
|
+
function forkFailure(copies) {
|
|
239
|
+
const failed = copies.filter((copy) => copy.state !== 'running');
|
|
240
|
+
if (failed.length === 0)
|
|
241
|
+
return null;
|
|
242
|
+
const cause = failed.find((copy) => copy.errorReason && !copy.errorReason.startsWith(FORK_UNDONE_PREFIX)) ??
|
|
243
|
+
failed.find((copy) => copy.state === 'error') ??
|
|
244
|
+
failed.find((copy) => copy.errorReason) ??
|
|
245
|
+
failed[0];
|
|
246
|
+
if (cause.errorReason) {
|
|
247
|
+
return new PrizedError('sandbox_error', `The sandbox failed: ${cause.errorReason}`, {
|
|
248
|
+
sandboxId: cause.id,
|
|
249
|
+
reason: cause.errorReason,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return failedState(cause) ?? new PrizedError('sandbox_error', null, { sandboxId: cause.id });
|
|
253
|
+
}
|
|
228
254
|
/**
|
|
229
255
|
* A wait's timeout. One ended by a read its deadline cut short (the retry it
|
|
230
256
|
* was due would have ended past it: a 503's Retry-After, say) may come
|
|
@@ -411,8 +437,9 @@ export class Sandbox {
|
|
|
411
437
|
/**
|
|
412
438
|
* Delete every sandbox carrying all of `labels` (at least one) and resolve
|
|
413
439
|
* with their ids (the API takes up to 250 a request; this repeats it until
|
|
414
|
-
* none are left).
|
|
415
|
-
*
|
|
440
|
+
* none are left). Each request selects the current matches, so a repeat
|
|
441
|
+
* can delete sandboxes created since the first request. An uncertain
|
|
442
|
+
* answer is not retried; list the matches before deleting again. Each
|
|
416
443
|
* counts toward the workspace's quotas until it is gone;
|
|
417
444
|
* `Sandbox.list({ labels })` coming back empty means they all are.
|
|
418
445
|
*/
|
|
@@ -426,7 +453,15 @@ export class Sandbox {
|
|
|
426
453
|
const deleted = new Set();
|
|
427
454
|
const stuck = (calls) => new PrizedError('delete_incomplete', `Deleted ${deleted.size} sandboxes in ${calls} requests and some are left; call again to delete the rest.`);
|
|
428
455
|
for (let call = 1; call <= MAX_BULK_DELETE_CALLS; call += 1) {
|
|
429
|
-
|
|
456
|
+
// A label is a live selector, not a stable resource or idempotency key.
|
|
457
|
+
const result = await request(config, {
|
|
458
|
+
method: 'DELETE', url: apiUrl(config, `/sandboxes${query}`), retry: 'exec', deadline,
|
|
459
|
+
effect: {
|
|
460
|
+
maybe: 'some matching sandboxes may already be deleting',
|
|
461
|
+
did: 'The matching sandboxes were marked for deletion',
|
|
462
|
+
check: 'It was not sent again: list the matching sandboxes before deleting again, because the matches may have changed.',
|
|
463
|
+
},
|
|
464
|
+
});
|
|
430
465
|
const answer = result.json;
|
|
431
466
|
const ids = answer?.ids;
|
|
432
467
|
if (!Array.isArray(ids) || !ids.every((id) => typeof id === 'string')) {
|
|
@@ -560,9 +595,9 @@ export class Sandbox {
|
|
|
560
595
|
return children;
|
|
561
596
|
try {
|
|
562
597
|
await Sandbox.settle(this.config, children, deadline, timeoutMs, options.signal, this.id);
|
|
563
|
-
const failed = children.
|
|
598
|
+
const failed = forkFailure(children.map((child) => child.current));
|
|
564
599
|
if (failed)
|
|
565
|
-
throw
|
|
600
|
+
throw failed;
|
|
566
601
|
}
|
|
567
602
|
catch (error) {
|
|
568
603
|
// All or nothing: copies the caller will never get are not left billing.
|
package/dist/esm/shell.d.ts
CHANGED
|
@@ -11,7 +11,8 @@ export declare const RUN_ID_RE: RegExp;
|
|
|
11
11
|
/**
|
|
12
12
|
* Minutes (by the sandbox's clock) a cleaned or abandoned run's directory
|
|
13
13
|
* stays: what keeps a resend of its start from running the command again.
|
|
14
|
-
* The SDK stops resending a start
|
|
14
|
+
* The SDK stops resending a start once pollOutageMs (2 minutes by default)
|
|
15
|
+
* has run out after its first copy's retries, so an hour is ample; each
|
|
15
16
|
* cleanup then removes the ones older than that, so they never pile up.
|
|
16
17
|
*/
|
|
17
18
|
export declare const CLEANED_KEEP_MIN = 60;
|
package/dist/esm/shell.js
CHANGED
|
@@ -50,7 +50,8 @@ export const RUN_ID_RE = /^run_[0-9a-f]{16}$/;
|
|
|
50
50
|
/**
|
|
51
51
|
* Minutes (by the sandbox's clock) a cleaned or abandoned run's directory
|
|
52
52
|
* stays: what keeps a resend of its start from running the command again.
|
|
53
|
-
* The SDK stops resending a start
|
|
53
|
+
* The SDK stops resending a start once pollOutageMs (2 minutes by default)
|
|
54
|
+
* has run out after its first copy's retries, so an hour is ample; each
|
|
54
55
|
* cleanup then removes the ones older than that, so they never pile up.
|
|
55
56
|
*/
|
|
56
57
|
export const CLEANED_KEEP_MIN = 60;
|
package/dist/esm/toolbox.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export declare function rawExec(toolbox: Toolbox, req: RawExecRequest, options:
|
|
|
30
30
|
deadline?: number | undefined;
|
|
31
31
|
/** What the command does, for an error that says whether it happened (`exec`: a command unless given). */
|
|
32
32
|
effect?: Effect | undefined;
|
|
33
|
+
/** An earlier copy of a keyed start may have reached the sandbox. */
|
|
34
|
+
acted?: boolean | undefined;
|
|
33
35
|
}): Promise<ExecResult>;
|
|
34
36
|
/** A helper command the SDK sends itself; a non-zero exit is the caller's to judge. */
|
|
35
37
|
export declare function helperExec(toolbox: Toolbox, command: string, options?: {
|
package/dist/esm/toolbox.js
CHANGED
|
@@ -46,6 +46,7 @@ export async function rawExec(toolbox, req, options) {
|
|
|
46
46
|
deadline: options.deadline,
|
|
47
47
|
sandboxId: toolbox.sandboxId,
|
|
48
48
|
...(effect === undefined ? {} : { effect }),
|
|
49
|
+
...(options.acted ? { acted: true } : {}),
|
|
49
50
|
});
|
|
50
51
|
const doc = result.json;
|
|
51
52
|
if (doc === null || typeof doc !== 'object' || typeof doc.exitCode !== 'number') {
|
|
@@ -58,13 +59,15 @@ export async function rawExec(toolbox, req, options) {
|
|
|
58
59
|
}
|
|
59
60
|
const stdout = typeof doc.stdout === 'string' ? doc.stdout : '';
|
|
60
61
|
const stderr = typeof doc.stderr === 'string' ? doc.stderr : '';
|
|
62
|
+
const truncated = keptBytes(stdout) >= MAX_OUTPUT_BYTES || keptBytes(stderr) >= MAX_OUTPUT_BYTES;
|
|
61
63
|
return {
|
|
62
64
|
stdout,
|
|
63
65
|
stderr,
|
|
64
66
|
exitCode: doc.exitCode,
|
|
65
67
|
durationMs: typeof doc.durationMs === 'number' ? doc.durationMs : 0,
|
|
66
68
|
timedOut: doc.timedOut === true,
|
|
67
|
-
truncated
|
|
69
|
+
truncated,
|
|
70
|
+
...(truncated ? { truncatedReason: 'output_limit' } : {}),
|
|
68
71
|
};
|
|
69
72
|
}
|
|
70
73
|
/** A helper command the SDK sends itself; a non-zero exit is the caller's to judge. */
|
package/dist/esm/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/esm/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.2";
|
package/dist/esm/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This package's version; test/version.test.ts holds it equal to package.json. */
|
|
2
|
-
export const VERSION = '0.1.
|
|
2
|
+
export const VERSION = '0.1.2';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prized/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Prized Sandboxes (beta): create microVM sandboxes, run commands, move files, pause, resume and fork them from TypeScript.",
|
|
5
5
|
"homepage": "https://prized.dev/docs/sandboxes",
|
|
6
6
|
"license": "Apache-2.0",
|