Skip to content

computer: Pass isolate capability calls as native RPC values - #183

Open
mattzcarey wants to merge 1 commit into
feat/ws-container-modulefrom
feat/native-rpc-modules
Open

mattzcarey wants to merge 1 commit into
feat/ws-container-modulefrom
feat/native-rpc-modules

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Stacked on #172, after #182 merged into it.

The Dynamic Worker already gets a real RPC object, the runtime bridge. Even so, every node:fs and host module call was JSON-encoded on top of it:

// before, inside the isolate
const request = JSON.stringify(args.map(encode));       // bytes → { __workspace_codec__: { type: "bytes", data: [255, 255, …] } }
const raw = await host.call(name, request);             // string in
return decode(JSON.parse(raw).result);                  // string out

Bytes travelled as arrays of numbers, about four times their real size against maxCapabilityBytes, and both sides carried an encoder and a decoder.

Values now cross as Workers RPC values:

// after
const payload = await host.call(name, args);            // real values in
if (payload.error) throw rebuild(payload.error);         // { message, code, path }
return payload.result;                                   // real values out
sequenceDiagram
  participant Code as Isolate code
  participant Bridge as Host bridge (proxy)
  participant Host as node:fs / host module
  Code->>Bridge: call("fs.writeFile", ["/a.bin", Uint8Array])
  Note over Bridge: cancelled? concurrency, call count,<br/>measure bytes, reject non-plain data
  Bridge->>Host: run with deadline and abort signal
  Host-->>Bridge: value
  Note over Bridge: measure response, response budget
  Bridge-->>Code: { result } or { error }
Loading

The bridge stays the one proxy every call goes through, so its controls are unchanged:

  • cancellation
  • concurrent and total call counts
  • per-call deadlines with an abort signal
  • draining accepted calls before an execution ends, so a late write can't land after exit

Byte budgets now measure the values themselves: UTF-8 bytes of strings and keys, raw bytes of byte arrays, and a fixed cost per scalar. The same walk rejects anything that is not plain data, including cycles. That includes functions and RPC stubs, which Workers RPC would otherwise carry into the Durable Object as live callbacks. The isolate keeps a rough size check so an obviously oversized request skips the round trip.

What changes for callers: bytes count at their real size, so a 900-byte write now fits under a 1024-byte limit where it used to be rejected. A new script runner test shows this; it fails on the old codec with "capability request exceeds 1024 bytes". Host module results are still JSON-compatible plain data. Allowing byte arrays or streams there, for example to stream ws:container output, is now a small follow-up rather than a codec change.

The script runner suite runs every path through a real Dynamic Worker: node:fs bytes and errors with codes, host modules, the call, concurrency, and byte limits, and oversized errors. The bridge unit tests add plain-value pass-through, rejection of functions, class instances, and cycles, and UTF-8 sizing.


Devin Review

@mattzcarey mattzcarey added the allow-pr Allow a PR to remain open. label Oct 1, 2026
@changeset-bot

changeset-bot Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2040d21

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computer Patch
@cloudflare/dofs Patch
@cloudflare/computer-rpc Patch
@cloudflare/computerd Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 4 potential issues.

Devin Review

throw error;
}
return decode(payload.result);
return payload.result;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Optional host fields fail executions

When a host module returns an object with an undefined field, call passes that field to the isolate. assertRuntimeValue rejects it if user code returns the object, so the execution fails.

Learn more

Host module return values pass assertBridgeValues, which skips undefined object fields without removing them. Native RPC preserves those fields. The runner passes the returned value to assertResult, where assertRuntimeValue rejects undefined. The previous codec dropped those fields before the isolate received them. Native transport also changes undefined array arguments from null to undefined and preserves undefined request fields, changing what host functions receive.

Example: A host function returns { value: 1, optional: undefined }. export default () => getValue() previously completed with { value: 1 }; now result validation fails before the exit frame.

Recommended fix: Normalize host-module arguments and results to the documented JSON-compatible semantics before sending them across RPC: omit undefined object fields and convert undefined array elements to null. Keep the existing plain-value checks and byte limits on the normalized values.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

});
return call.then((response) => {
const bytes = new TextEncoder().encode(response).byteLength;
if (!("result" in response)) return response;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Errors bypass the response byte budget

When host calls fail, call returns their errors without charging #responseBytes. Repeated large errors can exceed maxTotalResponseBytes across one execution.

Learn more

The bridge limits each response to maxPayloadBytes and also tracks the total response bytes across an execution. boundedError constructs a response for failed operations. The new early return bypasses the total-byte counter for every such response; the previous implementation counted the encoded error response as well.

Example: With a 256-byte cumulative response budget, a host function throws a 500-byte error ten times. Each bounded error reaches the isolate although together they exceed the 256-byte budget.

Recommended fix: Measure both success and error envelopes before returning them, and enforce maxTotalResponseBytes for each response. Ensure a response-budget rejection does not itself enable an unlimited stream of oversized rejection envelopes.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +395 to 397
message: truncateText(message, Math.max(0, maxPayloadBytes - 64)),
...(typeof value?.code === "string" ? { code: value.code } : {}),
...(typeof value?.path === "string" ? { path: value.path } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Long error paths exceed response limits

When a filesystem call fails on a long path, boundedError truncates the message but retains the full path. The error can exceed maxCapabilityBytes, so an ordinary filesystem failure can breach the per-call response limit.

Learn more

A filesystem operation can throw an error with a path field, as createWorkspaceError does. The request limit permits paths approaching maxCapabilityBytes, but the response contains both the path and the error message. Truncating only the message does not bound the complete error envelope. The previous serializer dropped code and path if they made the envelope too large.

Example: At a 1024-byte capability limit, a roughly 900-byte missing path fits in the request. Its error response includes the roughly 900-byte path plus the error message, exceeding 1024 bytes.

Recommended fix: Measure the complete error response, including code and path, before returning it. Truncate or omit optional details when needed to keep the entire envelope within maxPayloadBytes.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +314 to +316
if (kind === "response" && values > MAX_RESPONSE_VALUES) {
throw new Error("Workspace capability response has too many values.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Unbounded request trees reach the host

An isolate can pass many empty strings or objects to call without exhausting its byte budget. measureValue caps node counts only for responses, so the host traverses the entire request before applying call limits.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@pkg-pr-new

pkg-pr-new Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@183

commit: 2040d21

@mattzcarey
mattzcarey force-pushed the feat/native-rpc-modules branch from 5d1931c to aa38a8f Compare October 1, 2026 11:01
Base automatically changed from fix/exec-tool-review to feat/ws-container-module October 1, 2026 11:02

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Devin Review

Comment on lines +140 to +148
const selected = selectBackends(options.backends, runtime.backends?.());
const [first] = selected;
if (first === undefined) throw new Error("createExecTool: no backends to run on");
const backendIds = selected.map((backend) => backend.id);
const single = backendIds.length === 1;
const known = runtime.backends?.();
const backends = selected.map(({ id, guidance }) => {
const callable = runtime.isCallable?.(id) === true;
const own = runtime.describe?.(id);
const info = known?.find((backend) => backend.id === id);
const callable = info?.callable === true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Backend metadata read twice

createExecTool reads backend metadata separately for selection and descriptions. If a custom runtime changes between reads, the schema can disagree with its backend list.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +330 to +332
if (item instanceof Uint8Array) {
add(item.byteLength);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Different value rules for filesystem and host modules

The native bridge accepts byte arrays, but host-module validation still rejects them. Keep that distinction explicit when documenting the new RPC value semantics.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@mattzcarey
mattzcarey force-pushed the feat/ws-container-module branch from 1adc1e6 to 0970232 Compare October 1, 2026 11:23
@mattzcarey
mattzcarey force-pushed the feat/native-rpc-modules branch 2 times, most recently from 81daafe to 5828e04 Compare October 1, 2026 11:32
The Dynamic Worker already received a real RPC object, the runtime
bridge, but every node:fs and host module call was JSON-encoded on top
of it: arguments became a string with a custom codec for bytes,
arrays, and objects, and results came back the same way. Bytes
travelled as arrays of numbers, roughly four times their size against
the capability byte limit, and both sides carried an encoder and a
decoder.

Arguments and results now cross as Workers RPC values. The isolate
passes its argument list straight to host.call, and the bridge answers
with { result } or a bounded { error } carrying code and path for
node:fs. The bridge stays the single proxy for every call, so its
controls are unchanged: cancellation, concurrent and total call
counts, per-call deadlines with abort, and draining accepted calls
before an execution ends.

Byte budgets now measure the values themselves: UTF-8 bytes of strings
and keys, raw bytes of byte arrays, and a fixed cost per scalar. The
same walk rejects anything that is not plain data, including functions
and RPC stubs that Workers RPC would otherwise carry into the host as
live callbacks, and cycles.
@mattzcarey
mattzcarey force-pushed the feat/native-rpc-modules branch from 5828e04 to 2040d21 Compare October 1, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

allow-pr Allow a PR to remain open.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant