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