withruntime 0.11.0 → 0.11.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/BLAXEL.md +8 -9
- package/DAYTONA.md +8 -9
- package/E2B.md +63 -21
- package/README.md +6 -5
- package/README.src.md +6 -5
- package/VERCEL.md +3 -2
- package/compatibility-lock.json +69 -14
- package/dist/api-defaults.d.ts +1 -1
- package/dist/api-defaults.js +1 -1
- package/dist/blaxel/errors.js +4 -4
- package/dist/blaxel/lookup.d.ts +2 -1
- package/dist/blaxel/lookup.js +2 -1
- package/dist/blaxel/sandbox.d.ts +8 -7
- package/dist/blaxel/sandbox.js +16 -12
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +99 -27
- package/dist/client.d.ts +16 -60
- package/dist/client.js +14 -103
- package/dist/compat/files.js +5 -1
- package/dist/compat/lease.d.ts +1 -0
- package/dist/compat/lease.js +14 -8
- package/dist/credentials.d.ts +9 -0
- package/dist/credentials.js +26 -4
- package/dist/daytona/daytona.d.ts +1 -2
- package/dist/daytona/daytona.js +30 -14
- package/dist/daytona/sandbox.d.ts +9 -4
- package/dist/daytona/sandbox.js +24 -5
- package/dist/e2b/client.d.ts +15 -2
- package/dist/e2b/client.js +61 -7
- package/dist/e2b/commands.d.ts +3 -0
- package/dist/e2b/commands.js +16 -5
- package/dist/e2b/errors.d.ts +26 -2
- package/dist/e2b/errors.js +38 -5
- package/dist/e2b/filesystem.d.ts +1 -1
- package/dist/e2b/filesystem.js +82 -16
- package/dist/e2b/index.d.ts +2 -2
- package/dist/e2b/index.js +2 -2
- package/dist/e2b/sandbox.d.ts +25 -12
- package/dist/e2b/sandbox.js +196 -165
- package/dist/e2b/unsupported.d.ts +19 -0
- package/dist/e2b/unsupported.js +21 -0
- package/dist/e2b/users.d.ts +9 -0
- package/dist/e2b/users.js +22 -0
- package/dist/errors.d.ts +10 -4
- package/dist/errors.js +18 -10
- package/dist/identity.js +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/machine.d.ts +180 -0
- package/dist/machine.js +504 -0
- package/dist/modal/index.js +6 -2
- package/dist/network-cli.js +7 -7
- package/dist/openai-agents/index.d.ts +1 -1
- package/dist/openai-agents/index.js +1 -1
- package/dist/products/browser.d.ts +2 -1
- package/dist/products/browser.js +9 -19
- package/dist/products/desktop.d.ts +2 -1
- package/dist/products/desktop.js +5 -30
- package/dist/products/jobs.d.ts +6 -5
- package/dist/products/jobs.js +6 -3
- package/dist/products/limits.d.ts +4 -10
- package/dist/products/mcp.d.ts +5 -1
- package/dist/products/mcp.js +29 -8
- package/dist/products/network-products.d.ts +1 -1
- package/dist/products/network-products.js +1 -1
- package/dist/products/network.d.ts +1 -1
- package/dist/products/observability.d.ts +1 -1
- package/dist/products/private-network.d.ts +1 -1
- package/dist/products/secrets.d.ts +3 -3
- package/dist/products/switching.d.ts +2 -3
- package/dist/runloop/index.js +6 -2
- package/dist/sandbox.d.ts +7 -84
- package/dist/sandbox.js +58 -345
- package/dist/tar.d.ts +2 -0
- package/dist/tar.js +74 -16
- package/dist/transport.d.ts +36 -8
- package/dist/transport.js +174 -37
- package/dist/types.d.ts +102 -12
- package/dist/vercel/filesystem.js +3 -1
- package/dist/vercel/sandbox.d.ts +18 -12
- package/dist/vercel/sandbox.js +27 -15
- package/dist/wait.d.ts +12 -0
- package/dist/wait.js +47 -0
- package/package.json +1 -1
package/dist/sandbox.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { CommandError, ConnectionError, NotFoundError, RuntimeError } from "./errors.js";
|
|
3
|
-
import { Page } from "./page.js";
|
|
1
|
+
import { STREAMED_EXEC_TIMEOUT_MS } from "./api-defaults.js";
|
|
2
|
+
import { CommandError, ConnectionError, DELIBERATE, errorFor, NotFoundError, RateLimitError, RuntimeError, ServiceUnavailableError, } from "./errors.js";
|
|
4
3
|
import { sandboxWatches, watchDirectory, } from "./products/watch.js";
|
|
5
4
|
import { Transport } from "./transport.js";
|
|
6
5
|
import { sandboxFactories } from "./products/index.js";
|
|
7
6
|
import { Tunnel } from "./tunnel.js";
|
|
7
|
+
import { pause } from "./wait.js";
|
|
8
|
+
import { Machine, machinePage, requestOnly } from "./machine.js";
|
|
9
|
+
/** Sandboxes, as a product of the machine engine (machine.ts). */
|
|
10
|
+
export const SANDBOXES = { plural: "sandboxes", noun: "Sandbox" };
|
|
8
11
|
const CHUNK = 1_048_576;
|
|
9
12
|
/** Chunks of a large write in flight at once. Each chunk's reply waits on the
|
|
10
13
|
* API and the guest, and the link idles while every chunk in flight waits:
|
|
@@ -56,15 +59,6 @@ function requestScope(options) {
|
|
|
56
59
|
};
|
|
57
60
|
return { request, check, wait };
|
|
58
61
|
}
|
|
59
|
-
/** DELETE /v1/sandboxes/{id}, for `sandbox.delete()` and
|
|
60
|
-
* `runtime.sandboxes.delete(id)`. */
|
|
61
|
-
export function deleteSandbox(t, id, options = {}) {
|
|
62
|
-
return t.json({
|
|
63
|
-
method: "DELETE",
|
|
64
|
-
path: `/v1/sandboxes/${enc(id)}`,
|
|
65
|
-
...options,
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
62
|
/* Web APIs only, so the sandbox's commands, processes and files work in a
|
|
69
63
|
browser with a session token (Sandbox.fromSession): no Buffer, no
|
|
70
64
|
node:crypto. */
|
|
@@ -94,26 +88,21 @@ function commandBody(command, options) {
|
|
|
94
88
|
};
|
|
95
89
|
}
|
|
96
90
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
97
|
-
export class Sandbox {
|
|
98
|
-
#info;
|
|
99
|
-
#keepAlive;
|
|
91
|
+
export class Sandbox extends Machine {
|
|
100
92
|
#t;
|
|
101
93
|
files;
|
|
102
94
|
processes;
|
|
103
95
|
/** Short-lived tokens that let a browser reach this sandbox directly. */
|
|
104
96
|
sessions;
|
|
105
97
|
constructor(transport, info) {
|
|
98
|
+
super(SANDBOXES, transport, info);
|
|
106
99
|
this.#t = transport;
|
|
107
|
-
this.#info = info;
|
|
108
100
|
this.files = new Files(transport, info.id);
|
|
109
101
|
this.processes = new Processes(transport, info.id);
|
|
110
102
|
this.sessions = new SandboxSessions(transport, info.id);
|
|
111
103
|
for (const [name, make] of sandboxFactories())
|
|
112
104
|
Object.defineProperty(this, name, { value: make(transport, this), enumerable: false });
|
|
113
105
|
}
|
|
114
|
-
get id() {
|
|
115
|
-
return this.#info.id;
|
|
116
|
-
}
|
|
117
106
|
/** A sandbox reached with a session token instead of an API key, for code in
|
|
118
107
|
* a browser: commands and their streams, processes, files and previews of
|
|
119
108
|
* this one sandbox. Makes no call; `info` holds only the id until
|
|
@@ -138,41 +127,6 @@ export class Sandbox {
|
|
|
138
127
|
});
|
|
139
128
|
return new Sandbox(transport, { id: session.sandboxId });
|
|
140
129
|
}
|
|
141
|
-
/** The API's latest answer. `start`, the report of an image's start
|
|
142
|
-
* command, is the create's alone: the API keeps no record of it, so a
|
|
143
|
-
* later read of this sandbox keeps it here. */
|
|
144
|
-
#keep(info) {
|
|
145
|
-
const start = this.#info?.start;
|
|
146
|
-
this.#info = info.start === undefined && start !== undefined ? { ...info, start } : info;
|
|
147
|
-
}
|
|
148
|
-
/** What the API last said about this sandbox. `refresh()` asks again. */
|
|
149
|
-
get info() {
|
|
150
|
-
return this.#info;
|
|
151
|
-
}
|
|
152
|
-
get state() {
|
|
153
|
-
return this.#info.state;
|
|
154
|
-
}
|
|
155
|
-
async refresh(options = {}) {
|
|
156
|
-
this.#keep(await this.#t.json({
|
|
157
|
-
method: "GET",
|
|
158
|
-
path: `/v1/sandboxes/${enc(this.id)}`,
|
|
159
|
-
...options,
|
|
160
|
-
}));
|
|
161
|
-
return this;
|
|
162
|
-
}
|
|
163
|
-
/** Waits (server-side, no polling) until the sandbox reaches `state`. */
|
|
164
|
-
async waitFor(state, options = {}) {
|
|
165
|
-
this.#keep(await this.#t.json({
|
|
166
|
-
method: "GET",
|
|
167
|
-
path: `/v1/sandboxes/${enc(this.id)}`,
|
|
168
|
-
query: {
|
|
169
|
-
waitFor: state,
|
|
170
|
-
timeoutSeconds: options.timeoutSeconds ?? WAIT_FOR_TIMEOUT_SECONDS,
|
|
171
|
-
},
|
|
172
|
-
...options,
|
|
173
|
-
}));
|
|
174
|
-
return this;
|
|
175
|
-
}
|
|
176
130
|
/** Runs a command and returns its exit code and output. A string runs under
|
|
177
131
|
* `bash -c`; an array runs without a shell. With onStdout/onStderr the output
|
|
178
132
|
* streams as it happens. A timeout is a result (timedOut: true, the output so
|
|
@@ -365,251 +319,6 @@ export class Sandbox {
|
|
|
365
319
|
throw error;
|
|
366
320
|
}
|
|
367
321
|
}
|
|
368
|
-
async stop(options = {}) {
|
|
369
|
-
this.#keepAlive?.();
|
|
370
|
-
return this.#lifecycle("stop", options);
|
|
371
|
-
}
|
|
372
|
-
async pause(options = {}) {
|
|
373
|
-
return this.#lifecycle("pause", options);
|
|
374
|
-
}
|
|
375
|
-
/** Deletes it for good: stops it if it runs or is paused, deletes its disk
|
|
376
|
-
* and paused memory, revokes its previews and ports, and removes it from
|
|
377
|
-
* lists. Its snapshots, usage and audit entries stay. Deleting it again
|
|
378
|
-
* answers the same. */
|
|
379
|
-
async delete(options = {}) {
|
|
380
|
-
this.#keepAlive?.();
|
|
381
|
-
return deleteSandbox(this.#t, this.id, options);
|
|
382
|
-
}
|
|
383
|
-
/** Carries on a paused sandbox; `timeoutSeconds` is its new lease. */
|
|
384
|
-
async wake(options = {}) {
|
|
385
|
-
const { timeoutSeconds, ...rest } = options;
|
|
386
|
-
try {
|
|
387
|
-
return await this.#lifecycle("wake", rest, timeoutSeconds === undefined ? {} : { timeoutSeconds });
|
|
388
|
-
}
|
|
389
|
-
catch (error) {
|
|
390
|
-
// Waking one that is already awake is done, not a mistake. With a new
|
|
391
|
-
// lease asked for, the refusal stands: it was not given.
|
|
392
|
-
if (!(error instanceof RuntimeError) || error.code !== "not_paused" || timeoutSeconds)
|
|
393
|
-
throw error;
|
|
394
|
-
await this.refresh(pick(rest));
|
|
395
|
-
if (this.state !== "running" && this.state !== "starting")
|
|
396
|
-
throw error;
|
|
397
|
-
return this;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
/** More time before the lease ends (at most an hour ahead of now). */
|
|
401
|
-
async extend(seconds, options = {}) {
|
|
402
|
-
return this.#lifecycle("extend", { ...options, wait: false }, { seconds });
|
|
403
|
-
}
|
|
404
|
-
/** Changes its name, labels, automatic wake, idle pause or persistence;
|
|
405
|
-
* fields left out stay as they are. `persistent: true` keeps it running
|
|
406
|
-
* while credit lasts (paid only). */
|
|
407
|
-
async update(settings, options = {}) {
|
|
408
|
-
return this.#lifecycle("update", { ...options, wait: false }, { ...settings });
|
|
409
|
-
}
|
|
410
|
-
/** Moves it to another image (id, name, name:tag or name@version), keeping
|
|
411
|
-
* its id, /workspace (its home: dotfiles, pip --user and npm -g installs),
|
|
412
|
-
* volumes, environment, name and previews. Its processes restart, and
|
|
413
|
-
* everything else on its old disk (sudo installs, apt packages, /etc) is
|
|
414
|
-
* lost, which `keep: "workspace"` says you know; snapshot it first to keep
|
|
415
|
-
* everything. A running sandbox is paused first. A switch that fails is
|
|
416
|
-
* undone (`switch_undone`), the sandbox on its old image with nothing lost.
|
|
417
|
-
* Charged as a wake. */
|
|
418
|
-
async switchImage(image, options) {
|
|
419
|
-
const { keep, ...rest } = options;
|
|
420
|
-
this.#keep(await this.#t.json({
|
|
421
|
-
method: "POST",
|
|
422
|
-
path: `/v1/sandboxes/${enc(this.id)}:switch-image`,
|
|
423
|
-
body: { image, keep },
|
|
424
|
-
// A pause, two boots and the copy between them.
|
|
425
|
-
wait: 120,
|
|
426
|
-
...pick(rest),
|
|
427
|
-
}));
|
|
428
|
-
return this;
|
|
429
|
-
}
|
|
430
|
-
/** Gives it more or fewer vCPUs or more or less memory by a restart: its id,
|
|
431
|
-
* whole disk, volumes, environment, name and previews stay, and its
|
|
432
|
-
* programs stop, which `restart: true` says you know (snapshot it first to
|
|
433
|
-
* keep its memory too). A running sandbox is paused, or stopped if it is
|
|
434
|
-
* persistent, and comes back running at the new size on the same server; a
|
|
435
|
-
* paused or stopped one is started. Memory bills on the new size from then.
|
|
436
|
-
* Refused, with nothing changed, when the server has no room
|
|
437
|
-
* (`no_capacity`), above your quota or the trial's 2 vCPU and 4 GiB, or
|
|
438
|
-
* while a snapshot or fork of it is being taken. */
|
|
439
|
-
async resize(size, options) {
|
|
440
|
-
const { restart, ...rest } = options;
|
|
441
|
-
this.#keep(await this.#t.json({
|
|
442
|
-
method: "POST",
|
|
443
|
-
path: `/v1/sandboxes/${enc(this.id)}:resize`,
|
|
444
|
-
body: { ...size, restart },
|
|
445
|
-
// A pause or stop and a cold boot.
|
|
446
|
-
wait: 120,
|
|
447
|
-
...pick(rest),
|
|
448
|
-
}));
|
|
449
|
-
return this;
|
|
450
|
-
}
|
|
451
|
-
/** Keeps a running sandbox's lease ahead of now, in the background, until
|
|
452
|
-
* stop() or the returned function ends it: every `everySeconds` (60) it
|
|
453
|
-
* extends the lease so that `marginSeconds` (600) remain, never more than
|
|
454
|
-
* the hour ahead the API allows. Running time is billed as it is used, as
|
|
455
|
-
* for any extension. A paused sandbox is left paused (a request wakes it,
|
|
456
|
-
* unless autoWake is off); a stopped one ends the loop. It does not keep a
|
|
457
|
-
* Node or Bun process alive by itself. */
|
|
458
|
-
keepAlive(options = {}) {
|
|
459
|
-
this.#keepAlive?.();
|
|
460
|
-
const every = Math.max(10, options.everySeconds ?? 60) * 1000;
|
|
461
|
-
const margin = Math.min(3600, Math.max(60, options.marginSeconds ?? KEEP_ALIVE_MARGIN_SECONDS));
|
|
462
|
-
let ended = false;
|
|
463
|
-
let timer;
|
|
464
|
-
const end = () => {
|
|
465
|
-
ended = true;
|
|
466
|
-
if (timer)
|
|
467
|
-
clearTimeout(timer);
|
|
468
|
-
if (this.#keepAlive === end)
|
|
469
|
-
this.#keepAlive = undefined;
|
|
470
|
-
};
|
|
471
|
-
const tick = async () => {
|
|
472
|
-
if (ended)
|
|
473
|
-
return;
|
|
474
|
-
try {
|
|
475
|
-
await this.refresh();
|
|
476
|
-
if (this.state === "stopped" || this.state === "stopping")
|
|
477
|
-
return end();
|
|
478
|
-
if (this.state === "running") {
|
|
479
|
-
const left = (Date.parse(this.info.expiresAt) - Date.now()) / 1000;
|
|
480
|
-
const need = Math.ceil(margin - left);
|
|
481
|
-
if (need >= 1)
|
|
482
|
-
await this.extend(Math.min(3600, need));
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
catch (error) {
|
|
486
|
-
options.onError?.(error);
|
|
487
|
-
}
|
|
488
|
-
if (ended)
|
|
489
|
-
return;
|
|
490
|
-
timer = setTimeout(() => void tick(), every);
|
|
491
|
-
timer.unref?.();
|
|
492
|
-
};
|
|
493
|
-
this.#keepAlive = end;
|
|
494
|
-
void tick();
|
|
495
|
-
return end;
|
|
496
|
-
}
|
|
497
|
-
/** Days a paused sandbox is kept before deletion (1 to 365). */
|
|
498
|
-
async setRetention(days, options = {}) {
|
|
499
|
-
return this.#lifecycle("retention", { ...options, wait: false }, { days });
|
|
500
|
-
}
|
|
501
|
-
/** Keeps this sandbox's whole machine (files, memory, running processes) as
|
|
502
|
-
* a snapshot to start new sandboxes from. A running sandbox is paused for
|
|
503
|
-
* the moment it takes, then woken; a paused one stays paused. */
|
|
504
|
-
async snapshot(options = {}) {
|
|
505
|
-
const { idempotencyKey, signal, timeoutMs, ...body } = options;
|
|
506
|
-
if (body.mode !== undefined && body.mode !== "memory" && body.mode !== "disk")
|
|
507
|
-
throw new TypeError("Snapshot mode must be memory or disk.");
|
|
508
|
-
signal?.throwIfAborted();
|
|
509
|
-
const until = performance.now() + Math.min(timeoutMs && timeoutMs > 0 ? timeoutMs : 60_000, 60_000);
|
|
510
|
-
const request = () => {
|
|
511
|
-
signal?.throwIfAborted();
|
|
512
|
-
const left = Math.ceil(until - performance.now());
|
|
513
|
-
if (left <= 0)
|
|
514
|
-
throw new RuntimeError({
|
|
515
|
-
code: "snapshot_timeout",
|
|
516
|
-
status: 0,
|
|
517
|
-
message: "Snapshot capture ran past its deadline.",
|
|
518
|
-
});
|
|
519
|
-
return { ...(signal ? { signal } : {}), timeoutMs: left };
|
|
520
|
-
};
|
|
521
|
-
await this.refresh(request());
|
|
522
|
-
// Straight after a fork or a wake the sandbox is still `resuming`, and
|
|
523
|
-
// after a pause still `pausing`: wait for where it is going, or a
|
|
524
|
-
// snapshot of it is refused as not paused (user lane, 23 September 2026).
|
|
525
|
-
if (this.state === "resuming" || this.state === "starting")
|
|
526
|
-
await this.waitFor("running", request());
|
|
527
|
-
else if (this.state === "pausing")
|
|
528
|
-
await this.waitFor("paused", request());
|
|
529
|
-
const running = this.state === "running";
|
|
530
|
-
// Check before entering cleanup: an abort during refresh must not pause or wake.
|
|
531
|
-
const pauseOptions = running ? request() : undefined;
|
|
532
|
-
let captured;
|
|
533
|
-
let primary;
|
|
534
|
-
let failed = false;
|
|
535
|
-
try {
|
|
536
|
-
if (running)
|
|
537
|
-
await this.pause(pauseOptions);
|
|
538
|
-
let snapshot = await this.#t.json({
|
|
539
|
-
method: "POST",
|
|
540
|
-
path: `/v1/sandboxes/${enc(this.id)}:snapshot`,
|
|
541
|
-
body,
|
|
542
|
-
wait: 10,
|
|
543
|
-
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
544
|
-
...request(),
|
|
545
|
-
});
|
|
546
|
-
captured = snapshot;
|
|
547
|
-
// Prefer: wait is bounded on the server. A capture still in progress
|
|
548
|
-
// must keep its source paused, or the worker refuses it after we wake.
|
|
549
|
-
while (snapshot.state === "capturing") {
|
|
550
|
-
signal?.throwIfAborted();
|
|
551
|
-
if (performance.now() >= until)
|
|
552
|
-
throw new RuntimeError({
|
|
553
|
-
code: "snapshot_timeout",
|
|
554
|
-
status: 0,
|
|
555
|
-
message: "Snapshot capture did not finish within one minute.",
|
|
556
|
-
details: { snapshotId: snapshot.id },
|
|
557
|
-
});
|
|
558
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
559
|
-
snapshot = await this.#t.json({
|
|
560
|
-
method: "GET",
|
|
561
|
-
path: `/v1/snapshots/${enc(snapshot.id)}`,
|
|
562
|
-
...request(),
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
if (snapshot.state !== "ready")
|
|
566
|
-
throw new RuntimeError({
|
|
567
|
-
code: "snapshot_failed",
|
|
568
|
-
status: 409,
|
|
569
|
-
message: snapshot.error ?? `Snapshot capture ended in state ${snapshot.state}.`,
|
|
570
|
-
details: { snapshotId: snapshot.id },
|
|
571
|
-
});
|
|
572
|
-
if (body.mode === "disk" && snapshot.mode !== "disk")
|
|
573
|
-
throw new RuntimeError({
|
|
574
|
-
code: "snapshot_mode_mismatch",
|
|
575
|
-
status: 409,
|
|
576
|
-
message: "The server did not confirm a disk-only snapshot.",
|
|
577
|
-
details: { snapshotId: snapshot.id },
|
|
578
|
-
});
|
|
579
|
-
captured = snapshot;
|
|
580
|
-
}
|
|
581
|
-
catch (error) {
|
|
582
|
-
failed = true;
|
|
583
|
-
primary = error;
|
|
584
|
-
}
|
|
585
|
-
if (running) {
|
|
586
|
-
try {
|
|
587
|
-
await this.wake();
|
|
588
|
-
}
|
|
589
|
-
catch (wakeError) {
|
|
590
|
-
const recovery = {
|
|
591
|
-
...(captured ? { snapshotId: captured.id } : {}),
|
|
592
|
-
sourceSandboxId: this.id,
|
|
593
|
-
sourceWakeError: {
|
|
594
|
-
...(wakeError instanceof RuntimeError ? { code: wakeError.code } : {}),
|
|
595
|
-
message: wakeError instanceof Error ? wakeError.message : String(wakeError),
|
|
596
|
-
},
|
|
597
|
-
};
|
|
598
|
-
const reported = failed ? primary : wakeError;
|
|
599
|
-
if (reported instanceof Error && Object.isExtensible(reported)) {
|
|
600
|
-
if (reported instanceof RuntimeError)
|
|
601
|
-
Object.assign(reported, { details: { ...reported.details, ...recovery } });
|
|
602
|
-
else
|
|
603
|
-
Object.assign(reported, recovery);
|
|
604
|
-
}
|
|
605
|
-
if (!failed)
|
|
606
|
-
throw wakeError;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
if (failed)
|
|
610
|
-
throw primary;
|
|
611
|
-
return captured;
|
|
612
|
-
}
|
|
613
322
|
async fork(options = {}) {
|
|
614
323
|
const { idempotencyKey, signal, timeoutMs, ...body } = options;
|
|
615
324
|
const reply = await this.#t.json({
|
|
@@ -617,38 +326,11 @@ export class Sandbox {
|
|
|
617
326
|
path: `/v1/sandboxes/${enc(this.id)}:fork`,
|
|
618
327
|
body,
|
|
619
328
|
wait: 60,
|
|
620
|
-
...
|
|
621
|
-
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
622
|
-
...(signal ? { signal } : {}),
|
|
623
|
-
...(timeoutMs ? { timeoutMs } : {}),
|
|
624
|
-
}),
|
|
329
|
+
...requestOnly({ idempotencyKey, signal, timeoutMs }),
|
|
625
330
|
});
|
|
626
331
|
const sandboxes = reply.sandboxes.map((info) => new Sandbox(this.#t, info));
|
|
627
332
|
return options.count === undefined ? sandboxes[0] : sandboxes;
|
|
628
333
|
}
|
|
629
|
-
/** Starts a stopped persistent sandbox again from its disk (memory is not kept). */
|
|
630
|
-
async restart(options = {}) {
|
|
631
|
-
return this.#lifecycle("restart", options);
|
|
632
|
-
}
|
|
633
|
-
async #lifecycle(verb, options, body = {}) {
|
|
634
|
-
this.#keep(await this.#t.json({
|
|
635
|
-
method: "POST",
|
|
636
|
-
path: `/v1/sandboxes/${enc(this.id)}:${verb}`,
|
|
637
|
-
body,
|
|
638
|
-
wait: options.wait === false ? 0 : 60,
|
|
639
|
-
...pick(options),
|
|
640
|
-
}));
|
|
641
|
-
return this;
|
|
642
|
-
}
|
|
643
|
-
async [Symbol.asyncDispose]() {
|
|
644
|
-
this.#keepAlive?.();
|
|
645
|
-
if (this.#info.state === "stopped")
|
|
646
|
-
return;
|
|
647
|
-
await this.stop({ wait: false }).catch(() => undefined);
|
|
648
|
-
}
|
|
649
|
-
toJSON() {
|
|
650
|
-
return this.#info;
|
|
651
|
-
}
|
|
652
334
|
}
|
|
653
335
|
/** Where a reader of a process's output is, in bytes of stdout and stderr
|
|
654
336
|
* together. Output it did not receive, named by the server or not, becomes a
|
|
@@ -660,13 +342,10 @@ class OutputCursor {
|
|
|
660
342
|
this.cursor = cursor;
|
|
661
343
|
}
|
|
662
344
|
*pass(event) {
|
|
345
|
+
// As the status the API would have answered it, so a passing failure (a
|
|
346
|
+
// host restarting) is followed again rather than ending the read.
|
|
663
347
|
if (event.type === "error")
|
|
664
|
-
throw
|
|
665
|
-
message: event.error.message,
|
|
666
|
-
code: event.error.code,
|
|
667
|
-
status: 0,
|
|
668
|
-
...(event.error.requestId ? { requestId: event.error.requestId } : {}),
|
|
669
|
-
});
|
|
348
|
+
throw errorFor(event.error.status ?? 0, event);
|
|
670
349
|
if (event.type === "truncated")
|
|
671
350
|
this.cursor = Math.max(this.cursor, event.resumeAt);
|
|
672
351
|
if (event.type === "stdout" || event.type === "stderr") {
|
|
@@ -697,11 +376,20 @@ function processBytes(encoded) {
|
|
|
697
376
|
});
|
|
698
377
|
}
|
|
699
378
|
}
|
|
700
|
-
/** A stream cut off by the network
|
|
701
|
-
*
|
|
379
|
+
/** A stream cut off by the network, or refused by a failure that passes by
|
|
380
|
+
* itself (a busy API, a 429, a host restarting), rather than refused for good:
|
|
381
|
+
* worth reconnecting to from the cursor. */
|
|
702
382
|
function cutOff(error) {
|
|
703
|
-
return !(error instanceof RuntimeError) ||
|
|
383
|
+
return (!(error instanceof RuntimeError) ||
|
|
384
|
+
error instanceof ConnectionError ||
|
|
385
|
+
((error instanceof RateLimitError || error instanceof ServiceUnavailableError) &&
|
|
386
|
+
[429, 502, 503, 504].includes(error.status) &&
|
|
387
|
+
!DELIBERATE.has(error.code)));
|
|
704
388
|
}
|
|
389
|
+
/** How long a follow keeps reconnecting while every attempt fails. A command
|
|
390
|
+
* runs for hours whether or not anyone reads it; a few minutes of a deploy, a
|
|
391
|
+
* network blip or a busy API must not end the read of it. */
|
|
392
|
+
const FOLLOW_OUTAGE_MS = 300_000;
|
|
705
393
|
/** A streamed command keeps all its output unless the reader fell more than the
|
|
706
394
|
* process's output buffer behind, which the server marks with a "truncated"
|
|
707
395
|
* event. That event does not say which stream lost bytes, so both flags carry
|
|
@@ -709,6 +397,8 @@ function cutOff(error) {
|
|
|
709
397
|
function truncation(dropped) {
|
|
710
398
|
return { stdoutTruncated: dropped, stderrTruncated: dropped };
|
|
711
399
|
}
|
|
400
|
+
/** A command's request options: its `timeoutMs` is the command's own limit
|
|
401
|
+
* in the guest, so the call is given a minute more for the answer. */
|
|
712
402
|
function pick(options) {
|
|
713
403
|
return {
|
|
714
404
|
...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
|
|
@@ -804,12 +494,19 @@ export class Processes {
|
|
|
804
494
|
/** Output events from `cursor` until the process exits, across stream slices. */
|
|
805
495
|
async *follow(processId, options = {}) {
|
|
806
496
|
const read = new OutputCursor(options.cursor ?? 0);
|
|
807
|
-
// Reconnections in a row that
|
|
808
|
-
// closing without output or an exit is given up, not followed
|
|
497
|
+
// Reconnections in a row that closed cleanly with nothing: a stream that
|
|
498
|
+
// keeps closing without output or an exit is given up, not followed
|
|
499
|
+
// forever.
|
|
809
500
|
let idle = 0;
|
|
501
|
+
// Since when every reconnection has failed (a cut, a busy API, a host
|
|
502
|
+
// restarting). The command runs on regardless, so these are waited out
|
|
503
|
+
// for FOLLOW_OUTAGE_MS, with backoff, before the follow gives up.
|
|
504
|
+
let failingSince;
|
|
505
|
+
let failures = 0;
|
|
810
506
|
for (;;) {
|
|
811
507
|
const before = read.cursor;
|
|
812
508
|
let resumed = false;
|
|
509
|
+
let failure;
|
|
813
510
|
try {
|
|
814
511
|
for await (const event of this.t.events({
|
|
815
512
|
method: "GET",
|
|
@@ -831,8 +528,28 @@ export class Processes {
|
|
|
831
528
|
catch (error) {
|
|
832
529
|
if (options.signal?.aborted || !cutOff(error))
|
|
833
530
|
throw error;
|
|
531
|
+
failure = error;
|
|
532
|
+
}
|
|
533
|
+
if (resumed || read.cursor > before) {
|
|
534
|
+
idle = failures = 0;
|
|
535
|
+
failingSince = undefined;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (failure === undefined)
|
|
539
|
+
idle++;
|
|
540
|
+
else {
|
|
541
|
+
failingSince ??= performance.now();
|
|
542
|
+
if (performance.now() - failingSince >= FOLLOW_OUTAGE_MS)
|
|
543
|
+
throw new ConnectionError({
|
|
544
|
+
message: `Lost the output stream of process ${processId} for ${FOLLOW_OUTAGE_MS / 60_000} minutes; the command may still be running.`,
|
|
545
|
+
code: "connection_error",
|
|
546
|
+
status: 0,
|
|
547
|
+
hint: `Follow it again: runtime sandbox logs ${this.sandboxId} ${processId} -f`,
|
|
548
|
+
details: { sandboxId: this.sandboxId, processId },
|
|
549
|
+
cause: failure,
|
|
550
|
+
});
|
|
551
|
+
await pause(Math.min(5000, 50 * 2 ** failures++), options.signal);
|
|
834
552
|
}
|
|
835
|
-
idle = resumed || read.cursor > before ? 0 : idle + 1;
|
|
836
553
|
if (idle > 3)
|
|
837
554
|
throw new ConnectionError({
|
|
838
555
|
message: `The output stream of process ${processId} keeps closing before it ends.`,
|
|
@@ -1685,11 +1402,7 @@ export class Files {
|
|
|
1685
1402
|
}
|
|
1686
1403
|
}
|
|
1687
1404
|
}
|
|
1405
|
+
/** One page of `runtime.sandboxes.list()`, and the pages after it. */
|
|
1688
1406
|
export function sandboxPage(t, body, query, options = {}) {
|
|
1689
|
-
return
|
|
1690
|
-
method: "GET",
|
|
1691
|
-
path: "/v1/sandboxes",
|
|
1692
|
-
query: Object.assign({}, query, { cursor }),
|
|
1693
|
-
...options,
|
|
1694
|
-
}), query, options));
|
|
1407
|
+
return machinePage(SANDBOXES, t, (transport, info) => new Sandbox(transport, info), body, query, options);
|
|
1695
1408
|
}
|
package/dist/tar.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** One entry's header: the ustar block, after the long-name and long-link
|
|
2
|
+
* entries it needs. A whole number of 512-byte blocks. */
|
|
1
3
|
export declare function tarHeader(name: string, size: number, mode: number, type: "0" | "5" | "2", link?: string, mtime?: number): Uint8Array;
|
|
2
4
|
export declare function packDirectory(root: string): Promise<Uint8Array>;
|
|
3
5
|
/** Unpacks a gzipped tar that arrives as a stream into `target`, refusing any
|
package/dist/tar.js
CHANGED
|
@@ -1,25 +1,35 @@
|
|
|
1
|
-
import { chmod, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink, realpath, rename, rm, symlink, unlink, } from "node:fs/promises";
|
|
1
|
+
import { chmod, copyFile, link as hardLink, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink, realpath, rename, rm, symlink, unlink, } from "node:fs/promises";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { Readable } from "node:stream";
|
|
4
4
|
import { createGunzip, gzipSync } from "node:zlib";
|
|
5
5
|
import { RuntimeError } from "./errors.js";
|
|
6
|
-
/* A small
|
|
7
|
-
SDK needs no dependency. Regular files, directories
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
prefix
|
|
19
|
-
|
|
6
|
+
/* A small tar writer and reader for directory uploads and downloads, so the
|
|
7
|
+
SDK needs no dependency. Regular files, directories, symbolic links and,
|
|
8
|
+
reading, hard links. A name that does not fit the ustar name and prefix
|
|
9
|
+
fields, or a link target longer than 100 bytes, travels in a GNU long-name
|
|
10
|
+
or long-link entry, which GNU tar, BusyBox tar and every SDK's reader
|
|
11
|
+
take; nothing is cut short. */
|
|
12
|
+
const encode = (value) => new TextEncoder().encode(value);
|
|
13
|
+
/** The ustar prefix and name of `name`, split at a slash so that each fits
|
|
14
|
+
* its field in bytes, or null when no split fits. */
|
|
15
|
+
function ustarName(name) {
|
|
16
|
+
const bytes = encode(name);
|
|
17
|
+
if (bytes.length <= 100)
|
|
18
|
+
return { prefix: new Uint8Array(), short: bytes };
|
|
19
|
+
for (let split = name.lastIndexOf("/"); split > 0; split = name.lastIndexOf("/", split - 1)) {
|
|
20
|
+
const prefix = encode(name.slice(0, split));
|
|
21
|
+
const short = encode(name.slice(split + 1));
|
|
22
|
+
if (short.length > 100)
|
|
23
|
+
return null;
|
|
24
|
+
if (prefix.length <= 155)
|
|
25
|
+
return { prefix, short };
|
|
20
26
|
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function headerBlock(short, prefix, size, mode, type, link, mtime) {
|
|
30
|
+
const block = new Uint8Array(512);
|
|
21
31
|
const put = (offset, value, length) => {
|
|
22
|
-
const data = typeof value === "string" ?
|
|
32
|
+
const data = typeof value === "string" ? encode(value) : value;
|
|
23
33
|
block.set(data.subarray(0, length), offset);
|
|
24
34
|
};
|
|
25
35
|
const octal = (value, length) => value.toString(8).padStart(length - 1, "0") + "\0";
|
|
@@ -40,6 +50,29 @@ export function tarHeader(name, size, mode, type, link = "", mtime = 0) {
|
|
|
40
50
|
put(148, sum.toString(8).padStart(6, "0") + "\0 ", 8);
|
|
41
51
|
return block;
|
|
42
52
|
}
|
|
53
|
+
/** A GNU long-name ("L") or long-link ("K") entry carrying `value`. */
|
|
54
|
+
function longEntry(type, value) {
|
|
55
|
+
const size = value.length + 1;
|
|
56
|
+
const out = new Uint8Array(512 + Math.ceil(size / 512) * 512);
|
|
57
|
+
out.set(headerBlock(encode("././@LongLink"), new Uint8Array(), size, 0o644, type, new Uint8Array(), 0));
|
|
58
|
+
out.set(value, 512);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** One entry's header: the ustar block, after the long-name and long-link
|
|
62
|
+
* entries it needs. A whole number of 512-byte blocks. */
|
|
63
|
+
export function tarHeader(name, size, mode, type, link = "", mtime = 0) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
let fitted = ustarName(name);
|
|
66
|
+
if (!fitted) {
|
|
67
|
+
parts.push(longEntry("L", encode(name)));
|
|
68
|
+
fitted = { prefix: new Uint8Array(), short: encode(name).subarray(0, 100) };
|
|
69
|
+
}
|
|
70
|
+
const target = encode(link);
|
|
71
|
+
if (target.length > 100)
|
|
72
|
+
parts.push(longEntry("K", target));
|
|
73
|
+
parts.push(headerBlock(fitted.short, fitted.prefix, size, mode, type, target, mtime));
|
|
74
|
+
return parts.length === 1 ? parts[0] : new Uint8Array(Buffer.concat(parts));
|
|
75
|
+
}
|
|
43
76
|
export async function packDirectory(root) {
|
|
44
77
|
const parts = [];
|
|
45
78
|
const base = resolve(root);
|
|
@@ -352,8 +385,15 @@ async function unpackEntries(bytes, root, signal) {
|
|
|
352
385
|
links.push({ destination, link, name });
|
|
353
386
|
await skip();
|
|
354
387
|
}
|
|
388
|
+
else if (type === "1") {
|
|
389
|
+
await hardLinkEntry(root, destination, link, name);
|
|
390
|
+
await skip();
|
|
391
|
+
}
|
|
355
392
|
else if (type === "0" || type === "\0" || type === "7") {
|
|
356
393
|
await mkdir(dirname(destination), { recursive: true });
|
|
394
|
+
// A name already unpacked may share its file with a hard link; writing
|
|
395
|
+
// through it would change the link's copy too.
|
|
396
|
+
await unlink(destination).catch(() => undefined);
|
|
357
397
|
const file = await open(destination, "w");
|
|
358
398
|
try {
|
|
359
399
|
for await (const part of bytes.take(size)) {
|
|
@@ -378,6 +418,24 @@ async function unpackEntries(bytes, root, signal) {
|
|
|
378
418
|
await skip();
|
|
379
419
|
}
|
|
380
420
|
}
|
|
421
|
+
/** A second name for a file the archive already carried: tar writes the
|
|
422
|
+
* first name as a file and every other as a hard link to it. The link names
|
|
423
|
+
* a plain file unpacked earlier in this archive, or the unpack fails; it
|
|
424
|
+
* never reaches outside, through a link or ahead. */
|
|
425
|
+
async function hardLinkEntry(root, destination, link, name) {
|
|
426
|
+
const named = link.replace(/^(\.\/)+/, "");
|
|
427
|
+
const source = resolve(root, named);
|
|
428
|
+
if (!named || isAbsolute(named) || source === root || !source.startsWith(root + sep))
|
|
429
|
+
throw new Error(`Refusing an archive hard link that leads outside the target: ${name} -> ${link}`);
|
|
430
|
+
await assertPlain(root, source, named);
|
|
431
|
+
if (!(await lstat(source).catch(() => undefined))?.isFile())
|
|
432
|
+
throw new Error(`Refusing an archive hard link to a file it does not carry: ${name} -> ${link}`);
|
|
433
|
+
if (source === destination)
|
|
434
|
+
return;
|
|
435
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
436
|
+
await unlink(destination).catch(() => undefined);
|
|
437
|
+
await hardLink(source, destination).catch(() => copyFile(source, destination));
|
|
438
|
+
}
|
|
381
439
|
/** Moves what was unpacked in `from` into `to`, merging with what is there
|
|
382
440
|
* and replacing files of the same name, never through a link in `to`. */
|
|
383
441
|
async function merge(from, to, root, signal) {
|