@prized/sdk 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -29
- package/dist/cjs/commands.d.ts +3 -1
- package/dist/cjs/commands.js +45 -7
- package/dist/cjs/files.d.ts +6 -5
- package/dist/cjs/files.js +8 -5
- package/dist/cjs/http.d.ts +49 -6
- package/dist/cjs/http.js +149 -19
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/outcome.d.ts +9 -0
- package/dist/cjs/outcome.js +21 -1
- package/dist/cjs/output.js +1 -0
- package/dist/cjs/sandbox.d.ts +68 -2
- package/dist/cjs/sandbox.js +112 -7
- package/dist/cjs/shell.d.ts +2 -1
- package/dist/cjs/shell.js +2 -1
- package/dist/cjs/toolbox.d.ts +2 -0
- package/dist/cjs/toolbox.js +1 -0
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/commands.d.ts +3 -1
- package/dist/esm/commands.js +46 -8
- package/dist/esm/files.d.ts +6 -5
- package/dist/esm/files.js +9 -6
- package/dist/esm/http.d.ts +49 -6
- package/dist/esm/http.js +148 -18
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/outcome.d.ts +9 -0
- package/dist/esm/outcome.js +20 -1
- package/dist/esm/output.js +2 -1
- package/dist/esm/sandbox.d.ts +68 -2
- package/dist/esm/sandbox.js +113 -8
- package/dist/esm/shell.d.ts +2 -1
- package/dist/esm/shell.js +2 -1
- package/dist/esm/toolbox.d.ts +2 -0
- package/dist/esm/toolbox.js +1 -0
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/dist/cjs/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This package's version; test/version.test.ts holds it equal to package.json. */
|
|
2
|
-
export declare const VERSION = "0.1.
|
|
2
|
+
export declare const VERSION = "0.1.3";
|
package/dist/cjs/version.js
CHANGED
package/dist/esm/commands.d.ts
CHANGED
|
@@ -104,7 +104,9 @@ export interface ExecOptions {
|
|
|
104
104
|
* its polls fail (no answer, a 5xx, a rate limit), in ms, before it gives
|
|
105
105
|
* up and stops the command. Default: the client's `pollOutageMs`
|
|
106
106
|
* (120 000). A refused key, or an answer that settles the run (the
|
|
107
|
-
* sandbox deleted or not running, the run gone), stops it at once.
|
|
107
|
+
* sandbox deleted or not running, the run gone), stops it at once. The
|
|
108
|
+
* start of a background, streamed or long command is sent again for as
|
|
109
|
+
* long (a resend finds the first start, so the command runs once).
|
|
108
110
|
*/
|
|
109
111
|
pollOutageMs?: number;
|
|
110
112
|
}
|
package/dist/esm/commands.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { CommandTimeoutError, PrizedError } from './errors.js';
|
|
4
4
|
import { backoffMs, cutByDeadline, LAST_LOOK_MAX_MS, MAX_RETRY_AFTER_MS, Polls, validOutage } from './http.js';
|
|
5
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';
|
|
6
|
+
import { mayHaveActed, pollFailed, runAborted, signalEffect, startAbandoned, startEffect, startGaveUp, withAbort, withOutcome } from './outcome.js';
|
|
7
7
|
import { execFullOutput } from './output.js';
|
|
8
8
|
import { helperExec, rawExec } from './toolbox.js';
|
|
9
9
|
/** The timeout a command gets when the call names none. */
|
|
@@ -470,7 +470,48 @@ async function cleanupOnceGone(toolbox, id) {
|
|
|
470
470
|
}
|
|
471
471
|
}
|
|
472
472
|
}
|
|
473
|
-
|
|
473
|
+
/**
|
|
474
|
+
* Send a start script. It is keyed by a run id the SDK chose, so a resend
|
|
475
|
+
* finds the first start instead of starting the command twice. Once one
|
|
476
|
+
* copy's retries run out, it is sent again through an outage of up to
|
|
477
|
+
* `outageMs` (pollOutageMs), paced as a wait's polls are (Outage): an edge
|
|
478
|
+
* that restarts mid-start does not cost the command, as it does not cost a
|
|
479
|
+
* running one its wait. Each copy's error counts the copies before it
|
|
480
|
+
* (`acted`), so one that gives up says the command may have started when any
|
|
481
|
+
* copy may have reached the sandbox. The Python SDK's _send_start.
|
|
482
|
+
*/
|
|
483
|
+
async function sendStart(toolbox, req, signal, outageMs) {
|
|
484
|
+
const config = toolbox.config;
|
|
485
|
+
const outage = new Outage(outageMs);
|
|
486
|
+
// Whether an earlier copy may have reached the sandbox: an abort between
|
|
487
|
+
// copies then says the command may have started, else that it never did.
|
|
488
|
+
let acted = false;
|
|
489
|
+
const aborted = (cause) => withAbort(new PrizedError('aborted', null, { sandboxId: toolbox.sandboxId, cause }), startEffect(), acted ? 'maybe' : 'never');
|
|
490
|
+
for (;;) {
|
|
491
|
+
const sentAt = config.now();
|
|
492
|
+
try {
|
|
493
|
+
return await rawExec(toolbox, req, { retry: 'safe', signal, deadline: outage.deadline(sentAt), effect: startEffect(), acted });
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
if (!(error instanceof PrizedError))
|
|
497
|
+
throw error;
|
|
498
|
+
acted = acted || mayHaveActed(error);
|
|
499
|
+
if (signal?.aborted)
|
|
500
|
+
throw error;
|
|
501
|
+
const step = outage.failed(error, sentAt, config.now(), cutByDeadline(error), (tries) => backoffMs(config, tries));
|
|
502
|
+
// What a copy met, not the window's cut.
|
|
503
|
+
if (step.pause === null)
|
|
504
|
+
throw step.failure === error ? error : startGaveUp(step.failure, acted);
|
|
505
|
+
try {
|
|
506
|
+
await config.sleep(step.pause, signal);
|
|
507
|
+
}
|
|
508
|
+
catch (abort) {
|
|
509
|
+
throw abort instanceof PrizedError && abort.code === 'aborted' ? aborted(step.failure) : abort;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function startBackground(toolbox, command, cwd, env, timeoutMs, signal, unbuffered, outageMs) {
|
|
474
515
|
// Nothing sent yet: nothing to stop.
|
|
475
516
|
if (signal?.aborted)
|
|
476
517
|
throw withOutcome(new PrizedError('aborted', null, { sandboxId: toolbox.sandboxId }), startEffect(), 'never');
|
|
@@ -482,10 +523,7 @@ async function startBackground(toolbox, command, cwd, env, timeoutMs, signal, un
|
|
|
482
523
|
// Launched, but no pid came within the start's own wait: it may yet run.
|
|
483
524
|
let unsettled = false;
|
|
484
525
|
try {
|
|
485
|
-
const result = await
|
|
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() });
|
|
526
|
+
const result = await sendStart(toolbox, { command: startScript(id, command, timeoutMs === null ? null : timeoutMs / 1000, unbuffered), cwd, env, timeoutMs: 60_000 }, signal, outageMs);
|
|
489
527
|
pid = Number(result.stdout.trim());
|
|
490
528
|
if (result.exitCode !== 0 || !Number.isInteger(pid) || pid <= 0) {
|
|
491
529
|
unsettled = result.exitCode === NO_PID_EXIT;
|
|
@@ -531,7 +569,7 @@ export async function execCommand(toolbox, command, options = {}) {
|
|
|
531
569
|
const cwd = options.cwd === undefined ? undefined : sandboxPath(options.cwd);
|
|
532
570
|
if (options.background) {
|
|
533
571
|
const limit = options.timeoutMs ? options.timeoutMs : null;
|
|
534
|
-
const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, true);
|
|
572
|
+
const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, true, outageMs);
|
|
535
573
|
handle.timeoutMs = limit;
|
|
536
574
|
return handle;
|
|
537
575
|
}
|
|
@@ -539,7 +577,7 @@ export async function execCommand(toolbox, command, options = {}) {
|
|
|
539
577
|
const streamed = options.onStdout !== undefined || options.onStderr !== undefined;
|
|
540
578
|
if (streamed || timeoutMs === 0 || timeoutMs > MAX_BLOCKING_EXEC_MS) {
|
|
541
579
|
const limit = timeoutMs === 0 ? null : timeoutMs;
|
|
542
|
-
const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, streamed);
|
|
580
|
+
const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, streamed, outageMs);
|
|
543
581
|
const waitOptions = {};
|
|
544
582
|
if (options.onStdout)
|
|
545
583
|
waitOptions.onStdout = options.onStdout;
|
package/dist/esm/files.d.ts
CHANGED
|
@@ -13,9 +13,10 @@ export interface WriteOptions {
|
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* The sandbox's file transfers take at most this long, retries included:
|
|
16
|
-
* the edge's own limit on one transfer.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* the edge's own limit on one transfer. An attempt that moves no bytes for
|
|
17
|
+
* timing.transferStallMs (2 minutes) is given up; a download, or a write's
|
|
18
|
+
* staged upload, is then sent again (as after a lost answer or a 502, 503 or
|
|
19
|
+
* 504) while time is left.
|
|
19
20
|
*/
|
|
20
21
|
export declare const FILE_TIMEOUT_MS: number;
|
|
21
22
|
/** The publish script's own exits (the Python SDK's too). */
|
|
@@ -60,8 +61,8 @@ export declare class Files {
|
|
|
60
61
|
* stays executable; a new file gets the sandbox's default (0600 in
|
|
61
62
|
* prized-base-v1 and prized-agent-v1). `mode` sets the permission bits either way.
|
|
62
63
|
* The bytes go up under a temporary name beside the file, sent again if
|
|
63
|
-
* need be (a lost answer, a 502, 503 or 504
|
|
64
|
-
* transfer's 10 minutes),
|
|
64
|
+
* need be (a lost answer, a 502, 503 or 504, 2 minutes with no bytes
|
|
65
|
+
* moving; all copies within the transfer's 10 minutes),
|
|
65
66
|
* and one command that is never sent again puts them in place: a write
|
|
66
67
|
* whose answer to that command was lost throws, since it may have landed
|
|
67
68
|
* and the sandbox written the file since (read it back, or write again,
|
package/dist/esm/files.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// sandbox.files: read, write, list, remove, upload, download.
|
|
2
2
|
import { PrizedError } from './errors.js';
|
|
3
|
-
import { request } from './http.js';
|
|
3
|
+
import { request, timing } from './http.js';
|
|
4
4
|
import { mayHaveActed, removeEffect, withOutcome, writeEffect } from './outcome.js';
|
|
5
5
|
import { existsScript, listScript, removeScript, sandboxPath, shellQuote, WORKSPACE_DIR } from './shell.js';
|
|
6
6
|
import { helperExec, MAX_FILE_BYTES, rawExec } from './toolbox.js';
|
|
7
7
|
/**
|
|
8
8
|
* The sandbox's file transfers take at most this long, retries included:
|
|
9
|
-
* the edge's own limit on one transfer.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* the edge's own limit on one transfer. An attempt that moves no bytes for
|
|
10
|
+
* timing.transferStallMs (2 minutes) is given up; a download, or a write's
|
|
11
|
+
* staged upload, is then sent again (as after a lost answer or a 502, 503 or
|
|
12
|
+
* 504) while time is left.
|
|
12
13
|
*/
|
|
13
14
|
export const FILE_TIMEOUT_MS = 10 * 60_000;
|
|
14
15
|
function byteSize(data) {
|
|
@@ -163,6 +164,7 @@ export class Files {
|
|
|
163
164
|
retry: 'safe',
|
|
164
165
|
read: 'bytes',
|
|
165
166
|
timeoutMs: FILE_TIMEOUT_MS,
|
|
167
|
+
stallMs: timing.transferStallMs,
|
|
166
168
|
deadline: this.toolbox.config.now() + FILE_TIMEOUT_MS,
|
|
167
169
|
sandboxId: this.toolbox.sandboxId,
|
|
168
170
|
});
|
|
@@ -175,8 +177,8 @@ export class Files {
|
|
|
175
177
|
* stays executable; a new file gets the sandbox's default (0600 in
|
|
176
178
|
* prized-base-v1 and prized-agent-v1). `mode` sets the permission bits either way.
|
|
177
179
|
* The bytes go up under a temporary name beside the file, sent again if
|
|
178
|
-
* need be (a lost answer, a 502, 503 or 504
|
|
179
|
-
* transfer's 10 minutes),
|
|
180
|
+
* need be (a lost answer, a 502, 503 or 504, 2 minutes with no bytes
|
|
181
|
+
* moving; all copies within the transfer's 10 minutes),
|
|
180
182
|
* and one command that is never sent again puts them in place: a write
|
|
181
183
|
* whose answer to that command was lost throws, since it may have landed
|
|
182
184
|
* and the sandbox written the file since (read it back, or write again,
|
|
@@ -256,6 +258,7 @@ export class Files {
|
|
|
256
258
|
retry,
|
|
257
259
|
effect,
|
|
258
260
|
timeoutMs: FILE_TIMEOUT_MS,
|
|
261
|
+
stallMs: timing.transferStallMs,
|
|
259
262
|
deadline: this.toolbox.config.now() + FILE_TIMEOUT_MS,
|
|
260
263
|
sandboxId: this.toolbox.sandboxId,
|
|
261
264
|
});
|
package/dist/esm/http.d.ts
CHANGED
|
@@ -32,7 +32,9 @@ export interface ClientOptions {
|
|
|
32
32
|
* rate limit), in ms, before it gives up: a streamed or long `exec` then
|
|
33
33
|
* stops the command, a handle's `wait()` throws. Default 120 000. `0`
|
|
34
34
|
* gives up once one poll's retries run out. `exec` and `wait()` take it
|
|
35
|
-
* per call too.
|
|
35
|
+
* per call too. The start of a background, streamed or long `exec` is
|
|
36
|
+
* sent again for as long, so an edge that restarts mid-start does not
|
|
37
|
+
* cost the command.
|
|
36
38
|
*/
|
|
37
39
|
pollOutageMs?: number;
|
|
38
40
|
/** Sleep seam (tests inject one that never really waits). */
|
|
@@ -54,13 +56,21 @@ export interface Config {
|
|
|
54
56
|
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
55
57
|
now: () => number;
|
|
56
58
|
random: () => number;
|
|
59
|
+
/**
|
|
60
|
+
* Whether an upload's body goes out as a stream the SDK feeds piece by
|
|
61
|
+
* piece, so a stalled one is seen (RequestSpec.stallMs): Node's own fetch
|
|
62
|
+
* only, which asks for each piece once the last has gone into the socket.
|
|
63
|
+
* Elsewhere (another runtime, a fetch you pass) the body goes as it is.
|
|
64
|
+
*/
|
|
65
|
+
streamBodies: boolean;
|
|
57
66
|
}
|
|
58
67
|
export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
59
68
|
/**
|
|
60
69
|
* How long a wait (a streamed or long exec, a handle's wait()) keeps polling
|
|
61
70
|
* while its polls fail before it gives up (pollOutageMs), and a streamed or
|
|
62
|
-
* long exec stops its command
|
|
63
|
-
* or an edge that restarts
|
|
71
|
+
* long exec stops its command, and how long a start is sent again: long
|
|
72
|
+
* enough to ride out a dropped connection or an edge that restarts (about
|
|
73
|
+
* 70 s, the last time it crashed). The Python SDK's DEFAULT_POLL_OUTAGE.
|
|
64
74
|
*/
|
|
65
75
|
export declare const DEFAULT_POLL_OUTAGE_MS = 120000;
|
|
66
76
|
/** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
|
|
@@ -97,8 +107,8 @@ export declare function warmEdge(config: Config): Promise<void>;
|
|
|
97
107
|
* Retries network failures, 429/502/503/504, and everything `exec`
|
|
98
108
|
* retries (the edge's 500 control_plane_unavailable among them). A GET
|
|
99
109
|
* retries any 500 too (the API's `internal`).
|
|
100
|
-
* - `exec`: a command, an upload
|
|
101
|
-
*
|
|
110
|
+
* - `exec`: a command, an upload, a remove or a label-selected delete:
|
|
111
|
+
* a second copy could affect later work. Retried only when it
|
|
102
112
|
* provably never arrived: a connection that never opened, or an answer
|
|
103
113
|
* saying so (the edge's rate limit, its lookup failing). A lost answer
|
|
104
114
|
* is never retried: it may already have run.
|
|
@@ -120,7 +130,7 @@ export type RetryMode = 'safe' | 'exec' | 'staged';
|
|
|
120
130
|
export declare function neverSent(error: unknown, depth?: number): boolean;
|
|
121
131
|
export declare function retryable(error: PrizedError, mode: RetryMode, method: RequestSpec['method']): boolean;
|
|
122
132
|
export interface RequestSpec {
|
|
123
|
-
method: 'GET' | 'POST' | 'DELETE';
|
|
133
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
124
134
|
/** The URL, or one built per attempt from the ms left before `deadline`
|
|
125
135
|
* (Infinity without one): a held request asks only for what is left. */
|
|
126
136
|
url: string | ((leftMs: number) => string);
|
|
@@ -139,6 +149,14 @@ export interface RequestSpec {
|
|
|
139
149
|
sandboxId?: string | null;
|
|
140
150
|
/** How to read a 2xx body. Default `json`. */
|
|
141
151
|
read?: 'json' | 'bytes' | 'none';
|
|
152
|
+
/**
|
|
153
|
+
* A file transfer's no-progress bound: an attempt that moves no bytes
|
|
154
|
+
* either way for this long (a connection dropped without a reset) ends as
|
|
155
|
+
* a timeout, sent again by `retry`'s rules while `deadline` allows. An
|
|
156
|
+
* upload's sending is watched only where its body is streamed
|
|
157
|
+
* (Config.streamBodies); its answer always is.
|
|
158
|
+
*/
|
|
159
|
+
stallMs?: number;
|
|
142
160
|
/**
|
|
143
161
|
* Told of every attempt that failed (no answer, an error answer, or a 2xx
|
|
144
162
|
* that could not be read), before it is retried or thrown. A create or
|
|
@@ -153,6 +171,12 @@ export interface RequestSpec {
|
|
|
153
171
|
* its error says so too.
|
|
154
172
|
*/
|
|
155
173
|
effect?: Effect;
|
|
174
|
+
/**
|
|
175
|
+
* An earlier copy of this keyed request may have reached the sandbox (the
|
|
176
|
+
* background start, sent again through an outage), so its error says so
|
|
177
|
+
* too.
|
|
178
|
+
*/
|
|
179
|
+
acted?: boolean;
|
|
156
180
|
}
|
|
157
181
|
export interface HttpResult {
|
|
158
182
|
status: number;
|
|
@@ -215,6 +239,25 @@ export declare class Polls {
|
|
|
215
239
|
}
|
|
216
240
|
/** Delay before retry number `attempt` (0-based): exponential with jitter. */
|
|
217
241
|
export declare function backoffMs(config: Config, attempt: number): number;
|
|
242
|
+
/**
|
|
243
|
+
* How long a file transfer may move no bytes before the attempt is given up
|
|
244
|
+
* (sdk/retry-spec.json transferStallMs; the Python SDK's TRANSFER_STALL).
|
|
245
|
+
* The edge holds a request about 20 s at most before it reads the body (its
|
|
246
|
+
* admission queue, then waking a paused sandbox), and ends a body that brings
|
|
247
|
+
* nothing for 60 s itself: kept above that, a stalled copy is over at the
|
|
248
|
+
* edge before the next one goes to the same staged name, so the two never
|
|
249
|
+
* race. Once an upload's last byte is handed over, its answer is waited for
|
|
250
|
+
* that and a second more for every uploadAnswerBytesPerSecond it sent: what
|
|
251
|
+
* the socket's buffers, or a proxy that takes a whole body before it sends it
|
|
252
|
+
* on, still hold moves unseen, and must not read as a stall (the Python SDK's
|
|
253
|
+
* UPLOAD_ANSWER_RATE). Mutable for tests only.
|
|
254
|
+
*/
|
|
255
|
+
export declare const timing: {
|
|
256
|
+
transferStallMs: number;
|
|
257
|
+
uploadAnswerBytesPerSecond: number;
|
|
258
|
+
};
|
|
259
|
+
/** A streamed upload goes out this many bytes at a time. */
|
|
260
|
+
export declare const UPLOAD_CHUNK: number;
|
|
218
261
|
/**
|
|
219
262
|
* What an error answer (not a 2xx: request() judges those) says about
|
|
220
263
|
* whether the sandbox acted: `never` for the codes that prove the request
|
package/dist/esm/http.js
CHANGED
|
@@ -5,6 +5,11 @@ import { commandEffect, restate, withAbort, withOutcome } from './outcome.js';
|
|
|
5
5
|
import { VERSION } from './version.js';
|
|
6
6
|
export const DEFAULT_BASE_URL = 'https://api.prized.dev';
|
|
7
7
|
export const DEFAULT_EDGE_URL = 'https://term.prized.dev';
|
|
8
|
+
/** Node's own fetch (not Bun's or Deno's, which also say they are Node), with web streams. */
|
|
9
|
+
function nodeFetch() {
|
|
10
|
+
const versions = globalThis.process?.versions;
|
|
11
|
+
return typeof versions?.node === 'string' && versions.bun === undefined && versions.deno === undefined && typeof ReadableStream === 'function';
|
|
12
|
+
}
|
|
8
13
|
function envApiKey() {
|
|
9
14
|
try {
|
|
10
15
|
const env = globalThis.process?.env;
|
|
@@ -66,8 +71,9 @@ export function abortableSleep(ms, signal) {
|
|
|
66
71
|
/**
|
|
67
72
|
* How long a wait (a streamed or long exec, a handle's wait()) keeps polling
|
|
68
73
|
* while its polls fail before it gives up (pollOutageMs), and a streamed or
|
|
69
|
-
* long exec stops its command
|
|
70
|
-
* or an edge that restarts
|
|
74
|
+
* long exec stops its command, and how long a start is sent again: long
|
|
75
|
+
* enough to ride out a dropped connection or an edge that restarts (about
|
|
76
|
+
* 70 s, the last time it crashed). The Python SDK's DEFAULT_POLL_OUTAGE.
|
|
71
77
|
*/
|
|
72
78
|
export const DEFAULT_POLL_OUTAGE_MS = 120_000;
|
|
73
79
|
/** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
|
|
@@ -109,6 +115,7 @@ export function resolveConfig(options = {}) {
|
|
|
109
115
|
sleep: options.sleep ?? abortableSleep,
|
|
110
116
|
now: options.now ?? Date.now,
|
|
111
117
|
random: options.random ?? Math.random,
|
|
118
|
+
streamBodies: options.fetch === undefined && nodeFetch(),
|
|
112
119
|
};
|
|
113
120
|
}
|
|
114
121
|
/** Prized's own domain: its API and its edges are hosts under it. */
|
|
@@ -225,6 +232,8 @@ const NEVER_RETRY = new Set([
|
|
|
225
232
|
'idempotency_conflict',
|
|
226
233
|
'paid_balance_required',
|
|
227
234
|
'insufficient_balance',
|
|
235
|
+
// A 503 that says preview links are off on this deployment: no wait turns them on.
|
|
236
|
+
'preview_unavailable',
|
|
228
237
|
]);
|
|
229
238
|
const EXEC_UNDELIVERED = new Set([
|
|
230
239
|
'rate_limited',
|
|
@@ -355,6 +364,84 @@ export function backoffMs(config, attempt) {
|
|
|
355
364
|
const base = Math.min(MAX_BACKOFF_MS, BACKOFF_FIRST_MS * 2 ** attempt);
|
|
356
365
|
return Math.round(base / 2 + (config.random() * base) / 2);
|
|
357
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* How long a file transfer may move no bytes before the attempt is given up
|
|
369
|
+
* (sdk/retry-spec.json transferStallMs; the Python SDK's TRANSFER_STALL).
|
|
370
|
+
* The edge holds a request about 20 s at most before it reads the body (its
|
|
371
|
+
* admission queue, then waking a paused sandbox), and ends a body that brings
|
|
372
|
+
* nothing for 60 s itself: kept above that, a stalled copy is over at the
|
|
373
|
+
* edge before the next one goes to the same staged name, so the two never
|
|
374
|
+
* race. Once an upload's last byte is handed over, its answer is waited for
|
|
375
|
+
* that and a second more for every uploadAnswerBytesPerSecond it sent: what
|
|
376
|
+
* the socket's buffers, or a proxy that takes a whole body before it sends it
|
|
377
|
+
* on, still hold moves unseen, and must not read as a stall (the Python SDK's
|
|
378
|
+
* UPLOAD_ANSWER_RATE). Mutable for tests only.
|
|
379
|
+
*/
|
|
380
|
+
export const timing = { transferStallMs: 120_000, uploadAnswerBytesPerSecond: 128 * 1024 };
|
|
381
|
+
/** A streamed upload goes out this many bytes at a time. */
|
|
382
|
+
export const UPLOAD_CHUNK = 64 * 1024;
|
|
383
|
+
/**
|
|
384
|
+
* A body's bytes, to stream; null for one to send as it is. By shape, not
|
|
385
|
+
* instanceof: a Uint8Array or Buffer from another realm (a test runner's vm
|
|
386
|
+
* context, say) is one too.
|
|
387
|
+
*/
|
|
388
|
+
async function bodyBytes(body) {
|
|
389
|
+
if (typeof body === 'string')
|
|
390
|
+
return new TextEncoder().encode(body);
|
|
391
|
+
if (ArrayBuffer.isView(body))
|
|
392
|
+
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
|
393
|
+
if (Object.prototype.toString.call(body) === '[object ArrayBuffer]')
|
|
394
|
+
return new Uint8Array(body);
|
|
395
|
+
if (typeof body.arrayBuffer === 'function')
|
|
396
|
+
return new Uint8Array(await body.arrayBuffer());
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* `bytes` as a stream fed UPLOAD_CHUNK at a time, `moved` told each time
|
|
401
|
+
* fetch asks for more, and at the end given the answer's allowance
|
|
402
|
+
* (timing.uploadAnswerBytesPerSecond).
|
|
403
|
+
*/
|
|
404
|
+
function inPieces(bytes, moved) {
|
|
405
|
+
let at = 0;
|
|
406
|
+
return new ReadableStream({
|
|
407
|
+
pull(controller) {
|
|
408
|
+
if (at >= bytes.byteLength) {
|
|
409
|
+
moved((bytes.byteLength / timing.uploadAnswerBytesPerSecond) * 1000);
|
|
410
|
+
controller.close();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
moved();
|
|
414
|
+
// A copy: a runtime may take the piece's buffer as it sends it.
|
|
415
|
+
controller.enqueue(bytes.slice(at, at + UPLOAD_CHUNK));
|
|
416
|
+
at += UPLOAD_CHUNK;
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
// Nothing read ahead: a pull is the socket taking the last piece.
|
|
420
|
+
{ highWaterMark: 0 });
|
|
421
|
+
}
|
|
422
|
+
/** A body read whole, `moved` told of every piece. */
|
|
423
|
+
async function readAll(response, moved) {
|
|
424
|
+
if (response.body === null)
|
|
425
|
+
return new Uint8Array(0);
|
|
426
|
+
const reader = response.body.getReader();
|
|
427
|
+
const parts = [];
|
|
428
|
+
let size = 0;
|
|
429
|
+
for (;;) {
|
|
430
|
+
const { done, value } = await reader.read();
|
|
431
|
+
if (done)
|
|
432
|
+
break;
|
|
433
|
+
moved();
|
|
434
|
+
parts.push(value);
|
|
435
|
+
size += value.byteLength;
|
|
436
|
+
}
|
|
437
|
+
const whole = new Uint8Array(size);
|
|
438
|
+
let at = 0;
|
|
439
|
+
for (const part of parts) {
|
|
440
|
+
whole.set(part, at);
|
|
441
|
+
at += part.byteLength;
|
|
442
|
+
}
|
|
443
|
+
return whole;
|
|
444
|
+
}
|
|
358
445
|
/**
|
|
359
446
|
* One fetch, body included, under one timeout and the caller's signal. A
|
|
360
447
|
* body that stalls or breaks after the headers fails the attempt like a
|
|
@@ -363,10 +450,24 @@ export function backoffMs(config, attempt) {
|
|
|
363
450
|
async function attemptOnce(config, spec, url, timeoutMs) {
|
|
364
451
|
const controller = new AbortController();
|
|
365
452
|
let timedOut = false;
|
|
453
|
+
let stalled = false;
|
|
366
454
|
const timer = setTimeout(() => {
|
|
367
455
|
timedOut = true;
|
|
368
456
|
controller.abort();
|
|
369
457
|
}, timeoutMs);
|
|
458
|
+
// The stall bound starts over whenever bytes move (stallMs), until the attempt is over.
|
|
459
|
+
let stall;
|
|
460
|
+
let over = false;
|
|
461
|
+
const moved = (extraMs = 0) => {
|
|
462
|
+
if (spec.stallMs === undefined || over)
|
|
463
|
+
return;
|
|
464
|
+
clearTimeout(stall);
|
|
465
|
+
stall = setTimeout(() => {
|
|
466
|
+
stalled = true;
|
|
467
|
+
timedOut = true;
|
|
468
|
+
controller.abort();
|
|
469
|
+
}, spec.stallMs + extraMs);
|
|
470
|
+
};
|
|
370
471
|
const onAbort = () => controller.abort();
|
|
371
472
|
spec.signal?.addEventListener('abort', onAbort, { once: true });
|
|
372
473
|
const headers = {
|
|
@@ -376,30 +477,52 @@ async function attemptOnce(config, spec, url, timeoutMs) {
|
|
|
376
477
|
...spec.headers,
|
|
377
478
|
};
|
|
378
479
|
let body;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
else if (spec.body !== undefined) {
|
|
384
|
-
headers['content-type'] = spec.contentType ?? 'application/octet-stream';
|
|
385
|
-
body = spec.body;
|
|
386
|
-
}
|
|
480
|
+
// Watched from the start unless an upload goes out unwatched (Config.streamBodies).
|
|
481
|
+
let watched = spec.stallMs !== undefined && spec.body === undefined;
|
|
482
|
+
let streamed = false;
|
|
387
483
|
let status = null;
|
|
388
484
|
try {
|
|
485
|
+
if (spec.json !== undefined) {
|
|
486
|
+
headers['content-type'] = 'application/json';
|
|
487
|
+
body = JSON.stringify(spec.json);
|
|
488
|
+
}
|
|
489
|
+
else if (spec.body !== undefined) {
|
|
490
|
+
headers['content-type'] = spec.contentType ?? 'application/octet-stream';
|
|
491
|
+
body = spec.body;
|
|
492
|
+
const bytes = spec.stallMs !== undefined && config.streamBodies ? await bodyBytes(spec.body) : null;
|
|
493
|
+
if (bytes !== null) {
|
|
494
|
+
headers['content-length'] = String(bytes.byteLength);
|
|
495
|
+
body = inPieces(bytes, moved);
|
|
496
|
+
streamed = true;
|
|
497
|
+
watched = true;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (watched)
|
|
501
|
+
moved();
|
|
389
502
|
const response = await config.fetch(url, {
|
|
390
503
|
method: spec.method,
|
|
391
504
|
headers,
|
|
392
505
|
body: body,
|
|
506
|
+
// A streamed body (Node's fetch asks for it): sent before the answer is read.
|
|
507
|
+
...(streamed ? { duplex: 'half' } : {}),
|
|
393
508
|
// Never follow a redirect with the key attached.
|
|
394
509
|
redirect: 'manual',
|
|
395
510
|
signal: controller.signal,
|
|
396
511
|
});
|
|
397
512
|
status = response.status;
|
|
513
|
+
if (spec.stallMs !== undefined)
|
|
514
|
+
moved();
|
|
398
515
|
const ok = response.status >= 200 && response.status < 300;
|
|
399
516
|
if (response.status >= 300 && response.status < 400) {
|
|
400
517
|
await response.body?.cancel().catch(() => undefined);
|
|
401
518
|
return { answer: { status: response.status, headers: response.headers, text: '', bytes: null } };
|
|
402
519
|
}
|
|
520
|
+
if (spec.stallMs !== undefined) {
|
|
521
|
+
const whole = await readAll(response, moved);
|
|
522
|
+
if (ok && spec.read === 'bytes')
|
|
523
|
+
return { answer: { status: response.status, headers: response.headers, text: '', bytes: whole } };
|
|
524
|
+
return { answer: { status: response.status, headers: response.headers, text: new TextDecoder('utf-8').decode(whole), bytes: null } };
|
|
525
|
+
}
|
|
403
526
|
if (ok && spec.read === 'bytes') {
|
|
404
527
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
405
528
|
return { answer: { status: response.status, headers: response.headers, text: '', bytes } };
|
|
@@ -407,10 +530,12 @@ async function attemptOnce(config, spec, url, timeoutMs) {
|
|
|
407
530
|
return { answer: { status: response.status, headers: response.headers, text: await response.text(), bytes: null } };
|
|
408
531
|
}
|
|
409
532
|
catch (error) {
|
|
410
|
-
return { error, timedOut, status };
|
|
533
|
+
return { error, timedOut, stalled, status };
|
|
411
534
|
}
|
|
412
535
|
finally {
|
|
536
|
+
over = true;
|
|
413
537
|
clearTimeout(timer);
|
|
538
|
+
clearTimeout(stall);
|
|
414
539
|
spec.signal?.removeEventListener('abort', onAbort);
|
|
415
540
|
}
|
|
416
541
|
}
|
|
@@ -473,8 +598,8 @@ export async function request(config, spec) {
|
|
|
473
598
|
const timeoutMs = spec.timeoutMs ?? config.requestTimeoutMs;
|
|
474
599
|
const sandboxId = spec.sandboxId ?? null;
|
|
475
600
|
const effect = spec.effect ?? (spec.retry === 'exec' ? commandEffect() : undefined);
|
|
476
|
-
// Whether an attempt that was sent again may have reached the sandbox.
|
|
477
|
-
let acted = false;
|
|
601
|
+
// Whether an attempt that was sent again (or an earlier copy) may have reached the sandbox.
|
|
602
|
+
let acted = spec.acted ?? false;
|
|
478
603
|
// Names the last attempt's failure, if any, so a call cut short by its
|
|
479
604
|
// deadline still says what it was waiting out (a 503, a rate limit).
|
|
480
605
|
const pastDeadline = (last) => {
|
|
@@ -483,9 +608,10 @@ export async function request(config, spec) {
|
|
|
483
608
|
let error = new PrizedError('timeout', `The time allowed for this call ran out.${tail}`, { sandboxId, cause: last });
|
|
484
609
|
// A command is sent again only when it provably never arrived, so every
|
|
485
610
|
// attempt of one cut short this way never reached the sandbox. A staged
|
|
486
|
-
// upload's copies may have, unless each provably never arrived
|
|
487
|
-
|
|
488
|
-
|
|
611
|
+
// upload's copies may have, unless each provably never arrived, and so
|
|
612
|
+
// may a keyed start's (the one `safe` request that names an effect).
|
|
613
|
+
if (effect !== undefined)
|
|
614
|
+
error = withOutcome(error, effect, spec.retry !== 'exec' && acted ? 'maybe' : 'never');
|
|
489
615
|
cutShort.add(error);
|
|
490
616
|
return error;
|
|
491
617
|
};
|
|
@@ -523,7 +649,10 @@ export async function request(config, spec) {
|
|
|
523
649
|
}
|
|
524
650
|
const unsentAttempt = !outcome.timedOut && neverSent(outcome.error);
|
|
525
651
|
failure = failed(outcome.timedOut
|
|
526
|
-
? new PrizedError('timeout', `No answer within ${attemptMs} ms`, {
|
|
652
|
+
? new PrizedError('timeout', outcome.stalled ? `No bytes moved for ${spec.stallMs} ms` : `No answer within ${attemptMs} ms`, {
|
|
653
|
+
sandboxId,
|
|
654
|
+
cause: outcome.error,
|
|
655
|
+
})
|
|
527
656
|
: unsentAttempt
|
|
528
657
|
? new PrizedError('network_error', null, { sandboxId, cause: outcome.error })
|
|
529
658
|
: new PrizedError('network_error', lostMessage(spec.method, url, outcome.error), { sandboxId, cause: outcome.error }));
|
|
@@ -548,7 +677,8 @@ export async function request(config, spec) {
|
|
|
548
677
|
}
|
|
549
678
|
}
|
|
550
679
|
if (answer.status >= 300 && answer.status < 400) {
|
|
551
|
-
|
|
680
|
+
// A refusal; worded only when an earlier copy of a keyed start may have arrived.
|
|
681
|
+
throw raised(failed(new PrizedError('unexpected_redirect', null, { status: answer.status, sandboxId })), null, acted, effect);
|
|
552
682
|
}
|
|
553
683
|
const retryAfter = parseRetryAfter(answer.headers.get('retry-after'), config.now());
|
|
554
684
|
failure = failed(errorFromResponse(answer.status, answer.text, retryAfter, sandboxId, urlPath(url)));
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { Sandbox, DEFAULT_TEMPLATE, PREVIEW_KEY_HEADER } from './sandbox.js';
|
|
2
|
-
export type { CreateOptions, ForkOptions, ListOptions, WaitableOptions } from './sandbox.js';
|
|
2
|
+
export type { CreateOptions, ForkOptions, ListOptions, PortPreview, PreviewToken, PreviewTokenOptions, SetPortPublicOptions, WaitableOptions, } from './sandbox.js';
|
|
3
3
|
export { CommandHandle, DEFAULT_EXEC_TIMEOUT_MS, MAX_BLOCKING_EXEC_MS } from './commands.js';
|
|
4
4
|
export type { CommandStatus, ExecOptions, WaitOptions } from './commands.js';
|
|
5
5
|
export { Files } from './files.js';
|
package/dist/esm/outcome.d.ts
CHANGED
|
@@ -49,6 +49,15 @@ export declare function withOutcome(error: PrizedError, effect: Effect, outcome:
|
|
|
49
49
|
* connection closes. TypeScript only: the Python SDK takes no signal.
|
|
50
50
|
*/
|
|
51
51
|
export declare function withAbort(error: PrizedError, effect: Effect, outcome: 'never' | 'maybe' | 'did'): PrizedError;
|
|
52
|
+
/**
|
|
53
|
+
* What a start that gave up throws when the failure it gives up with
|
|
54
|
+
* (`failure`, Outage's pick) is not the last copy's own: an earlier copy's,
|
|
55
|
+
* worded before a later copy may have reached the sandbox, or what a copy
|
|
56
|
+
* cut short by the window met, not yet worded. Worded for the start: as one
|
|
57
|
+
* that may have started when any copy may have reached the sandbox
|
|
58
|
+
* (`anyActed`), else as one that never did. The Python SDK's start_gave_up.
|
|
59
|
+
*/
|
|
60
|
+
export declare function startGaveUp(failure: PrizedError, anyActed: boolean): PrizedError;
|
|
52
61
|
/**
|
|
53
62
|
* A background start that may have started (`error`, from withOutcome or
|
|
54
63
|
* withAbort) and that the SDK then stopped by its run id (`stopped`: the
|
package/dist/esm/outcome.js
CHANGED
|
@@ -90,12 +90,14 @@ function maybeTail(effect, closed) {
|
|
|
90
90
|
tail += ` ${effect.check}`;
|
|
91
91
|
return tail;
|
|
92
92
|
}
|
|
93
|
+
/** How an error says its request never reached the sandbox (withOutcome). */
|
|
94
|
+
const NEVER_TAIL = ' It never reached the sandbox, so it had no effect.';
|
|
93
95
|
/** `error` (same code, status and body), its message saying what it means for `effect`. */
|
|
94
96
|
export function withOutcome(error, effect, outcome) {
|
|
95
97
|
if (outcome === null)
|
|
96
98
|
return error;
|
|
97
99
|
if (outcome === 'never')
|
|
98
|
-
return restate(error, `${trimmed(error.message)}
|
|
100
|
+
return restate(error, `${trimmed(error.message)}.${NEVER_TAIL}`);
|
|
99
101
|
if (outcome === 'did')
|
|
100
102
|
return acting(error, `${effect.did}, but the answer could not be read (${detail(error)}).`);
|
|
101
103
|
return acting(error, `No clear answer came back (${detail(error)}), so ${maybeTail(effect, false)}`);
|
|
@@ -119,6 +121,23 @@ function stopTail(runId, stopped) {
|
|
|
119
121
|
? ` The SDK stopped ${runId}, or it had already ended.`
|
|
120
122
|
: ` The SDK could not confirm it stopped: it may still be running as ${runId} (exec('ps -ef | grep ${runId}') finds it).`;
|
|
121
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* What a start that gave up throws when the failure it gives up with
|
|
126
|
+
* (`failure`, Outage's pick) is not the last copy's own: an earlier copy's,
|
|
127
|
+
* worded before a later copy may have reached the sandbox, or what a copy
|
|
128
|
+
* cut short by the window met, not yet worded. Worded for the start: as one
|
|
129
|
+
* that may have started when any copy may have reached the sandbox
|
|
130
|
+
* (`anyActed`), else as one that never did. The Python SDK's start_gave_up.
|
|
131
|
+
*/
|
|
132
|
+
export function startGaveUp(failure, anyActed) {
|
|
133
|
+
if (acted.has(failure))
|
|
134
|
+
return failure;
|
|
135
|
+
const never = failure.message.endsWith(NEVER_TAIL);
|
|
136
|
+
if (never && !anyActed)
|
|
137
|
+
return failure;
|
|
138
|
+
const plain = never ? restate(failure, failure.message.slice(0, -NEVER_TAIL.length)) : failure;
|
|
139
|
+
return withOutcome(plain, startEffect(), anyActed ? 'maybe' : 'never');
|
|
140
|
+
}
|
|
122
141
|
/**
|
|
123
142
|
* A background start that may have started (`error`, from withOutcome or
|
|
124
143
|
* withAbort) and that the SDK then stopped by its run id (`stopped`: the
|
package/dist/esm/output.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// (sdk/python/src/prized/_output.py), rendered the same; both SDKs check
|
|
19
19
|
// them against sdk/output-scripts/.
|
|
20
20
|
import { OutputReadError, PrizedError } from './errors.js';
|
|
21
|
-
import { request } from './http.js';
|
|
21
|
+
import { request, timing } from './http.js';
|
|
22
22
|
import { restate } from './outcome.js';
|
|
23
23
|
import { LAUNCH_PATH } from './shell.js';
|
|
24
24
|
import { helperExec, MAX_FILE_BYTES, MAX_OUTPUT_BYTES, rawExec } from './toolbox.js';
|
|
@@ -270,6 +270,7 @@ async function download(toolbox, path, signal) {
|
|
|
270
270
|
retry: 'safe',
|
|
271
271
|
read: 'bytes',
|
|
272
272
|
timeoutMs: READ_MS,
|
|
273
|
+
stallMs: timing.transferStallMs,
|
|
273
274
|
deadline: toolbox.config.now() + READ_MS,
|
|
274
275
|
signal,
|
|
275
276
|
sandboxId: toolbox.sandboxId,
|