diff --git a/.changeset/long-running-javascript.md b/.changeset/long-running-javascript.md new file mode 100644 index 00000000..ebd0f85a --- /dev/null +++ b/.changeset/long-running-javascript.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +`WorkerJavaScriptBackend` keeps its Durable Object alive while executions run, and adds a `maxCpuMs` option. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index a6d6ae01..a2e69d72 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -71,7 +71,7 @@ A call made while the module loads fails the run with an error that names the ca The returned value becomes the result's `value` and must be JSON-compatible plain data. As with `JSON.stringify`, an `undefined` object field is left out, so `{ kept: 1, dropped: undefined }` completes as `{ kept: 1 }`, and returning `undefined` gives `null`. A function, a class instance such as a `Date`, an `undefined` array item, or a cycle fails the run. `options.input` is checked the same way. -`runtime.exec()` returns before the Dynamic Worker finishes: the run keeps advancing while its event stream is consumed and the host call into the Dynamic Worker stays in flight. That pending work keeps the Durable Object resident on its own. A run whose handle is returned but never read can be evicted once the object goes idle; drain the event stream (or `result()`) to keep the run alive, and schedule an alarm through `ctx.storage.setAlarm()` for work that must survive eviction. +`runtime.exec()` returns before the Dynamic Worker finishes, and the run keeps going whether or not anything reads its event stream. [Long-running executions](#long-running-executions) covers how long a run can last. ## Durable imports @@ -110,6 +110,36 @@ Cancellation stops new host capability calls, disposes the Dynamic Worker, and w Host calls have a caller-visible deadline, controlled by `maxHostCallMs` and defaulting to `maxTimeoutMs`. Missing the deadline fails the capability call and marks the execution failed, even if caller code catches that error. Execution still waits for the accepted host operation itself before publishing a terminal event because many host APIs cannot roll back an external side effect after dispatch. Host module functions receive a `signal` in their context and must stop promptly when it aborts. A host module that ignores cancellation and never settles will keep execution in its finalizing state. `compatibilityDate` and `compatibilityFlags` control the Dynamic Worker runtime and default to the package-tested settings. +## Long-running executions + +An execution runs until its code returns or its timeout passes, and `maxTimeoutMs` caps the timeout at 3 minutes by default. Several other limits apply to the whole execution, so a long run reaches them sooner: + +| Option | Default | Counts | +|---|---|---| +| `maxCapabilityCalls` | 256 | every filesystem and host module call | +| `maxCapabilityRequestBytes` | 8 MiB | bytes sent to those calls, in total | +| `maxCapabilityResponseBytes` | 8 MiB | bytes returned by those calls, in total | +| `maxStdioBytes` | 1 MiB | standard output and standard error, in total; output past it is dropped | + +Raise the ones a long run needs along with the timeout: + +```ts +new WorkerJavaScriptBackend({ + loader: env.LOADER, + maxTimeoutMs: 2 * 60 * 60_000, + maxCapabilityCalls: 100_000, + maxCapabilityResponseBytes: 256 * 1024 * 1024, +}); +``` + +`maxHostCallMs` defaults to `maxTimeoutMs`, so it grows with it. + +A run also needs its Durable Object to stay in memory, and that depends on the host Worker's compatibility date. From 2026-10-01, each piece of pending work keeps a Durable Object in memory for up to 15 minutes from when it starts. A run that keeps making host calls or printing output keeps renewing that, but a quiet one, such as a run waiting on one slow `fetch`, would be evicted 15 minutes after it started. To cover that case, the backend schedules a timer every `keepAliveIntervalMs` (60 seconds by default) while any run is in flight, and each timer counts as new pending work. A run stops counting once its timeout plus `maxHostCallMs` has passed, even if it is still waiting for a host call to settle. While the timer runs, the object is billed for duration and can't hibernate. The backend reads the host Worker's compatibility flags when it connects and schedules the timer only from 2026-10-01, or with `durable_object_io_tasks_prevent_eviction`. On earlier compatibility dates, pending work and timers don't keep a Durable Object in memory, so no timer is scheduled, and a run that outlives the request that started it is evicted about a minute after that request ends, unless the request awaits the run or the host keeps the object in memory itself, for example with an alarm. + +CPU time is limited separately from wall-clock time. Each run's CPU limit is the smaller of its timeout and `maxCpuMs`, which defaults to `maxTimeoutMs`. CPU time adds up over the whole run, however much of it is spent waiting, and Cloudflare currently stops a Dynamic Worker call at about five minutes of CPU whatever the limit. A run that mostly waits on I/O uses little of that and can last hours. A run that computes heavily fails with `Worker exceeded CPU time limit.` once it reaches the cap. Every execution gets a fresh Dynamic Worker and a fresh CPU budget, so heavy work can be split across executions or moved to a container. The platform's subrequest limit, 10,000 by default on the Workers Paid plan, also counts every `fetch` over the whole run. A long run also holds one of the ten Dynamic Workers a Durable Object can have in flight for as long as it lasts. + +Nothing keeps a run going across a deploy or a crash. A deploy shuts the old instance down a few minutes later, and a run that was in flight is marked as failed the next time the backend connects. + ## Environment, standard input, and the `process` shim Each execution installs a small `node:process` shim so ordinary module code can read its environment and standard streams. The shim exposes only what the caller supplies for that execution; the host environment is never visible. 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 c6922032..7e5c76e4 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -1241,6 +1241,319 @@ describe("WorkerJavaScriptBackend", () => { await workspace.close(); }); + describe("keep-alive", () => { + function gatedWorkspace(options: { keepAliveIntervalMs?: number } = {}) { + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + let started = 0; + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + ...options, + 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)); + }, + }; + }, + }; + }, + }, + }), + ], + }); + return { workspace, release, started: () => started }; + } + + it("keeps a timer scheduled while an execution runs and clears it once none do", async () => { + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); + try { + const { workspace, release, started } = gatedWorkspace(); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const first = await workspace.runtime.exec("export default 1", { id: "first" }); + const second = await workspace.runtime.exec("export default 1", { id: "second" }); + await vi.waitFor(() => expect(started()).toBe(2)); + + const keepAliveCalls = setIntervalSpy.mock.calls.filter(([, ms]) => ms === 60_000); + expect(keepAliveCalls).toHaveLength(1); + const timer = + setIntervalSpy.mock.results[setIntervalSpy.mock.calls.indexOf(keepAliveCalls[0])]?.value; + expect(clearIntervalSpy).not.toHaveBeenCalledWith(timer); + + release(); + await first.result(); + await second.result(); + expect(clearIntervalSpy).toHaveBeenCalledWith(timer); + await workspace.close(); + } finally { + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + } + }); + + it("stops keeping the object alive for a run stuck finalizing past its timeout and host call deadline", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + let callStarted!: () => void; + const started = new Promise((resolve) => { + callStarted = resolve; + }); + let releaseWrite!: () => void; + const writeReleased = new Promise((resolve) => { + releaseWrite = resolve; + }); + const originalWrite = fs.writeFile.bind(fs); + fs.writeFile = (async (...args: Parameters) => { + callStarted(); + await writeReleased; + return originalWrite(...args); + }) as typeof fs.writeFile; + let now = 1_000_000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); + try { + const backend = new WorkerJavaScriptBackend({ + maxTimeoutMs: 10_000, + maxHostCallMs: 5_000, + loader: { + load() { + return { + getEntrypoint() { + return { + evaluate( + _input: unknown, + host: { call(name: string, args: unknown[]): Promise }, + ) { + void host.call("fs.writeFile", ["/workspace/output.txt", "done"]); + return new Promise(() => undefined); + }, + }; + }, + }; + }, + }, + }); + const handle = await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + runtime: undefined as never, + }); + await handle.exec({ id: "stuck", source: "export default 1", timeoutMs: 10_000 }); + await started; + // The kill waits for the write, which never settles on its own. + const kill = handle.killExec({ id: "stuck" }); + + const call = setIntervalSpy.mock.calls.findIndex(([, ms]) => ms === 60_000); + const [tick] = setIntervalSpy.mock.calls[call] ?? []; + const timer = setIntervalSpy.mock.results[call]?.value; + expect(tick).toBeTypeOf("function"); + + now += 15_000; + (tick as () => void)(); + expect(clearIntervalSpy).not.toHaveBeenCalledWith(timer); + + now += 1; + (tick as () => void)(); + expect(clearIntervalSpy).toHaveBeenCalledWith(timer); + + releaseWrite(); + await kill; + await handle.close(); + } finally { + nowSpy.mockRestore(); + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + } + }); + + it("schedules no timer when keepAliveIntervalMs is 0", async () => { + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + try { + const { workspace, release, started } = gatedWorkspace({ keepAliveIntervalMs: 0 }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const handle = await workspace.runtime.exec("export default 1"); + await vi.waitFor(() => expect(started()).toBe(1)); + expect(setIntervalSpy).not.toHaveBeenCalled(); + release(); + await handle.result(); + await workspace.close(); + } finally { + setIntervalSpy.mockRestore(); + } + }); + + // The timer's id from the one setInterval call the keep-alive makes. + function keepAliveTimer(setIntervalSpy: { + mock: { calls: unknown[][]; results: { value: unknown }[] }; + }) { + const call = setIntervalSpy.mock.calls.findIndex(([, ms]) => ms === 60_000); + expect(call).not.toBe(-1); + return setIntervalSpy.mock.results[call]?.value; + } + + it("clears the timer when the last execution is killed", async () => { + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); + try { + const { workspace, started } = gatedWorkspace(); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const handle = await workspace.runtime.exec("export default 1", { id: "killed" }); + await vi.waitFor(() => expect(started()).toBe(1)); + const timer = keepAliveTimer(setIntervalSpy); + await workspace.runtime.killExec("killed"); + await expect(handle.result()).resolves.toMatchObject({ status: "cancelled" }); + expect(clearIntervalSpy).toHaveBeenCalledWith(timer); + await workspace.close(); + } finally { + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + } + }); + + it("clears the timer when the Workspace closes with executions running", async () => { + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval"); + try { + const { workspace, started } = gatedWorkspace(); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await workspace.runtime.exec("export default 1"); + await workspace.runtime.exec("export default 1"); + await vi.waitFor(() => expect(started()).toBe(2)); + const timer = keepAliveTimer(setIntervalSpy); + await workspace.close(); + expect(clearIntervalSpy).toHaveBeenCalledWith(timer); + } finally { + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + } + }); + + it("schedules no timer when the host's pending work can't keep the object in memory", async () => { + vi.stubGlobal("Cloudflare", { + compatibilityFlags: { durable_object_io_tasks_prevent_eviction: false }, + }); + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + try { + const { workspace, release, started } = gatedWorkspace(); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const handle = await workspace.runtime.exec("export default 1"); + await vi.waitFor(() => expect(started()).toBe(1)); + expect(setIntervalSpy.mock.calls.filter(([, ms]) => ms === 60_000)).toHaveLength(0); + release(); + await handle.result(); + await workspace.close(); + } finally { + setIntervalSpy.mockRestore(); + vi.unstubAllGlobals(); + } + }); + + it("schedules the timer when the host's pending work keeps the object in memory", async () => { + vi.stubGlobal("Cloudflare", { + compatibilityFlags: { durable_object_io_tasks_prevent_eviction: true }, + }); + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + try { + const { workspace, release, started } = gatedWorkspace(); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const handle = await workspace.runtime.exec("export default 1"); + await vi.waitFor(() => expect(started()).toBe(1)); + keepAliveTimer(setIntervalSpy); + release(); + await handle.result(); + await workspace.close(); + } finally { + setIntervalSpy.mockRestore(); + vi.unstubAllGlobals(); + } + }); + + it("rejects a keepAliveIntervalMs that is negative, non-finite, or 15 minutes or more", () => { + for (const keepAliveIntervalMs of [-1, Number.NaN, Number.POSITIVE_INFINITY, 900_000]) { + expect( + () => + new WorkerJavaScriptBackend({ loader: throwingLoader("unused"), keepAliveIntervalMs }), + ).toThrow(/keepAliveIntervalMs/); + } + }); + }); + + describe("CPU limit", () => { + function recordingLoader() { + return vi.fn(() => ({ + getEntrypoint() { + return { + evaluate: ( + _input: unknown, + host: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) => evaluateResult(host, null), + }; + }, + })); + } + + it("defaults the CPU limit to the execution timeout", async () => { + const load = recordingLoader(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new WorkerJavaScriptBackend({ loader: { load } })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await (await workspace.runtime.exec("export default null", { timeoutMs: 45_000 })).result(); + expect(load.mock.calls[0]?.[0]).toMatchObject({ limits: { cpuMs: 45_000 } }); + await workspace.close(); + }); + + it("caps the CPU limit at maxCpuMs without shortening the timeout", async () => { + const load = recordingLoader(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { load }, + maxTimeoutMs: 60 * 60_000, + maxCpuMs: 300_000, + }), + ], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await ( + await workspace.runtime.exec("export default null", { timeoutMs: 30 * 60_000 }) + ).result(); + expect(load.mock.calls[0]?.[0]).toMatchObject({ limits: { cpuMs: 300_000 } }); + await workspace.close(); + }); + + it("rejects a maxCpuMs that is not positive and finite", () => { + expect( + () => new WorkerJavaScriptBackend({ loader: throwingLoader("unused"), maxCpuMs: 0 }), + ).toThrow(/maxCpuMs/); + }); + }); + 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 5734be12..3a8ee6e3 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -54,6 +54,50 @@ export interface WorkerJavaScriptBackendOptions { modules?: Record; defaultTimeoutMs?: number; maxTimeoutMs?: number; + /** + * The most CPU time one execution can use, in milliseconds. Each + * execution's CPU limit is the smaller of its timeout and this value. + * Defaults to `maxTimeoutMs`, which makes the CPU limit the timeout. + * + * CPU time adds up over the whole execution, however much of it is spent + * waiting. Cloudflare currently stops a Dynamic Worker call at about five + * minutes of CPU whatever the limit, so values above 300000 have no + * effect. + */ + maxCpuMs?: number; + /** + * How often to schedule a timer while any execution is running, in + * milliseconds. Set it to 0 to turn the timer off. Defaults to 60 + * seconds. + * + * From compatibility date 2026-10-01, each piece of pending work keeps + * a Durable Object in memory for at most 15 minutes from when it starts. + * An execution is one call into a Dynamic Worker, so one that goes + * quiet, making no host calls and printing nothing, is evicted about 15 + * minutes after it starts, and the eviction ends it. Host calls and + * output also keep the object in memory while they keep coming, but an + * execution can't be relied on to make them. Every timer counts as new + * pending work, so a timer that keeps rescheduling holds the object in + * memory for as long as an execution is running. The interval must be + * under 15 minutes. + * + * The backend reads the host Worker's compatibility flags and schedules + * the timer only where it helps, from 2026-10-01 or with + * `durable_object_io_tasks_prevent_eviction`. + * + * An execution stops counting once its timeout plus `maxHostCallMs` has + * passed, even if it is still waiting for a host call to settle, so a + * host module that never settles can't keep the object in memory + * indefinitely. While the timer runs, the object is billed for duration + * and can't hibernate. + * + * On earlier compatibility dates, pending work and timers do not keep a + * Durable Object in memory, so no timer is scheduled, and an execution + * that outlives the request that started it is evicted after about a + * minute. A host on an earlier date needs its own keep-alive, such as an + * alarm. + */ + keepAliveIntervalMs?: number; maxSourceBytes?: number; maxInputBytes?: number; maxStdinBytes?: number; @@ -71,7 +115,7 @@ export interface WorkerJavaScriptBackendOptions { maxDirectoryEntries?: number; /** Maximum live replay subscribers per execution. Defaults to 8. */ maxExecutionSubscribers?: number; - /** Completed execution retention window. Defaults to five minutes. */ + /** Completed execution retention window. Defaults to 60 minutes. */ retentionMs?: number; /** Maximum completed executions retained per backend. Defaults to 100. */ maxRetainedExecutions?: number; @@ -88,6 +132,8 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "access" | "defaultTimeoutMs" | "maxTimeoutMs" + | "maxCpuMs" + | "keepAliveIntervalMs" | "maxSourceBytes" | "maxInputBytes" | "maxStdinBytes" @@ -170,6 +216,20 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { const defaultTimeoutMs = options.defaultTimeoutMs ?? Math.min(60_000, maxTimeoutMs); assertPositiveFinite(maxTimeoutMs, "maxTimeoutMs"); assertPositiveFinite(defaultTimeoutMs, "defaultTimeoutMs"); + const maxCpuMs = options.maxCpuMs ?? maxTimeoutMs; + assertPositiveFinite(maxCpuMs, "maxCpuMs"); + const keepAliveIntervalMs = options.keepAliveIntervalMs ?? 60_000; + // Pending work protects a Durable Object for at most 15 minutes, so a + // longer interval would leave gaps. + if ( + !Number.isFinite(keepAliveIntervalMs) || + keepAliveIntervalMs < 0 || + keepAliveIntervalMs >= 900_000 + ) { + throw new Error( + "WorkerJavaScriptBackend keepAliveIntervalMs must be zero or less than 15 minutes.", + ); + } assertPositiveFinite(options.maxSourceBytes ?? 1024 * 1024, "maxSourceBytes"); assertPositiveFinite(options.maxInputBytes ?? 1024 * 1024, "maxInputBytes"); assertPositiveFinite(options.maxStdinBytes ?? 256 * 1024, "maxStdinBytes"); @@ -221,6 +281,8 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { access: options.access ?? "read-write", defaultTimeoutMs, maxTimeoutMs, + maxCpuMs, + keepAliveIntervalMs, maxSourceBytes: options.maxSourceBytes ?? 1024 * 1024, maxInputBytes: options.maxInputBytes ?? 1024 * 1024, maxStdinBytes: options.maxStdinBytes ?? 256 * 1024, @@ -276,9 +338,16 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { readonly #activeStreams = new Map(); #pendingStarts = 0; readonly #pendingStartWaiters = new Set<() => void>(); + // Running executions, each with the time after which it stops counting + // towards the keep-alive. + readonly #running = new Map(); + #keepAlive: ReturnType | undefined; + // Zero when the timer is off or can't keep this object in memory. + readonly #keepAliveIntervalMs: number; constructor(options: ResolvedWorkerJavaScriptBackendOptions, host: WorkspaceModuleBackendHost) { this.#options = options; + this.#keepAliveIntervalMs = hostKeepsPendingWork() ? options.keepAliveIntervalMs : 0; this.#host = host; const functions = new Map(); const empty = new Set(); @@ -453,6 +522,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { throw error; } this.#records.set(id, record); + this.#markRunning(id, timeoutMs); try { const bridge = new WorkspaceRuntimeBridge(capability, { hostModules: this.#hostModuleFunctions, @@ -478,6 +548,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { }, bridge, timeoutMs, + cpuMs: Math.min(timeoutMs, this.#options.maxCpuMs), egress: this.#options.egress, compatibilityDate: this.#options.compatibilityDate, compatibilityFlags: this.#options.compatibilityFlags, @@ -559,6 +630,43 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { await Promise.allSettled(finalizations); for (const record of records) this.#close(record); this.#records.clear(); + this.#running.clear(); + this.#syncKeepAlive(); + } + + // An execution normally settles by its timeout. Finalizing then waits + // for host calls it already accepted, which can take up to + // `maxHostCallMs` for a well-behaved host module. A run still going past + // both stops counting, so a host module that never settles can't keep + // the object in memory indefinitely. + #markRunning(id: string, timeoutMs: number) { + this.#running.set(id, Date.now() + timeoutMs + this.#options.maxHostCallMs); + this.#syncKeepAlive(); + } + + #markSettled(id: string) { + this.#running.delete(id); + this.#syncKeepAlive(); + } + + // Hold the Durable Object in memory while any execution runs. Scheduling + // the timer is what counts as pending work; when it fires it only drops + // executions past their keep-alive deadline. See `keepAliveIntervalMs`. + #syncKeepAlive() { + if (this.#running.size > 0 && this.#keepAliveIntervalMs > 0) { + this.#keepAlive ??= setInterval(() => this.#expireKeepAlive(), this.#keepAliveIntervalMs); + } else if (this.#keepAlive !== undefined) { + clearInterval(this.#keepAlive); + this.#keepAlive = undefined; + } + } + + #expireKeepAlive() { + const now = Date.now(); + for (const [id, until] of this.#running) { + if (now > until) this.#running.delete(id); + } + this.#syncKeepAlive(); } get #backendId(): string { @@ -850,6 +958,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { } record.status = settledStatus; record.events.push(...settledEvents); + this.#markSettled(record.id); for (const subscriber of [...record.subscribers]) this.#pump(record, subscriber); this.#close(record); if (!record.persistenceFailed) { @@ -954,6 +1063,19 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { } } +// Whether pending work keeps this Durable Object in memory, which the host +// Worker's compatibility date decides: from 2026-10-01 it does, before it +// doesn't, and a keep-alive timer would do nothing. The Dynamic Worker's +// own compatibility date plays no part. Outside the Workers runtime the +// flags aren't there to read, and the timer is scheduled, which is +// harmless. +function hostKeepsPendingWork(): boolean { + const flags = ( + globalThis as { Cloudflare?: { compatibilityFlags?: Record } } + ).Cloudflare?.compatibilityFlags; + return flags?.durable_object_io_tasks_prevent_eviction ?? true; +} + function encodeEvent(event: WorkspaceRuntimeEvent): Uint8Array { if (event.name === "stdout" || event.name === "stderr") return event.value; const payload = @@ -988,6 +1110,7 @@ function startJavaScriptExecution(options: { context: WorkspaceExecutionContext; bridge: WorkspaceRuntimeBridge; timeoutMs: number; + cpuMs: number; egress: WorkspaceEgressPolicy; compatibilityDate: string; compatibilityFlags: string[]; @@ -1004,7 +1127,7 @@ function startJavaScriptExecution(options: { const worker = options.loader.load({ compatibilityDate: options.compatibilityDate, compatibilityFlags: options.compatibilityFlags, - limits: { cpuMs: options.timeoutMs }, + limits: { cpuMs: options.cpuMs }, mainModule: "workspace-runtime-runner.js", modules, ...dynamicWorkerEgress(options.egress), @@ -1012,7 +1135,7 @@ function startJavaScriptExecution(options: { let entrypoint: JavaScriptEntrypoint; try { entrypoint = worker.getEntrypoint(undefined, { - limits: { cpuMs: options.timeoutMs }, + limits: { cpuMs: options.cpuMs }, }) as JavaScriptEntrypoint; } catch (error) { disposeQuietly(worker as { [Symbol.dispose]?: () => void });