Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/long-running-javascript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

`WorkerJavaScriptBackend` keeps its Durable Object alive while executions run, and adds a `maxCpuMs` option.
32 changes: 31 additions & 1 deletion docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1241,6 +1241,319 @@ describe("WorkerJavaScriptBackend", () => {
await workspace.close();
});

describe("keep-alive", () => {
function gatedWorkspace(options: { keepAliveIntervalMs?: number } = {}) {
let release!: () => void;
const released = new Promise<void>((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<void>;
attachOutput(readable: ReadableStream<Uint8Array>): Promise<void>;
},
) => {
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<void>((resolve) => {
callStarted = resolve;
});
let releaseWrite!: () => void;
const writeReleased = new Promise<void>((resolve) => {
releaseWrite = resolve;
});
const originalWrite = fs.writeFile.bind(fs);
fs.writeFile = (async (...args: Parameters<typeof fs.writeFile>) => {
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<unknown> },
) {
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<WorkspaceRuntimeLoader["load"]>(() => ({
getEntrypoint() {
return {
evaluate: (
_input: unknown,
host: {
assertResult(value: unknown): Promise<void>;
attachOutput(readable: ReadableStream<Uint8Array>): Promise<void>;
},
) => 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<WorkspaceRuntimeLoader["load"]>();
const workspace = new Workspace({
Expand Down
Loading
Loading