@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.
Files changed (48) hide show
  1. package/README.md +76 -21
  2. package/dist/cjs/commands.d.ts +47 -14
  3. package/dist/cjs/commands.js +94 -26
  4. package/dist/cjs/errors.d.ts +43 -3
  5. package/dist/cjs/errors.js +54 -6
  6. package/dist/cjs/files.d.ts +21 -5
  7. package/dist/cjs/files.js +38 -14
  8. package/dist/cjs/http.d.ts +74 -15
  9. package/dist/cjs/http.js +208 -32
  10. package/dist/cjs/index.d.ts +3 -3
  11. package/dist/cjs/index.js +2 -1
  12. package/dist/cjs/outcome.d.ts +11 -1
  13. package/dist/cjs/outcome.js +28 -5
  14. package/dist/cjs/output.d.ts +19 -8
  15. package/dist/cjs/output.js +152 -56
  16. package/dist/cjs/sandbox.d.ts +3 -2
  17. package/dist/cjs/sandbox.js +40 -5
  18. package/dist/cjs/shell.d.ts +2 -1
  19. package/dist/cjs/shell.js +2 -1
  20. package/dist/cjs/toolbox.d.ts +2 -0
  21. package/dist/cjs/toolbox.js +4 -1
  22. package/dist/cjs/types.d.ts +13 -1
  23. package/dist/cjs/version.d.ts +1 -1
  24. package/dist/cjs/version.js +1 -1
  25. package/dist/esm/commands.d.ts +47 -14
  26. package/dist/esm/commands.js +95 -27
  27. package/dist/esm/errors.d.ts +43 -3
  28. package/dist/esm/errors.js +52 -5
  29. package/dist/esm/files.d.ts +21 -5
  30. package/dist/esm/files.js +39 -15
  31. package/dist/esm/http.d.ts +74 -15
  32. package/dist/esm/http.js +207 -31
  33. package/dist/esm/index.d.ts +3 -3
  34. package/dist/esm/index.js +1 -1
  35. package/dist/esm/outcome.d.ts +11 -1
  36. package/dist/esm/outcome.js +27 -5
  37. package/dist/esm/output.d.ts +19 -8
  38. package/dist/esm/output.js +153 -58
  39. package/dist/esm/sandbox.d.ts +3 -2
  40. package/dist/esm/sandbox.js +40 -5
  41. package/dist/esm/shell.d.ts +2 -1
  42. package/dist/esm/shell.js +2 -1
  43. package/dist/esm/toolbox.d.ts +2 -0
  44. package/dist/esm/toolbox.js +4 -1
  45. package/dist/esm/types.d.ts +13 -1
  46. package/dist/esm/version.d.ts +1 -1
  47. package/dist/esm/version.js +1 -1
  48. package/package.json +1 -1
@@ -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 reader on
13
- // each copies what comes through to a file in the exec's own directory, at a
14
- // fixed path under the sandbox user's home, and drops what passes 100 MiB, so
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: it
22
- // prints one marker line instead, and the SDK downloads both files through
23
- // the files API and removes the directory. The scripts are the Python SDK's
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 on one stream: tee copies it to the file as it comes (each read
61
- * written at once) until the file reaches FULL_OUTPUT_BYTES and one 512-byte
62
- * block more, which tells a cut stream from a full one. The file size limit
63
- * (ulimit -f, in 512-byte blocks in POSIX mode) stops the file there with
64
- * EFBIG, not SIGXFSZ, and tee goes on draining into /dev/null, so the command
65
- * never meets a closed pipe and a runaway one cannot fill the disk. One
66
- * process per stream (exec), in a session of its own (setsid, same pid): a
67
- * time limit or a `kill 0` that ends the command's process group leaves it to
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 tee ${d}/${name} < ${d}/${fifo} ) &`);
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 readers
78
- * still running stop (a pid is checked to be this exec's tee first, never
79
- * one reused since), and a process the command left holding its output
80
- * meets a closed pipe, as it would there. Stopped early (TERM, once the
81
- * readers are done), it takes its sleep with it.
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
- `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 &`);
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
- /** Sizes, the cut past FULL_OUTPUT_BYTES and the marker line, with `d` set to the directory. */
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 readers stop 5 s after the command ends if a process it
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. Without tee,
111
- * setsid or a directory to write to, the command runs as a plain exec.
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 tee && command -v setsid && mkfifo ${d}/o ${d}/e ) > /dev/null 2>&1; then`,
121
- reader(d, 'stdout', 'o'),
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', 'e'),
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" "$3" 2> /dev/null || :',
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
- `if [ "$o" -lt ${toolbox_js_1.MAX_OUTPUT_BYTES} ] && [ "$e" -lt ${toolbox_js_1.MAX_OUTPUT_BYTES} ]; then ` +
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's byte counts (a stream cut at FULL_OUTPUT_BYTES reads more), `gone`, or null for no 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]+)|(gone))\\n$`).exec(stdout);
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[3] === 'gone')
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) {
@@ -193,6 +279,7 @@ async function download(toolbox, path, signal) {
193
279
  retry: 'safe',
194
280
  read: 'bytes',
195
281
  timeoutMs: READ_MS,
282
+ stallMs: http_js_1.timing.transferStallMs,
196
283
  deadline: toolbox.config.now() + READ_MS,
197
284
  signal,
198
285
  sandboxId: toolbox.sandboxId,
@@ -201,7 +288,7 @@ async function download(toolbox, path, signal) {
201
288
  return new TextDecoder('utf-8', { ignoreBOM: true }).decode(result.bytes ?? new Uint8Array(0));
202
289
  }
203
290
  /** The files' contents in place of the answer's output. */
204
- async function readBack(toolbox, id, sizes, result, signal) {
291
+ async function readBack(toolbox, id, marker, result, signal) {
205
292
  const dir = `${exports.OUTPUT_DIR}/${id}`;
206
293
  // Both at once; one that fails stops the other, as the caller's abort stops both.
207
294
  const both = new AbortController();
@@ -218,17 +305,17 @@ async function readBack(toolbox, id, sizes, result, signal) {
218
305
  let stdout;
219
306
  let stderr;
220
307
  try {
221
- [stdout, stderr] = await Promise.all([read('stdout', sizes.out), read('stderr', sizes.err)]);
308
+ [stdout, stderr] = await Promise.all([read('stdout', marker.out), read('stderr', marker.err)]);
222
309
  }
223
310
  finally {
224
311
  signal?.removeEventListener('abort', stop);
225
312
  }
226
- return {
227
- ...result,
228
- stdout,
229
- stderr,
230
- truncated: sizes.out > exports.FULL_OUTPUT_BYTES || sizes.err > exports.FULL_OUTPUT_BYTES,
231
- };
313
+ const reason = markerTruncation(marker);
314
+ const { exitCode, durationMs, timedOut } = result;
315
+ const full = { stdout, stderr, exitCode, durationMs, timedOut, truncated: reason !== null };
316
+ if (reason !== null)
317
+ full.truncatedReason = reason;
318
+ return full;
232
319
  }
233
320
  /**
234
321
  * A blocking exec that keeps the whole output (up to FULL_OUTPUT_BYTES of
@@ -253,22 +340,22 @@ async function execFullOutput(toolbox, id, req, signal) {
253
340
  await teardown(toolbox, removeOutputScript(id));
254
341
  throw error;
255
342
  }
256
- let sizes = parseMarker(result.stdout, id);
257
- if (sizes === null && (result.timedOut || result.exitCode === -1)) {
343
+ let marker = parseMarker(result.stdout, id);
344
+ if (marker === null && (result.timedOut || result.exitCode === -1)) {
258
345
  // Its time limit (or a signal) ended the script with the command, before
259
346
  // it printed anything back: what the command printed is in its files.
260
347
  const collected = await teardown(toolbox, collectScript(id));
261
- sizes = collected === null ? null : parseMarker(collected.stdout, id);
262
- if (sizes === null)
348
+ marker = collected === null ? null : parseMarker(collected.stdout, id);
349
+ if (marker === null)
263
350
  return result; // no answer: nothing more to be done
264
351
  }
265
352
  // No marker: the output came back in the answer (or the command ran as a
266
353
  // plain exec), and the directory is already gone.
267
- if (sizes === null || sizes === 'gone')
354
+ if (marker === null || marker === 'gone')
268
355
  return result;
269
356
  let full;
270
357
  try {
271
- full = await readBack(toolbox, id, sizes, result, signal);
358
+ full = await readBack(toolbox, id, marker, result, signal);
272
359
  }
273
360
  catch (error) {
274
361
  const aborted = !(error instanceof errors_js_1.PrizedError) || error.code === 'aborted';
@@ -279,9 +366,18 @@ async function execFullOutput(toolbox, id, req, signal) {
279
366
  throw error;
280
367
  return result;
281
368
  }
369
+ if (error.code === 'sandbox_deleted') {
370
+ // Nothing is kept: the output went with the sandbox, as its code says for any call on it.
371
+ // The command ran: its exit code goes on the error too, so a caller
372
+ // that replaces the sandbox can tell not to run it again.
373
+ 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.`, {
374
+ cause: error,
375
+ exitCode: result.exitCode,
376
+ });
377
+ }
282
378
  const dir = `${exports.OUTPUT_DIR}/${id}`;
283
- throw (0, outcome_js_1.restate)(error, `The command ran (exit ${result.exitCode}), but its output could not be read back (${error.message.replace(/[.\s]+$/, '')}). ` +
284
- `It is kept in ${dir}/stdout and ${dir}/stderr for an hour: read them with files.read(), then remove ${dir}.`);
379
+ throw new errors_js_1.OutputReadError(`The command ran (exit ${result.exitCode}), but its output could not be read back (${error.message.replace(/[.\s]+$/, '')}). ` +
380
+ `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
381
  }
286
382
  await teardown(toolbox, removeOutputScript(id));
287
383
  return full;
@@ -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). Safe to repeat: sandboxes already being
132
- * deleted are in the answer again, and none matching resolves `[]`. Each
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
  */
@@ -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
@@ -414,8 +440,9 @@ class Sandbox {
414
440
  /**
415
441
  * Delete every sandbox carrying all of `labels` (at least one) and resolve
416
442
  * with their ids (the API takes up to 250 a request; this repeats it until
417
- * none are left). Safe to repeat: sandboxes already being
418
- * deleted are in the answer again, and none matching resolves `[]`. Each
443
+ * none are left). Each request selects the current matches, so a repeat
444
+ * can delete sandboxes created since the first request. An uncertain
445
+ * answer is not retried; list the matches before deleting again. Each
419
446
  * counts toward the workspace's quotas until it is gone;
420
447
  * `Sandbox.list({ labels })` coming back empty means they all are.
421
448
  */
@@ -429,7 +456,15 @@ class Sandbox {
429
456
  const deleted = new Set();
430
457
  const stuck = (calls) => new errors_js_1.PrizedError('delete_incomplete', `Deleted ${deleted.size} sandboxes in ${calls} requests and some are left; call again to delete the rest.`);
431
458
  for (let call = 1; call <= MAX_BULK_DELETE_CALLS; call += 1) {
432
- const result = await (0, http_js_1.request)(config, { method: 'DELETE', url: apiUrl(config, `/sandboxes${query}`), retry: 'safe', deadline });
459
+ // A label is a live selector, not a stable resource or idempotency key.
460
+ const result = await (0, http_js_1.request)(config, {
461
+ method: 'DELETE', url: apiUrl(config, `/sandboxes${query}`), retry: 'exec', deadline,
462
+ effect: {
463
+ maybe: 'some matching sandboxes may already be deleting',
464
+ did: 'The matching sandboxes were marked for deletion',
465
+ check: 'It was not sent again: list the matching sandboxes before deleting again, because the matches may have changed.',
466
+ },
467
+ });
433
468
  const answer = result.json;
434
469
  const ids = answer?.ids;
435
470
  if (!Array.isArray(ids) || !ids.every((id) => typeof id === 'string')) {
@@ -563,9 +598,9 @@ class Sandbox {
563
598
  return children;
564
599
  try {
565
600
  await Sandbox.settle(this.config, children, deadline, timeoutMs, options.signal, this.id);
566
- const failed = children.find((child) => child.state !== 'running');
601
+ const failed = forkFailure(children.map((child) => child.current));
567
602
  if (failed)
568
- throw failedState(failed.current) ?? new errors_js_1.PrizedError('sandbox_error', null, { sandboxId: failed.id });
603
+ throw failed;
569
604
  }
570
605
  catch (error) {
571
606
  // All or nothing: copies the caller will never get are not left billing.
@@ -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 within minutes, so an hour is ample; each
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/cjs/shell.js CHANGED
@@ -64,7 +64,8 @@ exports.RUN_ID_RE = /^run_[0-9a-f]{16}$/;
64
64
  /**
65
65
  * Minutes (by the sandbox's clock) a cleaned or abandoned run's directory
66
66
  * stays: what keeps a resend of its start from running the command again.
67
- * The SDK stops resending a start within minutes, so an hour is ample; each
67
+ * The SDK stops resending a start once pollOutageMs (2 minutes by default)
68
+ * has run out after its first copy's retries, so an hour is ample; each
68
69
  * cleanup then removes the ones older than that, so they never pile up.
69
70
  */
70
71
  exports.CLEANED_KEEP_MIN = 60;
@@ -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?: {
@@ -51,6 +51,7 @@ async function rawExec(toolbox, req, options) {
51
51
  deadline: options.deadline,
52
52
  sandboxId: toolbox.sandboxId,
53
53
  ...(effect === undefined ? {} : { effect }),
54
+ ...(options.acted ? { acted: true } : {}),
54
55
  });
55
56
  const doc = result.json;
56
57
  if (doc === null || typeof doc !== 'object' || typeof doc.exitCode !== 'number') {
@@ -63,13 +64,15 @@ async function rawExec(toolbox, req, options) {
63
64
  }
64
65
  const stdout = typeof doc.stdout === 'string' ? doc.stdout : '';
65
66
  const stderr = typeof doc.stderr === 'string' ? doc.stderr : '';
67
+ const truncated = keptBytes(stdout) >= exports.MAX_OUTPUT_BYTES || keptBytes(stderr) >= exports.MAX_OUTPUT_BYTES;
66
68
  return {
67
69
  stdout,
68
70
  stderr,
69
71
  exitCode: doc.exitCode,
70
72
  durationMs: typeof doc.durationMs === 'number' ? doc.durationMs : 0,
71
73
  timedOut: doc.timedOut === true,
72
- truncated: keptBytes(stdout) >= exports.MAX_OUTPUT_BYTES || keptBytes(stderr) >= exports.MAX_OUTPUT_BYTES,
74
+ truncated,
75
+ ...(truncated ? { truncatedReason: 'output_limit' } : {}),
73
76
  };
74
77
  }
75
78
  /** A helper command the SDK sends itself; a non-zero exit is the caller's to judge. */
@@ -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;
@@ -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.0";
2
+ export declare const VERSION = "0.1.2";
@@ -2,4 +2,4 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  /** This package's version; test/version.test.ts holds it equal to package.json. */
5
- exports.VERSION = '0.1.0';
5
+ exports.VERSION = '0.1.2';