withruntime 0.9.0 → 0.10.0
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 +4 -4
- package/DAYTONA.md +13 -7
- package/E2B.md +12 -8
- package/README.md +15 -7
- package/README.src.md +15 -7
- package/VERCEL.md +13 -3
- package/compatibility-lock.json +84 -8
- package/dist/blaxel/errors.js +1 -1
- package/dist/blaxel/lookup.d.ts +3 -2
- package/dist/blaxel/lookup.js +3 -2
- package/dist/blaxel/pagination.d.ts +2 -2
- package/dist/blaxel/pagination.js +45 -17
- package/dist/blaxel/preview.d.ts +2 -1
- package/dist/blaxel/preview.js +15 -13
- package/dist/blaxel/process.js +56 -10
- package/dist/blaxel/sandbox.d.ts +2 -0
- package/dist/blaxel/sandbox.js +2 -0
- package/dist/blaxel/snapshot.js +3 -5
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +189 -54
- package/dist/client.d.ts +29 -3
- package/dist/client.js +40 -11
- package/dist/cloudflare/identity.d.ts +7 -0
- package/dist/cloudflare/identity.js +13 -0
- package/dist/cloudflare/index.d.ts +9 -0
- package/dist/cloudflare/index.js +47 -9
- package/dist/cloudflare/worker.d.ts +4 -0
- package/dist/cloudflare/worker.js +166 -16
- package/dist/codesandbox/guest-service.js +57 -20
- package/dist/codesandbox/index.js +42 -8
- package/dist/compat/core.d.ts +9 -8
- package/dist/compat/core.js +60 -21
- package/dist/compat/files.js +22 -4
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +107 -38
- package/dist/daytona/daytona.js +11 -4
- package/dist/daytona/filesystem.d.ts +17 -4
- package/dist/daytona/filesystem.js +121 -38
- package/dist/daytona/index.d.ts +1 -1
- package/dist/daytona/sandbox.d.ts +1 -1
- package/dist/daytona/sandbox.js +16 -7
- package/dist/e2b/client.js +1 -1
- package/dist/e2b/commands.d.ts +30 -8
- package/dist/e2b/commands.js +142 -54
- package/dist/e2b/index.d.ts +1 -0
- package/dist/e2b/index.js +1 -0
- package/dist/e2b/pty.d.ts +24 -0
- package/dist/e2b/pty.js +80 -0
- package/dist/e2b/sandbox.d.ts +2 -1
- package/dist/e2b/sandbox.js +49 -15
- package/dist/file-source.d.ts +16 -0
- package/dist/file-source.js +162 -0
- package/dist/freestyle/index.d.ts +9 -1
- package/dist/freestyle/index.js +38 -5
- package/dist/index.d.ts +2 -1
- package/dist/login.js +16 -10
- package/dist/modal/index.js +2 -1
- package/dist/network-cli.js +111 -43
- package/dist/openai-agents/index.js +9 -45
- package/dist/page.d.ts +1 -1
- package/dist/page.js +2 -2
- package/dist/products/index.d.ts +12 -0
- package/dist/products/index.js +10 -0
- package/dist/products/observability.d.ts +44 -0
- package/dist/products/observability.js +6 -0
- package/dist/products/previews.d.ts +8 -2
- package/dist/products/previews.js +11 -3
- package/dist/products/volumes.d.ts +42 -0
- package/dist/products/volumes.js +36 -0
- package/dist/products/watch.js +61 -8
- package/dist/proxy.js +3 -1
- package/dist/runloop/index.d.ts +13 -27
- package/dist/runloop/index.js +149 -52
- package/dist/sandbox.d.ts +37 -8
- package/dist/sandbox.js +432 -68
- package/dist/snapshots.d.ts +9 -4
- package/dist/sprites/index.js +69 -33
- package/dist/tar.d.ts +3 -1
- package/dist/tar.js +140 -14
- package/dist/transport.d.ts +4 -7
- package/dist/transport.js +205 -122
- package/dist/vercel/command.d.ts +1 -1
- package/dist/vercel/command.js +3 -2
- package/dist/vercel/errors.d.ts +1 -1
- package/dist/vercel/errors.js +7 -1
- package/dist/vercel/sandbox.d.ts +8 -8
- package/dist/vercel/sandbox.js +154 -59
- package/dist/vercel/snapshot.d.ts +3 -2
- package/dist/vercel/snapshot.js +9 -5
- package/package.json +2 -1
package/dist/client.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { WAIT_FOR_TIMEOUT_SECONDS } from "./api-defaults.js";
|
|
2
2
|
import { RuntimeError } from "./errors.js";
|
|
3
|
-
import { clientFactories } from "./products/index.js";
|
|
3
|
+
import { clientFactories, clientProductAliases } from "./products/index.js";
|
|
4
|
+
import { usageExport as makeUsageExport } from "./products/observability.js";
|
|
4
5
|
import { Sandbox, deleteSandbox, sandboxPage } from "./sandbox.js";
|
|
5
6
|
import { Snapshots } from "./snapshots.js";
|
|
6
7
|
import { Transport, missingKey } from "./transport.js";
|
|
7
|
-
/** The key `runtime login` saved for this machine
|
|
8
|
-
|
|
8
|
+
/** The key `runtime login` saved for this machine and this API origin, found
|
|
9
|
+
* on first use. A constructor override must never receive another API's key. */
|
|
10
|
+
const savedKey = async (apiOrigin) => {
|
|
9
11
|
if (typeof process === "undefined" || !process.versions?.node)
|
|
10
12
|
throw missingKey();
|
|
11
13
|
const { resolveCredential } = await import("./credentials.js");
|
|
12
|
-
return resolveCredential(process.env);
|
|
14
|
+
return resolveCredential({ ...process.env, RUNTIME_API_URL: apiOrigin });
|
|
13
15
|
};
|
|
14
16
|
const env = (name) => typeof process === "undefined"
|
|
15
17
|
? undefined
|
|
@@ -17,32 +19,42 @@ const env = (name) => typeof process === "undefined"
|
|
|
17
19
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
18
20
|
export class Runtime {
|
|
19
21
|
transport;
|
|
22
|
+
/** The sandbox product; `sandboxes` remains the same client for compatibility. */
|
|
23
|
+
sandbox;
|
|
20
24
|
sandboxes;
|
|
25
|
+
snapshot;
|
|
21
26
|
snapshots;
|
|
22
27
|
feedback;
|
|
23
28
|
support;
|
|
24
29
|
account;
|
|
30
|
+
/** Date-range settlements, paged with exact charges and the server's CSV. */
|
|
31
|
+
usageExport;
|
|
25
32
|
constructor(options = {}) {
|
|
26
33
|
this.transport = new Transport({
|
|
27
34
|
// A key given, then RUNTIME_API_KEY, then (in Node and Bun) the key
|
|
28
35
|
// `runtime login` saved for this machine, read on the first call.
|
|
29
|
-
apiKey: options.apiKey ?? env("RUNTIME_API_KEY") ?? savedKey,
|
|
36
|
+
apiKey: options.apiKey ?? env("RUNTIME_API_KEY") ?? (() => savedKey(this.transport.baseUrl)),
|
|
30
37
|
baseUrl: options.baseUrl ?? env("RUNTIME_API_URL"),
|
|
31
38
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
32
|
-
...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}),
|
|
39
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
33
40
|
...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
|
|
34
|
-
...(options.maxConnections ? { maxConnections: options.maxConnections } : {}),
|
|
41
|
+
...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),
|
|
35
42
|
...(options.waitForCapacityMs !== undefined
|
|
36
43
|
? { waitForCapacityMs: options.waitForCapacityMs }
|
|
37
44
|
: {}),
|
|
38
45
|
});
|
|
39
46
|
this.sandboxes = new Sandboxes(this.transport);
|
|
47
|
+
this.sandbox = this.sandboxes;
|
|
40
48
|
this.snapshots = new Snapshots(this.transport);
|
|
49
|
+
this.snapshot = this.snapshots;
|
|
41
50
|
this.feedback = new FeedbackApi(this.transport);
|
|
42
51
|
this.support = new SupportApi(this.transport);
|
|
43
52
|
this.account = new AccountApi(this.transport);
|
|
53
|
+
this.usageExport = makeUsageExport(this.transport);
|
|
44
54
|
for (const [name, make] of clientFactories())
|
|
45
55
|
Object.defineProperty(this, name, { value: make(this.transport), enumerable: true });
|
|
56
|
+
for (const [alias, name] of Object.entries(clientProductAliases))
|
|
57
|
+
Object.defineProperty(this, alias, { value: this[name], enumerable: true });
|
|
46
58
|
}
|
|
47
59
|
/** Who this key is: organization, agent and credential. */
|
|
48
60
|
me(options = {}) {
|
|
@@ -61,6 +73,17 @@ export class Runtime {
|
|
|
61
73
|
...options,
|
|
62
74
|
});
|
|
63
75
|
}
|
|
76
|
+
/** This account's authenticated API answers, in hourly windows. Counts are
|
|
77
|
+
* exact decimal strings, and an empty window has a null error percentage.
|
|
78
|
+
* A key needs usage permission. */
|
|
79
|
+
usageRequests(range = "24h", options = {}) {
|
|
80
|
+
return this.transport.json({
|
|
81
|
+
method: "GET",
|
|
82
|
+
path: "/v1/usage/requests",
|
|
83
|
+
query: { range },
|
|
84
|
+
...options,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
64
87
|
}
|
|
65
88
|
export class Sandboxes {
|
|
66
89
|
t;
|
|
@@ -84,7 +107,12 @@ export class Sandboxes {
|
|
|
84
107
|
});
|
|
85
108
|
const sandbox = new Sandbox(this.t, info);
|
|
86
109
|
if (wait !== false && info.state !== "running") {
|
|
87
|
-
await sandbox.waitFor("running", {
|
|
110
|
+
await sandbox.waitFor("running", {
|
|
111
|
+
timeoutSeconds: WAIT_FOR_TIMEOUT_SECONDS,
|
|
112
|
+
signal: options.signal,
|
|
113
|
+
timeoutMs: options.timeoutMs,
|
|
114
|
+
idempotencyKey: options.idempotencyKey,
|
|
115
|
+
});
|
|
88
116
|
if (sandbox.state !== "running")
|
|
89
117
|
throw new RuntimeError({
|
|
90
118
|
message: `Sandbox ${info.id} is ${sandbox.state}, not running.`,
|
|
@@ -114,8 +142,9 @@ export class Sandboxes {
|
|
|
114
142
|
delete(id, options = {}) {
|
|
115
143
|
return deleteSandbox(this.t, id, options);
|
|
116
144
|
}
|
|
117
|
-
/** Live sandboxes, oldest first. Await for a page, or `for await` over all.
|
|
118
|
-
|
|
145
|
+
/** Live sandboxes, oldest first. Await for a page, or `for await` over all.
|
|
146
|
+
* Request options apply to the initial request and every subsequent page. */
|
|
147
|
+
async list(filter = {}, options = {}) {
|
|
119
148
|
const query = {
|
|
120
149
|
...(filter.state ? { state: filter.state } : {}),
|
|
121
150
|
...(filter.includeStopped ? { includeStopped: true } : {}),
|
|
@@ -125,7 +154,7 @@ export class Sandboxes {
|
|
|
125
154
|
...(filter.name ? { name: filter.name } : {}),
|
|
126
155
|
...(filter.limit ? { limit: filter.limit } : {}),
|
|
127
156
|
};
|
|
128
|
-
return sandboxPage(this.t, await this.t.json({ method: "GET", path: "/v1/sandboxes", query }), query);
|
|
157
|
+
return sandboxPage(this.t, await this.t.json({ method: "GET", path: "/v1/sandboxes", query, ...options }), query, options);
|
|
129
158
|
}
|
|
130
159
|
}
|
|
131
160
|
export class FeedbackApi {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A Durable Object ID includes its namespace; a display name does not. */
|
|
2
|
+
export function workerSandboxIdentity(objectId, stored) {
|
|
3
|
+
if (stored.nativeSandboxId !== undefined) {
|
|
4
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(stored.nativeSandboxId))
|
|
5
|
+
throw new Error("The stored Runtime sandbox identity is invalid");
|
|
6
|
+
return stored.nativeSandboxId;
|
|
7
|
+
}
|
|
8
|
+
if (stored.sandboxName)
|
|
9
|
+
throw new Error("This legacy Durable Object has no namespace-safe Runtime identity. Retain its existing sandbox and migrate its identity explicitly before using it.");
|
|
10
|
+
if (!/^[a-f0-9]{64}$/.test(objectId))
|
|
11
|
+
throw new Error("The Durable Object identity is invalid");
|
|
12
|
+
return `cf-do-${objectId}`;
|
|
13
|
+
}
|
|
@@ -70,6 +70,7 @@ export interface ProcessOptions {
|
|
|
70
70
|
onStart?: (process: Process) => void;
|
|
71
71
|
onError?: (error: Error) => void;
|
|
72
72
|
}
|
|
73
|
+
export declare function validateSandboxConfiguration(options: Pick<SandboxOptions, "sleepAfter" | "keepAlive">): void;
|
|
73
74
|
/** Worker bindings are deliberately not inspected or invoked. Supply Runtime credentials
|
|
74
75
|
* through options.runtime in Workers; Node also supports RUNTIME_API_KEY/login. */
|
|
75
76
|
export declare function getSandbox(namespace: unknown, id: string, options?: SandboxOptions): Sandbox;
|
|
@@ -77,9 +78,17 @@ export declare class Sandbox {
|
|
|
77
78
|
readonly runtime: Runtime;
|
|
78
79
|
private readonly options;
|
|
79
80
|
private pending?;
|
|
81
|
+
private existing?;
|
|
80
82
|
readonly id: string;
|
|
81
83
|
constructor(runtime: Runtime, id: string, options?: SandboxOptions);
|
|
82
84
|
configure(options: Pick<SandboxOptions, "sleepAfter" | "keepAlive">): Promise<void>;
|
|
85
|
+
/** Only a successfully initialized native handle can supply a recovery id. */
|
|
86
|
+
existingNativeId(): string | undefined;
|
|
87
|
+
/** Reconcile an uncertain update from an authoritative read, without allocation. */
|
|
88
|
+
reconcileConfiguration(nativeId?: string | undefined): Promise<{
|
|
89
|
+
sleepAfter: number;
|
|
90
|
+
keepAlive: boolean;
|
|
91
|
+
}>;
|
|
83
92
|
native(): Promise<NativeSandbox>;
|
|
84
93
|
private session;
|
|
85
94
|
exec(command: string, options?: ExecOptions): Promise<ExecResult>;
|
package/dist/cloudflare/index.js
CHANGED
|
@@ -22,12 +22,24 @@ export function sanitizeSandboxId(id) {
|
|
|
22
22
|
}
|
|
23
23
|
const cleanEnv = (env = {}) => Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== undefined));
|
|
24
24
|
function sleepSeconds(value = 600) {
|
|
25
|
+
let seconds;
|
|
25
26
|
if (typeof value === "number")
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
seconds = value;
|
|
28
|
+
else {
|
|
29
|
+
const match = /^(\d+)(s|m|h)$/.exec(value);
|
|
30
|
+
if (!match)
|
|
31
|
+
throw new TypeError("sleepAfter must be seconds or a duration such as 5m");
|
|
32
|
+
seconds = Number(match[1]) * ({ s: 1, m: 60, h: 3600 }[match[2]] ?? 1);
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0 || seconds > 86_400)
|
|
35
|
+
throw new CompatibilityError("Cloudflare", "sleepAfter outside whole seconds from 0 to 86400");
|
|
36
|
+
return seconds;
|
|
37
|
+
}
|
|
38
|
+
export function validateSandboxConfiguration(options) {
|
|
39
|
+
if (options.sleepAfter !== undefined)
|
|
40
|
+
sleepSeconds(options.sleepAfter);
|
|
41
|
+
if (options.keepAlive !== undefined && typeof options.keepAlive !== "boolean")
|
|
42
|
+
throw new TypeError("keepAlive must be a boolean");
|
|
31
43
|
}
|
|
32
44
|
/** Worker bindings are deliberately not inspected or invoked. Supply Runtime credentials
|
|
33
45
|
* through options.runtime in Workers; Node also supports RUNTIME_API_KEY/login. */
|
|
@@ -41,25 +53,50 @@ export class Sandbox {
|
|
|
41
53
|
runtime;
|
|
42
54
|
options;
|
|
43
55
|
pending;
|
|
56
|
+
existing;
|
|
44
57
|
id;
|
|
45
58
|
constructor(runtime, id, options = {}) {
|
|
46
59
|
this.runtime = runtime;
|
|
47
60
|
this.options = options;
|
|
61
|
+
validateSandboxConfiguration(options);
|
|
48
62
|
this.id = id;
|
|
49
63
|
}
|
|
50
64
|
async configure(options) {
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
only("Cloudflare configure", options, ["sleepAfter", "keepAlive"]);
|
|
66
|
+
validateSandboxConfiguration(options);
|
|
53
67
|
const changed = (options.sleepAfter !== undefined && options.sleepAfter !== this.options.sleepAfter) ||
|
|
54
68
|
(options.keepAlive !== undefined && options.keepAlive !== this.options.keepAlive);
|
|
55
|
-
|
|
69
|
+
const next = { ...this.options, ...options };
|
|
56
70
|
if (changed && this.pending) {
|
|
57
71
|
const s = await this.pending;
|
|
58
72
|
await s.update({
|
|
59
|
-
idlePauseSeconds:
|
|
73
|
+
idlePauseSeconds: next.keepAlive ? 0 : sleepSeconds(next.sleepAfter),
|
|
60
74
|
...(options.keepAlive !== undefined ? { persistent: options.keepAlive } : {}),
|
|
61
75
|
});
|
|
62
76
|
}
|
|
77
|
+
Object.assign(this.options, options);
|
|
78
|
+
}
|
|
79
|
+
/** Only a successfully initialized native handle can supply a recovery id. */
|
|
80
|
+
existingNativeId() {
|
|
81
|
+
return this.existing?.id;
|
|
82
|
+
}
|
|
83
|
+
/** Reconcile an uncertain update from an authoritative read, without allocation. */
|
|
84
|
+
async reconcileConfiguration(nativeId = this.existingNativeId()) {
|
|
85
|
+
if (!nativeId)
|
|
86
|
+
throw new Error("The uncertain Runtime configuration has no existing sandbox identity.");
|
|
87
|
+
const sandbox = await this.runtime.sandboxes.get(nativeId);
|
|
88
|
+
const { idlePauseSeconds, persistent } = sandbox.info;
|
|
89
|
+
if (typeof idlePauseSeconds !== "number" ||
|
|
90
|
+
!Number.isSafeInteger(idlePauseSeconds) ||
|
|
91
|
+
idlePauseSeconds < 0 ||
|
|
92
|
+
idlePauseSeconds > 86_400 ||
|
|
93
|
+
typeof persistent !== "boolean")
|
|
94
|
+
throw new Error("Runtime did not return authoritative idle and persistence settings.");
|
|
95
|
+
const configuration = { sleepAfter: idlePauseSeconds, keepAlive: persistent };
|
|
96
|
+
Object.assign(this.options, configuration);
|
|
97
|
+
this.existing = sandbox;
|
|
98
|
+
this.pending = Promise.resolve(sandbox);
|
|
99
|
+
return configuration;
|
|
63
100
|
}
|
|
64
101
|
async native() {
|
|
65
102
|
if (!this.pending) {
|
|
@@ -82,6 +119,7 @@ export class Sandbox {
|
|
|
82
119
|
throw e;
|
|
83
120
|
}
|
|
84
121
|
}
|
|
122
|
+
this.existing = s;
|
|
85
123
|
return s;
|
|
86
124
|
})();
|
|
87
125
|
this.pending.catch(() => {
|
|
@@ -14,12 +14,16 @@ type Configuration = {
|
|
|
14
14
|
name: string;
|
|
15
15
|
normalizeId?: boolean;
|
|
16
16
|
};
|
|
17
|
+
nativeSandboxId?: string;
|
|
17
18
|
sleepAfter?: string | number;
|
|
18
19
|
keepAlive?: boolean;
|
|
19
20
|
};
|
|
20
21
|
export declare class Sandbox<Env extends RuntimeWorkerEnv = RuntimeWorkerEnv> extends DurableObject<Env> {
|
|
21
22
|
private configuration;
|
|
23
|
+
private configurationIntent?;
|
|
22
24
|
private facade?;
|
|
25
|
+
private configurationTail;
|
|
26
|
+
private configurationOperation;
|
|
23
27
|
private previewTail;
|
|
24
28
|
private previewOperation;
|
|
25
29
|
private readonly initialized;
|
|
@@ -4,8 +4,47 @@
|
|
|
4
4
|
import { DurableObject } from "cloudflare:workers";
|
|
5
5
|
import { only, CompatibilityError } from "../compat/core.js";
|
|
6
6
|
import { Runtime } from "../client.js";
|
|
7
|
-
import {
|
|
7
|
+
import { workerSandboxIdentity } from "./identity.js";
|
|
8
|
+
import { Sandbox as RuntimeSandbox, sanitizeSandboxId, validateSandboxConfiguration, } from "./index.js";
|
|
8
9
|
export * from "./index.js";
|
|
10
|
+
const CONFIGURATION_KEY = "runtime.configuration";
|
|
11
|
+
const CONFIGURATION_INTENT_KEY = "runtime.configuration.pending";
|
|
12
|
+
/** Preserve the original failure even when durable recovery also fails. */
|
|
13
|
+
function configurationFailure(error, uncertain, cleanup) {
|
|
14
|
+
if (error !== null && (typeof error === "object" || typeof error === "function")) {
|
|
15
|
+
try {
|
|
16
|
+
if (uncertain)
|
|
17
|
+
Object.defineProperty(error, "configurationUncertain", { value: true, configurable: true });
|
|
18
|
+
if (cleanup !== undefined)
|
|
19
|
+
Object.defineProperty(error, "cause" in error && error.cause !== undefined ? "cleanupError" : "cause", {
|
|
20
|
+
value: cleanup,
|
|
21
|
+
configurable: true,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// A frozen exception still keeps its original identity and type.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
/** Queue waits share the caller's existing signal and never reset its deadline. */
|
|
31
|
+
async function waitForConfiguration(pending, signal) {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
if (!signal)
|
|
34
|
+
return pending;
|
|
35
|
+
let cancel;
|
|
36
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- rejects with the caller's abort reason as is, as the upstream SDK does
|
|
38
|
+
cancel = () => reject(signal.reason);
|
|
39
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
return await Promise.race([pending, aborted]);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
signal.removeEventListener("abort", cancel);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
9
48
|
/** The official Worker SDK returns plain records with callable RPC properties,
|
|
10
49
|
* so ids/status are values rather than remote-property promises. */
|
|
11
50
|
function sessionTarget(session) {
|
|
@@ -48,7 +87,14 @@ function processTarget(process) {
|
|
|
48
87
|
}
|
|
49
88
|
export class Sandbox extends DurableObject {
|
|
50
89
|
configuration = {};
|
|
90
|
+
configurationIntent;
|
|
51
91
|
facade;
|
|
92
|
+
configurationTail = Promise.resolve();
|
|
93
|
+
configurationOperation(operation) {
|
|
94
|
+
const result = this.configurationTail.catch(() => undefined).then(operation);
|
|
95
|
+
this.configurationTail = result;
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
52
98
|
previewTail = Promise.resolve();
|
|
53
99
|
previewOperation(operation) {
|
|
54
100
|
const result = this.previewTail.catch(() => undefined).then(operation);
|
|
@@ -59,29 +105,133 @@ export class Sandbox extends DurableObject {
|
|
|
59
105
|
constructor(ctx, env) {
|
|
60
106
|
super(ctx, env);
|
|
61
107
|
this.initialized = ctx.blockConcurrencyWhile(async () => {
|
|
62
|
-
this.configuration = (await ctx.storage.get(
|
|
108
|
+
this.configuration = (await ctx.storage.get(CONFIGURATION_KEY)) ?? {};
|
|
109
|
+
this.configurationIntent =
|
|
110
|
+
await ctx.storage.get(CONFIGURATION_INTENT_KEY);
|
|
63
111
|
});
|
|
64
112
|
}
|
|
65
|
-
async sandbox() {
|
|
66
|
-
await this.initialized;
|
|
113
|
+
async sandbox(signal) {
|
|
114
|
+
await waitForConfiguration(this.initialized, signal);
|
|
115
|
+
// A staged intent is transient while an update is still running. Wait for
|
|
116
|
+
// the queued update before deciding whether uncertainty was retained.
|
|
117
|
+
let configurationTail;
|
|
118
|
+
do {
|
|
119
|
+
configurationTail = this.configurationTail;
|
|
120
|
+
await waitForConfiguration(configurationTail.catch(() => undefined), signal);
|
|
121
|
+
} while (configurationTail !== this.configurationTail);
|
|
122
|
+
signal?.throwIfAborted();
|
|
123
|
+
if (this.configurationIntent)
|
|
124
|
+
throw configurationFailure(new Error("Runtime's configuration is uncertain. Call configure() to reconcile it before use."), true);
|
|
67
125
|
if (!this.configuration.sandboxName)
|
|
68
126
|
throw new Error("Call getSandbox(namespace, id) before using the Durable Object");
|
|
69
127
|
if (!this.env.RUNTIME_API_KEY?.startsWith("rtcloud_"))
|
|
70
128
|
throw new Error("Set the RUNTIME_API_KEY Worker secret to a Runtime API key");
|
|
71
|
-
this.facade ??= new RuntimeSandbox(new Runtime({ apiKey: this.env.RUNTIME_API_KEY, baseUrl: this.env.RUNTIME_API_URL }), this.
|
|
129
|
+
this.facade ??= new RuntimeSandbox(new Runtime({ apiKey: this.env.RUNTIME_API_KEY, baseUrl: this.env.RUNTIME_API_URL }), workerSandboxIdentity(this.ctx.id.toString(), this.configuration), { ...this.configuration });
|
|
72
130
|
return this.facade;
|
|
73
131
|
}
|
|
74
132
|
async configure(configuration) {
|
|
75
133
|
only("Cloudflare Worker configure", configuration, ["sandboxName", "sleepAfter", "keepAlive"]);
|
|
76
|
-
|
|
77
|
-
if (
|
|
78
|
-
configuration.sandboxName
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
134
|
+
validateSandboxConfiguration(configuration);
|
|
135
|
+
if (configuration.sandboxName !== undefined) {
|
|
136
|
+
only("Cloudflare Worker sandboxName", configuration.sandboxName, ["name", "normalizeId"]);
|
|
137
|
+
sanitizeSandboxId(configuration.sandboxName.name);
|
|
138
|
+
if (configuration.sandboxName.normalizeId !== undefined &&
|
|
139
|
+
typeof configuration.sandboxName.normalizeId !== "boolean")
|
|
140
|
+
throw new TypeError("normalizeId must be a boolean");
|
|
141
|
+
}
|
|
142
|
+
const requested = {
|
|
143
|
+
...(configuration.sandboxName === undefined
|
|
144
|
+
? {}
|
|
145
|
+
: { sandboxName: { ...configuration.sandboxName } }),
|
|
146
|
+
...(configuration.sleepAfter === undefined ? {} : { sleepAfter: configuration.sleepAfter }),
|
|
147
|
+
...(configuration.keepAlive === undefined ? {} : { keepAlive: configuration.keepAlive }),
|
|
148
|
+
};
|
|
149
|
+
return this.configurationOperation(async () => {
|
|
150
|
+
await this.initialized;
|
|
151
|
+
if (this.configuration.sandboxName &&
|
|
152
|
+
requested.sandboxName &&
|
|
153
|
+
this.configuration.sandboxName.name !== requested.sandboxName.name)
|
|
154
|
+
throw new Error("A Durable Object cannot change sandbox identity");
|
|
155
|
+
if (this.configurationIntent) {
|
|
156
|
+
const facade = this.facade ??
|
|
157
|
+
new RuntimeSandbox(new Runtime({ apiKey: this.env.RUNTIME_API_KEY, baseUrl: this.env.RUNTIME_API_URL }), workerSandboxIdentity(this.ctx.id.toString(), this.configuration), { ...this.configuration });
|
|
158
|
+
try {
|
|
159
|
+
// Recovery uses the saved UUID even after a Durable Object restart.
|
|
160
|
+
const actual = await facade.reconcileConfiguration(this.configurationIntent.sandboxId);
|
|
161
|
+
const reconciled = { ...this.configuration, ...actual };
|
|
162
|
+
await this.ctx.storage.put(CONFIGURATION_KEY, reconciled);
|
|
163
|
+
await this.ctx.storage.delete(CONFIGURATION_INTENT_KEY);
|
|
164
|
+
this.configuration = reconciled;
|
|
165
|
+
this.facade = facade;
|
|
166
|
+
this.configurationIntent = undefined;
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
throw configurationFailure(error, true);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const previous = this.configuration;
|
|
173
|
+
const stored = await this.ctx.storage.get(CONFIGURATION_KEY);
|
|
174
|
+
const nativeSandboxId = workerSandboxIdentity(this.ctx.id.toString(), previous);
|
|
175
|
+
const next = { ...previous, ...requested, nativeSandboxId };
|
|
176
|
+
if (!this.facade) {
|
|
177
|
+
await this.ctx.storage.put(CONFIGURATION_KEY, next);
|
|
178
|
+
this.configuration = next;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.configurationIntent = { sandboxId: this.facade.existingNativeId(), previous, next };
|
|
182
|
+
try {
|
|
183
|
+
await this.ctx.storage.put(CONFIGURATION_INTENT_KEY, this.configurationIntent);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
let cleanup;
|
|
187
|
+
try {
|
|
188
|
+
// Native mutation has not started. A failed write may still have
|
|
189
|
+
// persisted the intent, so clear this operation's staging record.
|
|
190
|
+
await this.ctx.storage.delete(CONFIGURATION_INTENT_KEY);
|
|
191
|
+
this.configurationIntent = undefined;
|
|
192
|
+
}
|
|
193
|
+
catch (failure) {
|
|
194
|
+
cleanup = failure;
|
|
195
|
+
}
|
|
196
|
+
throw configurationFailure(error, this.configurationIntent !== undefined, cleanup);
|
|
197
|
+
}
|
|
198
|
+
let nativeAttempted = false;
|
|
199
|
+
try {
|
|
200
|
+
await this.ctx.storage.put(CONFIGURATION_KEY, next);
|
|
201
|
+
nativeAttempted = true;
|
|
202
|
+
await this.facade.configure({
|
|
203
|
+
...(requested.sleepAfter === undefined ? {} : { sleepAfter: requested.sleepAfter }),
|
|
204
|
+
...(requested.keepAlive === undefined ? {} : { keepAlive: requested.keepAlive }),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
let cleanup;
|
|
209
|
+
try {
|
|
210
|
+
if (stored === undefined)
|
|
211
|
+
await this.ctx.storage.delete(CONFIGURATION_KEY);
|
|
212
|
+
else
|
|
213
|
+
await this.ctx.storage.put(CONFIGURATION_KEY, stored);
|
|
214
|
+
if (!nativeAttempted) {
|
|
215
|
+
await this.ctx.storage.delete(CONFIGURATION_INTENT_KEY);
|
|
216
|
+
this.configurationIntent = undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (failure) {
|
|
220
|
+
cleanup = failure;
|
|
221
|
+
}
|
|
222
|
+
// A rejected native request may have applied remotely. Keep its intent
|
|
223
|
+
// until an authoritative read succeeds; rollback is only local state.
|
|
224
|
+
throw configurationFailure(error, this.configurationIntent !== undefined, cleanup);
|
|
225
|
+
}
|
|
226
|
+
this.configuration = next;
|
|
227
|
+
try {
|
|
228
|
+
await this.ctx.storage.delete(CONFIGURATION_INTENT_KEY);
|
|
229
|
+
this.configurationIntent = undefined;
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
throw configurationFailure(error, true);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
85
235
|
}
|
|
86
236
|
setSandboxName(name, normalizeId = false) {
|
|
87
237
|
return this.configure({
|
|
@@ -95,10 +245,10 @@ export class Sandbox extends DurableObject {
|
|
|
95
245
|
return this.configure({ keepAlive });
|
|
96
246
|
}
|
|
97
247
|
async exec(command, options) {
|
|
98
|
-
return (await this.sandbox()).exec(command, options);
|
|
248
|
+
return (await this.sandbox(options?.signal)).exec(command, options);
|
|
99
249
|
}
|
|
100
250
|
async execStream(command, options) {
|
|
101
|
-
return (await this.sandbox()).execStream(command, options);
|
|
251
|
+
return (await this.sandbox(options?.signal)).execStream(command, options);
|
|
102
252
|
}
|
|
103
253
|
async startProcess(command, options) {
|
|
104
254
|
return processTarget(await (await this.sandbox()).startProcess(command, options));
|
|
@@ -30,15 +30,35 @@ def settled(run):
|
|
|
30
30
|
# Keep terminal failure readable over the socket even when storage is full.
|
|
31
31
|
run['exitCode']=125
|
|
32
32
|
run['error']=(run.get('error','')+'; ' if run.get('error') else '')+'Cannot persist task result: '+str(e)
|
|
33
|
+
def exited(proc):
|
|
34
|
+
if proc.returncode is not None: return True
|
|
35
|
+
# Leave the leader waitable: its PID cannot be reused before group cleanup.
|
|
36
|
+
try: return os.waitid(os.P_PID,proc.pid,os.WEXITED|os.WNOHANG|os.WNOWAIT) is not None
|
|
37
|
+
except ChildProcessError: return proc.returncode is not None
|
|
38
|
+
def kill_group(proc,signum):
|
|
39
|
+
# Callers hold the run lock, so no other thread can reap this owned leader.
|
|
40
|
+
if proc.returncode is not None: return
|
|
41
|
+
try: os.killpg(proc.pid,signum)
|
|
42
|
+
except ProcessLookupError: pass
|
|
43
|
+
except PermissionError:
|
|
44
|
+
# Darwin reports EPERM for a group containing only zombies. Keep real
|
|
45
|
+
# permission failures observable; only accept a verified empty/live-free
|
|
46
|
+
# group while our unreaped leader still prevents group-ID reuse.
|
|
47
|
+
if sys.platform!='darwin' or not exited(proc): raise
|
|
48
|
+
states=subprocess.run(['ps','-o','stat=','-g',str(proc.pid)],capture_output=True,text=True,timeout=2)
|
|
49
|
+
if states.returncode not in (0,1) or any(not state.startswith('Z') for state in states.stdout.split()): raise
|
|
33
50
|
def complete(run):
|
|
34
51
|
fd=run['fd']
|
|
35
52
|
failed=False
|
|
36
53
|
try:
|
|
37
54
|
with open(os.path.join(root,run['id']+'.log'),'ab',buffering=0) as out:
|
|
38
55
|
while True:
|
|
56
|
+
with run['_lock']:
|
|
57
|
+
done=exited(run['process'])
|
|
58
|
+
if done: kill_group(run['process'],signal.SIGKILL)
|
|
39
59
|
ready,_,_=select.select([fd],[],[],0.05)
|
|
40
60
|
if not ready:
|
|
41
|
-
if
|
|
61
|
+
if done: break
|
|
42
62
|
continue
|
|
43
63
|
try: data=os.read(fd,65536)
|
|
44
64
|
except OSError as e:
|
|
@@ -49,27 +69,35 @@ def complete(run):
|
|
|
49
69
|
except BaseException as e:
|
|
50
70
|
failed=True
|
|
51
71
|
with run['_lock']: run['error']=str(e)
|
|
52
|
-
|
|
53
|
-
except ProcessLookupError: pass
|
|
72
|
+
with run['_lock']: kill_group(run['process'],signal.SIGKILL)
|
|
54
73
|
finally:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
run['
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
# A terminal can report EIO as its leader exits, before the polling branch
|
|
75
|
+
# observes that exit. Cleanup must also precede this final reap. Do not hold
|
|
76
|
+
# the run lock while a still-running leader waits, so stop remains usable.
|
|
77
|
+
while True:
|
|
78
|
+
with run['_lock']:
|
|
79
|
+
if exited(run['process']):
|
|
80
|
+
kill_group(run['process'],signal.SIGKILL)
|
|
81
|
+
code=run['process'].wait()
|
|
82
|
+
try: os.close(fd)
|
|
83
|
+
except OSError: pass
|
|
84
|
+
run['fd']=None
|
|
85
|
+
run['exitCode']=125 if failed else code
|
|
86
|
+
run['state']='exited'
|
|
87
|
+
settled(run)
|
|
88
|
+
break
|
|
89
|
+
time.sleep(0.02)
|
|
63
90
|
def stop(run):
|
|
64
91
|
if run.get('state')!='running': return
|
|
65
92
|
proc=run.get('process')
|
|
66
|
-
if proc is not None
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
if proc is not None:
|
|
94
|
+
with run['_lock']:
|
|
95
|
+
kill_group(proc,signal.SIGTERM)
|
|
96
|
+
deadline=time.monotonic()+2
|
|
97
|
+
while not exited(proc) and time.monotonic()<deadline: time.sleep(0.02)
|
|
98
|
+
# The leader can exit on TERM while a descendant ignores it. Kill the
|
|
99
|
+
# remaining group before reaping, even when the leader already exited.
|
|
100
|
+
kill_group(proc,signal.SIGKILL)
|
|
73
101
|
proc.wait()
|
|
74
102
|
elif proc is None and run.get('pid') and run.get('identity') and identity(run['pid'])==run['identity']:
|
|
75
103
|
# On supervisor recovery, fence against PID reuse before touching a process.
|
|
@@ -235,10 +263,19 @@ def boot():
|
|
|
235
263
|
while True:
|
|
236
264
|
try:
|
|
237
265
|
with open(progress) as f: result=json.load(f)
|
|
238
|
-
except FileNotFoundError:
|
|
266
|
+
except FileNotFoundError: raise RuntimeError('Setup exited without terminal progress')
|
|
239
267
|
if result.get('state')!='IN_PROGRESS': break
|
|
268
|
+
# A second runner may have won the setup lock. Wait only while it owns
|
|
269
|
+
# that lock; an exited runner with stale progress cannot ever finish.
|
|
270
|
+
with open(os.path.join(setup_root,'lock'),'a') as active:
|
|
271
|
+
try: fcntl.flock(active,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
|
272
|
+
except BlockingIOError: pass
|
|
273
|
+
else:
|
|
274
|
+
with open(progress) as f: result=json.load(f)
|
|
275
|
+
if result.get('state')=='IN_PROGRESS': raise RuntimeError('Setup exited without terminal progress')
|
|
276
|
+
break
|
|
240
277
|
time.sleep(0.1)
|
|
241
|
-
if result.get('state')!='FINISHED' or result.get('config')!=setup['config']:
|
|
278
|
+
if result.get('state')!='FINISHED' or result.get('config')!=setup['config']: raise RuntimeError('Setup exited without matching terminal progress')
|
|
242
279
|
# The service owns automatic tasks independently of SDK connections.
|
|
243
280
|
with mutex:
|
|
244
281
|
for task,definition in cfg['tasks'].items():
|