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.
Files changed (90) hide show
  1. package/BLAXEL.md +4 -4
  2. package/DAYTONA.md +13 -7
  3. package/E2B.md +12 -8
  4. package/README.md +15 -7
  5. package/README.src.md +15 -7
  6. package/VERCEL.md +13 -3
  7. package/compatibility-lock.json +84 -8
  8. package/dist/blaxel/errors.js +1 -1
  9. package/dist/blaxel/lookup.d.ts +3 -2
  10. package/dist/blaxel/lookup.js +3 -2
  11. package/dist/blaxel/pagination.d.ts +2 -2
  12. package/dist/blaxel/pagination.js +45 -17
  13. package/dist/blaxel/preview.d.ts +2 -1
  14. package/dist/blaxel/preview.js +15 -13
  15. package/dist/blaxel/process.js +56 -10
  16. package/dist/blaxel/sandbox.d.ts +2 -0
  17. package/dist/blaxel/sandbox.js +2 -0
  18. package/dist/blaxel/snapshot.js +3 -5
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +189 -54
  21. package/dist/client.d.ts +29 -3
  22. package/dist/client.js +40 -11
  23. package/dist/cloudflare/identity.d.ts +7 -0
  24. package/dist/cloudflare/identity.js +13 -0
  25. package/dist/cloudflare/index.d.ts +9 -0
  26. package/dist/cloudflare/index.js +47 -9
  27. package/dist/cloudflare/worker.d.ts +4 -0
  28. package/dist/cloudflare/worker.js +166 -16
  29. package/dist/codesandbox/guest-service.js +57 -20
  30. package/dist/codesandbox/index.js +42 -8
  31. package/dist/compat/core.d.ts +9 -8
  32. package/dist/compat/core.js +60 -21
  33. package/dist/compat/files.js +22 -4
  34. package/dist/credentials.d.ts +6 -1
  35. package/dist/credentials.js +107 -38
  36. package/dist/daytona/daytona.js +11 -4
  37. package/dist/daytona/filesystem.d.ts +17 -4
  38. package/dist/daytona/filesystem.js +121 -38
  39. package/dist/daytona/index.d.ts +1 -1
  40. package/dist/daytona/sandbox.d.ts +1 -1
  41. package/dist/daytona/sandbox.js +16 -7
  42. package/dist/e2b/client.js +1 -1
  43. package/dist/e2b/commands.d.ts +30 -8
  44. package/dist/e2b/commands.js +142 -54
  45. package/dist/e2b/index.d.ts +1 -0
  46. package/dist/e2b/index.js +1 -0
  47. package/dist/e2b/pty.d.ts +24 -0
  48. package/dist/e2b/pty.js +80 -0
  49. package/dist/e2b/sandbox.d.ts +2 -1
  50. package/dist/e2b/sandbox.js +49 -15
  51. package/dist/file-source.d.ts +16 -0
  52. package/dist/file-source.js +162 -0
  53. package/dist/freestyle/index.d.ts +9 -1
  54. package/dist/freestyle/index.js +38 -5
  55. package/dist/index.d.ts +2 -1
  56. package/dist/login.js +16 -10
  57. package/dist/modal/index.js +2 -1
  58. package/dist/network-cli.js +111 -43
  59. package/dist/openai-agents/index.js +9 -45
  60. package/dist/page.d.ts +1 -1
  61. package/dist/page.js +2 -2
  62. package/dist/products/index.d.ts +12 -0
  63. package/dist/products/index.js +10 -0
  64. package/dist/products/observability.d.ts +44 -0
  65. package/dist/products/observability.js +6 -0
  66. package/dist/products/previews.d.ts +8 -2
  67. package/dist/products/previews.js +11 -3
  68. package/dist/products/volumes.d.ts +42 -0
  69. package/dist/products/volumes.js +36 -0
  70. package/dist/products/watch.js +61 -8
  71. package/dist/proxy.js +3 -1
  72. package/dist/runloop/index.d.ts +13 -27
  73. package/dist/runloop/index.js +149 -52
  74. package/dist/sandbox.d.ts +37 -8
  75. package/dist/sandbox.js +432 -68
  76. package/dist/snapshots.d.ts +9 -4
  77. package/dist/sprites/index.js +69 -33
  78. package/dist/tar.d.ts +3 -1
  79. package/dist/tar.js +140 -14
  80. package/dist/transport.d.ts +4 -7
  81. package/dist/transport.js +205 -122
  82. package/dist/vercel/command.d.ts +1 -1
  83. package/dist/vercel/command.js +3 -2
  84. package/dist/vercel/errors.d.ts +1 -1
  85. package/dist/vercel/errors.js +7 -1
  86. package/dist/vercel/sandbox.d.ts +8 -8
  87. package/dist/vercel/sandbox.js +154 -59
  88. package/dist/vercel/snapshot.d.ts +3 -2
  89. package/dist/vercel/snapshot.js +9 -5
  90. 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, found on first use. */
8
- const savedKey = async () => {
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", { timeoutSeconds: WAIT_FOR_TIMEOUT_SECONDS });
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
- async list(filter = {}) {
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,7 @@
1
+ /** A Durable Object ID includes its namespace; a display name does not. */
2
+ export declare function workerSandboxIdentity(objectId: string, stored: {
3
+ nativeSandboxId?: string;
4
+ sandboxName?: {
5
+ name: string;
6
+ };
7
+ }): string;
@@ -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>;
@@ -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
- return value;
27
- const match = /^(\d+)(s|m|h)$/.exec(value);
28
- if (!match)
29
- throw new TypeError("sleepAfter must be seconds or a duration such as 5m");
30
- return Number(match[1]) * ({ s: 1, m: 60, h: 3600 }[match[2]] ?? 1);
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
- if (options.sleepAfter !== undefined)
52
- sleepSeconds(options.sleepAfter);
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
- Object.assign(this.options, options);
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: this.options.keepAlive ? 0 : sleepSeconds(this.options.sleepAfter),
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 { Sandbox as RuntimeSandbox, sanitizeSandboxId, } from "./index.js";
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("runtime.configuration")) ?? {};
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.configuration.sandboxName.name, this.configuration);
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
- await this.initialized;
77
- if (this.configuration.sandboxName &&
78
- configuration.sandboxName &&
79
- this.configuration.sandboxName.name !== configuration.sandboxName.name)
80
- throw new Error("A Durable Object cannot change sandbox identity");
81
- this.configuration = { ...this.configuration, ...configuration };
82
- await this.ctx.storage.put("runtime.configuration", this.configuration);
83
- if (this.facade)
84
- await this.facade.configure(configuration);
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 run['process'].poll() is not None: break
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
- try: os.killpg(run['process'].pid,signal.SIGKILL)
53
- except ProcessLookupError: pass
72
+ with run['_lock']: kill_group(run['process'],signal.SIGKILL)
54
73
  finally:
55
- code=run['process'].wait()
56
- with run['_lock']:
57
- try: os.close(fd)
58
- except OSError: pass
59
- run['fd']=None
60
- run['exitCode']=125 if failed else code
61
- run['state']='exited'
62
- settled(run)
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 and proc.poll() is None:
67
- try: os.killpg(proc.pid,signal.SIGTERM)
68
- except ProcessLookupError: pass
69
- try: proc.wait(timeout=2)
70
- except subprocess.TimeoutExpired:
71
- try: os.killpg(proc.pid,signal.SIGKILL)
72
- except ProcessLookupError: pass
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: return
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']: return
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():