@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.
- package/LICENSE +202 -0
- package/README.md +666 -2
- package/dist/cjs/commands.d.ts +199 -0
- package/dist/cjs/commands.js +597 -0
- package/dist/cjs/errors.d.ts +113 -0
- package/dist/cjs/errors.js +306 -0
- package/dist/cjs/files.d.ts +101 -0
- package/dist/cjs/files.js +387 -0
- package/dist/cjs/http.d.ts +226 -0
- package/dist/cjs/http.js +618 -0
- package/dist/cjs/index.d.ts +12 -0
- package/dist/cjs/index.js +23 -0
- package/dist/cjs/outcome.d.ts +76 -0
- package/dist/cjs/outcome.js +170 -0
- package/dist/cjs/output.d.ts +70 -0
- package/dist/cjs/output.js +383 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/sandbox.d.ts +218 -0
- package/dist/cjs/sandbox.js +836 -0
- package/dist/cjs/shell.d.ts +152 -0
- package/dist/cjs/shell.js +393 -0
- package/dist/cjs/toolbox.d.ts +48 -0
- package/dist/cjs/toolbox.js +88 -0
- package/dist/cjs/types.d.ts +111 -0
- package/dist/cjs/types.js +102 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/esm/commands.d.ts +199 -0
- package/dist/esm/commands.js +590 -0
- package/dist/esm/errors.d.ts +113 -0
- package/dist/esm/errors.js +297 -0
- package/dist/esm/files.d.ts +101 -0
- package/dist/esm/files.js +381 -0
- package/dist/esm/http.d.ts +226 -0
- package/dist/esm/http.js +601 -0
- package/dist/esm/index.d.ts +12 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/outcome.d.ts +76 -0
- package/dist/esm/outcome.js +155 -0
- package/dist/esm/output.d.ts +70 -0
- package/dist/esm/output.js +374 -0
- package/dist/esm/sandbox.d.ts +218 -0
- package/dist/esm/sandbox.js +832 -0
- package/dist/esm/shell.d.ts +152 -0
- package/dist/esm/shell.js +379 -0
- package/dist/esm/toolbox.d.ts +48 -0
- package/dist/esm/toolbox.js +83 -0
- package/dist/esm/types.d.ts +111 -0
- package/dist/esm/types.js +95 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- 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
|
+
}
|