@prized/sdk 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: long enough to ride out a dropped connection
70
- * or an edge that restarts. The Python SDK's DEFAULT_POLL_OUTAGE.
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. */
@@ -355,6 +362,84 @@ export function backoffMs(config, attempt) {
355
362
  const base = Math.min(MAX_BACKOFF_MS, BACKOFF_FIRST_MS * 2 ** attempt);
356
363
  return Math.round(base / 2 + (config.random() * base) / 2);
357
364
  }
365
+ /**
366
+ * How long a file transfer may move no bytes before the attempt is given up
367
+ * (sdk/retry-spec.json transferStallMs; the Python SDK's TRANSFER_STALL).
368
+ * The edge holds a request about 20 s at most before it reads the body (its
369
+ * admission queue, then waking a paused sandbox), and ends a body that brings
370
+ * nothing for 60 s itself: kept above that, a stalled copy is over at the
371
+ * edge before the next one goes to the same staged name, so the two never
372
+ * race. Once an upload's last byte is handed over, its answer is waited for
373
+ * that and a second more for every uploadAnswerBytesPerSecond it sent: what
374
+ * the socket's buffers, or a proxy that takes a whole body before it sends it
375
+ * on, still hold moves unseen, and must not read as a stall (the Python SDK's
376
+ * UPLOAD_ANSWER_RATE). Mutable for tests only.
377
+ */
378
+ export const timing = { transferStallMs: 120_000, uploadAnswerBytesPerSecond: 128 * 1024 };
379
+ /** A streamed upload goes out this many bytes at a time. */
380
+ export const UPLOAD_CHUNK = 64 * 1024;
381
+ /**
382
+ * A body's bytes, to stream; null for one to send as it is. By shape, not
383
+ * instanceof: a Uint8Array or Buffer from another realm (a test runner's vm
384
+ * context, say) is one too.
385
+ */
386
+ async function bodyBytes(body) {
387
+ if (typeof body === 'string')
388
+ return new TextEncoder().encode(body);
389
+ if (ArrayBuffer.isView(body))
390
+ return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
391
+ if (Object.prototype.toString.call(body) === '[object ArrayBuffer]')
392
+ return new Uint8Array(body);
393
+ if (typeof body.arrayBuffer === 'function')
394
+ return new Uint8Array(await body.arrayBuffer());
395
+ return null;
396
+ }
397
+ /**
398
+ * `bytes` as a stream fed UPLOAD_CHUNK at a time, `moved` told each time
399
+ * fetch asks for more, and at the end given the answer's allowance
400
+ * (timing.uploadAnswerBytesPerSecond).
401
+ */
402
+ function inPieces(bytes, moved) {
403
+ let at = 0;
404
+ return new ReadableStream({
405
+ pull(controller) {
406
+ if (at >= bytes.byteLength) {
407
+ moved((bytes.byteLength / timing.uploadAnswerBytesPerSecond) * 1000);
408
+ controller.close();
409
+ return;
410
+ }
411
+ moved();
412
+ // A copy: a runtime may take the piece's buffer as it sends it.
413
+ controller.enqueue(bytes.slice(at, at + UPLOAD_CHUNK));
414
+ at += UPLOAD_CHUNK;
415
+ },
416
+ },
417
+ // Nothing read ahead: a pull is the socket taking the last piece.
418
+ { highWaterMark: 0 });
419
+ }
420
+ /** A body read whole, `moved` told of every piece. */
421
+ async function readAll(response, moved) {
422
+ if (response.body === null)
423
+ return new Uint8Array(0);
424
+ const reader = response.body.getReader();
425
+ const parts = [];
426
+ let size = 0;
427
+ for (;;) {
428
+ const { done, value } = await reader.read();
429
+ if (done)
430
+ break;
431
+ moved();
432
+ parts.push(value);
433
+ size += value.byteLength;
434
+ }
435
+ const whole = new Uint8Array(size);
436
+ let at = 0;
437
+ for (const part of parts) {
438
+ whole.set(part, at);
439
+ at += part.byteLength;
440
+ }
441
+ return whole;
442
+ }
358
443
  /**
359
444
  * One fetch, body included, under one timeout and the caller's signal. A
360
445
  * body that stalls or breaks after the headers fails the attempt like a
@@ -363,10 +448,24 @@ export function backoffMs(config, attempt) {
363
448
  async function attemptOnce(config, spec, url, timeoutMs) {
364
449
  const controller = new AbortController();
365
450
  let timedOut = false;
451
+ let stalled = false;
366
452
  const timer = setTimeout(() => {
367
453
  timedOut = true;
368
454
  controller.abort();
369
455
  }, timeoutMs);
456
+ // The stall bound starts over whenever bytes move (stallMs), until the attempt is over.
457
+ let stall;
458
+ let over = false;
459
+ const moved = (extraMs = 0) => {
460
+ if (spec.stallMs === undefined || over)
461
+ return;
462
+ clearTimeout(stall);
463
+ stall = setTimeout(() => {
464
+ stalled = true;
465
+ timedOut = true;
466
+ controller.abort();
467
+ }, spec.stallMs + extraMs);
468
+ };
370
469
  const onAbort = () => controller.abort();
371
470
  spec.signal?.addEventListener('abort', onAbort, { once: true });
372
471
  const headers = {
@@ -376,30 +475,52 @@ async function attemptOnce(config, spec, url, timeoutMs) {
376
475
  ...spec.headers,
377
476
  };
378
477
  let body;
379
- if (spec.json !== undefined) {
380
- headers['content-type'] = 'application/json';
381
- body = JSON.stringify(spec.json);
382
- }
383
- else if (spec.body !== undefined) {
384
- headers['content-type'] = spec.contentType ?? 'application/octet-stream';
385
- body = spec.body;
386
- }
478
+ // Watched from the start unless an upload goes out unwatched (Config.streamBodies).
479
+ let watched = spec.stallMs !== undefined && spec.body === undefined;
480
+ let streamed = false;
387
481
  let status = null;
388
482
  try {
483
+ if (spec.json !== undefined) {
484
+ headers['content-type'] = 'application/json';
485
+ body = JSON.stringify(spec.json);
486
+ }
487
+ else if (spec.body !== undefined) {
488
+ headers['content-type'] = spec.contentType ?? 'application/octet-stream';
489
+ body = spec.body;
490
+ const bytes = spec.stallMs !== undefined && config.streamBodies ? await bodyBytes(spec.body) : null;
491
+ if (bytes !== null) {
492
+ headers['content-length'] = String(bytes.byteLength);
493
+ body = inPieces(bytes, moved);
494
+ streamed = true;
495
+ watched = true;
496
+ }
497
+ }
498
+ if (watched)
499
+ moved();
389
500
  const response = await config.fetch(url, {
390
501
  method: spec.method,
391
502
  headers,
392
503
  body: body,
504
+ // A streamed body (Node's fetch asks for it): sent before the answer is read.
505
+ ...(streamed ? { duplex: 'half' } : {}),
393
506
  // Never follow a redirect with the key attached.
394
507
  redirect: 'manual',
395
508
  signal: controller.signal,
396
509
  });
397
510
  status = response.status;
511
+ if (spec.stallMs !== undefined)
512
+ moved();
398
513
  const ok = response.status >= 200 && response.status < 300;
399
514
  if (response.status >= 300 && response.status < 400) {
400
515
  await response.body?.cancel().catch(() => undefined);
401
516
  return { answer: { status: response.status, headers: response.headers, text: '', bytes: null } };
402
517
  }
518
+ if (spec.stallMs !== undefined) {
519
+ const whole = await readAll(response, moved);
520
+ if (ok && spec.read === 'bytes')
521
+ return { answer: { status: response.status, headers: response.headers, text: '', bytes: whole } };
522
+ return { answer: { status: response.status, headers: response.headers, text: new TextDecoder('utf-8').decode(whole), bytes: null } };
523
+ }
403
524
  if (ok && spec.read === 'bytes') {
404
525
  const bytes = new Uint8Array(await response.arrayBuffer());
405
526
  return { answer: { status: response.status, headers: response.headers, text: '', bytes } };
@@ -407,10 +528,12 @@ async function attemptOnce(config, spec, url, timeoutMs) {
407
528
  return { answer: { status: response.status, headers: response.headers, text: await response.text(), bytes: null } };
408
529
  }
409
530
  catch (error) {
410
- return { error, timedOut, status };
531
+ return { error, timedOut, stalled, status };
411
532
  }
412
533
  finally {
534
+ over = true;
413
535
  clearTimeout(timer);
536
+ clearTimeout(stall);
414
537
  spec.signal?.removeEventListener('abort', onAbort);
415
538
  }
416
539
  }
@@ -473,8 +596,8 @@ export async function request(config, spec) {
473
596
  const timeoutMs = spec.timeoutMs ?? config.requestTimeoutMs;
474
597
  const sandboxId = spec.sandboxId ?? null;
475
598
  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;
599
+ // Whether an attempt that was sent again (or an earlier copy) may have reached the sandbox.
600
+ let acted = spec.acted ?? false;
478
601
  // Names the last attempt's failure, if any, so a call cut short by its
479
602
  // deadline still says what it was waiting out (a 503, a rate limit).
480
603
  const pastDeadline = (last) => {
@@ -483,9 +606,10 @@ export async function request(config, spec) {
483
606
  let error = new PrizedError('timeout', `The time allowed for this call ran out.${tail}`, { sandboxId, cause: last });
484
607
  // A command is sent again only when it provably never arrived, so every
485
608
  // 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
- if (effect !== undefined && spec.retry !== 'safe')
488
- error = withOutcome(error, effect, spec.retry === 'staged' && acted ? 'maybe' : 'never');
609
+ // upload's copies may have, unless each provably never arrived, and so
610
+ // may a keyed start's (the one `safe` request that names an effect).
611
+ if (effect !== undefined)
612
+ error = withOutcome(error, effect, spec.retry !== 'exec' && acted ? 'maybe' : 'never');
489
613
  cutShort.add(error);
490
614
  return error;
491
615
  };
@@ -523,7 +647,10 @@ export async function request(config, spec) {
523
647
  }
524
648
  const unsentAttempt = !outcome.timedOut && neverSent(outcome.error);
525
649
  failure = failed(outcome.timedOut
526
- ? new PrizedError('timeout', `No answer within ${attemptMs} ms`, { sandboxId, cause: outcome.error })
650
+ ? new PrizedError('timeout', outcome.stalled ? `No bytes moved for ${spec.stallMs} ms` : `No answer within ${attemptMs} ms`, {
651
+ sandboxId,
652
+ cause: outcome.error,
653
+ })
527
654
  : unsentAttempt
528
655
  ? new PrizedError('network_error', null, { sandboxId, cause: outcome.error })
529
656
  : new PrizedError('network_error', lostMessage(spec.method, url, outcome.error), { sandboxId, cause: outcome.error }));
@@ -548,7 +675,8 @@ export async function request(config, spec) {
548
675
  }
549
676
  }
550
677
  if (answer.status >= 300 && answer.status < 400) {
551
- throw failed(new PrizedError('unexpected_redirect', null, { status: answer.status, sandboxId }));
678
+ // A refusal; worded only when an earlier copy of a keyed start may have arrived.
679
+ throw raised(failed(new PrizedError('unexpected_redirect', null, { status: answer.status, sandboxId })), null, acted, effect);
552
680
  }
553
681
  const retryAfter = parseRetryAfter(answer.headers.get('retry-after'), config.now());
554
682
  failure = failed(errorFromResponse(answer.status, answer.text, retryAfter, sandboxId, urlPath(url)));
@@ -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
@@ -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)}. It never reached the sandbox, so it had no effect.`);
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
@@ -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,
@@ -128,8 +128,9 @@ export declare class Sandbox {
128
128
  /**
129
129
  * Delete every sandbox carrying all of `labels` (at least one) and resolve
130
130
  * with their ids (the API takes up to 250 a request; this repeats it until
131
- * none are left). Safe to repeat: sandboxes already being
132
- * deleted are in the answer again, and none matching resolves `[]`. Each
131
+ * none are left). Each request selects the current matches, so a repeat
132
+ * can delete sandboxes created since the first request. An uncertain
133
+ * answer is not retried; list the matches before deleting again. Each
133
134
  * counts toward the workspace's quotas until it is gone;
134
135
  * `Sandbox.list({ labels })` coming back empty means they all are.
135
136
  */
@@ -437,8 +437,9 @@ export class Sandbox {
437
437
  /**
438
438
  * Delete every sandbox carrying all of `labels` (at least one) and resolve
439
439
  * with their ids (the API takes up to 250 a request; this repeats it until
440
- * none are left). Safe to repeat: sandboxes already being
441
- * deleted are in the answer again, and none matching resolves `[]`. Each
440
+ * none are left). Each request selects the current matches, so a repeat
441
+ * can delete sandboxes created since the first request. An uncertain
442
+ * answer is not retried; list the matches before deleting again. Each
442
443
  * counts toward the workspace's quotas until it is gone;
443
444
  * `Sandbox.list({ labels })` coming back empty means they all are.
444
445
  */
@@ -452,7 +453,15 @@ export class Sandbox {
452
453
  const deleted = new Set();
453
454
  const stuck = (calls) => new PrizedError('delete_incomplete', `Deleted ${deleted.size} sandboxes in ${calls} requests and some are left; call again to delete the rest.`);
454
455
  for (let call = 1; call <= MAX_BULK_DELETE_CALLS; call += 1) {
455
- const result = await request(config, { method: 'DELETE', url: apiUrl(config, `/sandboxes${query}`), retry: 'safe', deadline });
456
+ // A label is a live selector, not a stable resource or idempotency key.
457
+ const result = await request(config, {
458
+ method: 'DELETE', url: apiUrl(config, `/sandboxes${query}`), retry: 'exec', deadline,
459
+ effect: {
460
+ maybe: 'some matching sandboxes may already be deleting',
461
+ did: 'The matching sandboxes were marked for deletion',
462
+ check: 'It was not sent again: list the matching sandboxes before deleting again, because the matches may have changed.',
463
+ },
464
+ });
456
465
  const answer = result.json;
457
466
  const ids = answer?.ids;
458
467
  if (!Array.isArray(ids) || !ids.every((id) => typeof id === 'string')) {
@@ -11,7 +11,8 @@ export declare const RUN_ID_RE: RegExp;
11
11
  /**
12
12
  * Minutes (by the sandbox's clock) a cleaned or abandoned run's directory
13
13
  * stays: what keeps a resend of its start from running the command again.
14
- * The SDK stops resending a start within minutes, so an hour is ample; each
14
+ * The SDK stops resending a start once pollOutageMs (2 minutes by default)
15
+ * has run out after its first copy's retries, so an hour is ample; each
15
16
  * cleanup then removes the ones older than that, so they never pile up.
16
17
  */
17
18
  export declare const CLEANED_KEEP_MIN = 60;
package/dist/esm/shell.js CHANGED
@@ -50,7 +50,8 @@ export const RUN_ID_RE = /^run_[0-9a-f]{16}$/;
50
50
  /**
51
51
  * Minutes (by the sandbox's clock) a cleaned or abandoned run's directory
52
52
  * stays: what keeps a resend of its start from running the command again.
53
- * The SDK stops resending a start within minutes, so an hour is ample; each
53
+ * The SDK stops resending a start once pollOutageMs (2 minutes by default)
54
+ * has run out after its first copy's retries, so an hour is ample; each
54
55
  * cleanup then removes the ones older than that, so they never pile up.
55
56
  */
56
57
  export const CLEANED_KEEP_MIN = 60;
@@ -30,6 +30,8 @@ export declare function rawExec(toolbox: Toolbox, req: RawExecRequest, options:
30
30
  deadline?: number | undefined;
31
31
  /** What the command does, for an error that says whether it happened (`exec`: a command unless given). */
32
32
  effect?: Effect | undefined;
33
+ /** An earlier copy of a keyed start may have reached the sandbox. */
34
+ acted?: boolean | undefined;
33
35
  }): Promise<ExecResult>;
34
36
  /** A helper command the SDK sends itself; a non-zero exit is the caller's to judge. */
35
37
  export declare function helperExec(toolbox: Toolbox, command: string, options?: {
@@ -46,6 +46,7 @@ export async function rawExec(toolbox, req, options) {
46
46
  deadline: options.deadline,
47
47
  sandboxId: toolbox.sandboxId,
48
48
  ...(effect === undefined ? {} : { effect }),
49
+ ...(options.acted ? { acted: true } : {}),
49
50
  });
50
51
  const doc = result.json;
51
52
  if (doc === null || typeof doc !== 'object' || typeof doc.exitCode !== 'number') {
@@ -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.1";
2
+ export declare const VERSION = "0.1.2";
@@ -1,2 +1,2 @@
1
1
  /** This package's version; test/version.test.ts holds it equal to package.json. */
2
- export const VERSION = '0.1.1';
2
+ export const VERSION = '0.1.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prized/sdk",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Prized Sandboxes (beta): create microVM sandboxes, run commands, move files, pause, resume and fork them from TypeScript.",
5
5
  "homepage": "https://prized.dev/docs/sandboxes",
6
6
  "license": "Apache-2.0",