@prized/sdk 0.0.0-stage → 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.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +666 -2
  3. package/dist/cjs/commands.d.ts +199 -0
  4. package/dist/cjs/commands.js +597 -0
  5. package/dist/cjs/errors.d.ts +113 -0
  6. package/dist/cjs/errors.js +306 -0
  7. package/dist/cjs/files.d.ts +101 -0
  8. package/dist/cjs/files.js +387 -0
  9. package/dist/cjs/http.d.ts +226 -0
  10. package/dist/cjs/http.js +618 -0
  11. package/dist/cjs/index.d.ts +12 -0
  12. package/dist/cjs/index.js +23 -0
  13. package/dist/cjs/outcome.d.ts +76 -0
  14. package/dist/cjs/outcome.js +170 -0
  15. package/dist/cjs/output.d.ts +70 -0
  16. package/dist/cjs/output.js +383 -0
  17. package/dist/cjs/package.json +1 -0
  18. package/dist/cjs/sandbox.d.ts +218 -0
  19. package/dist/cjs/sandbox.js +836 -0
  20. package/dist/cjs/shell.d.ts +152 -0
  21. package/dist/cjs/shell.js +393 -0
  22. package/dist/cjs/toolbox.d.ts +48 -0
  23. package/dist/cjs/toolbox.js +88 -0
  24. package/dist/cjs/types.d.ts +111 -0
  25. package/dist/cjs/types.js +102 -0
  26. package/dist/cjs/version.d.ts +2 -0
  27. package/dist/cjs/version.js +5 -0
  28. package/dist/esm/commands.d.ts +199 -0
  29. package/dist/esm/commands.js +590 -0
  30. package/dist/esm/errors.d.ts +113 -0
  31. package/dist/esm/errors.js +297 -0
  32. package/dist/esm/files.d.ts +101 -0
  33. package/dist/esm/files.js +381 -0
  34. package/dist/esm/http.d.ts +226 -0
  35. package/dist/esm/http.js +601 -0
  36. package/dist/esm/index.d.ts +12 -0
  37. package/dist/esm/index.js +7 -0
  38. package/dist/esm/outcome.d.ts +76 -0
  39. package/dist/esm/outcome.js +155 -0
  40. package/dist/esm/output.d.ts +70 -0
  41. package/dist/esm/output.js +374 -0
  42. package/dist/esm/sandbox.d.ts +218 -0
  43. package/dist/esm/sandbox.js +832 -0
  44. package/dist/esm/shell.d.ts +152 -0
  45. package/dist/esm/shell.js +379 -0
  46. package/dist/esm/toolbox.d.ts +48 -0
  47. package/dist/esm/toolbox.js +83 -0
  48. package/dist/esm/types.d.ts +111 -0
  49. package/dist/esm/types.js +95 -0
  50. package/dist/esm/version.d.ts +2 -0
  51. package/dist/esm/version.js +2 -0
  52. package/package.json +53 -4
@@ -0,0 +1,590 @@
1
+ // sandbox.exec: one call for short commands, a detached run for background
2
+ // work, long timeouts and streamed output.
3
+ import { CommandTimeoutError, PrizedError } from './errors.js';
4
+ import { backoffMs, cutByDeadline, LAST_LOOK_MAX_MS, MAX_RETRY_AFTER_MS, Polls, validOutage } from './http.js';
5
+ import { abandonScript, cleanupScript, commandText, killScript, NO_PID_EXIT, pollScript, RUN_ID_RE, sandboxPath, SIGNALS, startScript, } from './shell.js';
6
+ import { mayHaveActed, pollFailed, runAborted, signalEffect, startAbandoned, startEffect, withOutcome } from './outcome.js';
7
+ import { execFullOutput } from './output.js';
8
+ import { helperExec, rawExec } from './toolbox.js';
9
+ /** The timeout a command gets when the call names none. */
10
+ export const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
11
+ /**
12
+ * The longest command one blocking call carries. Longer timeouts (and
13
+ * `timeoutMs: 0`) run detached and are polled: Node's fetch gives up on an
14
+ * answer after 300 s, and load balancers drop idle connections not long after.
15
+ */
16
+ export const MAX_BLOCKING_EXEC_MS = 240_000;
17
+ /** Bytes of each stream one poll brings back. */
18
+ const POLL_CHUNK_BYTES = 1024 * 1024;
19
+ const POLL_MIN_MS = 200;
20
+ const POLL_MAX_MS = 1_000;
21
+ /**
22
+ * The bound on each teardown request the SDK makes on its own (stopping or
23
+ * abandoning a run, removing its files), so it never holds up the caller's
24
+ * result or error for long. The Python SDK's TEARDOWN_SECONDS.
25
+ */
26
+ const TEARDOWN_MS = 10_000;
27
+ /**
28
+ * The bound on abandoning a start (ABANDON_SECONDS): the abandon script
29
+ * waits up to 10 s for a slow start's pid before it KILLs the run, and a
30
+ * request cut off sooner would stop it before the KILL.
31
+ */
32
+ const ABANDON_MS = 20_000;
33
+ /** How long a stopped run's cleanup keeps trying while its group goes (CLEANUP_SECONDS). */
34
+ const CLEANUP_MS = 3_000;
35
+ const CLEANUP_RETRY_MS = 200;
36
+ /**
37
+ * The errors a wait's poll threw (waitRaw) that leave open what became of
38
+ * the run: the run polled, and whether the wait had already seen it end.
39
+ * exec tells them from a callback's own and says what became of the command.
40
+ */
41
+ const failedPolls = new WeakMap();
42
+ /**
43
+ * The poll errors that settle what became of the run: it is gone with its
44
+ * sandbox (deleted, or not running), or there is no such run. Any other (no
45
+ * answer, an answer that could not be read, a 5xx, a rate limit, a refused
46
+ * key) leaves the command perhaps still running. The Python SDK's
47
+ * _RUN_SETTLED.
48
+ */
49
+ const RUN_SETTLED = new Set(['not_found', 'sandbox_deleted', 'sandbox_not_running']);
50
+ /** The poll errors with no answer behind them that an outage window rides out. */
51
+ const NO_ANSWER = new Set(['network_error', 'timeout', 'bad_response']);
52
+ /**
53
+ * Whether a failed poll is one an outage window rides out (sdk/retry-spec.json
54
+ * `pollOutage`): no answer, an answer that could not be read, a rate limit or
55
+ * any 5xx. Any other (a refused key, any other refusal, an answer that
56
+ * settles the run, an abort) ends the wait at once. The Python SDK's
57
+ * rides_out.
58
+ */
59
+ export function ridesOut(error) {
60
+ if (RUN_SETTLED.has(error.code))
61
+ return false;
62
+ if (error.status === null)
63
+ return NO_ANSWER.has(error.code);
64
+ return error.status >= 500 || error.status === 429 || error.code === 'bad_response';
65
+ }
66
+ /**
67
+ * The failed polls of a wait that rides out an outage of up to `windowMs`
68
+ * (pollOutageMs), from when the first poll's retries ran out, and, while the
69
+ * command runs, never past the wait's own deadline (`waitDeadline`, epoch
70
+ * ms; null for none): polled again with backoff, each poll and its retries
71
+ * ending before the last look, which goes out LAST_LOOK_MAX_MS before the
72
+ * window's end (half the window, for one shorter than twice that) with room
73
+ * for its own retries. So an outage that ends inside the window is always
74
+ * seen to end. The Python SDK's Outage.
75
+ */
76
+ export class Outage {
77
+ windowMs;
78
+ waitDeadline;
79
+ end = null;
80
+ last = null;
81
+ tries = 0;
82
+ room = 0;
83
+ finished = false;
84
+ constructor(windowMs, waitDeadline = null) {
85
+ this.windowMs = windowMs;
86
+ this.waitDeadline = waitDeadline;
87
+ }
88
+ /** The command has ended: draining its output, and an outage meanwhile, are not held to the deadline. */
89
+ finish() {
90
+ this.finished = true;
91
+ }
92
+ /**
93
+ * Whether a wait's failed poll (`error`, at `now`; `cut`: its bound cut it
94
+ * short) means the wait's own time ran out: its deadline cut the poll
95
+ * short while the command ran, with no outage being ridden out, so the
96
+ * command was still running when last seen. With no window, any timeout
97
+ * while it runs says so, as it always has. Never once the command has
98
+ * ended. The Python SDK's Outage.ran_out.
99
+ */
100
+ ranOut(error, now, cut) {
101
+ if (this.waitDeadline === null || this.finished || error.code !== 'timeout')
102
+ return false;
103
+ return this.windowMs <= 0 || (this.end === null && (cut || now >= this.waitDeadline));
104
+ }
105
+ /** 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. */
106
+ deadline(now) {
107
+ if (this.end === null)
108
+ return undefined;
109
+ // (A millisecond's slack, as Polls has.)
110
+ return now < this.end - this.room - 1 ? this.end - this.room : this.end;
111
+ }
112
+ /** A poll was answered: the outage, if any, is over. */
113
+ answered() {
114
+ this.end = null;
115
+ this.last = null;
116
+ this.tries = 0;
117
+ }
118
+ /**
119
+ * A poll sent at `sentAt` failed `now` with `error` (`cut`: its bound cut
120
+ * it short). Returns the pause before polling again, or null to give up,
121
+ * with the failure to give up with: the last one a poll met on its own,
122
+ * never the cut.
123
+ */
124
+ failed(error, sentAt, now, cut, backoff) {
125
+ if (this.windowMs <= 0 || !ridesOut(error))
126
+ return { pause: null, failure: error };
127
+ if (this.end === null) {
128
+ this.end = Math.min(now + this.windowMs, this.finished || this.waitDeadline === null ? Infinity : this.waitDeadline);
129
+ this.room = Math.max(0, Math.min(LAST_LOOK_MAX_MS, (this.end - now) / 2));
130
+ }
131
+ const met = error.cause instanceof PrizedError ? error.cause : null;
132
+ if (!cut) {
133
+ this.last = error;
134
+ this.tries += 1;
135
+ }
136
+ else if (met !== null && ridesOut(met) && met.code !== this.last?.code) {
137
+ // What the cut poll met, when it is news (a 503 after lost connections).
138
+ this.last = met;
139
+ }
140
+ const failure = this.last ?? error;
141
+ // The last look has been taken: this poll went out with no more than its room left.
142
+ if (this.end - sentAt <= this.room + 1)
143
+ return { pause: null, failure };
144
+ // 1 s, 2 s, 4 s, then 8 s apart (jittered as retries are), each poll with
145
+ // its own retries, none past the last look's start; straight on to it
146
+ // when the bound cut this one short.
147
+ const toLast = this.end - this.room - now;
148
+ if (cut)
149
+ return { pause: Math.max(0, toLast), failure };
150
+ // A Retry-After the last answer asked for is waited out too (up to MAX_RETRY_AFTER_MS).
151
+ const pause = Math.max(backoff(Math.min(this.tries, 4)), Math.min((error.retryAfter ?? 0) * 1000, MAX_RETRY_AFTER_MS));
152
+ return { pause: Math.max(0, Math.min(pause, toLast)), failure };
153
+ }
154
+ }
155
+ function randomHex(bytes) {
156
+ const out = new Uint8Array(bytes);
157
+ const webcrypto = globalThis.crypto;
158
+ if (webcrypto?.getRandomValues) {
159
+ webcrypto.getRandomValues(out);
160
+ }
161
+ else {
162
+ for (let i = 0; i < bytes; i += 1)
163
+ out[i] = Math.floor(Math.random() * 256);
164
+ }
165
+ return Array.from(out, (b) => b.toString(16).padStart(2, '0')).join('');
166
+ }
167
+ function base64Bytes(text) {
168
+ if (text === '')
169
+ return new Uint8Array(0);
170
+ const binary = atob(text);
171
+ const out = new Uint8Array(binary.length);
172
+ for (let i = 0; i < binary.length; i += 1)
173
+ out[i] = binary.charCodeAt(i);
174
+ return out;
175
+ }
176
+ const REPORT_RE = /^prized-run (running|done|gone|missing) (-|-?[0-9]+) ([0-9]+) ([0-9]+) ([01]) (-|-?[0-9]+)$/;
177
+ function parseReport(result, toolbox, id) {
178
+ const lines = result.stdout.split('\n');
179
+ const head = REPORT_RE.exec(lines[0] ?? '');
180
+ if (result.exitCode !== 0 || head === null) {
181
+ throw new PrizedError('bad_response', `Could not read a background command's state: ${(result.stderr || lines[0] || '').slice(0, 200)}`, {
182
+ sandboxId: toolbox.sandboxId,
183
+ });
184
+ }
185
+ let stdout = new Uint8Array(0);
186
+ let stderr = new Uint8Array(0);
187
+ try {
188
+ // A missing run is not_found (poll()), whatever follows its line.
189
+ if (head[1] !== 'missing') {
190
+ stdout = base64Bytes((lines[1] ?? '').trim());
191
+ stderr = base64Bytes((lines[2] ?? '').trim());
192
+ }
193
+ }
194
+ catch (error) {
195
+ // As the Python SDK reads it: an answer that could not be read.
196
+ throw new PrizedError('bad_response', `Unreadable report for ${id}: ${error instanceof Error ? error.message : String(error)}`, {
197
+ sandboxId: toolbox.sandboxId,
198
+ cause: error,
199
+ });
200
+ }
201
+ return {
202
+ state: head[1],
203
+ exitCode: head[2] === '-' ? null : Number(head[2]),
204
+ outSize: Number(head[3]),
205
+ errSize: Number(head[4]),
206
+ timedOut: head[5] === '1',
207
+ durationMs: head[6] === '-' ? null : Number(head[6]),
208
+ stdout,
209
+ stderr,
210
+ };
211
+ }
212
+ /** One output stream: bytes read so far, decoded as UTF-8 across chunk boundaries. */
213
+ class StreamReader {
214
+ onChunk;
215
+ offset = 0;
216
+ text = '';
217
+ decoder = new TextDecoder('utf-8');
218
+ constructor(onChunk) {
219
+ this.onChunk = onChunk;
220
+ }
221
+ push(bytes) {
222
+ if (bytes.length === 0)
223
+ return;
224
+ this.offset += bytes.length;
225
+ this.emit(this.decoder.decode(bytes, { stream: true }));
226
+ }
227
+ finish() {
228
+ this.emit(this.decoder.decode());
229
+ return this.text;
230
+ }
231
+ emit(chunk) {
232
+ if (chunk === '')
233
+ return;
234
+ this.text += chunk;
235
+ this.onChunk?.(chunk);
236
+ }
237
+ }
238
+ /**
239
+ * A command running in the background (`sandbox.exec(cmd, { background: true })`).
240
+ * It runs in its own session: it keeps running after this process exits, and
241
+ * while it runs, kill() reaches every process it started in its process group.
242
+ */
243
+ export class CommandHandle {
244
+ /** The run's id (`run_…`). */
245
+ id;
246
+ /** The process group id of the command inside the sandbox. */
247
+ pid;
248
+ sandboxId;
249
+ toolbox;
250
+ startedAt;
251
+ /** @internal The time limit exec gave the command, in ms; null for none. */
252
+ timeoutMs = null;
253
+ /** @internal */
254
+ constructor(toolbox, id, pid, startedAt) {
255
+ this.toolbox = toolbox;
256
+ this.id = id;
257
+ this.pid = pid;
258
+ this.sandboxId = toolbox.sandboxId;
259
+ this.startedAt = startedAt;
260
+ }
261
+ async poll(outOffset, errOffset, maxChunk, signal, deadline) {
262
+ const result = await helperExec(this.toolbox, pollScript(this.id, outOffset, errOffset, maxChunk), { signal, deadline });
263
+ const report = parseReport(result, this.toolbox, this.id);
264
+ if (report.state === 'missing') {
265
+ throw new PrizedError('not_found', `No background command ${this.id} in this sandbox.`, {
266
+ sandboxId: this.sandboxId,
267
+ });
268
+ }
269
+ return report;
270
+ }
271
+ /** Whether the command is still running, and its exit code once it is not. */
272
+ async status() {
273
+ const report = await this.poll(0, 0, 0);
274
+ if (report.state === 'running')
275
+ return { running: true, exitCode: null };
276
+ const exitCode = report.timedOut || report.state !== 'done' || report.exitCode === null ? -1 : report.exitCode;
277
+ return { running: false, exitCode };
278
+ }
279
+ /** Everything the command has printed so far. */
280
+ async logs() {
281
+ const out = new StreamReader();
282
+ const err = new StreamReader();
283
+ for (;;) {
284
+ const report = await this.poll(out.offset, err.offset, POLL_CHUNK_BYTES);
285
+ out.push(report.stdout);
286
+ err.push(report.stderr);
287
+ const caughtUp = (report.stdout.length === 0 || out.offset >= report.outSize) &&
288
+ (report.stderr.length === 0 || err.offset >= report.errSize);
289
+ if (caughtUp)
290
+ return { stdout: out.finish(), stderr: err.finish() };
291
+ }
292
+ }
293
+ /**
294
+ * Wait for the command to finish and return everything it printed.
295
+ * `onStdout`/`onStderr` receive the output as it arrives, from the start.
296
+ * A `timeoutMs` here bounds the wait only; the command keeps running. If
297
+ * the command ran past its own timeout (the one given to exec), it was
298
+ * killed and this throws CommandTimeoutError with what it printed. While
299
+ * polls fail (a dropped connection, a 5xx), it keeps polling for up to
300
+ * `pollOutageMs` (the client's, 120 000 by default) before it throws.
301
+ */
302
+ async wait(options = {}) {
303
+ const outageMs = validOutage(options.pollOutageMs) ?? this.toolbox.config.pollOutageMs;
304
+ return finished(await this.waitRaw(options, outageMs), this.timeoutMs, this.sandboxId);
305
+ }
306
+ /**
307
+ * @internal wait() without the timeout check: exec cleans up before it
308
+ * throws. `outageMs` (pollOutageMs): how long polls may fail before the
309
+ * wait gives up (Outage), never past the wait's own deadline.
310
+ */
311
+ async waitRaw(options, outageMs) {
312
+ const config = this.toolbox.config;
313
+ const deadline = options.timeoutMs ? config.now() + options.timeoutMs : null;
314
+ const out = new StreamReader(options.onStdout);
315
+ const err = new StreamReader(options.onStderr);
316
+ let delay = POLL_MIN_MS;
317
+ let finished = false;
318
+ const polls = deadline === null ? null : new Polls(config, deadline);
319
+ const outage = new Outage(outageMs, deadline);
320
+ for (;;) {
321
+ let report;
322
+ const sentAt = config.now();
323
+ polls?.sent();
324
+ try {
325
+ // The wait's deadline bounds each poll and its retries too, so a
326
+ // stalled or rate-limited edge cannot hold a short wait for minutes.
327
+ // Once the command has finished, draining the rest of its output is
328
+ // not held to it. While polls fail, the outage window's end bounds them.
329
+ const until = outage.deadline(sentAt) ?? (finished ? undefined : deadline ?? undefined);
330
+ report = await this.poll(out.offset, err.offset, POLL_CHUNK_BYTES, options.signal, until);
331
+ polls?.answered();
332
+ }
333
+ catch (error) {
334
+ const now = config.now();
335
+ if (error instanceof PrizedError && outage.ranOut(error, now, cutByDeadline(error))) {
336
+ throw this.waitTimeout(options.timeoutMs);
337
+ }
338
+ let failure = error;
339
+ if (error instanceof PrizedError && !options.signal?.aborted) {
340
+ // A dropped connection, an edge restarting: the command may be
341
+ // fine. Poll again until the window runs out, then give up.
342
+ const step = outage.failed(error, sentAt, now, cutByDeadline(error), (tries) => backoffMs(config, tries));
343
+ if (step.pause !== null) {
344
+ await config.sleep(step.pause, options.signal);
345
+ continue;
346
+ }
347
+ failure = step.failure;
348
+ }
349
+ if (failure instanceof PrizedError && !RUN_SETTLED.has(failure.code))
350
+ failedPolls.set(failure, { runId: this.id, ended: finished });
351
+ throw failure;
352
+ }
353
+ outage.answered();
354
+ if (report.state !== 'running') {
355
+ finished = true;
356
+ outage.finish();
357
+ }
358
+ out.push(report.stdout);
359
+ err.push(report.stderr);
360
+ const more = (report.stdout.length > 0 && out.offset < report.outSize) ||
361
+ (report.stderr.length > 0 && err.offset < report.errSize);
362
+ if (report.state !== 'running' && !more)
363
+ return this.result(report, out.finish(), err.finish());
364
+ // Checked before draining more output, so a chatty command cannot hold
365
+ // the wait past its limit.
366
+ if (report.state === 'running' && deadline !== null && config.now() >= deadline) {
367
+ throw this.waitTimeout(options.timeoutMs);
368
+ }
369
+ if (more)
370
+ continue;
371
+ // Back off while it is quiet; poll briskly again once output flows.
372
+ if (report.stdout.length > 0 || report.stderr.length > 0)
373
+ delay = POLL_MIN_MS;
374
+ // With a limit, the last poll goes out just before it (Polls).
375
+ const pause = polls === null ? delay : polls.pause(delay);
376
+ if (pause === null)
377
+ throw this.waitTimeout(options.timeoutMs);
378
+ if (pause > 0)
379
+ await config.sleep(pause, options.signal);
380
+ delay = Math.min(POLL_MAX_MS, delay * 2);
381
+ }
382
+ }
383
+ waitTimeout(timeoutMs) {
384
+ return new PrizedError('timeout', `${this.id} was still running after ${timeoutMs} ms`, { sandboxId: this.sandboxId });
385
+ }
386
+ result(report, stdout, stderr) {
387
+ // Both come from the run's own files, not from when the caller looked.
388
+ const durationMs = report.durationMs ?? Math.max(0, this.toolbox.config.now() - this.startedAt);
389
+ const exitCode = report.timedOut || report.state !== 'done' || report.exitCode === null ? -1 : report.exitCode;
390
+ return { stdout, stderr, exitCode, durationMs, timedOut: report.timedOut, truncated: false };
391
+ }
392
+ /**
393
+ * Send a signal (default SIGTERM) to the command and everything it
394
+ * started in its process group. Resolves true if it was sent, false if
395
+ * the run had already ended. Processes that left the group (`setsid`, a
396
+ * daemon) are not reached, nor anything after the run itself has ended.
397
+ */
398
+ async kill(signal = 'SIGTERM') {
399
+ const name = signal.toUpperCase().replace(/^SIG/, '');
400
+ if (!SIGNALS.has(name)) {
401
+ throw new PrizedError('invalid_argument', `Unsupported signal ${signal}; use one of ${[...SIGNALS].map((s) => `SIG${s}`).join(', ')}`);
402
+ }
403
+ // Like a command: a signal whose answer was lost is not sent again (the
404
+ // second would report false for a kill that landed).
405
+ const result = await helperExec(this.toolbox, killScript(this.id, name), { retry: 'exec', effect: signalEffect(this.id, name) });
406
+ if (result.exitCode === 0)
407
+ return true;
408
+ if (result.exitCode === 3) {
409
+ throw new PrizedError('not_found', `No background command ${this.id} in this sandbox.`, { sandboxId: this.sandboxId });
410
+ }
411
+ return false;
412
+ }
413
+ /**
414
+ * Remove the command's files (its output under `~/.prized/runs/<id>/`)
415
+ * from the sandbox once it has ended: call it after wait() resolves or
416
+ * throws CommandTimeoutError (after a kill(), await wait() first). On a
417
+ * command still running it does nothing, so status(), wait() and kill()
418
+ * keep working. Once its files are gone, status(), logs(), wait() and
419
+ * kill() reject with `not_found`. Best effort: it never rejects.
420
+ */
421
+ async cleanup() {
422
+ try {
423
+ // Exit 5 (still running) is not an error: the files stay on purpose.
424
+ await helperExec(this.toolbox, cleanupScript(this.id));
425
+ }
426
+ catch {
427
+ // Best effort: a leftover run directory costs a few bytes of disk.
428
+ }
429
+ }
430
+ }
431
+ /**
432
+ * One teardown helper the SDK sends on its own (stopping or abandoning a
433
+ * run, removing its files): best effort, and within `deadline` (TEARDOWN_MS
434
+ * from now by default), so it never holds up the caller's result or error
435
+ * for long. Its exit code, or null when it got no answer.
436
+ */
437
+ async function teardown(toolbox, script, deadline = toolbox.config.now() + TEARDOWN_MS) {
438
+ try {
439
+ return (await helperExec(toolbox, script, { deadline })).exitCode;
440
+ }
441
+ catch {
442
+ return null;
443
+ }
444
+ }
445
+ /**
446
+ * SIGKILL a run for a caller that is leaving, sent again like a read (a
447
+ * second KILL changes nothing). True once nothing of it is running: sent,
448
+ * or there was nothing left to send it to.
449
+ */
450
+ async function stopRun(toolbox, id) {
451
+ const code = await teardown(toolbox, killScript(id, 'KILL'));
452
+ return code === 0 || code === 3 || code === 4;
453
+ }
454
+ /**
455
+ * Remove the files of a run just stopped: its group may take a moment to
456
+ * go, and cleanup keeps a running run's files (exit 5), so it is tried
457
+ * again for CLEANUP_MS.
458
+ */
459
+ async function cleanupOnceGone(toolbox, id) {
460
+ const config = toolbox.config;
461
+ const deadline = config.now() + CLEANUP_MS;
462
+ for (;;) {
463
+ if ((await teardown(toolbox, cleanupScript(id), deadline)) !== 5 || config.now() + CLEANUP_RETRY_MS >= deadline)
464
+ return;
465
+ try {
466
+ await config.sleep(CLEANUP_RETRY_MS);
467
+ }
468
+ catch {
469
+ return; // never in place of the error the caller is leaving with
470
+ }
471
+ }
472
+ }
473
+ async function startBackground(toolbox, command, cwd, env, timeoutMs, signal, unbuffered) {
474
+ // Nothing sent yet: nothing to stop.
475
+ if (signal?.aborted)
476
+ throw withOutcome(new PrizedError('aborted', null, { sandboxId: toolbox.sandboxId }), startEffect(), 'never');
477
+ const id = `run_${randomHex(8)}`;
478
+ if (!RUN_ID_RE.test(id))
479
+ throw new Error('unreachable: bad run id');
480
+ const startedAt = toolbox.config.now();
481
+ let pid;
482
+ // Launched, but no pid came within the start's own wait: it may yet run.
483
+ let unsettled = false;
484
+ try {
485
+ const result = await rawExec(toolbox, { command: startScript(id, command, timeoutMs === null ? null : timeoutMs / 1000, unbuffered), cwd, env, timeoutMs: 60_000 },
486
+ // Keyed by a run id this call chose, so a resend finds the first start
487
+ // instead of starting the command twice.
488
+ { retry: 'safe', signal, effect: startEffect() });
489
+ pid = Number(result.stdout.trim());
490
+ if (result.exitCode !== 0 || !Number.isInteger(pid) || pid <= 0) {
491
+ unsettled = result.exitCode === NO_PID_EXIT;
492
+ throw new PrizedError('command_failed', `Could not start the background command: ${(result.stderr || result.stdout).trim().slice(0, 300)}`, {
493
+ sandboxId: toolbox.sandboxId,
494
+ });
495
+ }
496
+ }
497
+ catch (error) {
498
+ // The start may have run even though its answer never came (or the
499
+ // caller left mid-start). The caller gets no handle, so the run is
500
+ // stopped by its id rather than left going in its own session, and the
501
+ // error says so when it does not settle whether the command started.
502
+ const stopped = (await teardown(toolbox, abandonScript(id), toolbox.config.now() + ABANDON_MS)) === 0;
503
+ if (mayHaveActed(error) || (unsettled && error instanceof PrizedError))
504
+ throw startAbandoned(error, id, stopped);
505
+ throw error;
506
+ }
507
+ return new CommandHandle(toolbox, id, pid, startedAt);
508
+ }
509
+ function validTimeout(value) {
510
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
511
+ throw new PrizedError('invalid_argument', 'timeoutMs must be a number of ms ≥ 0');
512
+ }
513
+ }
514
+ /**
515
+ * A command's result for the caller: throw CommandTimeoutError when its
516
+ * timeout killed it, so a cut-off run never reads as a finished one. The
517
+ * SDK's own helpers read their results raw.
518
+ */
519
+ function finished(result, timeoutMs, sandboxId) {
520
+ if (result.timedOut)
521
+ throw new CommandTimeoutError(result, timeoutMs, sandboxId);
522
+ return result;
523
+ }
524
+ /** sandbox.exec's body. */
525
+ export async function execCommand(toolbox, command, options = {}) {
526
+ const text = commandText(command);
527
+ if (text.trim() === '')
528
+ throw new PrizedError('invalid_argument', 'The command is empty.');
529
+ validTimeout(options.timeoutMs);
530
+ const outageMs = validOutage(options.pollOutageMs) ?? toolbox.config.pollOutageMs;
531
+ const cwd = options.cwd === undefined ? undefined : sandboxPath(options.cwd);
532
+ if (options.background) {
533
+ const limit = options.timeoutMs ? options.timeoutMs : null;
534
+ const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, true);
535
+ handle.timeoutMs = limit;
536
+ return handle;
537
+ }
538
+ const timeoutMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
539
+ const streamed = options.onStdout !== undefined || options.onStderr !== undefined;
540
+ if (streamed || timeoutMs === 0 || timeoutMs > MAX_BLOCKING_EXEC_MS) {
541
+ const limit = timeoutMs === 0 ? null : timeoutMs;
542
+ const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, streamed);
543
+ const waitOptions = {};
544
+ if (options.onStdout)
545
+ waitOptions.onStdout = options.onStdout;
546
+ if (options.onStderr)
547
+ waitOptions.onStderr = options.onStderr;
548
+ if (options.signal)
549
+ waitOptions.signal = options.signal;
550
+ let result;
551
+ try {
552
+ result = await handle.waitRaw(waitOptions, outageMs);
553
+ }
554
+ catch (error) {
555
+ // Leaving the call (an abort, a callback that threw, polls that kept
556
+ // failing past pollOutageMs, or failed in a way waiting cannot mend)
557
+ // stops the command, as a blocking call would: the caller has
558
+ // no handle to it. Its files go only once the stop is confirmed: until
559
+ // then they are how the run is found and stopped by its id.
560
+ const stopped = await stopRun(toolbox, handle.id);
561
+ if (stopped)
562
+ await cleanupOnceGone(toolbox, handle.id);
563
+ // The caller's abort says what became of the command (the Python SDK
564
+ // has no signal), as does a poll that failed: with the network down,
565
+ // the stop may not have got through either. A callback's own error
566
+ // stays as it was thrown.
567
+ if (options.signal?.aborted && error instanceof PrizedError && error.code === 'aborted') {
568
+ throw runAborted(error, handle.id, stopped, toolbox.sandboxId);
569
+ }
570
+ if (error instanceof PrizedError) {
571
+ const poll = failedPolls.get(error);
572
+ // A run the wait saw end is not running, whatever became of the stop.
573
+ if (poll?.runId === handle.id)
574
+ throw pollFailed(error, handle.id, stopped || poll.ended, toolbox.sandboxId);
575
+ }
576
+ throw error;
577
+ }
578
+ await teardown(toolbox, cleanupScript(handle.id));
579
+ return finished(result, limit, toolbox.sandboxId);
580
+ }
581
+ if (options.fullOutput) {
582
+ const result = await execFullOutput(toolbox, `exec_${randomHex(8)}`, { command: text, cwd, env: options.env, timeoutMs }, options.signal);
583
+ return finished(result, timeoutMs, toolbox.sandboxId);
584
+ }
585
+ // A command that reached the sandbox may have run even if its answer was
586
+ // lost, so a blocking exec is retried only when the edge proves it never
587
+ // delivered it.
588
+ const result = await rawExec(toolbox, { command: text, cwd, env: options.env, timeoutMs }, { retry: 'exec', signal: options.signal });
589
+ return finished(result, timeoutMs, toolbox.sandboxId);
590
+ }