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
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
5 changes: 2 additions & 3 deletions packages/computer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,8 @@ Alongside `exec`, the runtime exposes `getExec`, `killExec`, and
- **Worker JavaScript** evaluates a module with structured
input/results, durable relative imports, configured libraries,
Workspace-backed `node:fs/promises`, and trusted `ws:git` /
`ws:artifacts` modules. It runs after `runtime.exec()` returns, so
pass `waitUntil: ctx.waitUntil.bind(ctx)` to `Workspace`; the backend
refuses to connect without it. See
`ws:artifacts` modules. It runs after `runtime.exec()` returns; the
run stays alive while its event stream is consumed. See
[`docs/17_isolate_javascript.md`](../../docs/17_isolate_javascript.md)
and [`examples/worker-javascript`](../../examples/worker-javascript).

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
31 changes: 18 additions & 13 deletions packages/computer/src/backends/worker-javascript/frames.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,19 @@ 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","code":0}`)).toEqual({ name: "exit", code: 0 });
expect(parseRuntimeFrame(`{"name":"exit","code":130}`)).toEqual({ name: "exit", code: 130 });
});

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","code":0,"result":{"a":[1,2,null]}}`);
expect(frame).toEqual({ name: "exit", code: 0, result: { a: [1, 2, null] } });
});

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

it("rejects invalid JSON", () => {
Expand All @@ -59,20 +64,20 @@ describe("parseRuntimeFrame", () => {
});

it("rejects an exit frame whose value is not an integer", () => {
expect(() => parseRuntimeFrame(`{"name":"exit","value":"x"}`)).toThrow();
expect(() => parseRuntimeFrame(`{"name":"exit","code":"x"}`)).toThrow();
});
});

describe("decodeRuntimeFrames", () => {
it("decodes newline-delimited frames arriving in one chunk", async () => {
const frames = await collect(
decodeRuntimeFrames(
streamOf(`{"name":"stdout","b64":"${b64("hi")}"}\n{"name":"exit","value":0}\n`),
streamOf(`{"name":"stdout","b64":"${b64("hi")}"}\n{"name":"exit","code":0}\n`),
),
);
expect(frames).toEqual([
{ name: "stdout", value: new TextEncoder().encode("hi") },
{ name: "exit", value: 0 },
{ name: "exit", code: 0 },
]);
});

Expand All @@ -86,13 +91,13 @@ describe("decodeRuntimeFrames", () => {
});

it("emits a trailing frame that arrives without a final newline", async () => {
const frames = await collect(decodeRuntimeFrames(streamOf(`{"name":"exit","value":1}`)));
expect(frames).toEqual([{ name: "exit", value: 1 }]);
const frames = await collect(decodeRuntimeFrames(streamOf(`{"name":"exit","code":1}`)));
expect(frames).toEqual([{ name: "exit", code: 1 }]);
});

it("skips blank lines between frames", async () => {
const frames = await collect(decodeRuntimeFrames(streamOf(`{"name":"exit","value":0}\n\n`)));
expect(frames).toEqual([{ name: "exit", value: 0 }]);
const frames = await collect(decodeRuntimeFrames(streamOf(`{"name":"exit","code":0}\n\n`)));
expect(frames).toEqual([{ name: "exit", code: 0 }]);
});

it("errors the stream on a malformed frame", async () => {
Expand Down
17 changes: 10 additions & 7 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"; code: number; result?: WorkspaceRuntimeValue };

export function parseRuntimeFrame(line: string): RuntimeFrame {
let record: Record<string, unknown>;
Expand All @@ -20,14 +19,18 @@ 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)) {
if (!Number.isSafeInteger(record.code)) {
throw new Error("WorkerJavaScriptBackend received a malformed exit frame");
}
return { name, value: record.value as number };
if ("result" in record) {
return {
name,
code: record.code as number,
result: record.result as WorkspaceRuntimeValue,
};
}
return { name, code: record.code as number };
}
throw new Error("WorkerJavaScriptBackend received an unknown execution frame");
}
Expand Down
Loading
Loading