From 150189988d9af6a9b3d7380a22ef123a63e05581 Mon Sep 17 00:00:00 2001 From: scuffi Date: Wed, 14 Oct 2026 09:46:27 +0100 Subject: [PATCH 01/10] computer: Correct the documented retentionMs default The doc comment on WorkerJavaScriptBackendOptions.retentionMs said completed executions are kept for five minutes. The backend keeps them for 60 minutes. --- .../src/backends/worker-javascript/worker-javascript.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 5734be12..9b2b93c5 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -71,7 +71,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; From 71db63327af356c844b093017a1c4cf214e0d636 Mon Sep 17 00:00:00 2001 From: scuffi Date: Wed, 14 Oct 2026 09:47:04 +0100 Subject: [PATCH 02/10] computer: Separate the JavaScript CPU limit from the timeout WorkerJavaScriptBackend passed each execution's timeout to the Worker Loader as its CPU limit, so a run allowed to last two hours also asked for two hours of CPU. A long run that mostly waits on I/O needs a long timeout but little CPU. Add a maxCpuMs option. Each execution's CPU limit is the smaller of its timeout and maxCpuMs, which defaults to maxTimeoutMs so existing configurations behave as before. CPU time adds up over the whole Dynamic Worker call, and Cloudflare currently stops a call at about five minutes of CPU whatever the limit, so values above 300000 have no effect. The option comment says so. --- .changeset/javascript-cpu-limit.md | 5 ++ .../worker-javascript.test.ts | 56 +++++++++++++++++++ .../worker-javascript/worker-javascript.ts | 21 ++++++- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 .changeset/javascript-cpu-limit.md diff --git a/.changeset/javascript-cpu-limit.md b/.changeset/javascript-cpu-limit.md new file mode 100644 index 00000000..b57cafde --- /dev/null +++ b/.changeset/javascript-cpu-limit.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +`WorkerJavaScriptBackend` takes a `maxCpuMs` option. Each execution's CPU limit is now the smaller of its timeout and `maxCpuMs`, instead of always being the timeout, so a long timeout no longer asks for the same amount of CPU. It defaults to `maxTimeoutMs`, which keeps the previous behavior. 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..09120f53 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,62 @@ describe("WorkerJavaScriptBackend", () => { await workspace.close(); }); + 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 9b2b93c5..bdf3db64 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -54,6 +54,17 @@ 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; maxSourceBytes?: number; maxInputBytes?: number; maxStdinBytes?: number; @@ -88,6 +99,7 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "access" | "defaultTimeoutMs" | "maxTimeoutMs" + | "maxCpuMs" | "maxSourceBytes" | "maxInputBytes" | "maxStdinBytes" @@ -170,6 +182,8 @@ 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"); assertPositiveFinite(options.maxSourceBytes ?? 1024 * 1024, "maxSourceBytes"); assertPositiveFinite(options.maxInputBytes ?? 1024 * 1024, "maxInputBytes"); assertPositiveFinite(options.maxStdinBytes ?? 256 * 1024, "maxStdinBytes"); @@ -221,6 +235,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { access: options.access ?? "read-write", defaultTimeoutMs, maxTimeoutMs, + maxCpuMs, maxSourceBytes: options.maxSourceBytes ?? 1024 * 1024, maxInputBytes: options.maxInputBytes ?? 1024 * 1024, maxStdinBytes: options.maxStdinBytes ?? 256 * 1024, @@ -478,6 +493,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, @@ -988,6 +1004,7 @@ function startJavaScriptExecution(options: { context: WorkspaceExecutionContext; bridge: WorkspaceRuntimeBridge; timeoutMs: number; + cpuMs: number; egress: WorkspaceEgressPolicy; compatibilityDate: string; compatibilityFlags: string[]; @@ -1004,7 +1021,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 +1029,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 }); From 629080223f44fd4cb63d0aea03354b1a097bd368 Mon Sep 17 00:00:00 2001 From: scuffi Date: Wed, 14 Oct 2026 09:47:27 +0100 Subject: [PATCH 03/10] computer: Keep the Durable Object in memory while JavaScript runs From compatibility date 2026-10-01, pending work such as a call into a Dynamic Worker keeps a Durable Object in memory for at most 15 minutes from when it starts. An execution is one such call. If it makes no host calls and prints nothing for that long, for example while it waits on a slow fetch, the object is evicted and the execution dies with it. Before that date, pending work does not keep the object in memory at all. While any execution is running, the backend now schedules an interval timer every keepAliveIntervalMs, 60 seconds by default. Each timer counts as new pending work, so the object stays in memory for as long as an execution runs. The timer is cleared when the last execution settles, so an idle Workspace can still be evicted or hibernate. Set keepAliveIntervalMs to 0 to turn it off, for example when the host keeps the object in memory itself. --- .changeset/javascript-keep-alive.md | 5 + .../worker-javascript.test.ts | 91 +++++++++++++++++++ .../worker-javascript/worker-javascript.ts | 58 ++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 .changeset/javascript-keep-alive.md diff --git a/.changeset/javascript-keep-alive.md b/.changeset/javascript-keep-alive.md new file mode 100644 index 00000000..ebdc83fb --- /dev/null +++ b/.changeset/javascript-keep-alive.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +`WorkerJavaScriptBackend` now keeps its Durable Object in memory while any execution is running, by scheduling a timer every `keepAliveIntervalMs` (60 seconds by default, `0` turns it off). From compatibility date 2026-10-01, each piece of pending work keeps a Durable Object in memory for up to 15 minutes, so an execution that made no host calls and printed nothing was evicted 15 minutes after it started. With the timer, an execution can run for as long as its timeout allows. Earlier compatibility dates are unaffected and still need the host to keep the object in memory. 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 09120f53..27c1f791 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,97 @@ 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("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(); + } + }); + + it("rejects a negative or non-finite keepAliveIntervalMs", () => { + for (const keepAliveIntervalMs of [-1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect( + () => + new WorkerJavaScriptBackend({ loader: throwingLoader("unused"), keepAliveIntervalMs }), + ).toThrow(/keepAliveIntervalMs/); + } + }); + }); + describe("CPU limit", () => { function recordingLoader() { return vi.fn(() => ({ diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index bdf3db64..a9401996 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -65,6 +65,28 @@ export interface WorkerJavaScriptBackendOptions { * 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. Keep the interval well + * under 15 minutes. + * + * On earlier compatibility dates, pending work and timers do not keep a + * Durable Object in memory, 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; @@ -100,6 +122,7 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "defaultTimeoutMs" | "maxTimeoutMs" | "maxCpuMs" + | "keepAliveIntervalMs" | "maxSourceBytes" | "maxInputBytes" | "maxStdinBytes" @@ -184,6 +207,12 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { assertPositiveFinite(defaultTimeoutMs, "defaultTimeoutMs"); const maxCpuMs = options.maxCpuMs ?? maxTimeoutMs; assertPositiveFinite(maxCpuMs, "maxCpuMs"); + const keepAliveIntervalMs = options.keepAliveIntervalMs ?? 60_000; + if (!Number.isFinite(keepAliveIntervalMs) || keepAliveIntervalMs < 0) { + throw new Error( + "WorkerJavaScriptBackend keepAliveIntervalMs must be zero or a positive finite number.", + ); + } assertPositiveFinite(options.maxSourceBytes ?? 1024 * 1024, "maxSourceBytes"); assertPositiveFinite(options.maxInputBytes ?? 1024 * 1024, "maxInputBytes"); assertPositiveFinite(options.maxStdinBytes ?? 256 * 1024, "maxStdinBytes"); @@ -236,6 +265,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { defaultTimeoutMs, maxTimeoutMs, maxCpuMs, + keepAliveIntervalMs, maxSourceBytes: options.maxSourceBytes ?? 1024 * 1024, maxInputBytes: options.maxInputBytes ?? 1024 * 1024, maxStdinBytes: options.maxStdinBytes ?? 256 * 1024, @@ -291,6 +321,8 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { readonly #activeStreams = new Map(); #pendingStarts = 0; readonly #pendingStartWaiters = new Set<() => void>(); + readonly #running = new Set(); + #keepAlive: ReturnType | undefined; constructor(options: ResolvedWorkerJavaScriptBackendOptions, host: WorkspaceModuleBackendHost) { this.#options = options; @@ -468,6 +500,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { throw error; } this.#records.set(id, record); + this.#markRunning(id); try { const bridge = new WorkspaceRuntimeBridge(capability, { hostModules: this.#hostModuleFunctions, @@ -575,6 +608,30 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { await Promise.allSettled(finalizations); for (const record of records) this.#close(record); this.#records.clear(); + this.#running.clear(); + this.#syncKeepAlive(); + } + + #markRunning(id: string) { + this.#running.add(id); + this.#syncKeepAlive(); + } + + #markSettled(id: string) { + this.#running.delete(id); + this.#syncKeepAlive(); + } + + // Hold the Durable Object in memory while any execution runs. The + // timer does nothing when it fires: scheduling it is what counts as + // pending work. See `keepAliveIntervalMs`. + #syncKeepAlive() { + if (this.#running.size > 0 && this.#options.keepAliveIntervalMs > 0) { + this.#keepAlive ??= setInterval(() => {}, this.#options.keepAliveIntervalMs); + } else if (this.#keepAlive !== undefined) { + clearInterval(this.#keepAlive); + this.#keepAlive = undefined; + } } get #backendId(): string { @@ -866,6 +923,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) { From a415ac0b741fcbe14162e1d30ad756d8bcbb1917 Mon Sep 17 00:00:00 2001 From: scuffi Date: Wed, 14 Oct 2026 09:47:53 +0100 Subject: [PATCH 04/10] docs: Describe long-running isolate JavaScript executions Add a section on running isolate JavaScript for longer than the default three minutes: which limits to raise, how the compatibility date decides whether the Durable Object stays in memory, how CPU time is limited separately from wall-clock time, and that nothing carries a run across a deploy or a crash. Replace the paragraph that said pending work keeps the Durable Object resident and that reading the event stream keeps a run alive. Neither holds: before compatibility date 2026-10-01 pending work does not keep the object in memory, and from that date it does so for at most 15 minutes per piece of work. --- docs/17_isolate_javascript.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index a6d6ae01..f189f76d 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,26 @@ 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. To allow longer runs, raise it along with `maxCapabilityCalls`, which counts every filesystem and host module call a run makes and defaults to 256: + +```ts +new WorkerJavaScriptBackend({ + loader: env.LOADER, + maxTimeoutMs: 2 * 60 * 60_000, + maxCapabilityCalls: 100_000, +}); +``` + +`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. On earlier compatibility dates, pending work and timers don't keep a Durable Object in memory, 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. + +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. From 485f42a6badbc975c16afc5b381561a4ba736e4c Mon Sep 17 00:00:00 2001 From: scuffi Date: Wed, 14 Oct 2026 10:35:39 +0100 Subject: [PATCH 05/10] computer: Stop keeping the object alive for a stuck JavaScript run When an execution times out or is killed, the backend waits for host calls it already accepted before it records the execution as finished. A host module that ignores its abort signal and never settles leaves the execution finalizing forever, and the keep-alive timer kept the Durable Object in memory, and billed, for as long as that lasted. Without the timer the object would eventually have been evicted. Give each running execution a keep-alive deadline of its timeout plus maxHostCallMs, the longest a well-behaved host call accepted at the last moment can take. The timer drops executions past their deadline when it fires and stops once none are left, so the platform's own eviction rules apply again. --- .../worker-javascript.test.ts | 80 +++++++++++++++++++ .../worker-javascript/worker-javascript.ts | 40 ++++++++-- 2 files changed, 112 insertions(+), 8 deletions(-) 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 27c1f791..0176dfa2 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -1306,6 +1306,86 @@ describe("WorkerJavaScriptBackend", () => { } }); + 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 { diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index a9401996..c7b3735c 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -81,6 +81,12 @@ export interface WorkerJavaScriptBackendOptions { * memory for as long as an execution is running. Keep the interval well * under 15 minutes. * + * 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, and an execution that outlives the request * that started it is evicted after about a minute. A host on an earlier @@ -321,7 +327,9 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { readonly #activeStreams = new Map(); #pendingStarts = 0; readonly #pendingStartWaiters = new Set<() => void>(); - readonly #running = new Set(); + // Running executions, each with the time after which it stops counting + // towards the keep-alive. + readonly #running = new Map(); #keepAlive: ReturnType | undefined; constructor(options: ResolvedWorkerJavaScriptBackendOptions, host: WorkspaceModuleBackendHost) { @@ -500,7 +508,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { throw error; } this.#records.set(id, record); - this.#markRunning(id); + this.#markRunning(id, timeoutMs); try { const bridge = new WorkspaceRuntimeBridge(capability, { hostModules: this.#hostModuleFunctions, @@ -612,8 +620,13 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { this.#syncKeepAlive(); } - #markRunning(id: string) { - this.#running.add(id); + // 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(); } @@ -622,18 +635,29 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { this.#syncKeepAlive(); } - // Hold the Durable Object in memory while any execution runs. The - // timer does nothing when it fires: scheduling it is what counts as - // pending work. See `keepAliveIntervalMs`. + // 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.#options.keepAliveIntervalMs > 0) { - this.#keepAlive ??= setInterval(() => {}, this.#options.keepAliveIntervalMs); + this.#keepAlive ??= setInterval( + () => this.#expireKeepAlive(), + this.#options.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 { return this.#options.id ?? "worker-javascript"; } From dab32c5e5b7081d7bc7b0d9f8b189b07d1904da0 Mon Sep 17 00:00:00 2001 From: scuffi Date: Wed, 14 Oct 2026 10:35:40 +0100 Subject: [PATCH 06/10] computer: Reject a keepAliveIntervalMs of 15 minutes or more Pending work protects a Durable Object for at most 15 minutes from when it starts, so a keep-alive timer with a longer interval leaves gaps in which the object can be evicted mid-execution. The option accepted any finite value. Refuse 15 minutes or more when the backend is constructed. --- .../worker-javascript/worker-javascript.test.ts | 4 ++-- .../backends/worker-javascript/worker-javascript.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) 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 0176dfa2..ce40fb13 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -1402,8 +1402,8 @@ describe("WorkerJavaScriptBackend", () => { } }); - it("rejects a negative or non-finite keepAliveIntervalMs", () => { - for (const keepAliveIntervalMs of [-1, Number.NaN, Number.POSITIVE_INFINITY]) { + 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 }), diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index c7b3735c..5fb9e4f2 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -78,7 +78,7 @@ export interface WorkerJavaScriptBackendOptions { * 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. Keep the interval well + * memory for as long as an execution is running. The interval must be * under 15 minutes. * * An execution stops counting once its timeout plus `maxHostCallMs` has @@ -214,9 +214,15 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { const maxCpuMs = options.maxCpuMs ?? maxTimeoutMs; assertPositiveFinite(maxCpuMs, "maxCpuMs"); const keepAliveIntervalMs = options.keepAliveIntervalMs ?? 60_000; - if (!Number.isFinite(keepAliveIntervalMs) || keepAliveIntervalMs < 0) { + // 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 a positive finite number.", + "WorkerJavaScriptBackend keepAliveIntervalMs must be zero or less than 15 minutes.", ); } assertPositiveFinite(options.maxSourceBytes ?? 1024 * 1024, "maxSourceBytes"); From a2c8ca597c535385533db98c38e7709af1878051 Mon Sep 17 00:00:00 2001 From: scuffi Date: Wed, 14 Oct 2026 10:35:40 +0100 Subject: [PATCH 07/10] docs: List the limits a long isolate JavaScript run reaches The long-running section only mentioned maxCapabilityCalls. A run also reaches the cumulative capability request and response byte limits, the shared standard output and error limit, and the platform's subrequest limit, all counted over the whole execution. List them with their defaults, and note that the keep-alive timer stops counting a run past its timeout plus maxHostCallMs, that the object is billed and can't hibernate while it runs, and that a long run holds one of the ten Dynamic Workers a Durable Object can have in flight. --- docs/17_isolate_javascript.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index f189f76d..4a401c06 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -112,21 +112,31 @@ Host calls have a caller-visible deadline, controlled by `maxHostCallMs` and def ## Long-running executions -An execution runs until its code returns or its timeout passes, and `maxTimeoutMs` caps the timeout at 3 minutes by default. To allow longer runs, raise it along with `maxCapabilityCalls`, which counts every filesystem and host module call a run makes and defaults to 256: +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. On earlier compatibility dates, pending work and timers don't keep a Durable Object in memory, 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. +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. On earlier compatibility dates, pending work and timers don't keep a Durable Object in memory, 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. +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. From 123b64d9be2b3ac8eb3b27a6c90692c7a75d369e Mon Sep 17 00:00:00 2001 From: scuffi Date: Wed, 14 Oct 2026 10:38:24 +0100 Subject: [PATCH 08/10] computer: Merge the long-running JavaScript changesets Replace the two changesets for maxCpuMs and the keep-alive timer with one short entry covering both. --- .changeset/javascript-cpu-limit.md | 5 ----- .changeset/javascript-keep-alive.md | 5 ----- .changeset/long-running-javascript.md | 5 +++++ 3 files changed, 5 insertions(+), 10 deletions(-) delete mode 100644 .changeset/javascript-cpu-limit.md delete mode 100644 .changeset/javascript-keep-alive.md create mode 100644 .changeset/long-running-javascript.md diff --git a/.changeset/javascript-cpu-limit.md b/.changeset/javascript-cpu-limit.md deleted file mode 100644 index b57cafde..00000000 --- a/.changeset/javascript-cpu-limit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@cloudflare/computer": minor ---- - -`WorkerJavaScriptBackend` takes a `maxCpuMs` option. Each execution's CPU limit is now the smaller of its timeout and `maxCpuMs`, instead of always being the timeout, so a long timeout no longer asks for the same amount of CPU. It defaults to `maxTimeoutMs`, which keeps the previous behavior. diff --git a/.changeset/javascript-keep-alive.md b/.changeset/javascript-keep-alive.md deleted file mode 100644 index ebdc83fb..00000000 --- a/.changeset/javascript-keep-alive.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@cloudflare/computer": minor ---- - -`WorkerJavaScriptBackend` now keeps its Durable Object in memory while any execution is running, by scheduling a timer every `keepAliveIntervalMs` (60 seconds by default, `0` turns it off). From compatibility date 2026-10-01, each piece of pending work keeps a Durable Object in memory for up to 15 minutes, so an execution that made no host calls and printed nothing was evicted 15 minutes after it started. With the timer, an execution can run for as long as its timeout allows. Earlier compatibility dates are unaffected and still need the host to keep the object in memory. 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. From 603b11220cf4bf93836b0192f90b1123ccdc2eac Mon Sep 17 00:00:00 2001 From: scuffi Date: Wed, 14 Oct 2026 17:41:49 +0100 Subject: [PATCH 09/10] computer: Schedule the keep-alive timer only where it helps Pending work keeps a Durable Object in memory only when the host Worker's compatibility date is 2026-10-01 or later, or it sets durable_object_io_tasks_prevent_eviction. On earlier dates the keep-alive timer did nothing but fire every minute while a run was in flight. The backend now reads the host's flags from Cloudflare.compatibilityFlags when it connects and schedules the timer only when that flag is on. The Dynamic Worker's own compatibility date plays no part. Outside the Workers runtime, where the flags can't be read, the timer is scheduled as before, which is harmless. A host that moves to a newer date gets the timer without changing any configuration. --- docs/17_isolate_javascript.md | 2 +- .../worker-javascript.test.ts | 49 +++++++++++++++++++ .../worker-javascript/worker-javascript.ts | 34 ++++++++++--- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 4a401c06..a2e69d72 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -134,7 +134,7 @@ new WorkerJavaScriptBackend({ `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. On earlier compatibility dates, pending work and timers don't keep a Durable Object in memory, 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. +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. 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 ce40fb13..0fbf6307 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -1402,6 +1402,55 @@ describe("WorkerJavaScriptBackend", () => { } }); + // 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("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( diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 5fb9e4f2..3a8ee6e3 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -81,6 +81,10 @@ export interface WorkerJavaScriptBackendOptions { * 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 @@ -88,9 +92,10 @@ export interface WorkerJavaScriptBackendOptions { * and can't hibernate. * * On earlier compatibility dates, pending work and timers do not keep a - * Durable Object in memory, 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. + * 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; @@ -337,9 +342,12 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { // 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(); @@ -645,11 +653,8 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { // 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.#options.keepAliveIntervalMs > 0) { - this.#keepAlive ??= setInterval( - () => this.#expireKeepAlive(), - this.#options.keepAliveIntervalMs, - ); + 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; @@ -1058,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 = From d2d532897085c41c8e6ef162dfccd5f3e00f7f72 Mon Sep 17 00:00:00 2001 From: scuffi Date: Wed, 14 Oct 2026 17:41:49 +0100 Subject: [PATCH 10/10] computer: Test that kill and close release the keep-alive timer Killing the last running execution and closing the Workspace with executions running both clear the keep-alive timer. Only a run settling on its own was tested before. --- .../worker-javascript.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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 0fbf6307..7e5c76e4 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -1411,6 +1411,43 @@ describe("WorkerJavaScriptBackend", () => { 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 },