Skip to content
Closed
28 changes: 19 additions & 9 deletions docs/08_capnweb_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,15 +162,19 @@ See SUMMARY §0 'Investigation notes' (commits dc692c0, c95c74d,

```ts
interface ShellRPC {
// Spawn a command. Returns a handle whose `events` stream
// yields stdout / stderr / exit frames. The stream is the
// single source of truth — there is no buffered-return
// variant. The handle's id can be passed to getExec to
// reattach after a reconnect.
// Spawn an execution. `source` is a shell command line for a
// command backend, or module source for a callable backend.
// Returns a handle whose `events` stream yields stdout / stderr /
// exit frames. The stream is the single source of truth — there
// is no buffered-return variant. The handle's id can be passed to
// getExec to reattach after a reconnect. `input` carries a
// structured value for a callable backend; command backends
// ignore it.
exec(input: {
command: string;
cwd?: string;
id?: string;
source: string;
cwd?: string;
id?: string;
input?: unknown;
}): Promise<{ id: string; events: ReadableStream<ExecEvent> }>;

// Reattach to an in-flight or recently-completed exec by id.
Expand All @@ -195,9 +199,15 @@ interface ShellRPC {
type ExecEvent =
| { id: string; seq: number; name: "stdout"; value: Uint8Array }
| { id: string; seq: number; name: "stderr"; value: Uint8Array }
| { id: string; seq: number; name: "exit"; value: number };
| { id: string; seq: number; name: "exit"; value: number; result?: unknown };
```

The `exit` frame carries the process exit code and, for a callable
backend that ran to a zero exit, the structured return value on
`result`. The value and the exit code settle together, so a single
terminal frame carries both rather than splitting them across two
frames. Command backends never set `result`.

All payloads on the wire are binary. The host-side `Workspace.runtime`
converts to `string` when the caller passes `encoding: "utf8"`. Every
event carries a monotonic `seq` (per exec id) so callers can resume
Expand Down
2 changes: 1 addition & 1 deletion docs/11_lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ Two things have to change for capnweb + hibernation to work:
is dropped by the idempotent apply path. No attachment write is
required.
- **Exec streams: store `{ [id]: seq }` per in-flight exec.**
The `WorkspaceShell` driver inside the DO is the only place
The `CommandExecutor` driver inside the DO is the only place
that knows where the consumer got to in the event stream.
Every time it surfaces an event to the caller (or on some
reasonable debounce) it has to update the attachment so the
Expand Down
12 changes: 6 additions & 6 deletions docs/12_worker_backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ the same.
`BackendHandle.sync` is `"none"`. With a single authoritative
store there's nothing to ship or fetch; `Workspace.push` and
`Workspace.pull` short-circuit on the bit and the reconcile pass
on connect is skipped. The shell exec bracket still calls them so
the surface stays uniform — every `ExecResult.pushed`, `pulled`,
and `skipped` is empty.
on connect is skipped. The exec sync bracket still calls them so
the surface stays uniform — the pushed, pulled, and skipped counts
on the runtime result are empty.

## Event stream framing

Expand All @@ -152,9 +152,9 @@ shape across the isolate hop.

The backend decodes the frames into structured `ExecEvent` values
and re-encodes string payloads (`stdout` / `stderr`) into
`Uint8Array` so the existing `WorkspaceShell` utf8 decoder
transforms in `packages/computer/src/shell.ts` see the shape
they already handle.
`Uint8Array` so the runtime's utf8 decoder transforms, which
accumulate the result from raw events, see the shape they already
handle.

## Fetcher factory escape hatch

Expand Down
3 changes: 1 addition & 2 deletions docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-ja

const workspace = new Workspace({
storage: ctx.storage,
waitUntil: ctx.waitUntil.bind(ctx),
backends: [
new WorkerJavaScriptBackend({
loader: env.LOADER,
Expand Down Expand Up @@ -52,7 +51,7 @@ const result = await handle.result();

The source is a real ES module. Static imports, literal dynamic imports, and top-level await are supported. If the module default-exports a function, Workspace invokes it with `options.input`. Otherwise module evaluation completes with a `null` structured result.

`waitUntil` is required for this backend. `runtime.exec()` returns before the Dynamic Worker finishes, so the host must attach completion to the Durable Object event lifetime. Construction fails when a module backend connects without this hook.
`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.

## Durable relative imports

Expand Down
2 changes: 1 addition & 1 deletion docs/18_runtime_migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This change is a breaking preview-API migration. Public execution now uses one r
| `workspace.shell.dispose(id, options)` | `workspace.runtime.disposeExec(id, options)` |
| `workspace.code` / script execution | `workspace.runtime.exec(source, { backend: "worker-javascript", input })` |

`WorkspaceShell` still exists internally to implement command backends. It is not a public Workspace property.
`CommandExecutor` exists internally to implement command backends. It is not a public Workspace property.

## Default backend IDs

Expand Down
7 changes: 4 additions & 3 deletions examples/worker-javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ client ─► Worker /c/<name>/{file,exec}
store (the DO's SQLite); push and pull short-circuit.
`pushed` / `pulled` are always zero.

The backend requires `waitUntil`, so the DO passes
`ctx.waitUntil.bind(ctx)` into the Workspace options. The DO is a
thin host; the Dynamic Worker lifecycle is the loader's problem.
The DO is a thin host; the Dynamic Worker lifecycle is the loader's
problem. A run keeps advancing while its event stream is consumed, so
the request that drains the handle holds the object resident until the
run finishes.

## Paths

Expand Down
1 change: 0 additions & 1 deletion examples/worker-javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export class ContainerExample extends withWorkspace(class extends DurableObject<
const { ctx, env } = self as unknown as { ctx: DurableObjectState; env: Env };
return {
storage: ctx.storage as unknown as DurableObjectStorageLike,
waitUntil: ctx.waitUntil.bind(ctx),
backends: [new WorkerJavaScriptBackend({ loader: env.LOADER })],
mounts: {
"/workspace/r2": R2Bucket(env.Bucket),
Expand Down
4 changes: 2 additions & 2 deletions examples/worker-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ client ─► Worker /c/<name>/{file,exec}
and one workspace per DO is the natural boundary.
5. `BackendHandle.sync` is `"none"`. There's a single
authoritative store (the DO's SQLite); push and pull
short-circuit. `ExecResult.pushed` / `pulled` are always
zero.
short-circuit. The runtime result's `pushed` / `pulled`
counts are always zero.

The DO is a thin host. There's no Dockerfile; the Dynamic Worker
lifecycle is the loader's problem.
Expand Down
10 changes: 5 additions & 5 deletions packages/computer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ Three backends ship today on tree-shakeable subpaths:
`node:fs/promises`, and trusted `ws:git` / `ws:artifacts` modules. See
[`docs/17_isolate_javascript.md`](../../docs/17_isolate_javascript.md).

The worker-JavaScript backend runs after `runtime.exec()` returns. Pass
`waitUntil: ctx.waitUntil.bind(ctx)` to `Workspace` so completion remains
attached to the Durable Object event. The backend refuses to connect without
this lifecycle hook. It admits one execution at a time by default and bounds
completed execution retention by time and count.
The worker-JavaScript backend runs after `runtime.exec()` returns. The run
keeps advancing while its event stream is consumed, which holds the Durable
Object resident; a handle that is never read can be evicted once the object
goes idle. It admits one execution at a time by default and bounds completed
execution retention by time and count.

A backend can declare `sync: "none"` on the handle it returns to
opt out of the push/pull bracket entirely — the worker backend
Expand Down
1 change: 0 additions & 1 deletion packages/computer/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import type { WorkspaceRPC } from "@cloudflare/computer-rpc";
// their own transport); the in-process module backend uses it.
export interface WorkspaceBackendHost {
readonly db: import("@cloudflare/dofs").Database;
readonly waitUntil?: (promise: Promise<unknown>) => void;
readonly fs: import("@cloudflare/dofs").WorkspaceFilesystem;
readonly git: import("./git/index.js").GitClient;
readonly artifacts: import("./artifacts/index.js").ArtifactClient;
Expand Down
15 changes: 10 additions & 5 deletions packages/computer/src/backends/worker-javascript/frames.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,21 @@ describe("parseRuntimeFrame", () => {
expect(frame).toEqual({ name: "stderr", value: new TextEncoder().encode("oops") });
});

it("decodes a result frame carrying a structured value", () => {
const frame = parseRuntimeFrame(`{"name":"result","value":{"a":[1,2,null]}}`);
expect(frame).toEqual({ name: "result", value: { a: [1, 2, null] } });
});

it("decodes an exit frame carrying an integer", () => {
expect(parseRuntimeFrame(`{"name":"exit","value":0}`)).toEqual({ name: "exit", value: 0 });
expect(parseRuntimeFrame(`{"name":"exit","value":130}`)).toEqual({ name: "exit", value: 130 });
});

it("decodes an exit frame carrying a structured result", () => {
const frame = parseRuntimeFrame(`{"name":"exit","value":0,"result":{"a":[1,2,null]}}`);
expect(frame).toEqual({ name: "exit", value: 0, result: { a: [1, 2, null] } });
});

it("decodes an exit frame carrying a null result", () => {
const frame = parseRuntimeFrame(`{"name":"exit","value":0,"result":null}`);
expect(frame).toEqual({ name: "exit", value: 0, result: null });
});

it("rejects invalid JSON", () => {
expect(() => parseRuntimeFrame("not json")).toThrow();
});
Expand Down
13 changes: 8 additions & 5 deletions packages/computer/src/backends/worker-javascript/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import type { WorkspaceRuntimeValue } from "../../runtime/types.js";
export type RuntimeFrame =
| { name: "stdout"; value: Uint8Array }
| { name: "stderr"; value: Uint8Array }
| { name: "result"; value: WorkspaceRuntimeValue }
| { name: "exit"; value: number };
| { name: "exit"; value: number; result?: WorkspaceRuntimeValue };

export function parseRuntimeFrame(line: string): RuntimeFrame {
let record: Record<string, unknown>;
Expand All @@ -20,13 +19,17 @@ export function parseRuntimeFrame(line: string): RuntimeFrame {
}
return { name, value: decodeBase64(record.b64) };
}
if (name === "result") {
return { name, value: record.value as WorkspaceRuntimeValue };
}
if (name === "exit") {
if (!Number.isSafeInteger(record.value)) {
throw new Error("WorkerJavaScriptBackend received a malformed exit frame");
}
if ("result" in record) {
return {
name,
value: record.value as number,
result: record.result as WorkspaceRuntimeValue,
};
}
return { name, value: record.value as number };
}
throw new Error("WorkerJavaScriptBackend received an unknown execution frame");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,7 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing";
import { describe, expect, it, vi } from "vitest";

import { Workspace } from "../../workspace.js";
import { WorkerJavaScriptBackend as ProductionWorkerJavaScriptBackend } from "./worker-javascript.js";

class WorkerJavaScriptBackend extends ProductionWorkerJavaScriptBackend {
override readonly requiresWaitUntil = false;

override connect(host: Parameters<ProductionWorkerJavaScriptBackend["connect"]>[0]) {
return super.connect({ ...host, waitUntil: host.waitUntil ?? (() => {}) });
}
}
import { WorkerJavaScriptBackend } from "./worker-javascript.js";

function throwingLoader(message: string) {
return {
Expand All @@ -34,8 +26,7 @@ async function evaluateResult(
const frames: string[] = [];
try {
await host.assertResult(value);
frames.push(JSON.stringify({ name: "result", value }));
frames.push(JSON.stringify({ name: "exit", value: 0 }));
frames.push(JSON.stringify({ name: "exit", value: 0, result: value }));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
frames.push(JSON.stringify({ name: "stderr", b64: btoa(`${message}\n`) }));
Expand All @@ -52,18 +43,6 @@ async function evaluateResult(
}

describe("WorkerJavaScriptBackend", () => {
it("requires a host event-lifetime hook", async () => {
const backend = new ProductionWorkerJavaScriptBackend({ loader: throwingLoader("unused") });
await expect(
backend.connect({
db: undefined as never,
fs: undefined as never,
git: undefined as never,
artifacts: undefined as never,
}),
).rejects.toThrow(/requires WorkspaceOptions.waitUntil/);
});

it("validates timeout configuration", () => {
expect(
() =>
Expand All @@ -81,52 +60,11 @@ describe("WorkerJavaScriptBackend", () => {
).toThrow(/positive finite/);
});

it("cancels a started worker when waitUntil registration fails", async () => {
let entrypointDisposals = 0;
let workerDisposals = 0;
const workspace = new Workspace({
storage: new SQLiteTestStorage(),
waitUntil() {
throw new Error("waitUntil unavailable");
},
backends: [
new WorkerJavaScriptBackend({
loader: {
load() {
return {
getEntrypoint() {
return {
evaluate: () => new Promise(() => undefined),
[Symbol.dispose]() {
entrypointDisposals += 1;
},
};
},
[Symbol.dispose]() {
workerDisposals += 1;
},
};
},
},
}),
],
});
await workspace.fs.mkdir("/workspace", { recursive: true });
const execution = await workspace.runtime.exec("export default 1", { encoding: "utf8" });
await expect(execution.result()).resolves.toMatchObject({
status: "failed",
stderr: expect.stringContaining("waitUntil unavailable"),
});
expect(entrypointDisposals).toBe(1);
expect(workerDisposals).toBe(1);
});

it("disposes Loader resources when evaluate throws synchronously", async () => {
let entrypointDisposals = 0;
let workerDisposals = 0;
const workspace = new Workspace({
storage: new SQLiteTestStorage(),
waitUntil() {},
backends: [
new WorkerJavaScriptBackend({
loader: {
Expand Down Expand Up @@ -411,15 +349,13 @@ describe("WorkerJavaScriptBackend", () => {
await workspace.close();
});

it("limits concurrent Dynamic Workers and attaches execution to waitUntil", async () => {
it("limits concurrent Dynamic Workers", async () => {
let resolveEvaluation!: (value: { result: number }) => void;
const evaluation = new Promise<{ result: number }>((resolve) => {
resolveEvaluation = resolve;
});
const waitUntil = vi.fn<(promise: Promise<unknown>) => void>();
const workspace = new Workspace({
storage: new SQLiteTestStorage(),
waitUntil,
backends: [
new WorkerJavaScriptBackend({
maxConcurrentExecutions: 1,
Expand All @@ -445,7 +381,6 @@ describe("WorkerJavaScriptBackend", () => {
});
await workspace.fs.mkdir("/workspace", { recursive: true });
const first = await workspace.runtime.exec("export default 1", { id: "first" });
expect(waitUntil).toHaveBeenCalledOnce();
await expect(
workspace.runtime.exec("export default 2", { id: "second" }),
).rejects.toMatchObject({ code: "EEXEC_BUSY" });
Expand Down
Loading
Loading