Skip to content

Stream JavaScript execution output live over a framed stream - #39

Open
aron-cf wants to merge 8 commits into
stack/4-unify-connect-hostfrom
stack/5-live-framed-streaming
Open

Stream JavaScript execution output live over a framed stream#39
aron-cf wants to merge 8 commits into
stack/4-unify-connect-hostfrom
stack/5-live-framed-streaming

Conversation

@aron-cf

@aron-cf aron-cf commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

The worker-javascript backend 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 maxStdioBytes ceiling 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 new attachOutput method 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 close
Loading

Result 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 Date is 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.

@pkg-pr-new

pkg-pr-new Bot commented Aug 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 92e0d31

aron-cf added 8 commits August 1, 2026 16:57
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant