computer: Pass isolate capability calls as native RPC values - #183
mattzcarey wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: 2040d21 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
| throw error; | ||
| } | ||
| return decode(payload.result); | ||
| return payload.result; |
There was a problem hiding this comment.
🔴 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.
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; |
There was a problem hiding this comment.
🔴 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| message: truncateText(message, Math.max(0, maxPayloadBytes - 64)), | ||
| ...(typeof value?.code === "string" ? { code: value.code } : {}), | ||
| ...(typeof value?.path === "string" ? { path: value.path } : {}), |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (kind === "response" && values > MAX_RESPONSE_VALUES) { | ||
| throw new Error("Workspace capability response has too many values."); | ||
| } |
There was a problem hiding this comment.
🟨 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
commit: |
5d1931c to
aa38a8f
Compare
| 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; |
| if (item instanceof Uint8Array) { | ||
| add(item.byteLength); | ||
| return; |
There was a problem hiding this comment.
1adc1e6 to
0970232
Compare
81daafe to
5828e04
Compare
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.
5828e04 to
2040d21
Compare
Stacked on #172, after #182 merged into it.
The Dynamic Worker already gets a real RPC object, the runtime bridge. Even so, every
node:fsand host module call was JSON-encoded on top of it: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:
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 }The bridge stays the one proxy every call goes through, so its controls are unchanged:
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:containeroutput, is now a small follow-up rather than a codec change.The script runner suite runs every path through a real Dynamic Worker:
node:fsbytes 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.