diff --git a/.changeset/js-no-execution-cap.md b/.changeset/js-no-execution-cap.md new file mode 100644 index 00000000..2b9cdab9 --- /dev/null +++ b/.changeset/js-no-execution-cap.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +`WorkerJavaScriptBackend` no longer caps concurrent executions, and the `maxConcurrentExecutions` option is removed. Its default of 24 sat above the platform's own limit of 10 concurrent Dynamic Workers per request, so runs 11 through 24 were admitted and then failed with a platform error anyway. Executions now start until the platform says no, and that error is the execution's error. Remove `maxConcurrentExecutions` from backend options. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index e06ae6de..9a702bd4 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -80,7 +80,7 @@ Workspace parses the graph before loading the Worker, confines every durable pat ## Execution limits and retention -The backend admits up to twenty-four executions at a time by default. A concurrent start past that ceiling fails with `EEXEC_BUSY` instead of creating an unbounded number of Dynamic Workers. Adjust `maxConcurrentExecutions` after measuring the Durable Object and Worker Loader limits for the deployment. +The backend does not cap concurrent executions itself. The platform limits how many Dynamic Workers run at once, and an execution started past that limit fails with the platform's error. Each execution also bounds combined stdout and stderr output, active event subscribers, directory entries per read, concurrent and total capability calls, and cumulative capability request and response bytes. The corresponding `maxStdioBytes`, `maxExecutionSubscribers`, `maxDirectoryEntries`, and `max*Capability*` options may be lowered for public workloads. Directory reads apply their limit in SQLite before materializing rows. Requests are checked inside the isolate before Workers RPC and again by the host. Every capability call goes through one host bridge that enforces these limits. Values cross as real Workers RPC values, measured as UTF-8 bytes for strings and raw bytes for byte arrays, and anything that is not plain data, such as a function, an RPC stub, or a cycle, is rejected before the host acts on it. diff --git a/examples/rlm/worker/executor-agent.ts b/examples/rlm/worker/executor-agent.ts index 860f919d..7e245ea5 100644 --- a/examples/rlm/worker/executor-agent.ts +++ b/examples/rlm/worker/executor-agent.ts @@ -46,7 +46,6 @@ export class ExecutorAgent extends AIChatAgent { root: WORKSPACE_ROOT, access: "read", egress: { mode: "none" }, - maxConcurrentExecutions: 1, maxConcurrentCapabilityCalls: 4, maxCapabilityCalls: 24, maxCapabilityBytes: 4 * 1024 * 1024, diff --git a/examples/rlm/worker/rlm-agent.ts b/examples/rlm/worker/rlm-agent.ts index bd34039e..a3fd3d3f 100644 --- a/examples/rlm/worker/rlm-agent.ts +++ b/examples/rlm/worker/rlm-agent.ts @@ -108,7 +108,6 @@ export class RlmAgent extends AIChatAgent { access: "read", egress: { mode: "none" }, modules: { "ws:model": modelCapability }, - maxConcurrentExecutions: 1, maxConcurrentCapabilityCalls: 4, // One manifest read + 24 chunk reads + one bounded ws:model batch. maxCapabilityCalls: 26, diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts index cae6ef76..ab87bc61 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -486,49 +486,6 @@ describe("WorkerJavaScriptBackend", () => { await workspace.close(); }); - it("limits concurrent Dynamic Workers", async () => { - let resolveEvaluation!: (value: { result: number }) => void; - const evaluation = new Promise<{ result: number }>((resolve) => { - resolveEvaluation = resolve; - }); - const workspace = new Workspace({ - storage: new SQLiteTestStorage(), - backends: [ - new WorkerJavaScriptBackend({ - maxConcurrentExecutions: 1, - loader: { - load() { - return { - getEntrypoint() { - return { - evaluate: ( - _input: unknown, - host: { - assertResult(value: unknown): Promise; - attachOutput(readable: ReadableStream): Promise; - }, - ) => evaluation.then((outcome) => evaluateResult(host, outcome.result)), - }; - }, - }; - }, - }, - }), - ], - }); - await workspace.fs.mkdir("/workspace", { recursive: true }); - const first = await workspace.runtime.exec("export default 1", { id: "first" }); - await expect( - workspace.runtime.exec("export default 2", { id: "second" }), - ).rejects.toMatchObject({ code: "EEXEC_BUSY" }); - resolveEvaluation({ result: 1 }); - await expect(first.result()).resolves.toMatchObject({ status: "completed" }); - await expect( - workspace.runtime.exec("export default 2", { id: "second" }), - ).resolves.toBeDefined(); - await workspace.close(); - }); - it("waits for accepted host calls before reporting successful completion", async () => { const db = new Database(new SQLiteTestStorage()); initializeSchema(db, () => 0); @@ -1170,6 +1127,52 @@ describe("WorkerJavaScriptBackend", () => { } }); + it("runs concurrent executions without a cap of its own", async () => { + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + let started = 0; + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { + evaluate: ( + _input: unknown, + host: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) => { + started += 1; + return released.then(() => evaluateResult(host, 1)); + }, + }; + }, + }; + }, + }, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const handles = await Promise.all( + Array.from({ length: 30 }, (_, index) => + workspace.runtime.exec("export default 1", { id: `run-${index}` }), + ), + ); + await vi.waitFor(() => expect(started).toBe(30)); + release(); + const results = await Promise.all(handles.map((handle) => handle.result())); + expect(results.every((result) => result.status === "completed")).toBe(true); + await workspace.close(); + }); + it("rejects relative imports that collide with internal Loader modules", async () => { const load = vi.fn(); const workspace = new Workspace({ diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 280ba395..7285b773 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -65,8 +65,6 @@ export interface WorkerJavaScriptBackendOptions { maxCapabilityResponseBytes?: number; /** Maximum entries returned by one isolated directory read. Defaults to 1024. */ maxDirectoryEntries?: number; - /** Maximum graph loads and Dynamic Workers active at once. Defaults to 1. */ - maxConcurrentExecutions?: number; /** Maximum live replay subscribers per execution. Defaults to 8. */ maxExecutionSubscribers?: number; /** Completed execution retention window. Defaults to five minutes. */ @@ -99,7 +97,6 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "maxCapabilityRequestBytes" | "maxCapabilityResponseBytes" | "maxDirectoryEntries" - | "maxConcurrentExecutions" | "maxExecutionSubscribers" | "retentionMs" | "maxRetainedExecutions" @@ -146,7 +143,6 @@ interface ExecutionRecord { control?: ActiveControl; bridge?: WorkspaceRuntimeBridge; finalization?: Promise; - admitted?: boolean; persistenceFailed?: boolean; result?: WorkspaceRuntimeValue; hasResult?: boolean; @@ -193,7 +189,6 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { "maxCapabilityResponseBytes", ); assertPositiveInteger(options.maxDirectoryEntries ?? 1024, "maxDirectoryEntries"); - assertPositiveInteger(options.maxConcurrentExecutions ?? 24, "maxConcurrentExecutions"); assertPositiveInteger(options.maxExecutionSubscribers ?? 8, "maxExecutionSubscribers"); assertPositiveFinite(options.retentionMs ?? 60 * 60_000, "retentionMs"); assertPositiveInteger(options.maxRetainedExecutions ?? 100, "maxRetainedExecutions"); @@ -236,7 +231,6 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { maxCapabilityRequestBytes: options.maxCapabilityRequestBytes ?? 8 * 1024 * 1024, maxCapabilityResponseBytes: options.maxCapabilityResponseBytes ?? 8 * 1024 * 1024, maxDirectoryEntries: options.maxDirectoryEntries ?? 1024, - maxConcurrentExecutions: options.maxConcurrentExecutions ?? 24, maxExecutionSubscribers: options.maxExecutionSubscribers ?? 8, retentionMs: options.retentionMs ?? 60 * 60_000, maxRetainedExecutions: options.maxRetainedExecutions ?? 100, @@ -265,7 +259,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { readonly #records = new Map(); readonly #pendingIds = new Set(); #closed = false; - #activeExecutions = 0; readonly #activeStreams = new Map(); #pendingStarts = 0; readonly #pendingStartWaiters = new Set<() => void>(); @@ -367,17 +360,11 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { if (new TextEncoder().encode(input.source).byteLength > this.#options.maxSourceBytes) { throw new Error(`Workspace runtime source exceeds ${this.#options.maxSourceBytes} bytes.`); } - if (this.#activeExecutions >= this.#options.maxConcurrentExecutions) { - throw runtimeError( - "EEXEC_BUSY", - `JavaScript backend already has ${this.#activeExecutions} active execution(s)`, - ); - } - - this.#activeExecutions += 1; + // There is no cap on concurrent executions here: the platform limits + // concurrent Dynamic Workers itself and reports that limit as the + // execution's error. this.#pendingStarts += 1; this.#pendingIds.add(id); - let admittedRecord: ExecutionRecord | undefined; try { const capability = new WorkspaceRuntimeCapability( this.#host.fs, @@ -403,7 +390,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { events: [], subscribers: new Set(), status: "running", - admitted: true, }; try { this.#host.db.run( @@ -422,7 +408,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { if (durable) throw runtimeError("EEXEC_EXISTS", `execution ${id} already exists`); throw error; } - admittedRecord = record; this.#records.set(id, record); try { const bridge = new WorkspaceRuntimeBridge(capability, { @@ -476,7 +461,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { for (const resolve of this.#pendingStartWaiters) resolve(); this.#pendingStartWaiters.clear(); } - if (!admittedRecord) this.#activeExecutions -= 1; } } @@ -873,10 +857,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { for (const subscriber of [...record.subscribers]) this.#pump(record, subscriber); record.control = undefined; record.bridge = undefined; - if (record.admitted) { - record.admitted = false; - this.#activeExecutions = Math.max(0, this.#activeExecutions - 1); - } if (record.status !== "running" && !record.persistenceFailed) { this.#records.delete(record.id); }