Stream JavaScript execution output live over a framed stream - #39
Open
aron-cf wants to merge 8 commits into
Open
Stream JavaScript execution output live over a framed stream#39aron-cf wants to merge 8 commits into
aron-cf wants to merge 8 commits into
Conversation
commit: |
Add an end-to-end probe establishing how byte streams cross the Worker Loader RPC boundary, the primitive the live stdio path depends on. A stream transfers natively only when it is passed as a call argument: the isolate hands a ReadableStream to a bridge method and the host drains it live, and the host hands stdin to the isolate as an evaluate argument. Returning a stream from a bridge method does not transfer it; the receiver gets an RPC stub whose getReader and getWriter are absent. This fixes the direction the stdio design must take: the isolate creates the stdout and stderr transforms and passes their readable ends outward, rather than receiving writable ends from a host openStdio call.
Introduce the host-side decoder for the framed execution stream the JavaScript backend will produce. Frames are newline-delimited JSON objects; stdout and stderr payloads are base64-encoded so arbitrary bytes round-trip losslessly, while result and exit carry their JSON values directly. The id and seq fields stay host-assigned as events are appended. The decoder buffers across chunk boundaries, emits a trailing frame that arrives without a final newline, skips blank lines, and errors the stream on a malformed frame.
Add assertResult to WorkspaceRuntimeBridge so the runner can validate a result before framing it. The value crosses as an RPC argument under structured clone, preserving fidelity, so a Date or other non-plain value is rejected at the boundary rather than silently coerced once the result is serialized to JSON in a frame. It reuses the existing assertRuntimeValue check and the result byte ceiling, which the backend now threads into the bridge.
Replace the runner's {result, logs, error} return value with a single
framed byte stream, the shape the container transport already uses. The
runner emits its captured stdout and stderr, then a result frame and a
zero exit on success, or a truncated stderr frame and exit 1 on failure.
The host drains the stream, decodes the frames, and drives the existing
append and finish machinery, assigning id and seq as events land.
Result validation moves to the runner, which calls the bridge's
assertResult before framing so a non-plain value like a Date is rejected
at the boundary rather than silently coerced by the JSON framing. The
host disposes the Dynamic Worker only after the stream is fully drained,
mirroring how the shell backend holds its worker for the event stream's
lifetime.
Replace the separate maxLogBytes and maxLogEvents limits with one maxStdioBytes that bounds combined stdout and stderr for an execution. A run is bounded by total output rather than per stream or per event, matching how a shell exec is bounded. The runner keeps a single byte counter and emits one "...[stdio truncated]" marker on the write that overflows, when the remaining budget still admits the marker. The event-count ceiling is gone; many small writes are bounded purely by bytes. Update the capping tests and the backend documentation.
Deliver stdout and stderr as they are produced rather than buffering the whole run, matching how the container backend streams exec events. The runner no longer returns its framed stream as the evaluate result, because a returned value cannot keep the host bridge alive: Workers RPC disposes an argument stub when the call that received it returns, so a detached writer would fail with "RPC stub used after being disposed." Instead the isolate hands the readable end to the new bridge attachOutput method as a call argument and awaits it, which keeps evaluate in flight for the whole execution and holds the bridge alive. The host drains that stream through a live pump, ingesting each frame into the event log the moment it arrives and settling the terminal result and exit once the stream closes. Add a backend test asserting a stdout event is observable before user code returns.
Rewrite the JavaScript runtime doc to describe live standard output and standard error streaming through the attachOutput bridge call, replacing the stale paragraph that said output was buffered until evaluation settled. Guard the host output pump against the read rejection that a worker disposed on timeout or cancellation raises: once the record is no longer running the rejection is a normal end-of-drain, while a rejection during a still-running execution still surfaces as a fault. Add a test that cancels mid-stream and asserts the run settles at the kill exit with no output after it.
Environment variables reached the JavaScript module backend through process.env but were dropped on the command path: the runtime command branch, the shell facade, the shell RPC contract, and the worker shell backend all discarded env. Thread env end to end so a command backend receives it too. The runtime command branch now forwards env to the shell, WorkspaceShell passes it on the exec envelope, the ShellRPC contract and its server carry it to the runner, and the worker shell backend and entrypoint hand it to just-bash. The container runner already merged a per-execution env over its base environment; it now receives one from the wire. Document env on the runtime interface as accepted everywhere: command backends inherit it for the spawned command and the JavaScript backend exposes it through process.env, applying to that execution only. Cover the command path with tests at the entrypoint, worker shell backend, workspace selection, and container runner layers.
aron-cf
force-pushed
the
stack/5-live-framed-streaming
branch
from
August 1, 2026 17:34
92e0d31 to
042893d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The
worker-javascriptbackend collected a whole run and returned its result and captured output in one value once user code finished. Output was invisible until the end, which is the wrong shape for long-running or chatty code and unlike the container backend, whose exec events stream as the process produces them. This change moves the JavaScript backend onto the same live model.Execution now travels over a single stream of newline-delimited frames. Standard output and standard error frames carry their bytes base64-encoded so any byte sequence round-trips without loss, and the result and exit ride the same stream as their own frames, so ordering is intrinsic and the result is just the frame before exit. The host decodes frames as they arrive and appends each to the execution's event log the moment it lands, assigning sequence numbers itself. A single
maxStdioBytesceiling now bounds combined standard output and standard error for a run, replacing the earlier separate byte and event limits; a run is bounded by total output rather than per stream.Making it live turned on one platform constraint worth calling out. The Worker Loader boundary transfers a byte stream only when it is passed as a call argument, and it disposes an argument stub when the call that received it returns. An earlier attempt had the runner return its stream and keep working under
waitUntil; that failed with "RPC stub used after being disposed" because the host capability the detached work still needed was torn down the moment the call returned. The working shape keeps the run in flight instead: the runner hands the readable end of its output to a newattachOutputmethod on the host bridge as a call argument and waits on it, which both delivers the stream in the direction the boundary supports and holds the bridge alive for the whole execution. The host drains that stream through a live pump.sequenceDiagram participant Runner as Runner (isolate) participant Bridge as Host bridge Runner->>Bridge: attachOutput(readable) Runner-->>Bridge: stdout / stderr frames (live) Runner-->>Bridge: result frame, exit frame Runner->>Runner: close writer Bridge-->>Runner: attachOutput resolves Note over Bridge: appends events as frames arrive, settles result and exit on closeResult validation moved to the runner, which calls a bridge method to check the value before framing it. The value crosses as a call argument under structured clone, so a non-plain value such as a
Dateis rejected at the boundary rather than silently turned into a string by the frame encoding.Verify with
npm test --workspace @cloudflare/computer. The suite includes a pure codec test for the frame decoder, a backend test asserting a standard output event is observable before user code returns, and the end-to-end runner tests against the real Worker Loader. The backend documentation is updated for the single stdio ceiling.