Unify the execution backends - #45
Merged
Merged
Conversation
The JavaScript execution path emitted a structured return value as its own result event, separate from the exit event that reported the exit code. A consumer had to collect both and correlate them by execution to learn the outcome of a single run, even though the runner produces the value and the exit code together and only when the run exits zero. Fold the value onto the exit event as an optional result field and drop the standalone result event. The change runs the length of the module path: the runner frame, the runner-to-host frame codec, the persist-and-replay codec, the event type, the backend's ingest and finalize, and the result drain in the runtime router. The persisted form now stores the exit payload as an object so a replayed execution restores the value alongside the code.
DurableObjectState.waitUntil() has no effect on a durable object's lifetime. It exists for compatibility with the Workers runtime and does not extend how long the object stays resident. The JavaScript backend was the only consumer of the workspace waitUntil hook, and it used the hook to attach a detached execution's completion promise to the object lifetime, which did nothing. A JavaScript run advances while its event stream is drained and the call into the Dynamic Worker stays in flight. That pending work keeps the object resident on its own. Remove the hook and everything that fed it: the requiresWaitUntil backend flag, the connect-time guard and the dead completion registration in the backend, the waitUntil field on the host bag and on WorkspaceOptions, and the constructor guard. The worker-javascript example stops passing the hook.
The shell exec wire took a command field while the module execution path took a source field for the same argument: the program to run. Unifying the two execution paths behind one interface needs one name for that argument. Rename the ShellRPC exec field to source and carry an optional input value and an optional result on the exit event, matching the module execution envelope. The shell server maps source onto the runner, which keeps its own command parameter since at that layer the value is always a shell command line. The worker shell adapter and the host shell facade pass source through. Command backends ignore input and never set result; the fields exist so the shell and module paths share one event and one request shape.
The runtime forked on a set of command backend ids in four places to choose between a WorkspaceShell facade that returned an ExecHandle and a module handle that returned an event envelope. The two shapes carried the same execution lifecycle and differed only in how the result was drained and whether a sync bracket ran. Give both backend kinds the same handle. The workspace now resolves a single WorkspaceModuleBackendHandle for every backend: module backends return their native handle, and command backends are presented through an adapter over their shell that produces the same envelope. The envelope carries the sync bracket stats for a backend with a remote store, so the module result drain reports the pushed and pulled counts that the command result used to carry on its own. Collapse the runtime's four forks to one path each, delete the command handle wrapper and the shell router, and drop the command-backend-id and shell accessors from the router options. Transport-failure invalidation moves onto the adapter, which classifies a failed exec dispatch and a mid-stream event error the same way the router did.
The host shell facade exposed an exec()/get() surface that returned a ReadableStream handle with a result() method, bolted on encoding and result accumulation, and drained the sync bracket through that result. Once every backend routed through the unified execution path, nothing in production called that surface: the runtime consumes an envelope of raw events and drains the result itself. The facade and its helpers survived only because their tests kept them alive, and those tests covered code the runtime no longer runs. Reduce the class to CommandExecutor: exec() and get() return the raw event envelope plus the sync bracket stats, and kill()/dispose() forward to the wire. Delete the handle wrapper, the per-stream utf8 transform, the result drain, and the ExecHandle and ExecResult types they produced. Retarget the executor tests onto the envelope surface and move the encoding, result-accumulation, and cancellation-code coverage onto the runtime, which is where that logic now lives.
Update the prose and interface listings for the execution changes: the shell wire field is source rather than command and carries an optional structured input; the exit event carries an optional structured result; the workspace no longer takes a waitUntil hook and a JavaScript run stays alive while its event stream is consumed; and the host command driver is the CommandExecutor, with encoding and result accumulation living in the runtime. Touches the capnweb wire contract, the runtime lifecycle and worker backend notes, the isolate JavaScript guide, the runtime migration table, and the computer and example READMEs.
Routing every backend through one envelope path left inert surface behind. ExecOptions and GetExecOptions carried encoding and backend fields the CommandExecutor never reads, and their encoding generic was only ever instantiated with undefined, so withPostPull threaded a type parameter it never varied. Drop the fields and the generic; withPostPull now operates on the raw ExecEvent stream directly. Cache the command adapter per backend so the command and module paths are symmetric, clearing it alongside the shell cache on every handle invalidation so an adapter never outlives the shell it wraps. In the module event transform, exit is the only non-stdio event, so flush the buffered partial output unconditionally before the terminal event rather than guarding on a condition that is always true. Refresh comments that still named the old shell facade in the worker shell backend and the workspace tests, and fix the stray wording in the backend-handle resolver.
The exit event carried its process exit code on a `value` field, the same key stdout and stderr use for their byte payloads, and it now sits next to the optional `result`. `value` and `result` read as near synonyms on one event. Rename the exit event's numeric payload to `code` across every shape it takes: the ShellRPC wire event, the runtime event and its wire codec, the JavaScript runner frame and its persist codec, and computerd's runner event. stdout and stderr keep `value` for their payloads; the worker shell's internal framing keeps `value` and is translated to `code` at the event boundary.
The unified backend handle required a close method, but the command adapter's was never reached: the workspace closes command transports through their BackendHandle and module transports through the module handle cache, never through the adapter the runtime consumes. The adapter's close was a no-op kept only to satisfy the type. Make close optional on WorkspaceModuleBackendHandle, drop the adapter's no-op, and guard the two teardown call sites. Module backends that own a transport still implement it.
The stub soak and exec-tests harnesses still sent the shell exec command on the old `command` field and read the exit code from `value`. After the field renames the command arrived undefined, so the soak harness spawned an empty command and its exec phase measured nothing, and exec-tests failed against real output. Send the command on `source` and read the exit code from `code` in both harnesses. The soak harness's exec phase again exercises a real spawn, so its stub accounting reflects exec traffic rather than a no-op.
The module result drain started the exit code at 1, so a stream that closed without an exit frame reported the same code as a command that genuinely exited 1. The command path used -1 for that case before the paths merged. Start the exit code at -1 so a truncated stream is distinguishable from a real exit 1. Both still settle as "failed". Cover the case in the runtime tests.
Removing the waitUntil hook left the execution completion promise unheld on the success path. That promise drives the finalize step, which writes the terminal execution rows and can throw, so a finalize rejection became an unhandled rejection in the Durable Object rather than a handled error. Attach a catch to the completion promise at its start site so a rejection is observed and swallowed. Cancellation and connect-time failure already awaited it.
The runtime and the exec tool each built the same "backend is not callable" message from scratch, so the two user-visible copies could drift. Export the message from the runtime and build the runtime's own throw from it, giving the tool one source to reuse.
commit: |
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
Workspaceruntime routed an execution down one of two internal paths. Command backends — the just-bashworker-shelland the Cloudflare Container — went through a host shell facade that returned aReadableStreamhandle with aresult()method and bracketed every spawn with the sync push and pull frames. The JavaScript backend went through a separate module path that returned an event envelope. The runtime kept a set of command-backend ids and forked on it in four places —exec,getExec,killExec,disposeExec— to pick which path an execution took.The split had outlived its reason. Both paths carried the same lifecycle: start an execution, stream its output, reattach, kill, dispose. What actually differed between backends was narrow — whether a backend accepts a structured input and returns a structured value, and whether it syncs against its own remote store. Neither needs two handle shapes or a routing fork. This change collapses everything onto one path.
flowchart TB subgraph Before R1[runtime.exec] -->|commandBackendIds fork| S[shell facade → ExecHandle] R1 -->|else| M[module handle → envelope] S --> W1[wrapCommandHandle] M --> W2[wrapModuleHandle] end subgraph After R2[runtime.exec] --> B[backendHandle id] B --> E[one envelope: id, events, sync] E --> W3[wrapModuleHandle] endThe work lands as five changes, each self-contained.
The first consolidates the terminal event. The JavaScript path used to emit a module's return value as its own
resultevent, separate from theexitevent that reported the code. A consumer had to collect both and correlate them to learn the outcome of one run, even though the runner produces the value and the code together and only on a zero exit. The value now rides the exit event as an optional field, and the standalone event is gone:The second removes the
waitUntilhook.DurableObjectState.waitUntil()has no effect on a durable object's lifetime — it exists for Workers-runtime API compatibility and does not extend how long the object stays resident. The JavaScript backend was the only consumer, and it used the hook to attach a detached execution's completion promise to the object lifetime, which did nothing. What actually keeps a run alive is ordinary pending work: the in-flight call into the Dynamic Worker and a consumer draining the event stream. The hook, the backend flag that required it, the connect-time guard, and the constructor guard all come out, and the workspace constructor no longer takes the option.The third reshapes the wire. The shell RPC took a
commandfield while the module path took asourcefield for the same argument — the program to run. Unifying the two behind one interface needs one name, soShellRPC.execnow takessourceand an optionalinput, and its event stream carries the consolidated exit:With the wire reshaped, the fourth change collapses the runtime. Every backend is now presented to the runtime as one
WorkspaceModuleBackendHandle: module backends return their native handle, and command backends are adapted from their executor. The sync bracket rides the envelope — the pre-exec push count is known when the envelope is built, and the post-drain pull outcome settles once the event stream ends — so the runtime's single result drain reports the pushed and pulled counts for every backend, and async: "none"backend simply reports zeroes.The four runtime forks become one path per method,
wrapModuleHandlebecomes the single wrapper for every backend, and thecommandBackendIdsset, the command handle wrapper, and the shell router are deleted. Transport-failure invalidation moves onto the adapter, which classifies a failed exec dispatch and a mid-stream event error the same way the router did.The fifth change finishes the cleanup. Once every backend routed through the unified path, nothing in production called the shell facade's
exec()/get()surface — the runtime consumes the envelope and drains the result itself. That surface, its handle wrapper, its per-stream utf8 transform, its result drain, and theExecHandleandExecResulttypes survived only because their tests kept them alive, and those tests covered code the runtime no longer runs. The class is reduced to aCommandExecutorwhoseexec/getreturn the raw event envelope plus the sync stats and whosekill/disposeforward to the wire. The executor tests move onto that envelope surface, and the encoding, result-accumulation, and cancellation-code coverage moves onto the runtime, where that logic now lives.To verify the change end to end, boot the linux-x64
computerdbinary and drive the reshaped wire and the sync bracket:Against that container, a
push → exec → pullcycle stages a host file, runs a command that reads it and writes a new one, and pulls the container's write back — exercising the relocated sync bracket through the unified handle. The command'ssourcedrives the run and the exit code round-trips, confirming the wire reshape against the real binary.The change was verified on three levels. The package unit suites cover the executor envelope surface, the runtime's encoding and result accumulation, and transport-failure invalidation. The stub soak harness confirms no disposal drift — the capnweb session settles to its baseline import and export counts after every phase, so the executor collapse leaks no stubs. The container harness confirms the wire and the sync bracket against a real FUSE-backed
computerd.Documentation moves with the code: the capnweb wire contract, the runtime lifecycle and worker backend notes, the isolate JavaScript guide, the runtime migration table, and the package and example READMEs all reflect the
sourcefield, the consolidated exit event, the removed hook, and theCommandExecutor.One deviation from a pure interface merge is worth calling out for reviewers. The sync bracket stays inside the
CommandExecutorand is adapted into the unified handle at the workspace, rather than being lifted into a separate workspace-level wrapper. The bracket and its push/pull accounting are convergence-critical and covered by the soak harness; reusing the executor's tested bracket keeps that behavior intact instead of rewriting it. The observable result is the same — one execution path, sync stats reported uniformly — but the bracket's home is the executor.